From fcdf0dcbe9d0b57fa2cc6bf36eaa961c0da99afc Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 3 Nov 2023 09:31:16 -0500 Subject: [PATCH] feat: `createContentScriptIframe` utility (#206) --- .../{iframe.scss => example-2.scss} | 0 demo/src/entrypoints/iframe-src/index.html | 12 + demo/src/entrypoints/iframe-src/main.ts | 1 + demo/src/entrypoints/iframe.content.ts | 13 ++ demo/wxt.config.ts | 6 + docs/entrypoints/content-scripts.md | 47 +++- e2e/tests/auto-imports.test.ts | 1 + e2e/tests/typescript-project.test.ts | 2 +- .../__tests__/content-script-iframe.test.ts | 214 ++++++++++++++++++ .../__tests__/content-script-ui.test.ts | 2 +- .../content-scripts/content-script-iframe.ts | 83 +++++++ .../content-scripts/content-script-ui.ts | 190 +++------------- src/client/content-scripts/index.ts | 1 + src/client/utils/content-script-ui.ts | 145 ++++++++++++ src/core/utils/building/generate-wxt-dir.ts | 2 +- src/types/modules.d.ts | 5 + src/types/project-types.d.ts | 3 + 17 files changed, 563 insertions(+), 164 deletions(-) rename demo/src/entrypoints/{iframe.scss => example-2.scss} (100%) create mode 100644 demo/src/entrypoints/iframe-src/index.html create mode 100644 demo/src/entrypoints/iframe-src/main.ts create mode 100644 demo/src/entrypoints/iframe.content.ts create mode 100644 src/client/content-scripts/__tests__/content-script-iframe.test.ts create mode 100644 src/client/content-scripts/content-script-iframe.ts create mode 100644 src/client/utils/content-script-ui.ts create mode 100644 src/types/project-types.d.ts diff --git a/demo/src/entrypoints/iframe.scss b/demo/src/entrypoints/example-2.scss similarity index 100% rename from demo/src/entrypoints/iframe.scss rename to demo/src/entrypoints/example-2.scss diff --git a/demo/src/entrypoints/iframe-src/index.html b/demo/src/entrypoints/iframe-src/index.html new file mode 100644 index 00000000..6858d934 --- /dev/null +++ b/demo/src/entrypoints/iframe-src/index.html @@ -0,0 +1,12 @@ + + + + + + Iframe Example + + +

Hello iframe page!

+ + + diff --git a/demo/src/entrypoints/iframe-src/main.ts b/demo/src/entrypoints/iframe-src/main.ts new file mode 100644 index 00000000..b02c5d4d --- /dev/null +++ b/demo/src/entrypoints/iframe-src/main.ts @@ -0,0 +1 @@ +console.log('iframe 2'); diff --git a/demo/src/entrypoints/iframe.content.ts b/demo/src/entrypoints/iframe.content.ts new file mode 100644 index 00000000..41a0cc45 --- /dev/null +++ b/demo/src/entrypoints/iframe.content.ts @@ -0,0 +1,13 @@ +export default defineContentScript({ + matches: ['*://*.google.com/*'], + + main(ctx) { + const ui = createContentScriptIframe(ctx, { + page: '/iframe-src.html', + type: 'overlay', + anchor: 'form[action="/search"]', + }); + ui.mount(); + console.log('Mounted iframe'); + }, +}); diff --git a/demo/wxt.config.ts b/demo/wxt.config.ts index ed3a0ec4..6f15ecd1 100644 --- a/demo/wxt.config.ts +++ b/demo/wxt.config.ts @@ -4,5 +4,11 @@ export default defineConfig({ srcDir: 'src', manifest: { default_locale: 'en', + web_accessible_resources: [ + { + resources: ['/iframe-src.html'], + matches: ['*://*.google.com/*'], + }, + ], }, }); diff --git a/docs/entrypoints/content-scripts.md b/docs/entrypoints/content-scripts.md index 6428753f..ae5b981f 100644 --- a/docs/entrypoints/content-scripts.md +++ b/docs/entrypoints/content-scripts.md @@ -153,6 +153,8 @@ const anchor = document.querySelector('#anchor-selector'); anchor.append(ui); ``` +You can try out [`createContentScriptIframe`](#createcontentscriptiframe) as an alternative solution + ::: ### Usage @@ -162,7 +164,7 @@ To use `createContentScriptUi`, follow these steps: 1. Import your CSS file at the top of your content script 2. Set `cssInjectionMode: "ui"` inside `defineContentScript` 3. Call `createContentScriptUi` -4. Call `ui.mount()` to add the UI to the webpage +4. Call `mount` to add the UI to the webpage Here's a basic example: @@ -185,7 +187,7 @@ export default defineContentScript({ }, }); - // Yoy must call `mount` to add the UI to the page. + // You must call `mount` to add the UI to the page. ui.mount(); }, }); @@ -290,3 +292,44 @@ There are 3 types of UI's you can mount. Because the overlay UI type results in a 0px by 0px container being added to the webpage, the `alignment` option allows you to configure which corner of your UI is aligned with the 0x0 element. > TODO: Add visualization of the different alignments. + +## UI + +WXT provides a utility function, `createContentScriptIframe` to simplify mounting a UI from a content script. It creates an iframe to an unlisted HTML page. Unlike `createContentScriptUi`, this API support HMR. + +`createContentScriptIframe` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage. + +### Usage + +To use `createContentScriptIframe`, follow these steps: + +1. Create an unlisted HTML page that will be loaded into your iframe +1. Add unlisted page to the manifest's `web_accessible_resouces` +1. Call `createContentScriptUi` +1. Call `mount` to add the UI to the webpage + +Here's a basic example: + +```ts +export default defineContentScript({ + // ... + + async main(ctx) { + const ui = await createContentScriptIframe(ctx, { + page: '/your-unlisted-page.html', + type: 'inline', + anchor: '#some-element', + append: 'after', + }); + + // You must call `mount` to add the UI to the page. + ui.mount(); + }, +}); +``` + +The options, other than `page`, are the same as [`createContentScriptUi`](#anchor). + +### `page` + +The HTML page you want to load inside the iframe. This string will be passed into `browser.runtime.getURL` to resolve the full path of to your HTML page. diff --git a/e2e/tests/auto-imports.test.ts b/e2e/tests/auto-imports.test.ts index f173002e..aeb192ab 100644 --- a/e2e/tests/auto-imports.test.ts +++ b/e2e/tests/auto-imports.test.ts @@ -18,6 +18,7 @@ describe('Auto Imports', () => { declare global { const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext'] const browser: typeof import('wxt/browser')['browser'] + const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe'] const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi'] const defineBackground: typeof import('wxt/client')['defineBackground'] const defineConfig: typeof import('wxt')['defineConfig'] diff --git a/e2e/tests/typescript-project.test.ts b/e2e/tests/typescript-project.test.ts index 979cdd60..731a2907 100644 --- a/e2e/tests/typescript-project.test.ts +++ b/e2e/tests/typescript-project.test.ts @@ -44,7 +44,7 @@ describe('TypeScript Project', () => { import \\"wxt/browser\\"; declare module \\"wxt/browser\\" { - type PublicPath = + export type PublicPath = | \\"/options.html\\" | \\"/popup.html\\" | \\"/sandbox.html\\" diff --git a/src/client/content-scripts/__tests__/content-script-iframe.test.ts b/src/client/content-scripts/__tests__/content-script-iframe.test.ts new file mode 100644 index 00000000..6012c81e --- /dev/null +++ b/src/client/content-scripts/__tests__/content-script-iframe.test.ts @@ -0,0 +1,214 @@ +/** @vitest-environment happy-dom */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createContentScriptIframe } from '~/client/content-scripts/content-script-iframe'; +import { ContentScriptContext } from '~/client/content-scripts/content-script-context'; + +const createCtx = () => new ContentScriptContext('test'); + +const fetch = vi.fn(); + +describe('createContentScriptIframe', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+

one

+

two

+

+
+ `; + + window.fetch = fetch; + fetch.mockResolvedValue({ text: () => Promise.resolve('') }); + }); + + describe('mount', () => { + describe('append option', () => { + it.each([undefined, 'last' as const])( + 'should append the element as the last child to the anchor when append=%s', + async (append) => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#parent', + append, + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(4)')?.tagName, + ).toEqual('DIV'); + }, + ); + + it('should append the element as the first child to the anchor when append=first', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#parent', + append: 'first', + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(1)')?.tagName, + ).toEqual('DIV'); + }); + + it('should append the element normally when append=first but there are no other children', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#three', + append: 'first', + }); + ui.mount(); + + expect( + document.querySelector('#three > :nth-child(1)')?.tagName, + ).toEqual('DIV'); + }); + + it('should replace the anchor with the element when append=replace', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#two', + append: 'replace', + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(2)')?.tagName, + ).toEqual('DIV'); + }); + + it('should append the element after the anchor when append=before', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#two', + append: 'before', + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(2)')?.tagName, + ).toEqual('DIV'); + }); + + it('should append the element after the anchor when append=after', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#two', + append: 'after', + }); + ui.mount(); + + // Happy DOM doesn't work in this case, so we just make sure the element is added. + // expect( + // document.querySelector('#parent > :nth-child(3)')?.tagName, + // ).toEqual('DIV'); + expect(document.querySelector('DIV')).toBeDefined(); + }); + + it('should apply a custom function', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: '#three', + append: (anchor, ui) => anchor.replaceWith(ui), + }); + ui.mount(); + + expect( + document.querySelector('#parent > :nth-child(3)')?.tagName, + ).toEqual('DIV'); + }); + }); + + it('should default the anchor to the body when unset', async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + }); + ui.mount(); + + expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual( + 'DIV', + ); + }); + + it.each(['#four', () => document.querySelector('#four')])( + "should throw an error if the anchor doesn't exist", + async (anchor) => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor, + }); + + expect(ui.mount).toThrow( + 'Failed to mount content script UI: could not find anchor element', + ); + }, + ); + }); + + describe('remove', () => { + it("should not fail if the ui hasn't been mounted", async () => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + }); + ui.remove(); + }); + + it('should automatically remove the UI when the context is invalidated', async () => { + const ctx = createCtx(); + const ui = await createContentScriptIframe(ctx, { + page: '/test.html', + type: 'inline', + }); + ui.mount(); + + expect(document.querySelector('.wxt-iframe-wrapper')).toBeDefined(); + + ctx.abort(); + expect(document.querySelector('.wxt-iframe-wrapper')).toBeNull(); + }); + }); + + describe('type', () => { + it.each(['inline', 'overlay', 'modal'] as const)( + 'should render type=%s', + async (type) => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type, + }); + ui.mount(); + + expect(document.querySelector('DIV')).toBeDefined(); + }, + ); + }); + + describe('anchor', () => { + it.each([ + () => '#two', + () => () => '#two', + () => document.querySelector('#two'), + () => () => document.querySelector('#two'), + ])('should render anchor=%s', async (getAnchor) => { + const ui = await createContentScriptIframe(createCtx(), { + page: '/test.html', + type: 'inline', + anchor: getAnchor(), + }); + ui.mount(); + + expect(document.querySelector('DIV')).toBeDefined(); + }); + }); +}); diff --git a/src/client/content-scripts/__tests__/content-script-ui.test.ts b/src/client/content-scripts/__tests__/content-script-ui.test.ts index e8036d47..205a16c5 100644 --- a/src/client/content-scripts/__tests__/content-script-ui.test.ts +++ b/src/client/content-scripts/__tests__/content-script-ui.test.ts @@ -266,7 +266,7 @@ describe('createContentScriptUi', () => { }); expect(ui.mount).toThrow( - 'Failed to mount content script ui: could not find anchor element', + 'Failed to mount content script UI: could not find anchor element', ); }, ); diff --git a/src/client/content-scripts/content-script-iframe.ts b/src/client/content-scripts/content-script-iframe.ts new file mode 100644 index 00000000..8247e13a --- /dev/null +++ b/src/client/content-scripts/content-script-iframe.ts @@ -0,0 +1,83 @@ +import browser from 'webextension-polyfill'; +import { + ContentScriptAnchoredOptions, + ContentScriptPositioningOptions, + applyContentScriptUiPosition, + mountContentScriptUiRoot, +} from '../utils/content-script-ui'; +import { ContentScriptContext } from './content-script-context'; + +/** + * Utility for mounting a content script UI inside an iframe. Automatically removed from the DOM + * when the content script's context is invalidated. + * + * See https://wxt.dev/entrypoints/content-scripts.html#iframe for full documentation. + * + * @example + * export default defineContentScript({ + * matches: ["*://*.google.com/*"], + * + * main(ctx) { + * const ui = await createContentScriptIframe(ctx, { + * page: "/content-script-overlay.html", + * type: "modal", + * }) + * ui.mount(); + * } + * }) + */ +export function createContentScriptIframe( + ctx: ContentScriptContext, + options: ContentScriptIframeOptions, +): ContentScriptIframe { + const wrapper = document.createElement('div'); + wrapper.classList.add('wxt-iframe-wrapper'); + const iframe = document.createElement('iframe'); + iframe.src = browser.runtime.getURL(options.page); + wrapper.appendChild(iframe); + + const mount = () => { + applyContentScriptUiPosition(wrapper, iframe, options); + mountContentScriptUiRoot(wrapper, options); + }; + + const remove = () => { + wrapper.remove(); + }; + + ctx.onInvalidated(remove); + + return { + iframe, + wrapper, + mount, + remove, + }; +} + +export interface ContentScriptIframe { + /** + * The iframe added to the DOM. + */ + iframe: HTMLIFrameElement; + /** + * A wrapper div that assists in positioning. + */ + wrapper: HTMLDivElement; + /** + * Function that mounts or remounts the UI on the page. + */ + mount: () => void; + /** + * Function that removes the UI from the webpage. + */ + remove: () => void; +} + +export type ContentScriptIframeOptions = ContentScriptPositioningOptions & + ContentScriptAnchoredOptions & { + /** + * The path to the unlisted HTML file to display in the iframe. + */ + page: import('wxt/browser').PublicPath; + }; diff --git a/src/client/content-scripts/content-script-ui.ts b/src/client/content-scripts/content-script-ui.ts index 06c89aba..13b76f16 100644 --- a/src/client/content-scripts/content-script-ui.ts +++ b/src/client/content-scripts/content-script-ui.ts @@ -2,6 +2,12 @@ import { createIsolatedElement } from '@webext-core/isolated-element'; import { browser } from '~/browser'; import { logger } from '~/client/utils/logger'; import { ContentScriptContext } from './content-script-context'; +import { + ContentScriptAnchoredOptions, + ContentScriptPositioningOptions, + applyContentScriptUiPosition, + mountContentScriptUiRoot, +} from '../utils/content-script-ui'; /** * Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM @@ -52,83 +58,19 @@ export async function createContentScriptUi( 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(resolved) ?? undefined; - return resolved ?? undefined; - }; - let mounted: TApp; 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'; - } - } + mountContentScriptUiRoot(shadowHost, options); + applyContentScriptUiPosition( + shadowHost, + shadow.querySelector('html'), + options, + ); }; const remove = () => { @@ -202,100 +144,30 @@ export interface ContentScriptUi { remove: () => void; } -export interface BaseContentScriptUiOptions { - /** - * 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) => TApp; - /** - * Callback called when the UI is removed from the webpage. Use to cleanup your UI, like - * unmounting your vue or react apps. - */ - onRemove?: (mounted: TApp) => 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 = - BaseContentScriptUiOptions & { - type: 'overlay'; +export type ContentScriptUiOptions = ContentScriptPositioningOptions & + ContentScriptAnchoredOptions & { /** - * When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies - * which corner is aligned with that 0x0 pixel space. + * The name of the custom component used to host the ShadowRoot. Must be kebab-case. + */ + name: string; + /** + * 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 * - * @default "top-left" + * Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback. */ - alignment?: ContentScriptUiOverlayAlignment; + mount: (container: Element) => TApp; /** - * The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website - * content. + * Callback called when the UI is removed from the webpage. Use to cleanup your UI, like + * unmounting your vue or react apps. */ - zIndex?: number; - }; - -export type ModalContentScriptUiOptions = - BaseContentScriptUiOptions & { - type: 'modal'; + onRemove?: (mounted: TApp) => void; /** - * The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website - * content. + * 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. */ - zIndex?: number; + css?: string; }; - -export type InlineContentScriptUiOptions = - BaseContentScriptUiOptions & { - type: 'inline'; - }; - -export type ContentScriptUiOverlayAlignment = - | 'top-left' - | 'top-right' - | 'bottom-left' - | 'bottom-right'; - -export type ContentScriptAppendMode = - | 'last' - | 'first' - | 'replace' - | 'before' - | 'after'; - -export type ContentScriptUiOptions = - | OverlayContentScriptUiOptions - | ModalContentScriptUiOptions - | InlineContentScriptUiOptions; diff --git a/src/client/content-scripts/index.ts b/src/client/content-scripts/index.ts index ed4c2227..e3964f02 100644 --- a/src/client/content-scripts/index.ts +++ b/src/client/content-scripts/index.ts @@ -1,3 +1,4 @@ export * from './content-script-context'; export * from './content-script-ui'; +export * from './content-script-iframe'; export * from './define-content-script'; diff --git a/src/client/utils/content-script-ui.ts b/src/client/utils/content-script-ui.ts new file mode 100644 index 00000000..934ba03d --- /dev/null +++ b/src/client/utils/content-script-ui.ts @@ -0,0 +1,145 @@ +export type ContentScriptOverlayAlignment = + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right'; + +export type ContentScriptAppendMode = + | 'last' + | 'first' + | 'replace' + | 'before' + | 'after' + | ((anchor: Element, ui: Element) => void); + +export function mountContentScriptUiRoot( + root: HTMLElement, + options: ContentScriptAnchoredOptions, +): void { + const anchor = getAnchor(options); + if (anchor == null) + throw Error( + 'Failed to mount content script UI: could not find anchor element', + ); + + switch (options.append) { + case undefined: + case 'last': + anchor.append(root); + break; + case 'first': + if (anchor.firstChild) { + anchor.insertBefore(root, anchor.firstChild); + } else { + anchor.append(root); + } + break; + case 'replace': + anchor.replaceWith(root); + break; + case 'after': + anchor.replaceWith(anchor, root); + break; + case 'before': + anchor.replaceWith(root, anchor); + break; + default: + options.append(anchor, root); + break; + } +} + +export type ContentScriptPositioningOptions = + | { type: 'inline' } + | { + type: 'overlay'; + /** + * The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website + * content. + */ + zIndex?: number; + /** + * 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?: ContentScriptOverlayAlignment; + } + | { + type: 'modal'; + /** + * The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website + * content. + */ + zIndex?: number; + }; + +export function applyContentScriptUiPosition( + root: HTMLElement, + positionedElement: HTMLElement | undefined | null, + options: ContentScriptPositioningOptions, +): void { + if (options.type !== 'inline') { + if (options.zIndex != null) root.style.zIndex = String(options.zIndex); + + root.style.overflow = 'visible'; + root.style.position = 'relative'; + root.style.width = '0'; + root.style.height = '0'; + root.style.display = 'block'; + + if (positionedElement) { + if (options.type === 'overlay') { + positionedElement.style.position = 'absolute'; + if (options.alignment?.startsWith('bottom-')) + positionedElement.style.bottom = '0'; + else positionedElement.style.top = '0'; + + if (options.alignment?.endsWith('-right')) + positionedElement.style.right = '0'; + else positionedElement.style.left = '0'; + } else { + positionedElement.style.position = 'fixed'; + positionedElement.style.top = '0'; + positionedElement.style.bottom = '0'; + positionedElement.style.left = '0'; + positionedElement.style.right = '0'; + } + } + } +} + +function getAnchor(options: ContentScriptAnchoredOptions): 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(resolved) ?? undefined; + return resolved ?? undefined; +} + +export interface ContentScriptAnchoredOptions { + /** + * 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); + /** + * 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); +} diff --git a/src/core/utils/building/generate-wxt-dir.ts b/src/core/utils/building/generate-wxt-dir.ts index c778fcfc..e808ca8f 100644 --- a/src/core/utils/building/generate-wxt-dir.ts +++ b/src/core/utils/building/generate-wxt-dir.ts @@ -77,7 +77,7 @@ async function writePathsDeclarationFile( import "wxt/browser"; declare module "wxt/browser" { - type PublicPath = + export type PublicPath = {{ union }} export interface WxtRuntime extends Runtime.Static { getURL(path: PublicPath): string; diff --git a/src/types/modules.d.ts b/src/types/modules.d.ts index 99fbc79a..49fb40ff 100644 --- a/src/types/modules.d.ts +++ b/src/types/modules.d.ts @@ -49,3 +49,8 @@ declare module 'web-ext-run/util/logger' { } export const consoleStream: IConsoleStream; } + +declare module 'wxt/browser' { + // Overridden when types are generated per project + export type PublicPath = string; +} diff --git a/src/types/project-types.d.ts b/src/types/project-types.d.ts new file mode 100644 index 00000000..1012f612 --- /dev/null +++ b/src/types/project-types.d.ts @@ -0,0 +1,3 @@ +// Types generated in the .wxt directory available after wxt prepare + +declare type PublicPath = string;