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;
}