Files
wxt/src/client/createContentScriptUi.ts
T

299 lines
8.5 KiB
TypeScript

import { createIsolatedElement } from '@webext-core/isolated-element';
import { browser } from './browser';
import { logger } from './utils/logger';
import { ContentScriptContext } from '.';
/**
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
* when the content script's context is invalidated.
*
* See <https://wxt.dev/entrypoints/content-scripts.html#ui> for full documentation.
*
* @example
* // entrypoints/example-ui.content/index.ts
* import "./style.css"
*
* export default defineContentScript({
* matches: ["*://*.google.com/*"],
* cssInjectionMode: "ui",
*
* async main(ctx) {
* const ui = await createContentScriptUi(ctx, {
* name: "example-overlay",
* type: "modal",
* mount(container) {
* const app = document.createElement("div");
* app.textContent = "Content Script UI";
* container.append(app);
* }
* })
* ui.mount();
* }
* })
*/
export async function createContentScriptUi<T>(
ctx: ContentScriptContext,
options: ContentScriptUiOptions<T>,
): Promise<ContentScriptUi<T>> {
const css = [options.css ?? ''];
if (ctx.options?.cssInjectionMode === 'ui') {
css.push(await loadCss());
}
const {
isolatedElement: uiContainer,
parentElement: shadowHost,
shadow,
} = await createIsolatedElement({
name: options.name,
css: {
textContent: css.join('\n').trim(),
},
mode: 'open',
});
const getAnchor = (): Element | undefined => {
if (options.anchor == null) return document.body;
let resolved =
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
if (typeof resolved === 'string')
return document.querySelector<Element>(resolved) ?? undefined;
return resolved ?? undefined;
};
let mounted: T;
const mount = () => {
const anchor = getAnchor();
if (anchor == null)
throw Error(
'Failed to mount content script ui: could not find anchor element',
);
// Mount UI inside shadow root
mounted = options.mount(uiContainer);
// Add shadow root element to DOM
switch (options.append) {
case undefined:
case 'last':
anchor.append(shadowHost);
break;
case 'first':
if (anchor.firstChild) {
anchor.insertBefore(shadowHost, anchor.firstChild);
} else {
anchor.append(shadowHost);
}
break;
case 'replace':
anchor.replaceWith(shadowHost);
break;
case 'after':
anchor.replaceWith(anchor, shadowHost);
break;
case 'before':
anchor.replaceWith(shadowHost, anchor);
break;
default:
options.append(anchor, shadowHost);
break;
}
// Apply types
if (options.type !== 'inline') {
if (options.zIndex != null)
shadowHost.style.zIndex = String(options.zIndex);
shadowHost.style.overflow = 'visible';
shadowHost.style.position = 'relative';
shadowHost.style.width = '0';
shadowHost.style.height = '0';
shadowHost.style.display = 'block';
const html = shadow.querySelector('html')!;
// HTML doesn't exist in tests
if (options.type === 'overlay') {
html.style.position = 'absolute';
if (options.alignment?.startsWith('bottom-')) html.style.bottom = '0';
else html.style.top = '0';
if (options.alignment?.endsWith('-right')) html.style.right = '0';
else html.style.left = '0';
} else {
html.style.position = 'fixed';
html.style.top = '0';
html.style.bottom = '0';
html.style.left = '0';
html.style.right = '0';
}
}
};
const remove = () => {
// Detatch shadow root from DOM
shadowHost.remove();
// Cleanup mounted state
options.onRemove?.(mounted);
// Remove children from uiContainer
while (uiContainer.lastChild)
uiContainer.removeChild(uiContainer.lastChild);
};
ctx.onInvalidated(remove);
return {
shadow,
shadowHost,
uiContainer,
mount,
remove,
mounted: mounted!,
};
}
/**
* Load the CSS for the current entrypoint.
*/
async function loadCss(): Promise<string> {
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
try {
const res = await fetch(url);
const css = await res.text();
// Replace :root selectors with :host since we're in a shadow root
return css.replaceAll(':root', ':host');
} catch (err) {
logger.warn(
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
err,
);
return '';
}
}
export interface ContentScriptUi<T> {
/**
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
* that get's added to the DOM. This element's style is not isolated from the webpage.
*/
shadowHost: HTMLElement;
/**
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
* inside this `HTMLElement`.
*/
uiContainer: HTMLElement;
/**
* The shadow root performing the isolation.
*/
shadow: ShadowRoot;
/**
* Custom data returned from the `options.mount` function.
*/
mounted: T;
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
}
interface BaseContentScriptUiOptions<T> {
/**
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
*/
name: string;
/**
* In combination with `anchor`, decide how to add the UI to the DOM.
*
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
* - `"first"` - Add the UI as the last child of the `anchor` element
* - `"replace"` - Replace the `anchor` element with the UI.
* - `"before"` - Add the UI as the sibling before the `anchor` element
* - `"after"` - Add the UI as the sibling after the `anchor` element
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
*/
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
/**
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
* `anchor` dictates where in the page the UI will be added.
*/
anchor?:
| string
| Element
| null
| undefined
| (() => string | Element | null | undefined);
/**
* Callback executed when mounting the UI. This function should create and append the UI to the
* `container` element. It is called every time `ui.mount()` is called
*
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
*/
mount: (container: Element) => T;
/**
* Callback called when the UI is removed from the webpage. Use to cleanup your UI, like
* unmounting your vue or react apps.
*/
onRemove?: (mounted: T) => void;
/**
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
* to pass those styles in here. This is for any additional styles not in the imported CSS.
*
* See <https://wxt.dev/entrypoints/content-scripts.html#ui> for more info.
*/
css?: string;
}
export type OverlayContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
type: 'overlay';
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* @default "top-left"
*/
alignment?: ContentScriptUiOverlayAlignment;
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
};
export type ModalContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
};
export type InlineContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
type: 'inline';
};
export type ContentScriptUiOverlayAlignment =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
export type ContentScriptAppendMode =
| 'last'
| 'first'
| 'replace'
| 'before'
| 'after';
export type ContentScriptUiOptions<T> =
| OverlayContentScriptUiOptions<T>
| ModalContentScriptUiOptions<T>
| InlineContentScriptUiOptions<T>;