import type { Editor } from '@tiptap/core' import type { Node } from '@tiptap/pm/model' import type { EditorView } from '@tiptap/pm/view' import type { NormalizedNestedOptions } from '../types/options.js' import { findBestDragTarget } from './findBestDragTarget.js' export type FindElementNextToCoords = { x: number y: number direction?: 'left' | 'right' editor: Editor nestedOptions?: NormalizedNestedOptions } /** * Finds the draggable block element that is a direct child of view.dom */ export function findClosestTopLevelBlock( element: Element, view: EditorView, ): HTMLElement | undefined { let current: Element | null = element while (current?.parentElement && current.parentElement !== view.dom) { current = current.parentElement } return current?.parentElement === view.dom ? (current as HTMLElement) : undefined } /** * Checks if a DOMRect has valid, finite dimensions. */ function isValidRect(rect: DOMRect): boolean { return ( Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && Number.isFinite(rect.left) && Number.isFinite(rect.right) && rect.width > 0 && rect.height > 0 ) } /** * Clamps coordinates to content bounds with O(1) layout reads */ function clampToContent( view: EditorView, x: number, y: number, inset = 5, ): { x: number; y: number } | null { // Validate input coordinates are finite numbers if (!Number.isFinite(x) || !Number.isFinite(y)) { return null } const container = view.dom const firstBlock = container.firstElementChild const lastBlock = container.lastElementChild if (!firstBlock || !lastBlock) { return null } // Clamp Y between first and last block const topRect = firstBlock.getBoundingClientRect() const botRect = lastBlock.getBoundingClientRect() // Validate bounding rects have finite values if (!isValidRect(topRect) || !isValidRect(botRect)) { return null } const clampedY = Math.min(Math.max(topRect.top + inset, y), botRect.bottom - inset) const epsilon = 0.5 const sameLeft = Math.abs(topRect.left - botRect.left) < epsilon const sameRight = Math.abs(topRect.right - botRect.right) < epsilon let rowRect: DOMRect = topRect if (sameLeft && sameRight) { // Most of the time, every block has the same width rowRect = topRect } else { // TODO // find the actual block at the clamped Y // This case is rare, avoid for now } // Clamp X to the chosen block's bounds const clampedX = Math.min(Math.max(rowRect.left + inset, x), rowRect.right - inset) // Final validation of output coordinates if (!Number.isFinite(clampedX) || !Number.isFinite(clampedY)) { return null } return { x: clampedX, y: clampedY } } export const findElementNextToCoords = ( options: FindElementNextToCoords, ): { resultElement: HTMLElement | null resultNode: Node | null pos: number | null } => { const { x, y, editor, nestedOptions } = options const { view, state } = editor const clamped = clampToContent(view, x, y, 5) // Return early if coordinates could not be clamped to valid bounds if (!clamped) { return { resultElement: null, resultNode: null, pos: null } } const { x: clampedX, y: clampedY } = clamped // When nested mode is enabled, use the scoring-based detection if (nestedOptions?.enabled) { const target = findBestDragTarget(view, { x: clampedX, y: clampedY }, nestedOptions) if (!target) { return { resultElement: null, resultNode: null, pos: null } } return { resultElement: target.dom, resultNode: target.node, pos: target.pos, } } // Original root-level detection for non-nested mode const elements = view.root.elementsFromPoint(clampedX, clampedY) let block: HTMLElement | undefined Array.prototype.some.call(elements, (el: Element) => { if (!view.dom.contains(el)) { return false } const candidate = findClosestTopLevelBlock(el, view) if (candidate) { block = candidate return true } return false }) if (!block) { // elementsFromPoint may return nothing if the coordinates land outside an element // (e.g., in margins, overlays, gaps, or indented nodes). posAtCoords uses the // caret API, so it still resolves to the nearest text position. const coords = view.posAtCoords({ left: clampedX, top: clampedY }) if (coords) { const $pos = state.doc.resolve(coords.pos) // Walk up to the top-level block const depth = Math.min($pos.depth, 1) const blockPos = depth > 0 ? $pos.before(depth) : $pos.pos const blockNode = state.doc.nodeAt(blockPos) if (blockNode) { const dom = view.nodeDOM(blockPos) return { resultElement: dom instanceof HTMLElement ? dom : null, resultNode: blockNode, pos: blockPos, } } } return { resultElement: null, resultNode: null, pos: null } } let pos: number try { pos = view.posAtDOM(block, 0) } catch { return { resultElement: null, resultNode: null, pos: null } } const node = state.doc.nodeAt(pos) if (!node) { // This case occurs when an atom node is allowed to contain inline content. // We need to resolve the position here to ensure we target the correct parent node. const resolvedPos = state.doc.resolve(pos) const parent = resolvedPos.parent return { resultElement: block, resultNode: parent, pos: resolvedPos.start(), } } return { resultElement: block, resultNode: node, pos, } }