diff --git a/extensions/src/hello-someone/hello-someone.ts b/extensions/src/hello-someone/hello-someone.ts index 655c11d559..c2d7f66030 100644 --- a/extensions/src/hello-someone/hello-someone.ts +++ b/extensions/src/hello-someone/hello-someone.ts @@ -314,6 +314,12 @@ export async function activate(context: ExecutionActivationContext): Promise { + if (addWebViewEvent.webView.webViewType === peopleWebViewType) + logger.info( + `We noticed a ${peopleWebViewType} webView was added with id ${addWebViewEvent.webView.id}`, + ); + }), ); logger.info('Hello Someone is finished activating!'); diff --git a/extensions/src/hello-someone/hello-someone.web-view.html b/extensions/src/hello-someone/hello-someone.web-view.html index 687f25d157..70ac158463 100644 --- a/extensions/src/hello-someone/hello-someone.web-view.html +++ b/extensions/src/hello-someone/hello-someone.web-view.html @@ -187,6 +187,10 @@ newWebViewButton.addEventListener('click', async () => { const webViewId = await papi.webViews.getWebView('helloSomeone.peopleViewer', { type: 'float', + floatSize: { + width: 450, + height: 350, + }, }); print(`New People webview id: ${webViewId}`); }); diff --git a/extensions/src/hello-world/assets/offline.svg b/extensions/src/hello-world/assets/offline.svg new file mode 100644 index 0000000000..a16275fc8b --- /dev/null +++ b/extensions/src/hello-world/assets/offline.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/src/hello-world/hello-world.ts b/extensions/src/hello-world/hello-world.ts index abc3747c44..514d292b88 100644 --- a/extensions/src/hello-world/hello-world.ts +++ b/extensions/src/hello-world/hello-world.ts @@ -52,6 +52,7 @@ const reactWebViewProvider: IWebViewProviderWithType = { ); return { ...savedWebView, + iconUrl: 'papi-extension://hello-world/assets/offline.svg', title: 'Hello World React', content: helloWorldReactWebView, styles: helloWorldReactWebViewStyles, 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..98cc2edc4f 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 @@ -14,8 +14,9 @@ import { import type { QuickVerseDataTypes } from 'quick-verse'; import type { PeopleDataProvider, PeopleDataTypes } from 'hello-someone'; import type { UsfmProviderDataTypes } from 'usfm-data-provider'; -import { Key, useCallback, useContext, useMemo, useState } from 'react'; +import { Key, useCallback, useContext, useMemo, useRef, useState } from 'react'; import type { HelloWorldEvent } from 'hello-world'; +import type { DialogTypes } from 'renderer/components/dialogs/dialog.data'; import Clock from './components/clock.component'; type Row = { @@ -27,7 +28,7 @@ type Row = { const { react: { context: { TestContext }, - hooks: { useData, useDataProvider, usePromise, useEvent, useSetting }, + hooks: { useData, useDataProvider, usePromise, useEvent, useSetting, useDialogCallback }, }, logger, } = papi; @@ -59,7 +60,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 +68,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( @@ -80,6 +86,16 @@ globalThis.webViewComponent = function HelloWorld() { 'retrieving', ); + const [project, selectProject] = useDialogCallback( + 'platform.selectProject', + useRef({ + prompt: 'Please select a project for Hello World WebView:', + iconUrl: 'papi-extension://hello-world/assets/offline.svg', + title: 'Select Hello World Project', + }).current, + 'None' as DialogTypes['platform.selectProject']['responseType'], + ); + const [latestVerseText] = useData.Verse( 'quickVerse.quickVerse', 'latest', @@ -147,6 +163,10 @@ globalThis.webViewComponent = function HelloWorld() {
{personGreeting}
{personAge}
+
Selected Project: {project}
+
+ +

John 1:1

{john11}

Psalm 1

diff --git a/extensions/src/resource-viewer/resource-viewer.web-view.tsx b/extensions/src/resource-viewer/resource-viewer.web-view.tsx index 86b3758472..38a2b93d1e 100644 --- a/extensions/src/resource-viewer/resource-viewer.web-view.tsx +++ b/extensions/src/resource-viewer/resource-viewer.web-view.tsx @@ -1,7 +1,7 @@ -import { VerseRef } from '@sillsdev/scripture'; +import { VerseRef } from '@sillsdev/scripture'; import papi from 'papi-frontend'; import { ScriptureReference } from 'papi-components'; -import { useMemo } from 'react'; +import { JSX, useMemo } from 'react'; // eslint-disable-next-line import/no-unresolved import { UsfmProviderDataTypes } from 'usfm-data-provider'; import UsxEditor from 'usxeditor'; @@ -24,8 +24,15 @@ interface ElementInfo { validStyles?: StyleInfo[]; } +const { + react: { + hooks: { useData, useSetting }, + }, + logger, +} = papi; + /** All available elements for use in slate editor */ -const EditorElements: { [type: string]: ElementInfo } = { +const editorElements: { [type: string]: ElementInfo } = { verse: { inline: true, validStyles: [{ style: 'v', oneWord: true }], @@ -109,9 +116,9 @@ const EditorElements: { [type: string]: ElementInfo } = { }, }; -const UsxEditorParaMap = EditorElements.para.validStyles?.map((style) => style.style) || []; -const UsxEditorCharMap = Object.fromEntries( - EditorElements.char.validStyles?.map((style) => [style.style, {}]) || [], +const usxEditorParaMap = editorElements.para.validStyles?.map((style) => style.style) || []; +const usxEditorCharMap = Object.fromEntries( + editorElements.char.validStyles?.map((style) => [style.style, {}]) || [], ); interface ScriptureTextPanelUsxProps { @@ -134,8 +141,8 @@ function ScriptureTextPanelUsxEditor({ usx }: ScriptureTextPanelUsxProps) {
{ /* Read only */ }} @@ -144,14 +151,7 @@ function ScriptureTextPanelUsxEditor({ usx }: ScriptureTextPanelUsxProps) { ); } -const { - react: { - hooks: { useData, useSetting }, - }, - logger, -} = papi; - -globalThis.webViewComponent = function ResourceViewer() { +globalThis.webViewComponent = function ResourceViewer(): JSX.Element { logger.info('Preparing to display the Resource Viewer'); const [scrRef] = useSetting('platform.verseRef', defaultScrRef); diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index d4ab3e9f12..55366a6850 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 @@ -1670,6 +1692,12 @@ declare module 'shared/data/web-view.model' { * {@link TabSaver} saves this into {@link SavedTabInfo} */ export type TabInfo = SavedTabInfo & { + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + tabIconUrl?: string; /** * Text to show on the title bar of the tab */ @@ -1679,11 +1707,11 @@ declare module 'shared/data/web-view.model' { */ content: ReactNode; /** - * (optional) Minimum width that the tab can become + * (optional) Minimum width that the tab can become in CSS `px` units */ minWidth?: number; /** - * (optional) Minimum height that the tab can become + * (optional) Minimum height that the tab can become in CSS `px` units */ minHeight?: number; }; @@ -1698,8 +1726,12 @@ declare module 'shared/data/web-view.model' { * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). + * + * @param tabInfo the Paranext tab to save + * + * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab */ - export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo; + export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; /** The type of code that defines a webview's content */ export enum WebViewContentType { /** @@ -1722,8 +1754,16 @@ declare module 'shared/data/web-view.model' { id: WebViewId; /** The code for the WebView that papi puts into an iframe */ content: string; + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + iconUrl?: 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 & { @@ -1755,13 +1795,25 @@ declare module 'shared/data/web-view.model' { interface TabLayout { type: 'tab'; } + /** + * Indicates where to display a floating window + * + * `cascade` - place the window a bit below and to the right of the previously created floating + * window + * `center` - center the window in the dock layout + */ + type FloatPosition = 'cascade' | 'center'; + /** The dimensions for a floating tab in CSS `px` units */ + export type FloatSize = { + width: number; + height: number; + }; /** Information about a floating window */ export interface FloatLayout { type: 'float'; - floatSize?: { - width: number; - height: number; - }; + floatSize?: FloatSize; + /** Where to display the floating window. Defaults to `cascade` */ + position?: FloatPosition; } export type PanelDirection = | 'left' @@ -1906,6 +1958,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'; @@ -1928,7 +2014,7 @@ declare module 'shared/services/web-view.service' { currentTabId?: string, direction?: DropDirection, ) => Promise; - /** Properties related to the dock layout provided by `paranext-dock-layout.component.tsx` */ + /** Properties related to the dock layout provided by `platform-dock-layout.component.tsx` */ type PapiDockLayout = { /** The rc-dock dock layout React element ref. Used to perform operations on the layout */ dockLayout: DockLayout; @@ -1937,14 +2023,35 @@ declare module 'shared/services/web-view.service' { * {@link onLayoutChange} function */ onLayoutChangeRef: MutableRefObject; - /** Function to call to add or update a webview in the layout */ - addWebViewToDock: (webView: WebViewProps, layout: Layout) => void; + /** + * Add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab + * + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` + */ + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => Layout | undefined; + /** + * Add or update a webview in the layout + * @param webView web view to add or update + * @param layout information about where to put a new webview + * + * @returns If WebView added, final layout used to display the new webView. If existing webView + * updated, `undefined` + */ + addWebViewToDock: (webView: WebViewProps, layout: Layout) => Layout | undefined; + /** + * Remove a tab in the layout + * @param tabId id of the tab to remove + */ + removeTabFromDock: (tabId: string) => boolean; /** * The layout to use as the default layout if the dockLayout doesn't have a layout loaded. * * TODO: This should be removed and the `testLayout` imported directly in this file once this * service is refactored to split the code between processes. The only reason this is passed from - * `paranext-dock-layout.component.tsx` is that we cannot import `testLayout` here since this + * `platform-dock-layout.component.tsx` is that we cannot import `testLayout` here since this * service is currently all shared code. Refactor should happen in #203 */ testLayout: LayoutBase; @@ -1995,8 +2102,36 @@ declare module 'shared/services/web-view.service' { * operations * @param dockLayout dock layout element to register along with other important properties * @returns function used to unregister this dock layout + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER + * + * Not exposed on the papi */ export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber; + /** + * Remove a tab in the layout + * @param tabId id of the tab to remove + * + * @returns true if successfully found the tab to remove + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER + * + * Not exposed on the papi + */ + export const removeTab: (tabId: string) => Promise; + /** + * Add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab + * + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER + * + * Not exposed on the papi + */ + export const addTab: (savedTabInfo: SavedTabInfo, layout: Layout) => Promise; /** * Creates a new web view or gets an existing one depending on if you request an existing one and * if the web view provider decides to give that existing one to you (it is up to the provider). @@ -2329,7 +2464,7 @@ declare module 'shared/models/project-metadata.model' { projectType: string; }; } -declare module 'shared/models/project-lookup.model' { +declare module 'shared/services/project-lookup.service-model' { import { ProjectMetadata } from 'shared/models/project-metadata.model'; /** * Provides metadata for projects known by the platform @@ -2350,7 +2485,7 @@ declare module 'shared/models/project-lookup.model' { export const projectLookupServiceNetworkObjectName = 'ProjectLookupService'; } declare module 'shared/services/project-lookup.service' { - import { ProjectLookupServiceType } from 'shared/models/project-lookup.model'; + import { ProjectLookupServiceType } from 'shared/services/project-lookup.service-model'; const projectLookupService: ProjectLookupServiceType; export default projectLookupService; } @@ -2517,12 +2652,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 +2680,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 +2818,272 @@ 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 'shared/models/dialog-options.model' { + /** General options to adjust dialogs (created from `papi.dialogs`) */ + export type DialogOptions = { + /** Dialog title to display in the header. Default depends on the dialog */ + title?: string; + /** Url of dialog icon to display in the header. Default is Platform.Bible logo */ + iconUrl?: string; + /** The message to show the user in the dialog. Default depends on the dialog */ + prompt?: string; + }; + /** data in each tab that is a dialog. Added to DialogOptions in `dialog.service-host.ts` */ + export type DialogData = DialogOptions & { + isDialog: true; + }; +} +declare module 'renderer/components/dialogs/dialog-base.data' { + import { FloatSize, TabLoader, TabSaver } from 'shared/data/web-view.model'; + import { DialogData } from 'shared/models/dialog-options.model'; + import { ReactElement } from 'react'; + /** Base type for DialogDefinition. Contains reasonable defaults for dialogs */ + export type DialogDefinitionBase = Readonly<{ + /** Overwritten in {@link DialogDefinition}. Must be specified by all DialogDefinitions */ + tabType?: string; + /** Overwritten in {@link DialogDefinition}. Must be specified by all DialogDefinitions */ + Component?: (props: DialogProps) => ReactElement; + /** + * The default icon for this dialog. This may be overridden by the `DialogOptions.iconUrl` + * + * Defaults to the Platform.Bible logo + */ + defaultIconUrl?: string; + /** + * The default title for this dialog. This may be overridden by the `DialogOptions.title` + * + * Defaults to the DialogDefinition's `tabType` + */ + defaultTitle?: string; + /** The width and height at which the dialog will be loaded in CSS `px` units */ + initialSize: FloatSize; + /** The minimum width to which the dialog can be set in CSS `px` units */ + minWidth?: number; + /** The minimum height to which the dialog can be set in CSS `px` units */ + minHeight?: number; + /** + * The function used to load the dialog into the dock layout. Default uses the `Component` field + * and passes in the `DialogProps` + */ + loadDialog: TabLoader; + /** + * The function used to save the dialog into the dock layout + * + * Default does not save the dialog as they cannot properly be restored yet. + * + * TODO: preserve requests between refreshes - save the dialog info in such a way that it works + * when loading again after refresh + */ + saveDialog: TabSaver; + }>; + export type DialogProps = DialogData & { + /** + * Sends the data as a resolved response to the dialog request and closes the dialog + * + * @param data data with which to resolve the request + */ + submitDialog(data: TData): void; + /** Cancels the dialog request (resolves the response with `null`) and closes the dialog */ + cancelDialog(): void; + /** + * Rejects the dialog request with the specified message and closes the dialog + * + * @param errorMessage message to explain why the dialog request was rejected + */ + rejectDialog(errorMessage: string): void; + }; + /** + * Set the functionality of submitting and canceling dialogs. This should be called specifically by + * `dialog.service-host.ts` immediately on startup and by nothing else. This is only here to + * mitigate a dependency cycle + * + * @param dialogServiceFunctions functions from the dialog service host for resolving and rejecting + * dialogs + */ + export function hookUpDialogService({ + resolveDialogRequest: resolve, + rejectDialogRequest: reject, + }: { + resolveDialogRequest: (id: string, data: unknown | null) => void; + rejectDialogRequest: (id: string, message: string) => void; + }): void; + /** + * Static definition of a dialog that can be shown in Platform.Bible + * + * For good defaults, dialogs can include all the properties of this dialog. Dialogs must then + * specify `tabType` and `Component` in order to comply with `DialogDefinition` + * + * Note: this is not a class that can be inherited because all properties would be static but then + * we would not be able to use the default `loadDialog` because it would be using a static reference + * to a nonexistent `Component`. Instead of inheriting this as a class, any dialog definition can + * spread this `{ ...DIALOG_BASE }` + */ + const DIALOG_BASE: DialogDefinitionBase; + export default DIALOG_BASE; +} +declare module 'renderer/components/dialogs/dialog-definition.model' { + import { DialogOptions } from 'shared/models/dialog-options.model'; + import { DialogDefinitionBase, DialogProps } from 'renderer/components/dialogs/dialog-base.data'; + import { ReactElement } from 'react'; + /** The tabType for the select project dialog in `select-project.dialog.tsx` */ + export const SELECT_PROJECT_DIALOG_TYPE = 'platform.selectProject'; + /** + * Mapped type for dialog functions to use in getting various types for dialogs + * + * Keys should be dialog names, and values should be {@link DialogDataTypes} + * + * If you add a dialog here, you must also add it on {@link DIALOGS} + */ + export interface DialogTypes { + [SELECT_PROJECT_DIALOG_TYPE]: DialogDataTypes; + } + /** Each type of dialog. These are the tab types used in the dock layout */ + export type DialogTabTypes = keyof DialogTypes; + /** Types related to a specific dialog */ + export type DialogDataTypes = { + /** + * The dialog options to specify when calling the dialog. + * Passed into `loadDialog` as SavedTabInfo.data + * + * The default implementation of `loadDialog` passes all the options down to the dialog component + * as props + */ + options: TOptions; + /** The type of the response to the dialog request */ + responseType: TReturnType; + }; + export type DialogDefinition = Readonly< + DialogDefinitionBase & { + /** + * Type of tab - indicates what kind of built-in dock layout tab this dialog definition represents + */ + tabType: DialogTabType; + /** + * React component to render for this dialog + * + * This must be specified only if you do not overwrite the default `loadDialog` + * @param props props that will be passed through from the dialog tab's data + * @returns react element to render + */ + Component: ( + props: DialogProps & + DialogTypes[DialogTabType]['options'], + ) => ReactElement; + } + >; +} +declare module 'shared/services/dialog.service-model' { + import { DialogTabTypes, DialogTypes } from 'renderer/components/dialogs/dialog-definition.model'; + import { DialogOptions } from 'shared/models/dialog-options.model'; + /** + * Prompt the user for responses with dialogs + */ + export interface DialogService { + /** + * Shows a dialog to the user and prompts the user to respond + * + * @param dialogType the type of dialog to show the user + * @param options various options for configuring the dialog that shows + * + * @returns returns the user's response or `null` if the user cancels + * + * Note: canceling responds with `null` instead of `undefined` so that the dialog definition can + * use `undefined` as a meaningful value if desired. + * + * @type `TReturn` - the type of data the dialog responds with + */ + showDialog( + dialogType: DialogTabType, + options?: DialogTypes[DialogTabType]['options'], + ): Promise; + /** + * Shows a select project dialog to the user and prompts the user to select a dialog + * + * @param options various options for configuring the dialog that shows + * + * @returns returns the user's selected project id or `null` if the user cancels + */ + selectProject(options?: DialogOptions): Promise; + } + /** Prefix on requests that indicates that the request is related to dialog operations */ + export const CATEGORY_DIALOG = 'dialog'; +} +declare module 'shared/services/dialog.service' { + import { DialogService } from 'shared/services/dialog.service-model'; + const dialogService: DialogService; + export default dialogService; +} +declare module 'renderer/hooks/papi-hooks/use-dialog-callback.hook' { + import { DialogTabTypes, DialogTypes } from 'renderer/components/dialogs/dialog-definition.model'; + /** + * Enables using `papi.dialogs.showDialog` in React more easily. Provides a callback to run to get a + * response from a dialog as well as states that indicate the dialog's response and whether the + * dialog is open. + * + * Calling the dialog callback returned from this hook does nothing if you already previously opened + * the dialog and have not received a response + * + * @param dialogType dialog type you want to show on the screen + * + * WARNING: MUST BE STABLE - const or wrapped in useState, useMemo, etc. The reference must not + * be updated every render + * @param options various options for configuring the dialog that shows + * + * WARNING: MUST BE STABLE - const or wrapped in useState, useMemo, etc. The reference must not + * be updated every render + * @param defaultResponse the starting value for the response. Once a response is received, this is + * no longer used. Defaults to `null` + * + * @returns `[response, showDialogCallback, errorMessage, isShowingDialog]` + * - `response` - the response from the dialog or `defaultResponse` if a response has not been + * received (does not reset to `defaultResponse` if the user cancels the dialog). + * DOES NOT reset every time the callback is run + * - `showDialogCallback` - callback to run to show the dialog to prompt the user for a response + * - `errorMessage` - the error from the dialog if there is an error while calling the dialog or + * `undefined` if there is no error. DOES reset to `undefined` every time the callback is run + * - `isShowingDialog` - whether this dialog is showing (the callback has been run but has not + * responded) + * + * @type `DialogTabType` the dialog type you are using. Should be inferred by parameters + * @type `TResponse` the type that the response can be. If you do not specify a `defaultResponse`, + * this can be the dialog response type or `null`. If you specify a `defaultResponse`, this will + * be just the dialog response type. Should be inferred by parameters. + * - This mostly works. Unfortunately, if you specify a literal as `defaultResponse`, `TResponse` + * then becomes that literal instead of being the dialog response type. You can type assert it + * to the appropriate type. Let us know if you run into an issue with this! + * + */ + function useDialogCallback< + DialogTabType extends DialogTabTypes, + TResponse extends DialogTypes[DialogTabType]['responseType'] | null = + | DialogTypes[DialogTabType]['responseType'] + | null, + >( + dialogType: DialogTabType, + options?: DialogTypes[DialogTabType]['options'], + defaultResponse?: TResponse, + ): [TResponse, () => Promise, string | undefined, boolean]; + export default useDialogCallback; +} 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 +3091,14 @@ 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'; + import useDialogCallback from 'renderer/hooks/papi-hooks/use-dialog-callback.hook'; export interface PapiHooks { + useDialogCallback: typeof useDialogCallback; 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 @@ -2738,11 +3158,12 @@ declare module 'papi-frontend' { import { PapiWebViewService } from 'shared/services/web-view.service'; import { InternetService } from 'shared/services/internet.service'; import { DataProviderService } from 'shared/services/data-provider.service'; - import { ProjectLookupServiceType } from 'shared/models/project-lookup.model'; + import { ProjectLookupServiceType } from 'shared/services/project-lookup.service-model'; import { PapiFrontendProjectDataProviderService } from 'shared/services/project-data-provider.service'; import { PapiContext } from 'renderer/context/papi-context/index'; import { PapiHooks } from 'renderer/hooks/papi-hooks/index'; import { SettingsService } from 'shared/services/settings.service'; + import { DialogService } from 'shared/services/dialog.service-model'; const papi: { /** * Event manager - accepts subscriptions to an event and runs the subscription callbacks when the event is emitted @@ -2769,6 +3190,10 @@ declare module 'papi-frontend' { * Service exposing various functions related to using webViews */ webViews: PapiWebViewService; + /** + * Prompt the user for responses with dialogs + */ + dialogs: DialogService; /** * Service that provides a way to send and receive network events */ @@ -3068,7 +3493,8 @@ declare module 'papi-backend' { import { DataProviderService } from 'shared/services/data-provider.service'; import { PapiBackendProjectDataProviderService } from 'shared/services/project-data-provider.service'; import { ExtensionStorageService } from 'extension-host/services/extension-storage.service'; - import { ProjectLookupServiceType } from 'shared/models/project-lookup.model'; + import { ProjectLookupServiceType } from 'shared/services/project-lookup.service-model'; + import { DialogService } from 'shared/services/dialog.service-model'; const papi: { /** * Event manager - accepts subscriptions to an event and runs the subscription callbacks when the event is emitted @@ -3099,6 +3525,10 @@ declare module 'papi-backend' { * Interface for registering webView providers */ webViewProviders: PapiWebViewProviderService; + /** + * Prompt the user for responses with dialogs + */ + dialogs: DialogService; /** * Service that provides a way to send and receive network events */ diff --git a/src/__tests__/app.component.test.tsx b/src/__tests__/app.component.test.tsx index 6a2641bc93..a2461a3d05 100644 --- a/src/__tests__/app.component.test.tsx +++ b/src/__tests__/app.component.test.tsx @@ -35,7 +35,7 @@ jest.mock('@renderer/hooks/papi-hooks/use-event.hook', () => ({ __esModule: true, default: /** useEvent Mock */ () => {}, })); -jest.mock('@renderer/components/docking/paranext-dock-layout.component', () => ({ +jest.mock('@renderer/components/docking/platform-dock-layout.component', () => ({ __esModule: true, default: /** ParanextDockLayout Mock */ () => undefined, })); 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/extension-host/extension-host.ts b/src/extension-host/extension-host.ts index 6c0a0e388f..c07bb1fa03 100644 --- a/src/extension-host/extension-host.ts +++ b/src/extension-host/extension-host.ts @@ -9,7 +9,7 @@ import dataProviderService from '@shared/services/data-provider.service'; import extensionAssetService from '@shared/services/extension-asset.service'; import { getErrorMessage } from '@shared/utils/util'; import { CommandNames } from 'papi-shared-types'; -import { startProjectLookupService } from '@extension-host/services/project-lookup-backend.service'; +import { startProjectLookupService } from '@extension-host/services/project-lookup.service-host'; // #region Test logs diff --git a/src/extension-host/services/papi-backend.service.ts b/src/extension-host/services/papi-backend.service.ts index 5151afcaf3..2479a58fdf 100644 --- a/src/extension-host/services/papi-backend.service.ts +++ b/src/extension-host/services/papi-backend.service.ts @@ -23,8 +23,10 @@ import { import extensionStorageService, { ExtensionStorageService, } from '@extension-host/services/extension-storage.service'; -import { ProjectLookupServiceType } from '@shared/models/project-lookup.model'; +import { ProjectLookupServiceType } from '@shared/services/project-lookup.service-model'; import projectLookupService from '@shared/services/project-lookup.service'; +import dialogService from '@shared/services/dialog.service'; +import { DialogService } from '@shared/services/dialog.service-model'; // IMPORTANT NOTES: // 1) When adding new services here, consider whether they also belong in papi-frontend.service.ts. @@ -50,6 +52,8 @@ const papi = { webViews: papiWebViewService as PapiWebViewService, /** JSDOC DESTINATION papiWebViewProviderService */ webViewProviders: papiWebViewProviderService as PapiWebViewProviderService, + /** JSDOC DESTINATION dialogService */ + dialogs: dialogService as DialogService, /** JSDOC DESTINATION papiNetworkService */ network: papiNetworkService as PapiNetworkService, /** JSDOC DESTINATION logger */ diff --git a/src/extension-host/services/project-lookup-backend.service.ts b/src/extension-host/services/project-lookup.service-host.ts similarity index 93% rename from src/extension-host/services/project-lookup-backend.service.ts rename to src/extension-host/services/project-lookup.service-host.ts index 428d95c102..bde3f2dd58 100644 --- a/src/extension-host/services/project-lookup-backend.service.ts +++ b/src/extension-host/services/project-lookup.service-host.ts @@ -4,7 +4,7 @@ import { ProjectMetadata } from '@shared/models/project-metadata.model'; import { projectLookupServiceNetworkObjectName, ProjectLookupServiceType, -} from '@shared/models/project-lookup.model'; +} from '@shared/services/project-lookup.service-model'; import { joinUriPaths } from '@node/utils/util'; import logger from '@shared/services/logger.service'; import networkObjectService from '@shared/services/network-object.service'; @@ -118,19 +118,16 @@ const projectLookupService: ProjectLookupServiceType = { getMetadataForProject, }; -let networkObject: ProjectLookupServiceType; - /** * Register the network object that backs the PAPI project lookup service */ +// This doesn't really represent this service module, so we're not making it default. To use this +// service, you should use `project-lookup.service.ts` +// eslint-disable-next-line import/prefer-default-export export async function startProjectLookupService(): Promise { await initialize(); - networkObject = await networkObjectService.set( + await networkObjectService.set( projectLookupServiceNetworkObjectName, projectLookupService, ); } - -export function getNetworkObject(): ProjectLookupServiceType { - return networkObject; -} diff --git a/src/renderer/app.component.tsx b/src/renderer/app.component.tsx index fb72da8965..f7e16ff5ab 100644 --- a/src/renderer/app.component.tsx +++ b/src/renderer/app.component.tsx @@ -1,6 +1,6 @@ import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; import './app.component.css'; -import ParanextDockLayout from '@renderer/components/docking/paranext-dock-layout.component'; +import PlatformDockLayout from '@renderer/components/docking/platform-dock-layout.component'; import TestContext from '@renderer/context/papi-context/test.context'; import PlatformBibleToolbar from './components/platform-bible-toolbar'; @@ -8,7 +8,7 @@ function Main() { return ( - + ); } diff --git a/src/renderer/components/dialogs/dialog-base.data.ts b/src/renderer/components/dialogs/dialog-base.data.ts new file mode 100644 index 0000000000..cb9c63ed3b --- /dev/null +++ b/src/renderer/components/dialogs/dialog-base.data.ts @@ -0,0 +1,186 @@ +import { FloatSize, TabLoader, TabSaver } from '@shared/data/web-view.model'; +import { DialogData } from '@shared/models/dialog-options.model'; +import logger from '@shared/services/logger.service'; +import { ReactElement, createElement } from 'react'; + +/** Base type for DialogDefinition. Contains reasonable defaults for dialogs */ +export type DialogDefinitionBase = Readonly<{ + /** Overwritten in {@link DialogDefinition}. Must be specified by all DialogDefinitions */ + tabType?: string; + /** Overwritten in {@link DialogDefinition}. Must be specified by all DialogDefinitions */ + Component?: (props: DialogProps) => ReactElement; + /** + * The default icon for this dialog. This may be overridden by the `DialogOptions.iconUrl` + * + * Defaults to the Platform.Bible logo + */ + defaultIconUrl?: string; + /** + * The default title for this dialog. This may be overridden by the `DialogOptions.title` + * + * Defaults to the DialogDefinition's `tabType` + */ + defaultTitle?: string; + /** The width and height at which the dialog will be loaded in CSS `px` units */ + initialSize: FloatSize; + /** The minimum width to which the dialog can be set in CSS `px` units */ + minWidth?: number; + /** The minimum height to which the dialog can be set in CSS `px` units */ + minHeight?: number; + /** + * The function used to load the dialog into the dock layout. Default uses the `Component` field + * and passes in the `DialogProps` + */ + loadDialog: TabLoader; + /** + * The function used to save the dialog into the dock layout + * + * Default does not save the dialog as they cannot properly be restored yet. + * + * TODO: preserve requests between refreshes - save the dialog info in such a way that it works + * when loading again after refresh + */ + saveDialog: TabSaver; +}>; + +export type DialogProps = DialogData & { + /** + * Sends the data as a resolved response to the dialog request and closes the dialog + * + * @param data data with which to resolve the request + */ + submitDialog(data: TData): void; + /** Cancels the dialog request (resolves the response with `null`) and closes the dialog */ + cancelDialog(): void; + /** + * Rejects the dialog request with the specified message and closes the dialog + * + * @param errorMessage message to explain why the dialog request was rejected + */ + rejectDialog(errorMessage: string): void; +}; + +/** + * The default initial size for dialogs in CSS `px` units. Can be overridden by a dialog's + * `initialSize` property + */ +const DIALOG_DEFAULT_SIZE: FloatSize = { width: 300, height: 300 }; + +/** + * Resolve a dialog request + * + * This function is a reference holder and should be replaced by `dialog.service-host.ts` with its + * `resolveDialogRequest` in `hookUpDialogService` as soon as possible. This is written this way to + * mitigate dependency cycles + */ +let resolveDialogRequestInternal = (id: string, data: unknown | null): void => { + throw new Error( + `Dialog ${id} tried to resolve before being hooked up to the dialog service! This may indicate that the dialog service started after a dialog was submitted. data: ${JSON.stringify( + data, + )}`, + ); +}; + +/** + * Resolve a dialog request + * + * This function should just run `dialog.service-host.ts`'s `resolveDialogRequest` + */ +function resolveDialogRequest(id: string, data: unknown | null) { + return resolveDialogRequestInternal(id, data); +} + +/** + * Reject a dialog request. Synchronously rejects, then asynchronously closes the dialog + * + * This function is a reference holder and should be replaced by `dialog.service-host.ts` with its + * `rejectDialogRequest` in `hookUpDialogService` as soon as possible. This is written this way to + * mitigate dependency cycles + */ +let rejectDialogRequestInternal = (id: string, message: string): void => { + throw new Error( + `Dialog ${id} tried to reject before being hooked up to the dialog service! This may indicate that the dialog service started after a dialog was canceled. message: ${JSON.stringify( + message, + )}`, + ); +}; + +/** + * Reject a dialog request. Synchronously rejects, then asynchronously closes the dialog + * + * This function should just run `dialog.service-host.ts`'s `rejectDialogRequest` + */ +function rejectDialogRequest(id: string, message: string) { + return rejectDialogRequestInternal(id, message); +} + +/** + * Set the functionality of submitting and canceling dialogs. This should be called specifically by + * `dialog.service-host.ts` immediately on startup and by nothing else. This is only here to + * mitigate a dependency cycle + * + * @param dialogServiceFunctions functions from the dialog service host for resolving and rejecting + * dialogs + */ +export function hookUpDialogService({ + resolveDialogRequest: resolve, + rejectDialogRequest: reject, +}: { + resolveDialogRequest: (id: string, data: unknown | null) => void; + rejectDialogRequest: (id: string, message: string) => void; +}) { + resolveDialogRequestInternal = resolve; + rejectDialogRequestInternal = reject; +} + +/** + * Static definition of a dialog that can be shown in Platform.Bible + * + * For good defaults, dialogs can include all the properties of this dialog. Dialogs must then + * specify `tabType` and `Component` in order to comply with `DialogDefinition` + * + * Note: this is not a class that can be inherited because all properties would be static but then + * we would not be able to use the default `loadDialog` because it would be using a static reference + * to a nonexistent `Component`. Instead of inheriting this as a class, any dialog definition can + * spread this `{ ...DIALOG_BASE }` + */ +const DIALOG_BASE: DialogDefinitionBase = { + initialSize: DIALOG_DEFAULT_SIZE, + loadDialog(savedTabInfo) { + const maybeTabData = savedTabInfo.data as DialogData | undefined; + if (!maybeTabData || !maybeTabData.isDialog) + logger.error( + `Dialog ${ + this.tabType + } received savedTabInfo without data.isDialog! Please investigate. This could be a sign of a problem, but we will try to move forward for now. savedTabInfo: ${JSON.stringify( + savedTabInfo, + )}`, + ); + const tabData = maybeTabData as DialogData; + return { + ...savedTabInfo, + tabIconUrl: tabData?.iconUrl || this.defaultIconUrl, + // dialogs must define their own tabType, so this should never hit 'Dialog Title Error'. + tabTitle: tabData?.title || this.defaultTitle || this.tabType || 'Dialog Title Error', + minWidth: this.minWidth, + minHeight: this.minHeight, + // dialogs must define their own Component. It will then be used in this default + // implementation of `loadDialog` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + content: createElement(this.Component!, { + ...tabData, + submitDialog: (data) => resolveDialogRequest(savedTabInfo.id, data), + cancelDialog: () => resolveDialogRequest(savedTabInfo.id, null), + rejectDialog: (errorMessage) => rejectDialogRequest(savedTabInfo.id, errorMessage), + }), + }; + }, + saveDialog() { + // TODO: preserve requests between refreshes - save the dialog info in such a way that it works + // when loading again after refresh + return undefined; + }, +}; +Object.freeze(DIALOG_BASE); + +export default DIALOG_BASE; diff --git a/src/renderer/components/dialogs/dialog-definition.model.ts b/src/renderer/components/dialogs/dialog-definition.model.ts new file mode 100644 index 0000000000..dbc9c6db95 --- /dev/null +++ b/src/renderer/components/dialogs/dialog-definition.model.ts @@ -0,0 +1,55 @@ +import { DialogOptions } from '@shared/models/dialog-options.model'; +import { DialogDefinitionBase, DialogProps } from '@renderer/components/dialogs/dialog-base.data'; +import { ReactElement } from 'react'; + +/** The tabType for the select project dialog in `select-project.dialog.tsx` */ +export const SELECT_PROJECT_DIALOG_TYPE = 'platform.selectProject'; + +/** + * Mapped type for dialog functions to use in getting various types for dialogs + * + * Keys should be dialog names, and values should be {@link DialogDataTypes} + * + * If you add a dialog here, you must also add it on {@link DIALOGS} + */ +export interface DialogTypes { + [SELECT_PROJECT_DIALOG_TYPE]: DialogDataTypes; + // 'platform.selectMultipleProjects': DialogDataTypes; +} + +/** Each type of dialog. These are the tab types used in the dock layout */ +export type DialogTabTypes = keyof DialogTypes; + +/** Types related to a specific dialog */ +export type DialogDataTypes = { + /** + * The dialog options to specify when calling the dialog. + * Passed into `loadDialog` as SavedTabInfo.data + * + * The default implementation of `loadDialog` passes all the options down to the dialog component + * as props + */ + options: TOptions; + /** The type of the response to the dialog request */ + responseType: TReturnType; +}; + +export type DialogDefinition = Readonly< + DialogDefinitionBase & { + /** + * Type of tab - indicates what kind of built-in dock layout tab this dialog definition represents + */ + tabType: DialogTabType; + /** + * React component to render for this dialog + * + * This must be specified only if you do not overwrite the default `loadDialog` + * @param props props that will be passed through from the dialog tab's data + * @returns react element to render + */ + Component: ( + props: DialogProps & + DialogTypes[DialogTabType]['options'], + ) => ReactElement; + } +>; diff --git a/src/renderer/components/dialogs/index.ts b/src/renderer/components/dialogs/index.ts new file mode 100644 index 0000000000..7a703d01b0 --- /dev/null +++ b/src/renderer/components/dialogs/index.ts @@ -0,0 +1,16 @@ +import SELECT_PROJECT_DIALOG from '@renderer/components/dialogs/select-project.dialog'; +import { DialogDefinition, DialogTabTypes } from './dialog-definition.model'; + +/** + * Map of all available dialog definitions used to create dialogs + * + * If you add a dialog here, you must also add it on {@link DialogTypes} + */ +const DIALOGS: { [DialogTabType in DialogTabTypes]: DialogDefinition } = { + [SELECT_PROJECT_DIALOG.tabType]: SELECT_PROJECT_DIALOG, +}; + +/** All tab types for available dialogs */ +export const DIALOG_TAB_TYPES = Object.keys(DIALOGS) as DialogTabTypes[]; + +export default DIALOGS; diff --git a/src/renderer/components/dialogs/select-project.dialog.scss b/src/renderer/components/dialogs/select-project.dialog.scss new file mode 100644 index 0000000000..9e950ab2db --- /dev/null +++ b/src/renderer/components/dialogs/select-project.dialog.scss @@ -0,0 +1,3 @@ +.select-project-dialog { + overflow-y: auto; +} diff --git a/src/renderer/components/dialogs/select-project.dialog.tsx b/src/renderer/components/dialogs/select-project.dialog.tsx new file mode 100644 index 0000000000..a6ad26c5b1 --- /dev/null +++ b/src/renderer/components/dialogs/select-project.dialog.tsx @@ -0,0 +1,49 @@ +import { ListItemIcon } from '@mui/material'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import './select-project.dialog.scss'; +import { useMemo } from 'react'; +import ProjectList from '@renderer/components/projects/project-list.component'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; +import projectLookupService from '@shared/services/project-lookup.service'; +import DIALOG_BASE, { DialogProps } from '@renderer/components/dialogs/dialog-base.data'; +import { + DialogDefinition, + SELECT_PROJECT_DIALOG_TYPE, +} from '@renderer/components/dialogs/dialog-definition.model'; + +type SelectProjectDialogProps = DialogProps; + +function SelectProjectDialog({ prompt, submitDialog }: SelectProjectDialogProps) { + const [projects, isLoadingProjects] = usePromise( + projectLookupService.getMetadataForAllProjects, + useMemo(() => [], []), + ); + + return ( +
+
{prompt}
+ {isLoadingProjects ? ( +
Loading Projects
+ ) : ( + + + + + + )} +
+ ); +} + +const SELECT_PROJECT_DIALOG: DialogDefinition = Object.freeze({ + ...DIALOG_BASE, + tabType: SELECT_PROJECT_DIALOG_TYPE, + defaultTitle: 'Select Project', + initialSize: { + width: 500, + height: 350, + }, + Component: SelectProjectDialog, +}); + +export default SELECT_PROJECT_DIALOG; diff --git a/src/renderer/components/docking/error-tab.component.tsx b/src/renderer/components/docking/error-tab.component.tsx index aebafe2f41..90d3918d0e 100644 --- a/src/renderer/components/docking/error-tab.component.tsx +++ b/src/renderer/components/docking/error-tab.component.tsx @@ -1,6 +1,10 @@ -import { TabInfo } from '@shared/data/web-view.model'; +import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; import { newGuid } from '@shared/utils/util'; +export type ErrorTabData = { errorMessage: string }; + +export const TAB_TYPE_ERROR = 'error'; + export default function ErrorTab({ errorMessage }: { errorMessage: string }) { return ( <> @@ -23,5 +27,13 @@ export const createErrorTab = (errorMessage: string): TabInfo => { content: , minWidth: 150, minHeight: 150, + data: { + errorMessage, + } as ErrorTabData, }; }; + +export function saveErrorTab(): SavedTabInfo | undefined { + // No need to preserve error tabs between refreshes, I imagine + return undefined; +} diff --git a/src/renderer/components/docking/paranext-panel.component.tsx b/src/renderer/components/docking/paranext-panel.component.tsx deleted file mode 100644 index 6ecd2050dc..0000000000 --- a/src/renderer/components/docking/paranext-panel.component.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { ReactNode } from 'react'; -import './paranext-panel.component.css'; - -/** - * Used for possible styling on every panel in Paranext - * @param children The children of the panel (usually supplied from an extension) - */ -export default function ParanextPanel({ children }: { children: ReactNode }) { - return
{children}
; -} diff --git a/src/renderer/components/docking/paranext-tab-title.component.tsx b/src/renderer/components/docking/paranext-tab-title.component.tsx deleted file mode 100644 index 08cfb31289..0000000000 --- a/src/renderer/components/docking/paranext-tab-title.component.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import './paranext-tab-title.component.css'; -import logger from '@shared/services/logger.service'; - -/** - * Custom tab title for all tabs in Paranext - * @param text The text to show on the tab title - */ -export default function ParanextTabTitle({ text }: { text: string }) { - const toggleDropdown = () => { - logger.info('Pretend a menu was shown!'); - }; - - return ( -
-
- ); -} diff --git a/src/renderer/components/docking/paranext-dock-layout.component.css b/src/renderer/components/docking/platform-dock-layout.component.css similarity index 66% rename from src/renderer/components/docking/paranext-dock-layout.component.css rename to src/renderer/components/docking/platform-dock-layout.component.css index fe61ad7667..9c9aaf9284 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.css +++ b/src/renderer/components/docking/platform-dock-layout.component.css @@ -3,7 +3,7 @@ margin: 4px; } -.dock-layout .dock-panel.dock-style-paranext { +.dock-layout .dock-panel.dock-style-platform-bible { border: 0; } @@ -21,7 +21,7 @@ border: 0; } -.dock-layout .dock-panel.dock-style-paranext .dock-tab { +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab { margin-right: 0; border: 1px solid #5c5c5c; background: #bfbfbf; @@ -29,7 +29,7 @@ flex: 1 0 auto; } -.dock-layout .dock-panel.dock-style-paranext .dock-tab > div { +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab > div { padding-left: 7px; } @@ -37,47 +37,47 @@ * Show borders on the sides of the dock-nav, not on the very edges of the dock-tabs, * so the borders show even when the tabs don't fit in the space and you have to scroll */ -.dock-layout .dock-panel.dock-style-paranext .dock-tab:first-child { +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab:first-child { border-left: 0; } -.dock-layout .dock-panel.dock-style-paranext .dock-tab:nth-last-child(2) { +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab:nth-last-child(2) { border-right: 0; } -.dock-layout .dock-panel.dock-style-paranext .dock-nav { +.dock-layout .dock-panel.dock-style-platform-bible .dock-nav { border-left: 1px solid #5c5c5c; border-right: 1px solid #5c5c5c; } -.dock-layout .dock-panel.dock-style-paranext .dock-tab.dock-tab-active { +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab.dock-tab-active { background: #a6c9ff; color: #191919; border-bottom: 1px solid #a6c9ff; } /* Curved corners including on floating window */ -.dock-layout .dock-panel.dock-style-paranext, -.dock-layout .dock-panel.dock-style-paranext .dock-bar, -.dock-layout .dock-panel.dock-style-paranext .dock-nav, -.dock-layout .dock-panel.dock-style-paranext .dock-nav-wrap, -.dock-layout .dock-panel.dock-style-paranext .dock-tab { +.dock-layout .dock-panel.dock-style-platform-bible, +.dock-layout .dock-panel.dock-style-platform-bible .dock-bar, +.dock-layout .dock-panel.dock-style-platform-bible .dock-nav, +.dock-layout .dock-panel.dock-style-platform-bible .dock-nav-wrap, +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab { border-radius: 5px 5px 0 0; } -.dock-layout .dock-panel.dock-style-paranext .dock-bar { +.dock-layout .dock-panel.dock-style-platform-bible .dock-bar { background: #8c8c8c; padding: 0; border: 0; } -.dock-layout .dock-panel.dock-style-paranext .dock-tab-hit-area { +.dock-layout .dock-panel.dock-style-platform-bible .dock-tab-hit-area { /* expand the invisible dock-tab-hit-area a bit to cover the border area above it */ left: -1px; right: -1px; } -.dock-layout .dock-panel.dock-style-paranext .dock-nav-wrap { +.dock-layout .dock-panel.dock-style-platform-bible .dock-nav-wrap { /* * Remove the handle bar at the top of each dock-tab. Note: Now, the tab * group can only be grabbed by grabbing the intersection between two tabs @@ -91,7 +91,7 @@ } /* tabs take all the space */ -.dock-layout .dock-panel.dock-style-paranext .dock-nav-list { +.dock-layout .dock-panel.dock-style-platform-bible .dock-nav-list { flex-grow: 1; } diff --git a/src/renderer/components/docking/paranext-dock-layout.component.test.ts b/src/renderer/components/docking/platform-dock-layout.component.test.ts similarity index 89% rename from src/renderer/components/docking/paranext-dock-layout.component.test.ts rename to src/renderer/components/docking/platform-dock-layout.component.test.ts index c52226cbe4..e98bacd1f3 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.test.ts +++ b/src/renderer/components/docking/platform-dock-layout.component.test.ts @@ -17,7 +17,12 @@ jest.mock( import DockLayout, { FloatPosition } from 'rc-dock'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { FloatLayout, Layout, SavedTabInfo, WebViewProps } from '@shared/data/web-view.model'; -import { addWebViewToDock, getFloatPosition, loadTab } from './paranext-dock-layout.component'; +import { + addTabToDock, + addWebViewToDock, + getFloatPosition, + loadTab, +} from './platform-dock-layout.component'; describe('Dock Layout Component', () => { const mockDockLayout = mock(DockLayout); @@ -83,6 +88,18 @@ describe('Dock Layout Component', () => { }); }); + describe('addTabToDock()', () => { + it('should throw when tab is not an object', () => { + const dockLayout = instance(mockDockLayout); + const layout: Layout = { type: 'tab' }; + + expect(() => + addTabToDock('this is wrong' as unknown as SavedTabInfo, layout, dockLayout), + ).toThrow(); + }); + // TODO: verify it adds an error tab if no/bad tab type provided + }); + describe('addWebViewToDock()', () => { it('should throw when no id', () => { const dockLayout = instance(mockDockLayout); diff --git a/src/renderer/components/docking/paranext-dock-layout.component.tsx b/src/renderer/components/docking/platform-dock-layout.component.tsx similarity index 56% rename from src/renderer/components/docking/paranext-dock-layout.component.tsx rename to src/renderer/components/docking/platform-dock-layout.component.tsx index 297039bf57..8429fa6bbc 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.tsx +++ b/src/renderer/components/docking/platform-dock-layout.component.tsx @@ -1,5 +1,5 @@ import 'rc-dock/dist/rc-dock.css'; -import './paranext-dock-layout.component.css'; +import './platform-dock-layout.component.css'; import { useRef, useEffect } from 'react'; import DockLayout, { BoxData, @@ -9,9 +9,14 @@ import DockLayout, { TabData, TabGroup, } from 'rc-dock'; -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 { + ErrorTabData, + TAB_TYPE_ERROR, + createErrorTab, + saveErrorTab, +} from '@renderer/components/docking/error-tab.component'; +import PlatformPanel from '@renderer/components/docking/platform-panel.component'; +import PlatformTabTitle from '@renderer/components/docking/platform-tab-title.component'; import { loadWebViewTab, TAB_TYPE_WEBVIEW, @@ -27,12 +32,14 @@ import { } from '@renderer/testing/test-quick-verse-heresy-panel.component'; import { FloatLayout, + FloatSize, SavedTabInfo, TabLoader, TabInfo, TabSaver, Layout, WebViewProps, + PanelDirection, } from '@shared/data/web-view.model'; import LogError from '@shared/log-error.model'; import { @@ -41,18 +48,14 @@ import { saveTabInfoBase, } from '@shared/services/web-view.service'; import { getErrorMessage } from '@shared/utils/util'; -import { - loadOpenProjectTab, - TAB_TYPE_OPEN_PROJECT_DIALOG, -} from '@renderer/components/project-dialogs/open-project-tab.component'; import { loadDownloadUpdateProjectTab, TAB_TYPE_DOWNLOAD_UPDATE_PROJECT_DIALOG, -} from '@renderer/components/project-dialogs/download-update-project-tab.component'; +} from '@renderer/components/projects/download-update-project-tab.component'; import { loadOpenMultipleProjectsTab, TAB_TYPE_OPEN_MULTIPLE_PROJECTS_DIALOG, -} from '@renderer/components/project-dialogs/open-multiple-projects-tab.component'; +} from '@renderer/components/projects/open-multiple-projects-tab.component'; import { TAB_TYPE_EXTENSION_MANAGER, loadExtensionManagerTab, @@ -65,15 +68,24 @@ import { TAB_TYPE_RUN_BASIC_CHECKS, loadRunBasicChecksTab, } from '@renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component'; +import { hasDialogRequest, resolveDialogRequest } from '@renderer/services/dialog.service-host'; +import { DialogData } from '@shared/models/dialog-options.model'; +import DIALOGS from '@renderer/components/dialogs'; +import cloneDeep from 'lodash/cloneDeep'; type TabType = string; type RCDockTabInfo = TabData & TabInfo; +/** The default initial size for floating tabs in CSS `px` units. Can be overridden by tabTypes' initial sizes */ +const DEFAULT_FLOAT_SIZE: FloatSize = { width: 300, height: 150 }; +/** Default direction a tab will be placed from an existing tab if created as a panel */ +const DEFAULT_PANEL_DIRECTION: PanelDirection = 'right'; + const DOCK_FLOAT_OFFSET = 28; // NOTE: 'card' is a built-in style. We can likely remove it when we create a full theme for -// Paranext. -const TAB_GROUP = 'card paranext'; +// Platform. +const TAB_GROUP = 'card platform-bible'; const groups: { [key: string]: TabGroup } = { [TAB_GROUP]: { @@ -90,26 +102,45 @@ const groups: { [key: string]: TabGroup } = { // a shared file. // TODO: please move these utility functions with #203 -/** tab loader functions for each Paranext tab type */ +/** tab loader functions for each Platform tab type */ const tabLoaderMap = new Map([ [TAB_TYPE_ABOUT, loadAboutTab], [TAB_TYPE_BUTTONS, loadButtonsTab], [TAB_TYPE_QUICK_VERSE_HERESY, loadQuickVerseHeresyTab], [TAB_TYPE_TEST, loadTestTab], [TAB_TYPE_WEBVIEW, loadWebViewTab], - [TAB_TYPE_OPEN_PROJECT_DIALOG, loadOpenProjectTab], [TAB_TYPE_DOWNLOAD_UPDATE_PROJECT_DIALOG, loadDownloadUpdateProjectTab], [TAB_TYPE_OPEN_MULTIPLE_PROJECTS_DIALOG, loadOpenMultipleProjectsTab], [TAB_TYPE_EXTENSION_MANAGER, loadExtensionManagerTab], [TAB_TYPE_SETTINGS_DIALOG, loadSettingsDialog], [TAB_TYPE_RUN_BASIC_CHECKS, loadRunBasicChecksTab], + ...Object.entries(DIALOGS).map( + ([dialogTabType, dialogDefinition]) => + // The default implementation of `loadDialog` uses `this`, so bind it to the definition + [dialogTabType, dialogDefinition.loadDialog.bind(dialogDefinition)] as const, + ), ]); -/** tab saver functions for each Paranext tab type that wants to override the default */ -const tabSaverMap = new Map([[TAB_TYPE_WEBVIEW, saveWebViewTab]]); +/** tab saver functions for each Platform tab type that wants to override the default */ +const tabSaverMap = new Map([ + [TAB_TYPE_WEBVIEW, saveWebViewTab], + [TAB_TYPE_ERROR, saveErrorTab], + ...Object.entries(DIALOGS).map( + ([dialogTabType, dialogDefinition]) => + // The default implementation of `saveDialog` uses `this`, so bind it to the definition + [dialogTabType, dialogDefinition.saveDialog.bind(dialogDefinition)] as const, + ), +]); + +/** Initial sizes for each tab in CSS `px` units if created as floating tabs */ +const tabInitialFloatingSize: Record = Object.fromEntries( + Object.entries(DIALOGS).map( + ([dialogTabType, dialogDefinition]) => [dialogTabType, dialogDefinition.initialSize] as const, + ), +); let previousTabId: string | undefined; -let floatPosition: FloatPosition = { left: 0, top: 0, width: 0, height: 0 }; +let previousFloatPosition: FloatPosition = { left: 0, top: 0, width: 0, height: 0 }; /** * Loads tab data from the specified saved tab information by running the tab loader provided by the @@ -144,8 +175,8 @@ export function loadTab(savedTabInfo: SavedTabInfo): RCDockTabInfo { // Translate the data from the loaded tab to be in the form needed by rc-dock return { ...tabInfo, - title: , - content: {tabInfo.content}, + title: , + content: {tabInfo.content}, group: TAB_GROUP, closable: true, }; @@ -157,7 +188,7 @@ export function loadTab(savedTabInfo: SavedTabInfo): RCDockTabInfo { * @param dockTabInfo the tab data to save * @returns saved tab info ready to be saved into the layout */ -function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo { +function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo | undefined { // 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 @@ -174,7 +205,7 @@ function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo { * @param tab to check. * @returns `true` if its a tab or `false` otherwise. */ -function isTab(tab: PanelData | TabData | BoxData | undefined): boolean { +function isTab(tab: PanelData | TabData | BoxData | undefined): tab is TabData { if (!tab || (tab as TabData).title == null) return false; return true; } @@ -205,9 +236,20 @@ export function getFloatPosition( // Defaults are added in `web-view.service.ts`. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { width, height } = layout.floatSize!; + let { left, top } = previousPosition; - left = offsetOrOverflowAxis(left, width, layoutSize.width); - top = offsetOrOverflowAxis(top, height, layoutSize.height); + + switch (layout.position) { + case 'center': + left = layoutSize.width / 2 - width / 2; + top = layoutSize.height / 2 - height / 2; + break; + case 'cascade': + default: + left = offsetOrOverflowAxis(left, width, layoutSize.width); + top = offsetOrOverflowAxis(top, height, layoutSize.height); + break; + } return { left, top, width, height }; } @@ -224,28 +266,68 @@ function findPreviousTab(dockLayout: DockLayout) { return dockLayout.find((tabData) => isTab(tabData)) as TabData; } +/** Set up defaults for webview layout instructions */ +function layoutDefaults(layout: Layout, savedTabInfo: SavedTabInfo): Layout { + const layoutDefaulted = cloneDeep(layout); + switch (layoutDefaulted.type) { + case 'float': { + if (!layoutDefaulted.floatSize) { + layoutDefaulted.floatSize = + tabInitialFloatingSize[savedTabInfo.tabType] || DEFAULT_FLOAT_SIZE; + } else { + if (!layoutDefaulted.floatSize.width || layoutDefaulted.floatSize.width <= 0) + layoutDefaulted.floatSize.width = + tabInitialFloatingSize[savedTabInfo.tabType]?.width || DEFAULT_FLOAT_SIZE.width; + + if (!layoutDefaulted.floatSize.height || layoutDefaulted.floatSize.height <= 0) + layoutDefaulted.floatSize.height = + tabInitialFloatingSize[savedTabInfo.tabType]?.height || DEFAULT_FLOAT_SIZE.height; + } + + break; + } + case 'panel': + if (!layoutDefaulted.direction) layoutDefaulted.direction = DEFAULT_PANEL_DIRECTION; + break; + case 'tab': + default: + // do nothing + } + return layoutDefaulted; +} + /** - * Function to call to add or update a webview in the layout - * @param webView web view to add or update - * @param layout information about where to put a new webview + * Add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab * @param dockLayout The rc-dock dock layout React component ref. Used to perform operations on the * layout + * + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` */ -export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayout: DockLayout) { - const tabId = webView.id; - const tab = loadTab({ id: tabId, tabType: TAB_TYPE_WEBVIEW, data: webView }); - let targetTab = dockLayout.find(tabId); - - // Update existing WebView +export function addTabToDock( + savedTabInfo: SavedTabInfo, + layout: Layout, + dockLayout: DockLayout, +): Layout | undefined { + const tab = loadTab(savedTabInfo); + let targetTab = dockLayout.find(tab.id); + + // Update existing tab if (targetTab) { - dockLayout.updateTab(tabId, tab); - if (isTab(targetTab)) previousTabId = tabId; - return; + dockLayout.updateTab(tab.id, tab); + if (isTab(targetTab)) previousTabId = tab.id; + + // We did not add a tab, so return undefined to indicate that + return undefined; } - // Add new WebView - const unknownLayoutType = layout.type; - switch (layout.type) { + // Figure out layout defaults for this tab + const updatedLayout = layoutDefaults(layout, savedTabInfo); + + // Add new tab + switch (updatedLayout.type) { case 'tab': targetTab = findPreviousTab(dockLayout); if (targetTab) { @@ -263,20 +345,31 @@ export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayo dockLayout.find(() => true) ?? null, 'middle', ); - previousTabId = tabId; + previousTabId = tab.id; break; - case 'float': - floatPosition = getFloatPosition(layout, floatPosition, dockLayout.getLayoutSize()); + case 'float': { + const floatPosition = getFloatPosition( + updatedLayout, + previousFloatPosition, + dockLayout.getLayoutSize(), + ); + + if (!updatedLayout.position || updatedLayout.position === 'cascade') + // Update the previous float position so the next cascading float layout will appear after it + previousFloatPosition = floatPosition; + dockLayout.dockMove(tab, null, 'float', floatPosition); break; - + } case 'panel': - if (layout.targetTabId !== undefined) { + if (updatedLayout.targetTabId !== undefined) { // Look for a specific tab - targetTab = dockLayout.find(layout.targetTabId); + targetTab = dockLayout.find(updatedLayout.targetTabId); if (!isTab(targetTab)) - throw new LogError(`When adding a panel, unknown target tab: '${layout.targetTabId}'`); + throw new LogError( + `When adding a panel, unknown target tab: '${updatedLayout.targetTabId}'`, + ); } // Didn't ask for a specific tab, so just get the previous tab and go from there else targetTab = findPreviousTab(dockLayout); @@ -290,18 +383,54 @@ export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayo null, // Defaults are added in `web-view.service.ts`. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - layout.direction!, + updatedLayout.direction!, ); break; default: - throw new LogError(`Unknown layoutType: '${unknownLayoutType}'`); + // Type assert here because TypeScript thinks this layout is `never` because the switch has + // covered all its options (if JS were statically typed, this `default` would never hit) + throw new LogError(`Unknown layoutType: '${(updatedLayout as Layout).type}'`); } + + // If there was an error loading the tab, we create an error tab. But we also want to throw here + // so people know there was a problem. + // TODO: Do we really want to create an error tab in the first place? Or maybe that should only + // happen on startup + if (tab.tabType === TAB_TYPE_ERROR) + throw new LogError( + `Dock Layout created an error tab: ${(tab.data as ErrorTabData)?.errorMessage}`, + ); + + return updatedLayout; +} + +/** + * Add or update a webview in the layout + * @param webView web view to add or update + * @param layout information about where to put a new webview + * @param dockLayout The rc-dock dock layout React component ref. Used to perform operations on the + * layout + * + * @returns If WebView added, final layout used to display the new webView. If existing webView + * updated, `undefined` + */ +export function addWebViewToDock( + webView: WebViewProps, + layout: Layout, + dockLayout: DockLayout, +): Layout | undefined { + const tabId = webView.id; + if (!tabId) + throw new Error( + `platform-dock-layout error: WebView of type ${webView.webViewType} has no id!`, + ); + return addTabToDock({ id: tabId, tabType: TAB_TYPE_WEBVIEW, data: webView }, layout, dockLayout); } // #endregion -export default function ParanextDockLayout() { +export default function PlatformDockLayout() { // This ref will always be defined // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const dockLayoutRef = useRef(null!); @@ -319,8 +448,16 @@ export default function ParanextDockLayout() { const unsub = registerDockLayout({ dockLayout: dockLayoutRef.current, onLayoutChangeRef, + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => + addTabToDock(savedTabInfo, layout, dockLayoutRef.current), addWebViewToDock: (webView: WebViewProps, layout: Layout) => addWebViewToDock(webView, layout, dockLayoutRef.current), + removeTabFromDock: (tabId: string) => { + const tabToRemove = dockLayoutRef.current.find(tabId); + if (isTab(tabToRemove)) dockLayoutRef.current.dockMove(tabToRemove, null, 'remove'); + // Return whether or not we found the tab to remove + return !!tabToRemove; + }, testLayout, }); return () => { @@ -336,8 +473,19 @@ export default function ParanextDockLayout() { defaultLayout={{ dockbox: { mode: 'horizontal', children: [] } }} dropMode="edge" loadTab={loadTab} - saveTab={saveTab} + // Type assert `saveTab` as not returning `undefined` because rc-dock's types are wrong + // Here, if `saveTab` returns `undefined` the tab is not saved + // https://github.com/ticlo/rc-dock/blob/8b6481dca4b4dd07f89107d6f48b1831bbdf0470/src/Serializer.ts#L68 + saveTab={saveTab as (dockTabInfo: RCDockTabInfo) => SavedTabInfo} onLayoutChange={(...args) => { + const [, currentTabId, direction] = args; + // If a dialog was closed, tell the dialog service + if (currentTabId && direction === 'remove') { + const removedTab = dockLayoutRef.current.find(currentTabId) as RCDockTabInfo; + if ((removedTab.data as DialogData)?.isDialog && hasDialogRequest(currentTabId)) + resolveDialogRequest(currentTabId, null); + } + if (onLayoutChangeRef.current) onLayoutChangeRef.current(...args); }} /> diff --git a/src/renderer/components/docking/paranext-panel.component.css b/src/renderer/components/docking/platform-panel.component.css similarity index 74% rename from src/renderer/components/docking/paranext-panel.component.css rename to src/renderer/components/docking/platform-panel.component.css index 561c8b9009..9e5fb40c08 100644 --- a/src/renderer/components/docking/paranext-panel.component.css +++ b/src/renderer/components/docking/platform-panel.component.css @@ -1,11 +1,11 @@ -.paranext-panel { +.platform-panel { width: 100%; height: 100%; display: flex; flex-direction: column; } -.paranext-panel iframe { +.platform-panel iframe { width: 100%; height: 100%; border: 0; diff --git a/src/renderer/components/docking/platform-panel.component.tsx b/src/renderer/components/docking/platform-panel.component.tsx new file mode 100644 index 0000000000..bfb3ddb6ff --- /dev/null +++ b/src/renderer/components/docking/platform-panel.component.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; +import './platform-panel.component.css'; + +/** + * Used for possible styling on every panel in Platform + * @param children The children of the panel (usually supplied from an extension) + */ +export default function PlatformPanel({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/renderer/components/docking/paranext-tab-title.component.css b/src/renderer/components/docking/platform-tab-title.component.css similarity index 100% rename from src/renderer/components/docking/paranext-tab-title.component.css rename to src/renderer/components/docking/platform-tab-title.component.css diff --git a/src/renderer/components/docking/platform-tab-title.component.tsx b/src/renderer/components/docking/platform-tab-title.component.tsx new file mode 100644 index 0000000000..5dd3d2c2d6 --- /dev/null +++ b/src/renderer/components/docking/platform-tab-title.component.tsx @@ -0,0 +1,38 @@ +import './platform-tab-title.component.css'; +import logger from '@shared/services/logger.service'; + +type PlatformTabTitleProps = { + /** Url to image to show on the tab. Defaults to Platform.Bible logo */ + iconUrl?: string; + /** Text to show on the tab */ + text: string; +}; + +/** + * Custom tab title for all tabs in Platform + * @param text The text to show on the tab title + */ +export default function PlatformTabTitle({ iconUrl, text }: PlatformTabTitleProps) { + const toggleDropdown = () => { + logger.info('Pretend a menu was shown!'); + }; + + return ( +
+
+ ); +} diff --git a/src/renderer/components/extension-manager/extension-manager-tab.component.tsx b/src/renderer/components/extension-manager/extension-manager-tab.component.tsx index da7c73358f..1537134e86 100644 --- a/src/renderer/components/extension-manager/extension-manager-tab.component.tsx +++ b/src/renderer/components/extension-manager/extension-manager-tab.component.tsx @@ -16,49 +16,49 @@ export function fetchExtensions(): Extension[] { hasUpdateAvailable: false, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Resource Viewer', description: 'View Scripture resources', hasUpdateAvailable: false, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Parallel Passages', description: 'Compare parallel passages of Scripture', hasUpdateAvailable: true, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Psalms layer-by-layer', description: 'Provide resources on the Psalms from Cambridge Digital Bible Research', hasUpdateAvailable: true, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Hello World', description: 'Example Bundled Extension', hasUpdateAvailable: true, isInstalled: false, iconFilePath: undefined, - } as Extension, + }, { name: 'Hello Someone', description: 'Example Bundled Extension', hasUpdateAvailable: true, isInstalled: false, iconFilePath: undefined, - } as Extension, + }, { name: 'Quick Verse', description: 'Example Bundled Extension', hasUpdateAvailable: false, isInstalled: false, iconFilePath: 'papi-extension://quick-verse/assets/letter-q.png', - } as Extension, + }, ]; } diff --git a/src/renderer/components/project-dialogs/open-project-tab.component.scss b/src/renderer/components/project-dialogs/open-project-tab.component.scss deleted file mode 100644 index 53d091c26f..0000000000 --- a/src/renderer/components/project-dialogs/open-project-tab.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.open-project-dialog { - overflow-y: auto; -} diff --git a/src/renderer/components/project-dialogs/open-project-tab.component.tsx b/src/renderer/components/project-dialogs/open-project-tab.component.tsx deleted file mode 100644 index e84ee419df..0000000000 --- a/src/renderer/components/project-dialogs/open-project-tab.component.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; -import { ListItemIcon } from '@mui/material'; -import FolderOpenIcon from '@mui/icons-material/FolderOpen'; -import logger from '@shared/services/logger.service'; -import './open-project-tab.component.scss'; -import { useMemo } from 'react'; -import ProjectList, { Project } from './project-list.component'; - -export const TAB_TYPE_OPEN_PROJECT_DIALOG = 'open-project-dialog'; - -export function fetchProjects(): Project[] { - return [ - { - id: 'project-1', - name: 'Project 1', - description: 'Description of project 1', - isDownloadable: true, - isDownloaded: false, - }, - { - id: 'project-2', - name: 'Project 2', - description: 'Description of project 2', - isDownloadable: false, - isDownloaded: true, - }, - { - id: 'project-3', - name: 'Project 3', - description: 'Description of project 3', - isDownloadable: true, - isDownloaded: false, - }, - { - id: 'project-4', - name: 'Project 4', - description: 'Description of project 4', - isDownloadable: false, - isDownloaded: false, - }, - { - id: 'project-5', - name: 'Project 5', - description: 'Description of project 5', - isDownloadable: false, - isDownloaded: true, - }, - ]; -} - -function openProject(project: Project) { - logger.info(`Opening Project ${project.name}`); -} - -export default function OpenProjectTab() { - const projects = useMemo(() => fetchProjects().filter((project) => project.isDownloaded), []); - - return ( -
- - - - - -
- ); -} - -export const loadOpenProjectTab = (savedTabInfo: SavedTabInfo): TabInfo => { - return { - ...savedTabInfo, - tabTitle: 'Open Project', - content: , - }; -}; diff --git a/src/renderer/components/project-dialogs/project-list.component.tsx b/src/renderer/components/project-dialogs/project-list.component.tsx deleted file mode 100644 index 8ee42d62ca..0000000000 --- a/src/renderer/components/project-dialogs/project-list.component.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { List, ListItem, ListItemButton, ListItemText, ListSubheader } from '@mui/material'; -import { PropsWithChildren, useCallback } from 'react'; - -export type Project = { - id: string; - name: string; - description: string; - isDownloadable: boolean; - isDownloaded: boolean; -}; - -export interface ProjectClickHandler { - (project: Project): void; -} - -export type ProjectListProps = PropsWithChildren<{ - /** - * Projects to display in the list - */ - projects: Project[]; - - /** - * Handler to perform an action when the project is clicked - */ - projectClickHandler: ProjectClickHandler; - - /** - * Optional flag to set the list to multiselect - */ - isMultiselect?: boolean; - - /** - * If multiple is selected, then the array of selected projects is passed to control the selected flag on ListItemButton - */ - selectedProjects?: Project[] | undefined; - - /** - * Optional subheader - */ - subheader?: string; -}>; - -/** - * Project List component that creates a list for a provided array of projects. Assumes there is only one button per project. - * @param ProjectListProps and any children elements - * @returns - */ -export default function ProjectList({ - projects, - projectClickHandler, - isMultiselect, - selectedProjects, - subheader, - children, -}: ProjectListProps) { - const isSelected = useCallback( - (project: Project) => { - if (isMultiselect && selectedProjects) { - return selectedProjects.includes(project); - } - return undefined; - }, - [isMultiselect, selectedProjects], - ); - - return ( -
- - {subheader} - {projects.map((project) => ( - - projectClickHandler(project)} - > - {children} - - - - ))} - -
- ); -} diff --git a/src/renderer/components/project-dialogs/download-update-project-tab.component.scss b/src/renderer/components/projects/download-update-project-tab.component.scss similarity index 100% rename from src/renderer/components/project-dialogs/download-update-project-tab.component.scss rename to src/renderer/components/projects/download-update-project-tab.component.scss diff --git a/src/renderer/components/project-dialogs/download-update-project-tab.component.tsx b/src/renderer/components/projects/download-update-project-tab.component.tsx similarity index 90% rename from src/renderer/components/project-dialogs/download-update-project-tab.component.tsx rename to src/renderer/components/projects/download-update-project-tab.component.tsx index c55b2b68cf..6c9f2daff0 100644 --- a/src/renderer/components/project-dialogs/download-update-project-tab.component.tsx +++ b/src/renderer/components/projects/download-update-project-tab.component.tsx @@ -12,14 +12,16 @@ import UpdateIcon from '@mui/icons-material/Update'; import DeleteIcon from '@mui/icons-material/Delete'; import logger from '@shared/services/logger.service'; import { useMemo } from 'react'; -import { fetchProjects } from './open-project-tab.component'; -import ProjectList, { Project } from './project-list.component'; +import ProjectList, { + fetchProjects, + Project, +} from '@renderer/components/projects/project-list.component'; import './download-update-project-tab.component.scss'; export const TAB_TYPE_DOWNLOAD_UPDATE_PROJECT_DIALOG = 'download-update-project-dialog'; -function downloadProject(project: Project) { - logger.info(`Downloading Project ${project.name}`); +function downloadProject(projectId: string) { + logger.info(`Downloading Project ${projectId}`); } function updateProject(project: Project) { @@ -45,7 +47,7 @@ export default function DownloadUpdateProjectTab() { diff --git a/src/renderer/components/project-dialogs/open-multiple-projects-tab.component.scss b/src/renderer/components/projects/open-multiple-projects-tab.component.scss similarity index 100% rename from src/renderer/components/project-dialogs/open-multiple-projects-tab.component.scss rename to src/renderer/components/projects/open-multiple-projects-tab.component.scss diff --git a/src/renderer/components/project-dialogs/open-multiple-projects-tab.component.tsx b/src/renderer/components/projects/open-multiple-projects-tab.component.tsx similarity index 74% rename from src/renderer/components/project-dialogs/open-multiple-projects-tab.component.tsx rename to src/renderer/components/projects/open-multiple-projects-tab.component.tsx index 7b155e767f..745ab6809e 100644 --- a/src/renderer/components/project-dialogs/open-multiple-projects-tab.component.tsx +++ b/src/renderer/components/projects/open-multiple-projects-tab.component.tsx @@ -5,8 +5,10 @@ import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import DoneIcon from '@mui/icons-material/Done'; import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; import { Button } from 'papi-components'; -import { fetchProjects } from './open-project-tab.component'; -import ProjectList, { Project } from './project-list.component'; +import ProjectList, { + fetchProjects, + Project, +} from '@renderer/components/projects/project-list.component'; import './open-multiple-projects-tab.component.scss'; export const TAB_TYPE_OPEN_MULTIPLE_PROJECTS_DIALOG = 'open-multiple-projects-dialog'; @@ -19,11 +21,12 @@ export default function OpenMultipleProjectsTab() { const [selectedProjects, setSelectedProjects] = useState([]); - const handleProjectToggle = (projectId: Project) => { - if (selectedProjects.includes(projectId)) { - setSelectedProjects(selectedProjects.filter((id) => id !== projectId)); + const handleProjectToggle = (projectId: string) => { + if (selectedProjects.some((project) => project.id === projectId)) { + setSelectedProjects(selectedProjects.filter((project) => project.id !== projectId)); } else { - setSelectedProjects([...selectedProjects, projectId]); + const selectedProject = downloadedProjects.find((project) => project.id === projectId); + if (selectedProject) setSelectedProjects([...selectedProjects, selectedProject]); } }; @@ -39,7 +42,7 @@ export default function OpenMultipleProjectsTab() {
diff --git a/src/renderer/components/projects/project-list.component.tsx b/src/renderer/components/projects/project-list.component.tsx new file mode 100644 index 0000000000..daf85048bd --- /dev/null +++ b/src/renderer/components/projects/project-list.component.tsx @@ -0,0 +1,142 @@ +import { List, ListItem, ListItemButton, ListItemText, ListSubheader } from '@mui/material'; +import { ProjectMetadata } from '@shared/models/project-metadata.model'; +import { PropsWithChildren, useCallback } from 'react'; + +export type Project = ProjectMetadata & { + id: string; + name: string; + description: string; + isDownloadable: boolean; + isDownloaded: boolean; +}; + +/** + * Get sample project data. + * + * This is mock data and will be replaced at some point. Probably by the following issues: + * + * [Projects: get list of project settings · Issue #368 · paranext/paranext-core](https://github.com/paranext/paranext-core/issues/368) + * + * [Projects: Support registering Downloadable Project Provider · Issue #372 · paranext/paranext-core](https://github.com/paranext/paranext-core/issues/372) + * + * @returns downloadable (and downloaded) project information + */ +export function fetchProjects(): Project[] { + return [ + { + id: 'project-1', + name: 'Project 1', + description: 'Description of project 1', + isDownloadable: true, + isDownloaded: false, + storageType: 'test', + projectType: 'test', + }, + { + id: 'project-2', + name: 'Project 2', + description: 'Description of project 2', + isDownloadable: false, + isDownloaded: true, + storageType: 'test', + projectType: 'test', + }, + { + id: 'project-3', + name: 'Project 3', + description: 'Description of project 3', + isDownloadable: true, + isDownloaded: false, + storageType: 'test', + projectType: 'test', + }, + { + id: 'project-4', + name: 'Project 4', + description: 'Description of project 4', + isDownloadable: false, + isDownloaded: false, + storageType: 'test', + projectType: 'test', + }, + { + id: 'project-5', + name: 'Project 5', + description: 'Description of project 5', + isDownloadable: false, + isDownloaded: true, + storageType: 'test', + projectType: 'test', + }, + ]; +} + +export type ProjectListProps = PropsWithChildren<{ + /** + * Projects to display in the list + */ + projects: ProjectMetadata[]; + + /** + * Handler to perform an action when the project is clicked + */ + handleSelectProject: (projectId: string) => void; + + /** + * Optional flag to set the list to multiselect + */ + isMultiselect?: boolean; + + /** + * If multiple is selected, then the array of selected projects is passed to control the selected flag on ListItemButton + */ + selectedProjects?: ProjectMetadata[] | undefined; + + /** + * Optional subheader + */ + subheader?: string; +}>; + +/** + * Project List component that creates a list for a provided array of projects. Assumes there is only one button per project. + * @param ProjectListProps and any children elements + * @returns + */ +export default function ProjectList({ + projects, + handleSelectProject, + isMultiselect, + selectedProjects, + subheader, + children, +}: ProjectListProps) { + const isSelected = useCallback( + (project: ProjectMetadata) => { + if (isMultiselect && selectedProjects) { + return selectedProjects.includes(project); + } + return undefined; + }, + [isMultiselect, selectedProjects], + ); + + return ( +
+ + {subheader} + {projects.map((project) => ( + + handleSelectProject(project.id)} + > + {children} + + + + ))} + +
+ ); +} 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/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 37e4914a65..8a39436b1b 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -90,6 +90,7 @@ export function loadWebViewTab(savedTabInfo: SavedTabInfo): TabInfo { return { ...savedTabInfo, + tabIconUrl: data.iconUrl, tabTitle: data.title ?? 'Unknown', content: , }; 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..6836404b3a 100644 --- a/src/renderer/hooks/papi-hooks/index.ts +++ b/src/renderer/hooks/papi-hooks/index.ts @@ -4,12 +4,16 @@ 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 '@renderer/hooks/papi-hooks/use-project-data-provider.hook'; +import useDialogCallback from '@renderer/hooks/papi-hooks/use-dialog-callback.hook'; // Declare an interface for the object we're exporting so that JSDoc comments propagate export interface PapiHooks { + useDialogCallback: typeof useDialogCallback; usePromise: typeof usePromise; useEvent: typeof useEvent; useEventAsync: typeof useEventAsync; + useProjectDataProvider: typeof useProjectDataProvider; useDataProvider: typeof useDataProvider; /** JSDOC DESTINATION UseDataHook */ useData: typeof useData; @@ -20,9 +24,11 @@ export interface PapiHooks { * All React hooks to be exposed on the papi */ const papiHooks: PapiHooks = { + useDialogCallback, 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-dialog-callback.hook.ts b/src/renderer/hooks/papi-hooks/use-dialog-callback.hook.ts new file mode 100644 index 0000000000..aec6141105 --- /dev/null +++ b/src/renderer/hooks/papi-hooks/use-dialog-callback.hook.ts @@ -0,0 +1,100 @@ +import { DialogTabTypes, DialogTypes } from '@renderer/components/dialogs/dialog-definition.model'; +import dialogService from '@shared/services/dialog.service'; +import { getErrorMessage } from '@shared/utils/util'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Enables using `papi.dialogs.showDialog` in React more easily. Provides a callback to run to get a + * response from a dialog as well as states that indicate the dialog's response and whether the + * dialog is open. + * + * Calling the dialog callback returned from this hook does nothing if you already previously opened + * the dialog and have not received a response + * + * @param dialogType dialog type you want to show on the screen + * + * WARNING: MUST BE STABLE - const or wrapped in useState, useMemo, etc. The reference must not + * be updated every render + * @param options various options for configuring the dialog that shows + * + * WARNING: MUST BE STABLE - const or wrapped in useState, useMemo, etc. The reference must not + * be updated every render + * @param defaultResponse the starting value for the response. Once a response is received, this is + * no longer used. Defaults to `null` + * + * @returns `[response, showDialogCallback, errorMessage, isShowingDialog]` + * - `response` - the response from the dialog or `defaultResponse` if a response has not been + * received (does not reset to `defaultResponse` if the user cancels the dialog). + * DOES NOT reset every time the callback is run + * - `showDialogCallback` - callback to run to show the dialog to prompt the user for a response + * - `errorMessage` - the error from the dialog if there is an error while calling the dialog or + * `undefined` if there is no error. DOES reset to `undefined` every time the callback is run + * - `isShowingDialog` - whether this dialog is showing (the callback has been run but has not + * responded) + * + * @type `DialogTabType` the dialog type you are using. Should be inferred by parameters + * @type `TResponse` the type that the response can be. If you do not specify a `defaultResponse`, + * this can be the dialog response type or `null`. If you specify a `defaultResponse`, this will + * be just the dialog response type. Should be inferred by parameters. + * - This mostly works. Unfortunately, if you specify a literal as `defaultResponse`, `TResponse` + * then becomes that literal instead of being the dialog response type. You can type assert it + * to the appropriate type. Let us know if you run into an issue with this! + * + */ +function useDialogCallback< + DialogTabType extends DialogTabTypes, + TResponse extends DialogTypes[DialogTabType]['responseType'] | null = + | DialogTypes[DialogTabType]['responseType'] + | null, +>( + dialogType: DialogTabType, + options?: DialogTypes[DialogTabType]['options'], + // Since `defaultResponse` could be unspecified which is equivalent to `null`, we need to + // type assert to tell TS that `null` will be part of `TResponse` if `defaultResponse` is not + // specified but is not otherwise + defaultResponse: TResponse = null as TResponse, +): [TResponse, () => Promise, string | undefined, boolean] { + // Keep track of whether we're mounted so we don't run stuff after unmount + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const [response, setResponse] = useState(defaultResponse); + const [isShowingDialog, setIsShowingDialog] = useState(false); + const [errorMessage, setErrorMessage] = useState(undefined); + + const showDialog = useCallback(async () => { + if (!isShowingDialog) { + setIsShowingDialog(true); + try { + // Looks like we need to type assert here because it can't tell this is a TResponse. It can + // just tell that it is the dialog response type, which does not include undefined + const dialogResponse = (await dialogService.showDialog( + dialogType, + options, + )) as TResponse | null; + if (mounted.current) { + if (dialogResponse !== null) + // For now, let's only set the response value if the user didn't cancel. Maybe we can + // expose an option for configuring this hook later if people want to reset to + // `defaultResponse` on canceling the dialog + setResponse(dialogResponse); + setIsShowingDialog(false); + } + } catch (e) { + if (mounted.current) { + setErrorMessage(getErrorMessage(e)); + setIsShowingDialog(false); + } + } + } + }, [dialogType, options, isShowingDialog]); + + return [response, showDialog, errorMessage, isShowingDialog]; +} + +export default useDialogCallback; 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..34ba796543 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -5,16 +5,59 @@ import * as commandService from '@shared/services/command.service'; 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 { startDialogService } from '@renderer/services/dialog.service-host'; import App from './app.component'; +import { cleanupOldWebViewState } from './services/web-view-state.service'; logger.info('Starting renderer'); // App-wide service setup -networkService.initialize(); -commandService.initialize(); -webViewProviderService.initialize(); -webViewService.initialize(); +// We are not awaiting these service startups for a few reasons: +// - They internally await other services when they need others in order to start +// - Nothing in this React tree requires the services to have started in order to get to first paint +// - If any of these fail, it is a very serious problem that we have not attempted to address up to +// this point. TODO: https://github.com/paranext/paranext-core/issues/559 +(async () => { + try { + await networkService.initialize(); + } catch (e) { + logger.error(`Network service failed to initialize! Error: ${e}`); + } +})(); +(async () => { + try { + await commandService.initialize(); + } catch (e) { + logger.error(`Command service failed to initialize! Error: ${e}`); + } +})(); +(async () => { + try { + await webViewProviderService.initialize(); + } catch (e) { + logger.error(`WebView Provider service failed to initialize! Error: ${e}`); + } +})(); +(async () => { + try { + await webViewService.initialize(); + } catch (e) { + logger.error(`WebView service failed to initialize! Error: ${e}`); + } +})(); +(async () => { + try { + await startDialogService(); + } catch (e) { + logger.error(`Dialog service failed to start! Error: ${e}`); + } +})(); 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/dialog.service-host.ts b/src/renderer/services/dialog.service-host.ts new file mode 100644 index 0000000000..d1fd127a3f --- /dev/null +++ b/src/renderer/services/dialog.service-host.ts @@ -0,0 +1,225 @@ +import { DialogData } from '@shared/models/dialog-options.model'; +import { CATEGORY_DIALOG, DialogService } from '@shared/services/dialog.service-model'; +import * as networkService from '@shared/services/network.service'; +import { aggregateUnsubscriberAsyncs, serializeRequestType } from '@shared/utils/papi-util'; +import * as webViewService from '@shared/services/web-view.service'; +import { newGuid } from '@shared/utils/util'; +import logger from '@shared/services/logger.service'; +import SELECT_PROJECT_DIALOG from '@renderer/components/dialogs/select-project.dialog'; +import { DialogTabTypes, DialogTypes } from '@renderer/components/dialogs/dialog-definition.model'; +import { hookUpDialogService } from '@renderer/components/dialogs/dialog-base.data'; + +/** A live dialog request. Includes the dialog's id and the functions to run on receiving results */ +// TODO: preserve requests between refreshes - save the request id or something? +type DialogRequest = { + id: string; + resolve: ( + value: + | (DialogTypes[DialogTabType]['responseType'] | null) + | PromiseLike, + ) => void; + reject: (reason?: unknown) => void; +}; + +/** Map of all live dialog requests */ +// Disabled no-explicit-any because assigning a DialogRequest with generic type to +// DialogRequest gave error +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const dialogRequests = new Map>(); + +let initializationPromise: Promise; +/** Sets up the dialog service. Runs only once */ +async function initialize(): Promise { + if (!initializationPromise) { + initializationPromise = (async () => { + await webViewService.initialize(); + })(); + } + return initializationPromise; +} + +/** + * Determine whether there is an unresolved dialog request for a specified dialog id + * + * @param id the dialog id to check for an existing unresolved request + * @returns true if there is an unresolved dialog request for the specified dialog; false otherwise + * + * Internal function; not exposed on papi + */ +export function hasDialogRequest(id: string) { + return dialogRequests.has(id); +} + +/** + * Resolve a dialog request. Synchronously resolves, then asynchronously closes the dialog + * + * @param id the id of the dialog whose request to reject + * @param data the data to resolve the request with. Either the user's response to the dialog or + * `null` if the user canceled + * + * Internal function; not exposed on papi + */ +export function resolveDialogRequest(id: string, data: TReturn | null) { + const dialogRequest = dialogRequests.get(id); + if (dialogRequest) { + dialogRequests.delete(id); + dialogRequest.resolve(data); + } + + // Clean up the dialog + // Close the dialog + // We're not awaiting closing it. Doesn't really matter right now if we do or don't successfully close it + (async () => { + try { + const didClose = await webViewService.removeTab(id); + if (!didClose) + logger.error( + `DialogService error: dialog ${id} that was resolved with data ${JSON.stringify( + data, + )} was not found in the dock layout in order to close. Please investigate`, + ); + } catch (e) { + logger.error( + `DialogService error: dialog ${id} that was resolved with data ${JSON.stringify( + data, + )} did not successfully close! Please investigate. Error: ${e}`, + ); + } + })(); + + // If we didn't find the request, throw + if (!dialogRequest) + throw new Error( + `DialogService error: request ${id} not found to resolve. data: ${JSON.stringify(data)}`, + ); +} + +/** + * Reject a dialog request. Synchronously rejects, then asynchronously closes the dialog + * + * @param id the id of the dialog whose request to reject + * @param message the error message for the rejected request + * + * Internal function; not exposed on papi + */ +export function rejectDialogRequest(id: string, message: string) { + const dialogRequest = dialogRequests.get(id); + if (dialogRequest) { + // We found the request. Reject it + dialogRequests.delete(id); + dialogRequest.reject(message); + } + + // Clean up the dialog + // Close the dialog + // We're not awaiting closing it. Doesn't really matter right now if we do or don't successfully close it + (async () => { + try { + const didClose = await webViewService.removeTab(id); + if (!didClose) + logger.error( + `DialogService error: dialog ${id} that was rejected with error message ${message} was not found in the dock layout in order to close. Please investigate`, + ); + } catch (e) { + logger.error( + `DialogService error: dialog ${id} that was rejected with error message ${message} did not successfully close! Please investigate. Error: ${e}`, + ); + } + })(); + + // If we didn't find the request, throw + if (!dialogRequest) + throw new Error(`DialogService error: request ${id} not found to reject. Message: ${message}`); +} + +// on the dialogService - see `dialog.service-model.ts` for JSDoc +async function showDialog( + dialogType: DialogTabType, + options?: DialogTypes[DialogTabType]['options'], +): Promise { + await initialize(); + + // Set up a DialogRequest + let dialogId = newGuid(); + // Dumbest way to make sure the guid is unique + while (dialogRequests.has(dialogId)) dialogId = newGuid(); + + let dialogRequest: DialogRequest; + + const dialogPromise = new Promise( + (resolve, reject) => { + dialogRequest = { + id: dialogId, + resolve, + reject, + }; + dialogRequests.set(dialogId, dialogRequest); + }, + ); + + try { + // Open dialog + await webViewService.addTab( + { + id: dialogId, + tabType: dialogType, + data: { ...options, isDialog: true } as DialogData, + }, + { + type: 'float', + position: 'center', + }, + ); + + // TODO: preserve requests between refreshes - add keepalive messages to indicate to the + // requestor if the dialog request is still alive + } catch (e) { + // Something went wrong while setting up the dialog. Delete the request and throw to let the + // requestor know + const message = `DialogService error: showDialog did not initialize successfully! ${e}`; + logger.error(message); + rejectDialogRequest(dialogId, message); + } + + // Return the DialogRequest's promise so the request can be resolved or rejected appropriately + return dialogPromise; +} + +// on the dialogService - see `dialog.service-model.ts` for JSDoc +async function selectProject( + options?: DialogTypes[typeof SELECT_PROJECT_DIALOG.tabType]['options'], +): Promise { + return showDialog(SELECT_PROJECT_DIALOG.tabType, options); +} + +const dialogService: DialogService = { + showDialog, + selectProject, +}; + +/** + * Register the commands that back the PAPI dialog service + */ +export async function startDialogService(): Promise { + await initialize(); + + // register functions as requests + const unsubPromises = Object.entries(dialogService).map(([fnName, handler]) => + networkService.registerRequestHandler(serializeRequestType(CATEGORY_DIALOG, fnName), handler), + ); + + // Wait to successfully register all requests + const unsubscribeRequests = aggregateUnsubscriberAsyncs(await Promise.all(unsubPromises)); + + // 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 () => { + // TODO: preserve requests between refreshes - stop rejecting all remaining requests + dialogRequests.forEach((request) => request.reject(`DialogService is shutting down`)); + await unsubscribeRequests(); + }); +} + +// Hook up the dialogs' resolve and reject functions immediately because this is only here +// to mitigate a dependency cycle +hookUpDialogService({ resolveDialogRequest, rejectDialogRequest }); diff --git a/src/renderer/services/papi-frontend.service.ts b/src/renderer/services/papi-frontend.service.ts index 7e40b06804..06255fe897 100644 --- a/src/renderer/services/papi-frontend.service.ts +++ b/src/renderer/services/papi-frontend.service.ts @@ -12,7 +12,7 @@ import { papiNetworkService, PapiNetworkService } from '@shared/services/network import { papiWebViewService, PapiWebViewService } from '@shared/services/web-view.service'; import internetService, { InternetService } from '@shared/services/internet.service'; import dataProviderService, { DataProviderService } from '@shared/services/data-provider.service'; -import { ProjectLookupServiceType } from '@shared/models/project-lookup.model'; +import { ProjectLookupServiceType } from '@shared/services/project-lookup.service-model'; import projectLookupService from '@shared/services/project-lookup.service'; import { papiFrontendProjectDataProviderService, @@ -21,6 +21,8 @@ import { import papiContext, { PapiContext } from '@renderer/context/papi-context'; import papiHooks, { PapiHooks } from '@renderer/hooks/papi-hooks'; import settingsService, { SettingsService } from '@shared/services/settings.service'; +import dialogService from '@shared/services/dialog.service'; +import { DialogService } from '@shared/services/dialog.service-model'; // IMPORTANT NOTES: // 1) When adding new services here, consider whether they also belong in papi-backend.service.ts. @@ -44,6 +46,8 @@ const papi = { util: papiUtil, /** JSDOC DESTINATION papiWebViewService */ webViews: papiWebViewService as PapiWebViewService, + /** JSDOC DESTINATION dialogService */ + dialogs: dialogService as DialogService, /** JSDOC DESTINATION papiNetworkService */ network: papiNetworkService as PapiNetworkService, /** JSDOC DESTINATION logger */ 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/renderer/testing/test-layout.data.ts b/src/renderer/testing/test-layout.data.ts index 166532a9d6..61075f9600 100644 --- a/src/renderer/testing/test-layout.data.ts +++ b/src/renderer/testing/test-layout.data.ts @@ -4,7 +4,6 @@ import { TAB_TYPE_ABOUT } from '@renderer/testing/about-panel.component'; import { TAB_TYPE_BUTTONS } from '@renderer/testing/test-buttons-panel.component'; // import { TAB_TYPE_QUICK_VERSE_HERESY } from '@renderer/testing/test-quick-verse-heresy-panel.component'; import { TAB_TYPE_TEST } from '@renderer/testing/test-panel.component'; -// import { TAB_TYPE_OPEN_PROJECT_DIALOG } from '@renderer/components/project-dialogs/open-project-tab.component'; // import { TAB_TYPE_DOWNLOAD_UPDATE_PROJECT_DIALOG } from '@renderer/components/project-dialogs/download-update-project-tab.component'; // import { TAB_TYPE_OPEN_MULTIPLE_PROJECTS_DIALOG } from '@renderer/components/project-dialogs/open-multiple-projects-tab.component'; // import { TAB_TYPE_EXTENSION_MANAGER } from '@renderer/components/extension-manager/extension-manager-tab.component'; @@ -49,15 +48,6 @@ const testLayout: LayoutBase = { // }, // { // tabs: [ - // { id: 'Open Project Dialog', tabType: TAB_TYPE_OPEN_PROJECT_DIALOG }, - // ] as SavedTabInfo[], - // x: 250, - // y: 170, - // w: 320, - // h: 190, - // }, - // { - // tabs: [ // { // id: 'Download/Update Project Dialog', // tabType: TAB_TYPE_DOWNLOAD_UPDATE_PROJECT_DIALOG, diff --git a/src/shared/data/web-view.model.ts b/src/shared/data/web-view.model.ts index d54005cb98..0821fc2ab8 100644 --- a/src/shared/data/web-view.model.ts +++ b/src/shared/data/web-view.model.ts @@ -29,6 +29,12 @@ export type SavedTabInfo = { * {@link TabSaver} saves this into {@link SavedTabInfo} */ export type TabInfo = SavedTabInfo & { + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + tabIconUrl?: string; /** * Text to show on the title bar of the tab */ @@ -38,11 +44,11 @@ export type TabInfo = SavedTabInfo & { */ content: ReactNode; /** - * (optional) Minimum width that the tab can become + * (optional) Minimum width that the tab can become in CSS `px` units */ minWidth?: number; /** - * (optional) Minimum height that the tab can become + * (optional) Minimum height that the tab can become in CSS `px` units */ minHeight?: number; }; @@ -59,8 +65,12 @@ export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). + * + * @param tabInfo the Paranext tab to save + * + * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab */ -export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo; +export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; /** The type of code that defines a webview's content */ export enum WebViewContentType { @@ -87,8 +97,16 @@ type WebViewDefinitionBase = { id: WebViewId; /** The code for the WebView that papi puts into an iframe */ content: string; + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + iconUrl?: 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 */ @@ -128,10 +146,24 @@ interface TabLayout { type: 'tab'; } +/** + * Indicates where to display a floating window + * + * `cascade` - place the window a bit below and to the right of the previously created floating + * window + * `center` - center the window in the dock layout + */ +type FloatPosition = 'cascade' | 'center'; + +/** The dimensions for a floating tab in CSS `px` units */ +export type FloatSize = { width: number; height: number }; + /** Information about a floating window */ export interface FloatLayout { type: 'float'; - floatSize?: { width: number; height: number }; + floatSize?: FloatSize; + /** Where to display the floating window. Defaults to `cascade` */ + position?: FloatPosition; } export type PanelDirection = 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/models/dialog-options.model.ts b/src/shared/models/dialog-options.model.ts new file mode 100644 index 0000000000..5820c87385 --- /dev/null +++ b/src/shared/models/dialog-options.model.ts @@ -0,0 +1,14 @@ +/** General options to adjust dialogs (created from `papi.dialogs`) */ +export type DialogOptions = { + /** Dialog title to display in the header. Default depends on the dialog */ + title?: string; + /** Url of dialog icon to display in the header. Default is Platform.Bible logo */ + iconUrl?: string; + /** The message to show the user in the dialog. Default depends on the dialog */ + prompt?: string; +}; + +/** data in each tab that is a dialog. Added to DialogOptions in `dialog.service-host.ts` */ +export type DialogData = DialogOptions & { + isDialog: true; +}; diff --git a/src/shared/services/dialog.service-model.ts b/src/shared/services/dialog.service-model.ts new file mode 100644 index 0000000000..7bae958b7c --- /dev/null +++ b/src/shared/services/dialog.service-model.ts @@ -0,0 +1,36 @@ +import { DialogTabTypes, DialogTypes } from '@renderer/components/dialogs/dialog-definition.model'; +import { DialogOptions } from '@shared/models/dialog-options.model'; + +/** JSDOC SOURCE dialogService + * Prompt the user for responses with dialogs + */ +export interface DialogService { + /** + * Shows a dialog to the user and prompts the user to respond + * + * @param dialogType the type of dialog to show the user + * @param options various options for configuring the dialog that shows + * + * @returns returns the user's response or `null` if the user cancels + * + * Note: canceling responds with `null` instead of `undefined` so that the dialog definition can + * use `undefined` as a meaningful value if desired. + * + * @type `TReturn` - the type of data the dialog responds with + */ + showDialog( + dialogType: DialogTabType, + options?: DialogTypes[DialogTabType]['options'], + ): Promise; + /** + * Shows a select project dialog to the user and prompts the user to select a dialog + * + * @param options various options for configuring the dialog that shows + * + * @returns returns the user's selected project id or `null` if the user cancels + */ + selectProject(options?: DialogOptions): Promise; +} + +/** Prefix on requests that indicates that the request is related to dialog operations */ +export const CATEGORY_DIALOG = 'dialog'; diff --git a/src/shared/services/dialog.service.ts b/src/shared/services/dialog.service.ts new file mode 100644 index 0000000000..93d32e05f3 --- /dev/null +++ b/src/shared/services/dialog.service.ts @@ -0,0 +1,26 @@ +import * as networkService from '@shared/services/network.service'; +import { CATEGORY_DIALOG, DialogService } from '@shared/services/dialog.service-model'; +import { serializeRequestType } from '@shared/utils/papi-util'; + +let initializationPromise: Promise; +async function initialize(): Promise { + if (!initializationPromise) { + initializationPromise = (async () => { + await networkService.initialize(); + })(); + } + return initializationPromise; +} + +const dialogService: DialogService = { + showDialog: async (...args) => { + await initialize(); + return networkService.request(serializeRequestType(CATEGORY_DIALOG, 'showDialog'), ...args); + }, + selectProject: async (...args) => { + await initialize(); + return networkService.request(serializeRequestType(CATEGORY_DIALOG, 'selectProject'), ...args); + }, +}; + +export default dialogService; diff --git a/src/shared/models/project-lookup.model.ts b/src/shared/services/project-lookup.service-model.ts similarity index 90% rename from src/shared/models/project-lookup.model.ts rename to src/shared/services/project-lookup.service-model.ts index e0e3abe957..fd733625a6 100644 --- a/src/shared/models/project-lookup.model.ts +++ b/src/shared/services/project-lookup.service-model.ts @@ -1,4 +1,4 @@ -import { ProjectMetadata } from './project-metadata.model'; +import { ProjectMetadata } from '@shared/models/project-metadata.model'; /** JSDOC SOURCE projectLookupService * Provides metadata for projects known by the platform diff --git a/src/shared/services/project-lookup.service.ts b/src/shared/services/project-lookup.service.ts index 11ddcd864d..dce2105a7d 100644 --- a/src/shared/services/project-lookup.service.ts +++ b/src/shared/services/project-lookup.service.ts @@ -1,7 +1,7 @@ import { projectLookupServiceNetworkObjectName, ProjectLookupServiceType, -} from '@shared/models/project-lookup.model'; +} from '@shared/services/project-lookup.service-model'; import networkObjectService from '@shared/services/network-object.service'; let networkObject: ProjectLookupServiceType; diff --git a/src/shared/services/web-view.service.ts b/src/shared/services/web-view.service.ts index 1797c86aec..131b1f6f79 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -17,7 +17,6 @@ import { createNetworkEventEmitter } from '@shared/services/network.service'; import { AddWebViewEvent, Layout, - PanelDirection, SavedTabInfo, TabInfo, WebViewDefinitionReact, @@ -44,7 +43,7 @@ export type OnLayoutChangeRCDock = ( direction?: DropDirection, ) => Promise; -/** Properties related to the dock layout provided by `paranext-dock-layout.component.tsx` */ +/** Properties related to the dock layout provided by `platform-dock-layout.component.tsx` */ type PapiDockLayout = { /** The rc-dock dock layout React element ref. Used to perform operations on the layout */ dockLayout: DockLayout; @@ -53,14 +52,35 @@ type PapiDockLayout = { * {@link onLayoutChange} function */ onLayoutChangeRef: MutableRefObject; - /** Function to call to add or update a webview in the layout */ - addWebViewToDock: (webView: WebViewProps, layout: Layout) => void; + /** + * Add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab + * + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` + */ + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => Layout | undefined; + /** + * Add or update a webview in the layout + * @param webView web view to add or update + * @param layout information about where to put a new webview + * + * @returns If WebView added, final layout used to display the new webView. If existing webView + * updated, `undefined` + */ + addWebViewToDock: (webView: WebViewProps, layout: Layout) => Layout | undefined; + /** + * Remove a tab in the layout + * @param tabId id of the tab to remove + */ + removeTabFromDock: (tabId: string) => boolean; /** * The layout to use as the default layout if the dockLayout doesn't have a layout loaded. * * TODO: This should be removed and the `testLayout` imported directly in this file once this * service is refactored to split the code between processes. The only reason this is passed from - * `paranext-dock-layout.component.tsx` is that we cannot import `testLayout` here since this + * `platform-dock-layout.component.tsx` is that we cannot import `testLayout` here since this * service is currently all shared code. Refactor should happen in #203 */ testLayout: LayoutBase; @@ -143,8 +163,6 @@ const FORBIDDEN_HTML_TAGS = ['object', 'base', 'embed', 'frame', 'frameset']; /** 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'; /** Name for request to get a web view */ const GET_WEB_VIEW_REQUEST = 'getWebView'; @@ -167,33 +185,16 @@ export const onDidAddWebView = onDidAddWebViewEmitter.event; /** * Variable that will hold the rc-dock dock layout along with a couple other props. This is - * populated by `paranext-dock-layout.component.tsx` registering its dock layout with this service, + * populated by `platform-dock-layout.component.tsx` registering its dock layout with this service, * allowing this service to manage layouts and such. * - * WARNING: YOU CANNOT USE THIS VARIABLE IN ANYTHING BUT THE RENDERER. Also please do not save this + * WARNING: YOU CAN ONLY USE THIS VARIABLE IN THE RENDERER. Also please do not save this * variable out anywhere because it can change, invalidating the old one (see `registerDockLayout`) */ let papiDockLayoutVar = createDockLayoutAsyncVar(); // #region functions related to the dock layout -/** Set up defaults for webview layout instructions */ -function layoutDefaults(layout: Layout): Layout { - const layoutDefaulted = cloneDeep(layout); - switch (layoutDefaulted.type) { - case 'float': - if (!layoutDefaulted.floatSize) layoutDefaulted.floatSize = DEFAULT_FLOAT_SIZE; - break; - case 'panel': - if (!layoutDefaulted.direction) layoutDefaulted.direction = DEFAULT_PANEL_DIRECTION; - break; - case 'tab': - default: - // do nothing - } - return layoutDefaulted; -} - /** * Basic `saveTabInfo` that simply strips the properties added by {@link TabInfo} off of the object * and returns it as a {@link SavedTabInfo}. Runs as the {@link TabSaver} by default if the tab type @@ -228,7 +229,7 @@ export function convertWebViewDefinitionToSaved( /** Create a new dock layout promise variable */ function createDockLayoutAsyncVar(): AsyncVariable { return new AsyncVariable( - 'web-view.service.paranextDockLayout', + 'web-view.service.platformDockLayout', // Use default timeout on renderer, but never timeout anywhere else because we will not be // resolving this. One of the serious pains of not having #203 isRenderer() ? undefined : -1, @@ -271,6 +272,8 @@ async function saveLayout(layout: LayoutBase): Promise { * Loads layout information into the dock layout. * @param layout If this parameter is provided, loads that layout information. If not provided, gets * the persisted layout information and loads it into the dock layout. + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER */ async function loadLayout(layout?: LayoutBase): Promise { const dockLayoutVar = await papiDockLayoutVar.promise; @@ -289,6 +292,10 @@ async function loadLayout(layout?: LayoutBase): Promise { * operations * @param dockLayout dock layout element to register along with other important properties * @returns function used to unregister this dock layout + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER + * + * Not exposed on the papi */ export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber { // Save the current async var so we know if it changed before we unsubscribed @@ -331,6 +338,39 @@ function getWebViewOptionsDefaults(options: GetWebViewOptions): GetWebViewOption return optionsDefaulted; } +/** + * Remove a tab in the layout + * @param tabId id of the tab to remove + * + * @returns true if successfully found the tab to remove + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER + * + * Not exposed on the papi + */ +export const removeTab = async (tabId: string): Promise => { + return (await papiDockLayoutVar.promise).removeTabFromDock(tabId); +}; + +/** + * Add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab + * + * @returns If tab added, final layout used to display the new tab. If existing tab updated, + * `undefined` + * + * WARNING: YOU CAN ONLY USE THIS FUNCTION IN THE RENDERER + * + * Not exposed on the papi + */ +export const addTab = async ( + savedTabInfo: SavedTabInfo, + layout: Layout, +): Promise => { + return (await papiDockLayoutVar.promise).addTabToDock(savedTabInfo, layout); +}; + /** * Creates a new web view or gets an existing one depending on if you request an existing one and * if the web view provider decides to give that existing one to you (it is up to the provider). @@ -390,6 +430,11 @@ export const getWebView = async ( throw new Error(`getWebView failed, but you should have seen a different error than this!`); } + // Conditional import when inside the renderer + const { getFullWebViewStateById, setFullWebViewStateById } = await import( + '@renderer/services/web-view-state.service' + ); + // Get the webview definition from the webview provider const webViewProvider = await webViewProviderService.get(webViewType); @@ -401,8 +446,6 @@ export const getWebView = async ( // Find existing webView if one exists /** Either the existing webview with the specified id or a placeholder webview if one was not found */ let existingSavedWebView: SavedWebViewDefinition | undefined; - /** Whether we found an existing web view to ask the provider for */ - let didFindExistingWebView = false; // Look for existing webview if (optionsDefaulted.existingId) { const existingWebView = (await papiDockLayoutVar.promise).dockLayout.find( @@ -423,7 +466,8 @@ export const getWebView = async ( existingSavedWebView = convertWebViewDefinitionToSaved( existingWebView.data as WebViewDefinition, ); - didFindExistingWebView = true; + // Load the web view state since the web view provider doesn't have access to the data store + existingSavedWebView.state = getFullWebViewStateById(existingWebView.id); } } @@ -441,23 +485,15 @@ export const getWebView = async ( // The web view provider didn't want to create this web view if (!webView) return undefined; - /** - * 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 - */ - // See if the provider gave us a new web view - // Meaning we got a web view from the provider (which is already the case since we are here) - // And we didn't find an existing one so the one we got must be new - // or the web view id we asked the provider for is not the one it gave us and it already exists - const webViewIsNew = - !didFindExistingWebView || - (existingSavedWebView.id !== webView.id && - (await papiDockLayoutVar.promise).dockLayout.find(webView.id)); + // The web view provider might have updated the web view state, so save it + if (webView.state) setFullWebViewStateById(webView.id, webView.state); // 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 +504,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; @@ -599,12 +640,12 @@ export const getWebView = async ( contentType, content: webViewContent, }; - const updatedLayout = layoutDefaults(layout); - (await papiDockLayoutVar.promise).addWebViewToDock(updatedWebView, updatedLayout); + const updatedLayout = (await papiDockLayoutVar.promise).addWebViewToDock(updatedWebView, layout); - // Inform web view consumers we added a new web view - if (webViewIsNew) + // If we received a layout (meaning it created a new webview instead of updating an existing one), + // inform web view consumers that we added a new web view + if (updatedLayout) onDidAddWebViewEmitter.emit({ webView: convertWebViewDefinitionToSaved(updatedWebView), layout: updatedLayout,