import { assertExists, Slot } from '@blockeditor/global/utils'; import { autoUpdate, type AutoUpdateOptions, computePosition, type ComputePositionConfig, type ComputePositionReturn, type ReferenceElement, } from '@floating-ui/dom'; import { html, LitElement, render, type RenderOptions, type TemplateResult, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; /** * Renders a template into a portal. Defaults to `document.body`. * * Note that every time the parent component re-renders, the portal will be re-called. * * See https://lit.dev/docs/components/rendering/#writing-a-good-render()-method * * @example * ```ts * render() { * return html`${showPortal * ? html`` * : null}`; * }; * ``` */ @customElement('blockeditor-portal') export class Portal extends LitElement { @property({ attribute: false }) public container = document.body; @property({ attribute: false }) public template = html``; @property({ attribute: false }) public shadowDom: boolean | ShadowRootInit = true; private _portalRoot: HTMLElement | null = null; override disconnectedCallback(): void { super.disconnectedCallback(); this._portalRoot?.remove(); } override createRenderRoot() { const portalRoot = document.createElement('div'); const renderRoot = this.shadowDom ? portalRoot.attachShadow({ mode: 'open', ...(typeof this.shadowDom !== 'boolean' ? this.shadowDom : {}), }) : portalRoot; portalRoot.classList.add('blockeditor-portal', 'blockeditor-overlay'); this.container.append(portalRoot); this._portalRoot = portalRoot; return renderRoot; } override render() { return this.template; } } declare global { interface HTMLElementTagNameMap { 'blockeditor-portal': Portal; } } /** * See https://lit.dev/docs/templates/expressions/#child-expressions */ type Renderable = | TemplateResult<1> // Any DOM node can be passed to a child expression. | HTMLElement // Numbers values like 5 will render the string '5'. Bigints are treated similarly. | number // A boolean value true will render 'true', and false will render 'false', but rendering a boolean like this is uncommon. | boolean // The empty string '', null, and undefined are specially treated and render nothing. | string | null | undefined; type PortalOptions = { template: Renderable | ((ctx: { updatePortal: () => void }) => Renderable); container?: Element; /** * The portal is removed when the AbortSignal is aborted. */ signal?: AbortSignal; /** * Defaults to `true`. */ shadowDom?: boolean | ShadowRootInit; renderOptions?: RenderOptions; /** * Defaults to `true`. * If true, the portalRoot will be added a class `blockeditor-portal`. It's useful for finding the portalRoot. */ identifyWrapper?: boolean; }; /** * Similar to ``, but only renders once when called. * * The template should be a **static** template since it will not be re-rendered unless `updatePortal` is called. * * See {@link Portal} for more details. */ export function createSimplePortal({ template, container = document.body, signal = new AbortController().signal, renderOptions, shadowDom = true, identifyWrapper = true, }: PortalOptions) { const portalRoot = document.createElement('div'); if (identifyWrapper) { portalRoot.classList.add('blockeditor-portal', 'blockeditor-overlay'); } if (shadowDom) { portalRoot.attachShadow({ mode: 'open', ...(typeof shadowDom !== 'boolean' ? shadowDom : {}), }); } signal.addEventListener('abort', () => { portalRoot.remove(); }); const root = shadowDom ? portalRoot.shadowRoot : portalRoot; assertExists(root); let updateId = 0; const updatePortal: (id: number) => void = id => { if (id !== updateId) { console.warn( 'Potentially infinite recursion! Please clean up the old event listeners before `updatePortal`' ); return; } updateId++; const curId = updateId; const templateResult = template instanceof Function ? template({ updatePortal: () => updatePortal(curId) }) : template; assertExists(templateResult); render(templateResult, root, renderOptions); }; updatePortal(updateId); container.append(portalRoot); return portalRoot; } type ComputePositionOptions = { referenceElement: ReferenceElement; /** * Default `false`. */ autoUpdate?: true | AutoUpdateOptions; /** * Default `true`. Only work when `referenceElement` is an `Element`. Check when position update (`autoUpdate` is `true` or first tick) */ abortWhenRefRemoved?: boolean; } & Partial; export type AdvancedPortalOptions = Omit< PortalOptions, 'template' | 'signal' > & { abortController: AbortController; template: | Renderable | ((context: { positionSlot: Slot; updatePortal: () => void; }) => Renderable); /** * See https://floating-ui.com/docs/computePosition */ computePosition?: | ComputePositionOptions | ((portalRoot: Element) => ComputePositionOptions); /** * Whether to close the portal when click away(click outside). * @default false */ closeOnClickAway?: boolean; }; /** * Where el is the DOM element you'd like to test for visibility */ function isElementVisible(el: Element) { // The API is not stable, so we need to check the existence of the function first // See also https://caniuse.com/?search=checkVisibility if (el.checkVisibility) { // See https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility return el.checkVisibility(); } // Fallback to the old way // Remove this when the `checkVisibility` API is stable if (!el.isConnected) return false; if (el instanceof HTMLElement) { // See https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom return !(el.offsetParent === null); } return true; } /** * Similar to `createSimplePortal`, but supports auto update position. * * The template should be a **static** template since it will not be re-rendered. * * See {@link createSimplePortal} for more details. * * @example * ```ts * createLitPortal({ * template: RenameModal({ * model, * abortController: renameAbortController, * }), * computePosition: { * referenceElement: anchor, * placement: 'top-end', * middleware: [flip(), offset(4)], * autoUpdate: true, * }, * abortController: renameAbortController, * }); * ``` */ export function createLitPortal({ computePosition: positionConfigOrFn, abortController, closeOnClickAway = false, ...portalOptions }: AdvancedPortalOptions) { let positionSlot = new Slot(); const template = portalOptions.template; const templateWithPosition = template instanceof Function ? ({ updatePortal }: { updatePortal: () => void }) => { // We need to create a new slot for each template, otherwise the slot may be used in the old template positionSlot = new Slot(); return template({ updatePortal, positionSlot }); } : template; const portalRoot = createSimplePortal({ ...portalOptions, signal: abortController.signal, template: templateWithPosition, }); if (closeOnClickAway) { // Avoid triggering click away listener on initial render setTimeout(() => document.addEventListener( 'click', e => { if (portalRoot.contains(e.target as Node)) return; abortController.abort(); }, { signal: abortController.signal, } ) ); } if (!positionConfigOrFn) { return portalRoot; } const visibility = portalRoot.style.visibility; portalRoot.style.visibility = 'hidden'; portalRoot.style.position = 'fixed'; portalRoot.style.left = '0'; portalRoot.style.top = '0'; const computePositionOptions = positionConfigOrFn instanceof Function ? positionConfigOrFn(portalRoot) : positionConfigOrFn; const { referenceElement, ...options } = computePositionOptions; assertExists(referenceElement, 'referenceElement is required'); const update = () => { if ( computePositionOptions.abortWhenRefRemoved !== false && referenceElement instanceof Element && !isElementVisible(referenceElement) ) { abortController.abort(); } computePosition(referenceElement, portalRoot, options) .then(positionReturn => { const { x, y } = positionReturn; // Use transform maybe cause overlay-mask offset issue // portalRoot.style.transform = `translate(${x}px, ${y}px)`; portalRoot.style.left = `${x}px`; portalRoot.style.top = `${y}px`; if (portalRoot.style.visibility === 'hidden') { portalRoot.style.visibility = visibility; } positionSlot.emit(positionReturn); }) .catch(console.error); }; if (!computePositionOptions.autoUpdate) { update(); } else { const autoUpdateOptions = computePositionOptions.autoUpdate === true ? {} : computePositionOptions.autoUpdate; const cleanup = autoUpdate( referenceElement, portalRoot, update, autoUpdateOptions ); abortController.signal.addEventListener('abort', () => { cleanup(); }); } return portalRoot; }