UNPKG

@wordpress/editor

Version:
486 lines (427 loc) 14.5 kB
// @ts-expect-error - No type declarations available for @wordpress/block-editor // prettier-ignore import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; import { unlock } from '../../lock-unlock'; const { isElementVisible } = unlock( blockEditorPrivateApis ); export interface SelectionRect { x: number; y: number; width: number; height: number; } export interface CursorCoords { x: number; y: number; height: number; } /** * Walk up from a hidden element (e.g. text inside a collapsed core/details * or an inactive core/accordion panel) to the nearest [data-block] ancestor * that's actually visible — always the collapsed container's own wrapper, * since only its *inner* content collapses, never the wrapper itself. * * Used to give collaborators a visible presence indicator (avatar/outline) * on the container when their cursor/selection is inside hidden content, in * place of a cursor that would otherwise have nowhere valid to render. * * @param element - The hidden element to walk up from. * @return The nearest visible [data-block] ancestor, or null if none found. */ export const getNearestVisibleBlockAncestor = ( element: HTMLElement ): HTMLElement | null => { let current = element.closest< HTMLElement >( '[data-block]' ); while ( current ) { if ( isElementVisible( current ) ) { return current; } current = current.parentElement?.closest< HTMLElement >( '[data-block]' ) ?? null; } return null; }; const MAX_NODE_OFFSET_COUNT = 500; /** * Given a selection, returns the coordinates of the cursor in the block. * * @param absolutePositionIndex - The absolute position index * @param blockElement - The block element (or null if deleted) * @param editorDocument - The editor document * @param overlayRect - Pre-computed bounding rect of the overlay element * @return The position of the cursor */ export const getCursorPosition = ( absolutePositionIndex: number | null, blockElement: HTMLElement | null, editorDocument: Document, overlayRect: DOMRect ): CursorCoords | null => { if ( absolutePositionIndex === null || ! blockElement ) { return null; } return ( getOffsetPositionInBlock( blockElement, absolutePositionIndex, editorDocument, overlayRect ) ?? null ); }; /** * Given a block element and a character offset, returns the coordinates for drawing a visual cursor in the block. * * @param blockElement - The block element * @param charOffset - The character offset * @param editorDocument - The editor document * @param overlayRect - Pre-computed bounding rect of the overlay element * @return The position of the cursor */ const getOffsetPositionInBlock = ( blockElement: HTMLElement, charOffset: number, editorDocument: Document, overlayRect: DOMRect ) => { // The target may be hidden inside collapsed content (e.g. a closed // core/details or an inactive core/accordion panel). Its range then has // no layout box, so don't draw a cursor at a fallback position — suppress // it entirely rather than misplacing it at the collapsed wrapper. if ( ! isElementVisible( blockElement ) ) { return null; } const { node, offset } = findInnerBlockOffset( blockElement, charOffset, editorDocument ); const cursorRange = editorDocument.createRange(); try { cursorRange.setStart( node, offset ); } catch { return null; } // Ensure the range only represents single point in the DOM. cursorRange.collapse( true ); const cursorRect = cursorRange.getBoundingClientRect(); const blockRect = blockElement.getBoundingClientRect(); let cursorX = 0; let cursorY = 0; if ( cursorRect.x === 0 && cursorRect.y === 0 && cursorRect.width === 0 && cursorRect.height === 0 ) { // This can happen for empty blocks. cursorX = blockRect.left - overlayRect.left; cursorY = blockRect.top - overlayRect.top; } else { cursorX = cursorRect.left - overlayRect.left; cursorY = cursorRect.top - overlayRect.top; } let cursorHeight = cursorRect.height; if ( cursorHeight === 0 ) { const view = editorDocument.defaultView ?? window; cursorHeight = parseInt( view.getComputedStyle( blockElement ).lineHeight, 10 ) || blockRect.height; } return { x: cursorX, y: cursorY, height: cursorHeight, }; }; /** * Computes selection highlight rectangles for a text range within a single block. * * @param blockElement - The block element * @param startOffset - Start character offset within the block * @param endOffset - End character offset within the block * @param editorDocument - The editor document * @param overlayRect - Pre-computed bounding rect of the overlay element * @return Array of selection rectangles relative to the overlay, or null on failure */ export const getSelectionRects = ( blockElement: HTMLElement, startOffset: number, endOffset: number, editorDocument: Document, overlayRect: DOMRect ): SelectionRect[] | null => { // Same rationale as getOffsetPositionInBlock: a hidden target has no // layout box to derive rects from, so skip it rather than draw a // misplaced or empty selection. if ( ! isElementVisible( blockElement ) ) { return null; } // Normalize direction. let normalizedStart = startOffset; let normalizedEnd = endOffset; if ( normalizedStart > normalizedEnd ) { [ normalizedStart, normalizedEnd ] = [ normalizedEnd, normalizedStart ]; } const startPos = findInnerBlockOffset( blockElement, normalizedStart, editorDocument ); const endPos = findInnerBlockOffset( blockElement, normalizedEnd, editorDocument ); const range = editorDocument.createRange(); try { range.setStart( startPos.node, startPos.offset ); range.setEnd( endPos.node, endPos.offset ); } catch { return null; } const clientRects = range.getClientRects(); const rects: SelectionRect[] = []; for ( const rect of clientRects ) { if ( rect.width === 0 && rect.height === 0 ) { continue; } const x = rect.left - overlayRect.left; const y = rect.top - overlayRect.top; // Range.getClientRects() can return duplicate rects at inline // formatting boundaries (e.g. <em>, <strong>). Skip exact matches. const isDuplicate = rects.some( ( r ) => r.x === x && r.y === y && r.width === rect.width && r.height === rect.height ); if ( isDuplicate ) { continue; } rects.push( { x, y, width: rect.width, height: rect.height, } ); } return rects.length > 0 ? rects : null; }; /** * Return the nearest [data-block] ancestor of el, or el itself if it has none. * * Used to promote inner blocks (e.g. list-items) to their parent container * (e.g. the list block) so the whole container is treated as one visual unit * rather than each child block being highlighted individually. * * @param el - The block element to promote. * @return The nearest [data-block] ancestor, or el itself. */ export const blockContainerOf = ( el: HTMLElement ): HTMLElement => { const parent = el.parentElement; return parent?.hasAttribute( 'data-block' ) ? parent : el; }; /** * Finds all block elements between two blocks in DOM order (exclusive of * start and end). Descendant blocks are filtered out — if a parent block is * already in the result, its children are skipped. This prevents * double-highlighting nested structures (e.g. selecting across a list returns * the list block, not the individual list items inside it). * * NOTE: startBlockId and endBlockId may be in either order — the function * normalises to DOM order internally. * * @param startBlockId - The clientId of one end block * @param endBlockId - The clientId of the other end block * @param editorDocument - The editor document * @return Intermediate block HTMLElements in document order, descendants excluded */ const getBlocksBetween = ( startBlockId: string, endBlockId: string, editorDocument: Document ): HTMLElement[] => { const allBlocks = editorDocument.querySelectorAll< HTMLElement >( '[data-block]' ); let startIndex = -1; let endIndex = -1; for ( let i = 0; i < allBlocks.length; i++ ) { const blockId = allBlocks[ i ].getAttribute( 'data-block' ); if ( blockId === startBlockId ) { startIndex = i; } if ( blockId === endBlockId ) { endIndex = i; } } if ( startIndex === -1 || endIndex === -1 ) { return []; } // Normalize order. if ( startIndex > endIndex ) { [ startIndex, endIndex ] = [ endIndex, startIndex ]; } const result: HTMLElement[] = []; for ( let i = startIndex + 1; i < endIndex; i++ ) { const block = allBlocks[ i ]; // Skip descendants of blocks already in the result to prevent // double-highlights on nested blocks (e.g. list items inside a list). if ( ! result.some( ( r ) => r.contains( block ) ) ) { result.push( block ); } } return result; }; /** * Result returned by getOrderedBlockRange. */ export interface BlockRangeResult { /** DOM-order first element, promoted to its nearest [data-block] ancestor. */ firstEl: HTMLElement; /** data-block value of firstEl. */ firstId: string; /** DOM-order last element, promoted to its nearest [data-block] ancestor. */ lastEl: HTMLElement; /** data-block value of lastEl. */ lastId: string; /** Block elements strictly between first and last, descendants of either excluded. */ middleEls: HTMLElement[]; /** True when firstEl and lastEl resolve to the same container after promotion. */ sameContainer: boolean; } /** * Resolve two block clientIds to a DOM-ordered, promotion-aware block range. * * Handles: querySelector for both blocks (returns null if either is missing), * DOM-order normalisation, promotion via blockContainerOf, and retrieval of * intermediate blocks with descendants of the endpoints excluded. * * When the input IDs are already at container level (e.g. already promoted by * the caller), blockContainerOf is a no-op and the result is identical to a * plain query + normalise. * * @param startId - clientId of one block endpoint (may be in either DOM order). * @param endId - clientId of the other block endpoint. * @param doc - The editor document. * @return Ordered, promoted range, or null if either element is not in the DOM. */ export const getOrderedBlockRange = ( startId: string, endId: string, doc: Document ): BlockRangeResult | null => { const startEl = doc.querySelector< HTMLElement >( `[data-block="${ startId }"]` ); const endEl = doc.querySelector< HTMLElement >( `[data-block="${ endId }"]` ); if ( ! startEl || ! endEl ) { return null; } // Normalise to DOM order. const rawFirstEl = isNodeBefore( endEl, startEl ) ? endEl : startEl; const rawLastEl = isNodeBefore( endEl, startEl ) ? startEl : endEl; // Promote inner-block elements (e.g. list-items) to their nearest // [data-block] ancestor so both callers operate on container-level blocks. const firstEl = blockContainerOf( rawFirstEl ); const lastEl = blockContainerOf( rawLastEl ); const firstId = firstEl.getAttribute( 'data-block' )!; const lastId = lastEl.getAttribute( 'data-block' )!; const sameContainer = firstId === lastId; const middleEls = sameContainer ? [] : getBlocksBetween( firstId, lastId, doc ).filter( ( el ) => ! firstEl.contains( el ) && ! lastEl.contains( el ) ); return { firstEl, firstId, lastEl, lastId, middleEls, sameContainer }; }; /** * Given a block element and a character offset, returns an exact inner node and offset for use in a range. * * @param blockElement - The block element * @param offset - The character offset * @param editorDocument - The editor document * @return The node and offset of the character at the offset */ export const findInnerBlockOffset = ( blockElement: HTMLElement, offset: number, editorDocument: Document ) => { const treeWalker = editorDocument.createTreeWalker( blockElement, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT // eslint-disable-line no-bitwise ); let currentOffset = 0; let lastTextNode: Node | null = null; let node: Node | null = null; let nodeCount = 1; while ( ( node = treeWalker.nextNode() ) ) { nodeCount++; if ( nodeCount > MAX_NODE_OFFSET_COUNT ) { // If we've walked too many nodes, return the last text node or the beginning of the block. if ( lastTextNode ) { return { node: lastTextNode, offset: 0 }; } return { node: blockElement, offset: 0 }; } const nodeLength = node.nodeValue?.length ?? 0; if ( node.nodeType === Node.ELEMENT_NODE ) { if ( node.nodeName === 'BR' ) { // Treat <br> as a single "\n" character. if ( currentOffset + 1 >= offset ) { // If the <br> occurs right on the target offset, return the next text node. const nodeAfterBr = treeWalker.nextNode(); if ( nodeAfterBr?.nodeType === Node.TEXT_NODE ) { return { node: nodeAfterBr, offset: 0 }; } else if ( lastTextNode ) { // If there's no text node after the <br>, return the end offset of the last text node. return { node: lastTextNode, offset: lastTextNode.nodeValue?.length ?? 0, }; } // Just in case, if there's no last text node, return the beginning of the block. return { node: blockElement, offset: 0 }; } // The <br> is before the target offset. Count it as a single character. currentOffset += 1; continue; } else { // Skip other element types. continue; } } if ( nodeLength === 0 ) { // Skip empty nodes. continue; } if ( currentOffset + nodeLength >= offset ) { // This node exceeds the target offset. Return the node and the position of the offset within it. return { node, offset: offset - currentOffset }; } currentOffset += nodeLength; if ( node.nodeType === Node.TEXT_NODE ) { lastTextNode = node; } } if ( lastTextNode && lastTextNode.nodeValue?.length ) { // We didn't reach the target offset. Return the last text node's last character. return { node: lastTextNode, offset: lastTextNode.nodeValue.length }; } // We didn't find any text nodes. Return the beginning of the block. return { node: blockElement, offset: 0 }; }; /** * Check if node `a` precedes node `b` in document order. * * @param a - First node. * @param b - Second node. * @return True if `a` comes before `b`. */ const isNodeBefore = ( a: Node, b: Node ): boolean => // eslint-disable-next-line no-bitwise !! ( a.compareDocumentPosition( b ) & Node.DOCUMENT_POSITION_FOLLOWING );