Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebView API #167

Merged
merged 10 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ npm-debug.log.*
*.sass.d.ts
*.scss.d.ts

# Vite (or any other packager) output
dist

# eslint ignores hidden directories by default:
# https://github.com/eslint/eslint/issues/8429
!.erb
4 changes: 3 additions & 1 deletion extensions/lib/hello-someone/hello-someone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ export async function activate() {
);

papi.webViews.addWebView({
id: 'Hello Someone',
title: 'Hello Someone HTML',
contentType: 'html' as WebViewContentType.HTML,
contents: helloSomeoneHtmlWebView,
content: helloSomeoneHtmlWebView,
});

const unsubPromises = [
Expand Down
8 changes: 6 additions & 2 deletions extensions/lib/hello-world/hello-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ export async function activate() {
.catch((e) => logger.error(`Could not get Scripture from bible-api! Reason: ${e}`));

papi.webViews.addWebView({
id: 'Hello World HTML',
title: 'Hello World HTML',
contentType: 'html' as WebViewContentType.HTML,
contents: helloWorldHtmlWebView,
content: helloWorldHtmlWebView,
});

await papi.webViews.addWebView({
id: 'Hello World React',
title: 'Hello World React',
componentName: 'HelloWorld',
contents: helloWorldReactWebView,
content: helloWorldReactWebView,
styles: helloWorldReactWebViewStyles,
});

Expand Down
64 changes: 41 additions & 23 deletions lib/papi-dts/papi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ declare module 'shared/utils/papi-util' {
export type CommandHandler<TParam extends Array<unknown> = any[], TReturn = any> = (
...args: TParam
) => Promise<TReturn> | TReturn;
/** Check that two objects are deeply equal, comparing members of each object and such */
export function deepEqual(a: unknown, b: unknown): boolean;
/** Information about a request that tells us what to do with it */
export type RequestType = {
/** the general category of request */
Expand All @@ -109,11 +111,29 @@ declare module 'shared/utils/papi-util' {
* @param directive specific identifier for this type of request
* @returns full requestType for use in network calls
*/
export const serializeRequestType: (category: string, directive: string) => string;
export function serializeRequestType(category: string, directive: string): string;
/** Split a request message requestType string into its parts */
export const deserializeRequestType: (requestType: string) => RequestType;
/** Check that two objects are deeply equal, comparing members of each object and such */
export function deepEqual(a: unknown, b: unknown): boolean;
export function deserializeRequestType(requestType: string): RequestType;
/** Parts of a Dock Tab ID */
export interface TabIdParts {
/** Type of the tab */
type: string;
/** ID of the particular tab type */
typeId: string;
}
/**
* Create a tab ID.
* @param type Type of the tab.
* @param typeId ID of the particular tab type.
* @returns a tab ID
*/
export function serializeTabId(type: string, typeId: string): string;
/**
* Split the tab ID into its parts.
* @param id Tab ID.
* @returns The two parts of the tab ID
*/
export function deserializeTabId(id: string): TabIdParts;
/**
* HTML Encodes the provided string.
* Thanks to ChatGPT
Expand Down Expand Up @@ -922,7 +942,7 @@ declare module 'shared/services/network.service' {
/**
* Handles requests, responses, subscriptions, etc. to the backend.
* Likely shouldn't need/want to expose this whole service on papi,
* but there are a few things that are exposed
* but there are a few things that are exposed via papiNetworkService
*/
import { ClientConnectEvent, ClientDisconnectEvent } from 'shared/data/internal-connection.model';
import {
Expand Down Expand Up @@ -1078,14 +1098,15 @@ declare module 'shared/services/command.service' {
}
declare module 'shared/data/web-view.model' {
import { ReactNode } from 'react';
export type WebViewProps = Omit<WebViewContents, 'componentName'>;
/**
* Information used to recreate a tab
*/
export type SavedTabInfo = {
/**
* The underlying tab type. Used to determine which extension owns it.
* Tab ID - must be unique
*/
type: string;
id?: string;
/**
* Data needed to recreate the tab during load
*/
Expand All @@ -1095,10 +1116,6 @@ declare module 'shared/data/web-view.model' {
* Information needed to create a tab inside of Paranext
*/
export type TabInfo = {
/**
* The underlying tab type. Used to determine which extension owns it.
*/
type: string;
/**
* Text to show on the title bar of the tab
*/
Expand Down Expand Up @@ -1126,7 +1143,8 @@ declare module 'shared/data/web-view.model' {
}
/** Base WebView properties that all WebViews share */
type WebViewContentsBase = {
contents: string;
id: string;
content: string;
title?: string;
};
/** WebView representation using React */
Expand All @@ -1141,21 +1159,15 @@ declare module 'shared/data/web-view.model' {
};
/** WebView definition created by extensions to show web content */
export type WebViewContents = WebViewContentsReact | WebViewContentsHtml;
}
declare module 'renderer/components/web-view.component' {
import { WebViewContents } from 'shared/data/web-view.model';
export type WebViewProps = Omit<WebViewContents, 'componentName'>;
export function WebView({ contents, title, contentType }: WebViewProps): JSX.Element;
export const TYPE_WEBVIEW = 'webView';
}
declare module 'shared/services/web-view.service' {
/**
* Service that handles WebView-related operations
*/
import { WebViewProps } from 'renderer/components/web-view.component';
import { WebViewContents } from 'shared/data/web-view.model';
import { WebViewContents, WebViewProps } from 'shared/data/web-view.model';
type LayoutType = 'tab' | 'panel' | 'float';
/** Event emitted when webViews are added */
export type AddWebViewEvent = {
webView: WebViewProps;
layoutType: LayoutType;
};
/** Event that emits with webView info when a webView is added */
export const onDidAddWebView: import('shared/models/papi-event.model').PapiEvent<AddWebViewEvent>;
Expand All @@ -1164,9 +1176,15 @@ declare module 'shared/services/web-view.service' {
* @param webView full html document to set as the webview iframe contents. Can be shortened to just a string
* @returns promise that resolves nothing if we successfully handled the webView
*/
export const addWebView: (webView: WebViewContents) => Promise<undefined>;
export const addWebView: (webView: WebViewContents, layoutType?: LayoutType) => Promise<void>;
/** Sets up the WebViewService. Runs only once */
export const initialize: () => Promise<void>;
/** All the exports in this service that are to be exposed on the PAPI */
export const papiWebViewService: {
onDidAddWebView: import('shared/models/papi-event.model').PapiEvent<AddWebViewEvent>;
addWebView: (webView: WebViewContents, layoutType?: LayoutType) => Promise<void>;
initialize: () => Promise<void>;
};
}
declare module 'shared/services/internet.service' {
const internetService: {
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/app.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ a:hover {
margin: 20px 0;
}

.hello-panel {
.about-panel {
width: 100%;
height: 100%;
background: linear-gradient(200.96deg, #b8d432 -29.09%, #5f7333 51.77%, #47314e 129.35%);
overflow-y: auto;
}

.hello-panel .hello {
.about-panel .hello {
color: white;
}

Expand Down
149 changes: 113 additions & 36 deletions src/renderer/components/docking/paranext-dock-layout.component.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,58 @@
import 'rc-dock/dist/rc-dock.css';
import './paranext-dock-layout.component.css';
import { newGuid } from '@shared/utils/util';
import { SavedTabInfo, TabCreator, TabInfo } from '@shared/data/web-view.model';
import DockLayout, { LayoutData, TabBase, TabData, TabGroup } from 'rc-dock';
import testLayout from '@renderer/testing/test-layout.data';
import createHelloPanel from '@renderer/testing/hello-panel.component';
import createButtonsPanel from '@renderer/testing/test-buttons-panel.component';
import createTabPanel from '@renderer/testing/test-panel.component';
import { useRef, useCallback } from 'react';
import DockLayout, { DropDirection, LayoutBase, LayoutData, 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 createWebViewPanel from '@renderer/components/web-view.component';
import useEvent from '@renderer/hooks/papi-hooks/use-event.hook';
import createAboutPanel from '@renderer/testing/about-panel.component';
import createButtonsPanel from '@renderer/testing/test-buttons-panel.component';
import testLayout, { WEBVIEW_PLACEHOLDER_TAB_ID } from '@renderer/testing/test-layout.data';
import createTabPanel from '@renderer/testing/test-panel.component';
import createQuickVerseHeresyPanel from '@renderer/testing/test-quick-verse-heresy-panel.component';
import { SavedTabInfo, TYPE_WEBVIEW, TabCreator, TabInfo } from '@shared/data/web-view.model';
import papi from '@shared/services/papi.service';
import { AddWebViewEvent } from '@shared/services/web-view.service';
import { serializeTabId, deserializeTabId } from '@shared/utils/papi-util';

type TabType = string;

// NOTE: 'card' is a built-in style. We can likely remove it when we
// create a full theme for Paranext.
const TAB_GROUPS = 'card paranext';
const DOCK_LAYOUT_KEY = 'dock-saved-layout';
// 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';

const groups: { [key: string]: TabGroup } = {
[TAB_GROUP]: {
maximizable: false, // Don't allow groups of tabs to be maximized
floatable: true, // Allow tabs to be floated
animated: false, // Don't animate tab transitions
// TODO: Currently allowing newWindow crashes since electron doesn't seem to have window.open defined?
// newWindow: true, // Allow floating windows to show in a native window
},
};
const savedLayout: LayoutData = getStorageValue(DOCK_LAYOUT_KEY, testLayout as LayoutData);

// TODO: Build this mapping from extensions so extensions can create their own panels
const tabTypeCreationMap = new Map<string, TabCreator>([
['hello', createHelloPanel],
const tabTypeCreationMap = new Map<TabType, TabCreator>([
['about', createAboutPanel],
['buttons', createButtonsPanel],
['quick-verse-heresy', createQuickVerseHeresyPanel],
['tab', createTabPanel],
[TYPE_WEBVIEW, createWebViewPanel],
]);

const getTabDataFromSavedInfo = (tabInfo: SavedTabInfo): TabInfo => {
if (!tabInfo.type) return createErrorTab(`No handler for the tab type '${tabInfo.type}'`);
let previousTabId: string = WEBVIEW_PLACEHOLDER_TAB_ID;

const tabCreator = tabTypeCreationMap.get(tabInfo.type);
if (!tabCreator) return createErrorTab(`No handler for the tab type '${tabInfo.type}'`);
function getTabDataFromSavedInfo(tabInfo: SavedTabInfo): TabInfo {
let tabCreator: TabCreator | undefined;
if (tabInfo.id) {
const { type } = deserializeTabId(tabInfo.id);
tabCreator = tabTypeCreationMap.get(type);
}
if (!tabCreator) return createErrorTab(`No handler for the tab type '${tabInfo.id}'`);

// Call the creation method to let the extension method create the tab
try {
Expand All @@ -38,51 +62,104 @@ const getTabDataFromSavedInfo = (tabInfo: SavedTabInfo): TabInfo => {
if (e instanceof Error) return createErrorTab(e.message);
return createErrorTab(String(e));
}
};
}

/**
* Creates tab data from the specified saved tab information by calling back to the
* extension that registered the creation of the tab type
* @param savedTabInfo Data that is to be used to create the new tab (comes from rc-dock, typically from disk)
*/
const loadTab = (savedTabInfo: TabBase): TabData => {
let { id } = savedTabInfo;
if (!id) id = newGuid();
function loadTab(savedTabInfo: SavedTabInfo): TabData & SavedTabInfo {
if (!savedTabInfo.id) throw new Error('loadTab: "id" is missing.');

const tabInfo = savedTabInfo as SavedTabInfo;
const newTabData = getTabDataFromSavedInfo(tabInfo);
const { id } = savedTabInfo;
const newTabData = getTabDataFromSavedInfo(savedTabInfo);

// Translate the data from the extension to be in the form needed by rc-dock
return {
id,
data: savedTabInfo.data,
title: <ParanextTabTitle text={newTabData.title} />,
content: <ParanextPanel>{newTabData.content}</ParanextPanel>,
minWidth: newTabData.minWidth,
minHeight: newTabData.minHeight,
group: TAB_GROUPS,
group: TAB_GROUP,
closable: true,
};
};
}

const groups: {
[key: string]: TabGroup;
} = {
[TAB_GROUPS]: {
maximizable: false, // Don't allow groups of tabs to be maximized
floatable: true, // Allow tabs to be floated
animated: false, // Don't animate tab transitions
// TODO: Currently allowing newWindow crashes since electron doesn't seem to have window.open defined?
// newWindow: true, // Allow floating windows to show in a native window
},
};
/**
* When rc-dock detects a changed layout, save it.
*
* TODO: We could filter whether we need to save based on the `direction` argument. - 2023-05-1 IJH
* @param newLayout the changed layout to save.
*/
function onLayoutChange(newLayout: LayoutBase): void {
localStorage.setItem(DOCK_LAYOUT_KEY, JSON.stringify(newLayout));
}

/**
* Safely load a value from local storage.
* @param key of the value.
* @param defaultValue to return if the key is not found.
* @returns the value of the key fetched from local storage, or the default value if not found.
*/
function getStorageValue<T>(key: string, defaultValue: T): T {
const saved = localStorage.getItem(key);
const initial = saved ? JSON.parse(saved) : undefined;
return initial || defaultValue;
}

function addWebViewToDock({ webView, layoutType }: AddWebViewEvent, dockLayout: DockLayout) {
const tabId = serializeTabId(TYPE_WEBVIEW, webView.id);
let targetTab = dockLayout.find(tabId) as TabData;
let willRemoveTarget = false;
let direction: DropDirection = 'update';
if (!targetTab) {
targetTab = dockLayout.find(previousTabId) as TabData;
direction = 'after-tab';
if (previousTabId === WEBVIEW_PLACEHOLDER_TAB_ID) willRemoveTarget = true;
}
const tab = loadTab({ id: tabId, data: webView });
previousTabId = tabId;

switch (layoutType) {
case 'tab':
if (direction === 'update') dockLayout.updateTab(tabId, tab);
else dockLayout.dockMove(tab, targetTab, direction);
if (willRemoveTarget) dockLayout.dockMove(targetTab, null, 'remove');
break;

case 'panel':
case 'float':
throw new Error(`Not yet implemented layoutType: '${layoutType}'`);

default:
throw new Error(`Unknown layoutType: '${layoutType}'`);
}
}

export default function ParanextDockLayout() {
// This ref will always be defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dockLayoutRef = useRef<DockLayout>(null!);

useEvent(
papi.webViews.onDidAddWebView,
useCallback((event: AddWebViewEvent) => {
const dockLayout = dockLayoutRef.current;
addWebViewToDock(event, dockLayout);
}, []),
);

return (
<DockLayout
ref={dockLayoutRef}
groups={groups}
defaultLayout={testLayout as LayoutData}
defaultLayout={savedLayout}
dropMode="edge"
loadTab={loadTab}
onLayoutChange={onLayoutChange}
/>
);
}
Loading