/**
* WordPress dependencies
*/
import { usePrevious } from '@wordpress/compose';
import { useState, useLayoutEffect } from '@wordpress/element';
import { getRectangleFromRange } from '@wordpress/dom';
/**
* Internal dependencies
*/
import type { WPFormat } from '../register-format-type';
/**
* Given a range and a format tag name and class name, returns the closest
* format element.
*
* @param range The Range to check.
* @param editableContentElement The editable wrapper.
* @param tagName The tag name of the format element.
* @param className The class name of the format element.
* @return The format element, if found.
*/
function getFormatElement(
range: Range,
editableContentElement: HTMLElement,
tagName: string,
className: string
): HTMLElement | undefined {
let element = range.startContainer;
// Even if the active format is defined, the actually DOM range's start
// container may be outside of the format's DOM element:
// `a‸b` (DOM) while visually it's `a‸b`.
// So at a given selection index, start with the deepest format DOM element.
if (
element.nodeType === element.TEXT_NODE &&
element instanceof window.Text &&
range.startOffset === element.length &&
element.nextSibling
) {
element = element.nextSibling;
while ( element.firstChild ) {
element = element.firstChild;
}
}
if ( element.nodeType !== element.ELEMENT_NODE ) {
if ( ! element.parentElement ) {
return;
}
element = element.parentElement;
}
if ( element === editableContentElement ) {
return;
}
if ( ! editableContentElement.contains( element ) ) {
return;
}
const selector = tagName + ( className ? '.' + className : '' );
// Element#matches will throw SyntaxError on an empty selector
if ( ! selector ) {
return;
}
if ( ! ( element instanceof window.HTMLElement ) ) {
return;
}
let closestElement: HTMLElement | null = element;
// .closest( selector ), but with a boundary. Check if the element matches
// the selector. If it doesn't match, try the parent element if it's not the
// editable wrapper. We don't want to try to match ancestors of the editable
// wrapper, which is what .closest( selector ) would do. When the element is
// the editable wrapper (which is most likely the case because most text is
// unformatted), this never runs.
while ( closestElement && closestElement !== editableContentElement ) {
if ( closestElement.matches( selector ) ) {
return closestElement;
}
closestElement = closestElement.parentElement;
}
return undefined;
}
interface VirtualAnchorElement {
getBoundingClientRect: () => DOMRect;
contextElement: HTMLElement;
}
/**
* Creates a virtual anchor element for a range.
*
* @param range The range to create a virtual anchor element for.
* @param editableContentElement The editable wrapper.
* @return The virtual anchor element.
*/
function createVirtualAnchorElement(
range: Range,
editableContentElement: HTMLElement
): VirtualAnchorElement {
return {
contextElement: editableContentElement,
getBoundingClientRect() {
if ( editableContentElement.contains( range.startContainer ) ) {
return (
getRectangleFromRange( range ) ??
range.getBoundingClientRect()
);
}
return editableContentElement.getBoundingClientRect();
},
};
}
/**
* Get the anchor: a format element if there is a matching one based on the
* tagName and className or a range otherwise.
*
* @param editableContentElement The editable wrapper.
* @param tagName The tag name of the format element.
* @param className The class name of the format element.
* @return The anchor.
*/
function getAnchor(
editableContentElement: HTMLElement | null,
tagName: string,
className: string
): HTMLElement | VirtualAnchorElement | undefined {
if ( ! editableContentElement ) {
return;
}
const { ownerDocument } = editableContentElement;
const { defaultView } = ownerDocument;
const selection = defaultView?.getSelection();
if ( ! selection ) {
return;
}
if ( ! selection.rangeCount ) {
return;
}
const range = selection.getRangeAt( 0 );
if ( ! range || ! range.startContainer ) {
return;
}
if ( ! tagName && ! className ) {
return createVirtualAnchorElement( range, editableContentElement );
}
return (
getFormatElement( range, editableContentElement, tagName, className ) ??
createVirtualAnchorElement( range, editableContentElement )
);
}
const DEFAULT_SETTINGS = {
tagName: '',
className: '',
};
/**
* This hook, to be used in a format type's Edit component, returns the active
* element that is formatted, or a virtual element for the selection range if
* no format is active. The returned value is meant to be used for positioning
* UI, e.g. by passing it to the `Popover` component via the `anchor` prop.
*
* @param obj Named parameters.
* @param obj.editableContentElement The element containing the editable content.
* @param obj.settings The format type's settings.
* @return The active element or selection range.
*/
export function useAnchor( {
editableContentElement,
settings,
}: {
editableContentElement: HTMLElement | null;
settings?: WPFormat;
} ): Element | VirtualAnchorElement | undefined | null {
const { tagName, className } = settings ?? DEFAULT_SETTINGS;
// `isActive` is not a property of `WPFormat`, but it has made its way into
// `settings` in certain cases (see `core/link` format). Avoid making this
// exception "public" in the function signature: tell TS how to look for it
// dynamically.
const isActive = !! (
settings &&
'isActive' in settings &&
settings.isActive
);
const [ anchor, setAnchor ] = useState( () =>
getAnchor( editableContentElement, tagName, className ?? '' )
);
const wasActive = usePrevious( isActive );
useLayoutEffect( () => {
if ( ! editableContentElement ) {
return;
}
function callback() {
setAnchor(
getAnchor( editableContentElement, tagName, className ?? '' )
);
}
function attach() {
ownerDocument.addEventListener( 'selectionchange', callback );
}
function detach() {
ownerDocument.removeEventListener( 'selectionchange', callback );
}
const { ownerDocument } = editableContentElement;
if (
editableContentElement === ownerDocument.activeElement ||
// When a link is created, we need to attach the popover to the newly created anchor.
( ! wasActive && isActive ) ||
// Sometimes we're _removing_ an active anchor, such as the inline color popover.
// When we add the color, it switches from a virtual anchor to a `` element.
// When we _remove_ the color, it switches from a `` element to a virtual anchor.
( wasActive && ! isActive )
) {
setAnchor(
getAnchor( editableContentElement, tagName, className ?? '' )
);
attach();
}
editableContentElement.addEventListener( 'focusin', attach );
editableContentElement.addEventListener( 'focusout', detach );
return () => {
detach();
editableContentElement.removeEventListener( 'focusin', attach );
editableContentElement.removeEventListener( 'focusout', detach );
};
}, [ editableContentElement, tagName, className, isActive, wasActive ] );
return anchor;
}