Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/core/src/layout-bridge/clickToPositionDom.ts
/**
* DOM-based Click-to-Position Mapping
*
* Uses the browser's actual rendered DOM to find ProseMirror positions.
* This is more accurate than geometry-based calculation because it uses
* the browser's own text rendering with document.elementsFromPoint().
*
* DOM elements are tagged with data-pm-start and data-pm-end attributes,
* enabling binary search to find exact character positions.
* @packageDocumentation
* @public
*/
import { findBodyEmptyRuns, findBodyPmSpans } from './findBodyPmSpans';
/**
* Find ProseMirror position from a click using DOM-based detection.
*
* @param container - The pages container element
* @param clientX - Client X coordinate from mouse event
* @param clientY - Client Y coordinate from mouse event
* @param zoom - Current zoom level (default 1)
* @returns ProseMirror position, or null if not found
*/
export function clickToPositionDom(
container: HTMLElement,
clientX: number,
clientY: number,
zoom: number = 1
): number | null {
// Get all elements at the click point
const elements = document.elementsFromPoint(clientX, clientY);
// Find the page element
const pageEl = elements.find((el) => el.classList.contains('layout-page')) as HTMLElement | null;
if (!pageEl) return null;
// Find span with PM position data
const spanEl = elements.find(
(el) =>
el.tagName === 'SPAN' &&
(el as HTMLElement).dataset.pmStart !== undefined &&
(el as HTMLElement).dataset.pmEnd !== undefined
) as HTMLElement | null;
if (spanEl) {
return findPositionInSpan(spanEl, clientX, clientY);
}
// Check for empty paragraphs (including inside table cells)
const emptyRun = elements.find((el) =>
el.classList.contains('layout-empty-run')
) as HTMLElement | null;
if (emptyRun) {
const paragraph = emptyRun.closest('.layout-paragraph') as HTMLElement | null;
if (paragraph && paragraph.dataset.pmStart) {
return Number(paragraph.dataset.pmStart);
}
}
// Check for paragraph elements directly (handles clicks in whitespace
// to the right of text, or table cells where the narrow empty-run span
// isn't hit but the parent paragraph div is)
const paragraphEl = elements.find(
(el) =>
el.classList.contains('layout-paragraph') && (el as HTMLElement).dataset.pmStart !== undefined
) as HTMLElement | null;
if (paragraphEl && paragraphEl.dataset.pmStart) {
// Try to find the nearest span within this paragraph so clicks to the
// right of text land at the end of the line, not the paragraph start.
const nearestPos = findNearestSpanInElement(paragraphEl, clientX, clientY);
if (nearestPos !== null) {
return nearestPos;
}
return Number(paragraphEl.dataset.pmStart);
}
// Check if click is within a table cell. When clicking in empty space below
// text in a cell, restrict the search to spans within that cell to avoid
// the cursor jumping to a different cell (fixes #54).
const cellEl = elements.find((el) =>
el.classList.contains('layout-table-cell')
) as HTMLElement | null;
if (cellEl) {
return findNearestSpanInElement(cellEl, clientX, clientY);
}
// Fallback: Find nearest text span on the page
return findNearestSpan(container, pageEl, clientX, clientY, zoom);
}
/**
* Find exact position within a text span using binary search on character boundaries.
*/
function findPositionInSpan(spanEl: HTMLElement, clientX: number, _clientY: number): number | null {
const pmStart = Number(spanEl.dataset.pmStart);
const pmEnd = Number(spanEl.dataset.pmEnd);
// Special handling for tab spans - they have a visual width but only contain NBSP
// Clicking anywhere on a tab should position cursor at start or end based on click position
if (spanEl.classList.contains('layout-run-tab')) {
const rect = spanEl.getBoundingClientRect();
const midpoint = (rect.left + rect.right) / 2;
// Click in left half -> start of tab, right half -> end of tab
return clientX < midpoint ? pmStart : pmEnd;
}
const textNode = spanEl.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
// No text content - return start position
return pmStart;
}
const text = textNode as Text;
const textLength = text.length;
if (textLength === 0) {
return pmStart;
}
const ownerDoc = spanEl.ownerDocument;
if (!ownerDoc) return pmStart;
// Binary search for the character boundary
let left = 0;
let right = textLength;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const range = ownerDoc.createRange();
range.setStart(text, mid);
range.setEnd(text, mid);
const rect = range.getBoundingClientRect();
const charX = rect.left;
if (clientX < charX) {
right = mid;
} else {
left = mid + 1;
}
}
// Refine: check if we're closer to left-1 or left
if (left > 0 && left <= textLength) {
const range = ownerDoc.createRange();
// Get position of character at left-1
range.setStart(text, left - 1);
range.setEnd(text, left - 1);
const leftRect = range.getBoundingClientRect();
// Get position of character at left
range.setStart(text, Math.min(left, textLength));
range.setEnd(text, Math.min(left, textLength));
const rightRect = range.getBoundingClientRect();
// Use the closer boundary
const distLeft = Math.abs(clientX - leftRect.left);
const distRight = Math.abs(clientX - rightRect.left);
if (distLeft < distRight) {
return pmStart + (left - 1);
}
}
return pmStart + Math.min(left, pmEnd - pmStart);
}
/**
* Find the nearest text span within a specific element (e.g. a table cell).
* Used when a click lands in empty space within a cell to keep the cursor
* inside that cell rather than jumping to a different one.
*/
function findNearestSpanInElement(
element: HTMLElement,
clientX: number,
clientY: number
): number | null {
// Check for empty paragraphs within this element
const emptyRun = element.querySelector('.layout-empty-run') as HTMLElement | null;
if (emptyRun) {
const paragraph = emptyRun.closest('.layout-paragraph') as HTMLElement | null;
if (paragraph && paragraph.dataset.pmStart) {
return Number(paragraph.dataset.pmStart);
}
}
// Find the closest line within this element
const lines = element.querySelectorAll('.layout-line');
let closestLine: HTMLElement | null = null;
let closestLineDistance = Infinity;
for (const line of Array.from(lines)) {
const lineEl = line as HTMLElement;
const rect = lineEl.getBoundingClientRect();
const centerY = (rect.top + rect.bottom) / 2;
const distance = Math.abs(clientY - centerY);
if (distance < closestLineDistance) {
closestLineDistance = distance;
closestLine = lineEl;
}
}
if (!closestLine) {
// No lines - try paragraph directly
const paragraph = element.querySelector(
'.layout-paragraph[data-pm-start]'
) as HTMLElement | null;
if (paragraph?.dataset.pmStart) {
return Number(paragraph.dataset.pmStart);
}
// Last resort: use the cell's own PM position
if (element.dataset.pmStart) {
return Number(element.dataset.pmStart);
}
return null;
}
// Find closest span in that line
const lineSpans = closestLine.querySelectorAll('span[data-pm-start][data-pm-end]');
if (lineSpans.length === 0) {
const paragraph = closestLine.closest('.layout-paragraph') as HTMLElement | null;
if (paragraph?.dataset.pmStart) {
return Number(paragraph.dataset.pmStart);
}
return null;
}
let closestSpan: HTMLElement | null = null;
let closestSpanDistance = Infinity;
for (const span of Array.from(lineSpans)) {
const spanEl = span as HTMLElement;
const rect = spanEl.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right) {
return findPositionInSpan(spanEl, clientX, clientY);
}
const distance = clientX < rect.left ? rect.left - clientX : clientX - rect.right;
if (distance < closestSpanDistance) {
closestSpanDistance = distance;
closestSpan = spanEl;
}
}
if (!closestSpan) return null;
const rect = closestSpan.getBoundingClientRect();
if (clientX < rect.left) {
return Number(closestSpan.dataset.pmStart);
} else {
return Number(closestSpan.dataset.pmEnd);
}
}
/**
* Find the nearest text span when click is not directly on text.
* This handles clicks in margins, between lines, etc.
*/
function findNearestSpan(
_container: HTMLElement,
pageEl: HTMLElement,
clientX: number,
clientY: number,
_zoom: number
): number | null {
// Get all text spans on this page
const spans = pageEl.querySelectorAll('span[data-pm-start][data-pm-end]');
if (spans.length === 0) {
// No text spans - return position based on paragraph
const paragraphs = pageEl.querySelectorAll('.layout-paragraph');
if (paragraphs.length > 0) {
const firstP = paragraphs[0] as HTMLElement;
return Number(firstP.dataset.pmStart) || 0;
}
return null;
}
// Find the closest line to the click Y
const lines = pageEl.querySelectorAll('.layout-line');
let closestLine: HTMLElement | null = null;
let closestLineDistance = Infinity;
for (const line of Array.from(lines)) {
const lineEl = line as HTMLElement;
const rect = lineEl.getBoundingClientRect();
const centerY = (rect.top + rect.bottom) / 2;
const distance = Math.abs(clientY - centerY);
if (distance < closestLineDistance) {
closestLineDistance = distance;
closestLine = lineEl;
}
}
if (!closestLine) return null;
// Get spans in this line
const lineSpans = closestLine.querySelectorAll('span[data-pm-start][data-pm-end]');
if (lineSpans.length === 0) {
// Empty line - find PM position from paragraph
const paragraph = closestLine.closest('.layout-paragraph') as HTMLElement | null;
if (paragraph?.dataset.pmStart) {
return Number(paragraph.dataset.pmStart);
}
return null;
}
// Find closest span in the line
let closestSpan: HTMLElement | null = null;
let closestSpanDistance = Infinity;
for (const span of Array.from(lineSpans)) {
const spanEl = span as HTMLElement;
const rect = spanEl.getBoundingClientRect();
// Check if click is within span bounds
if (clientX >= rect.left && clientX <= rect.right) {
return findPositionInSpan(spanEl, clientX, clientY);
}
// Calculate distance to span
const distance = clientX < rect.left ? rect.left - clientX : clientX - rect.right;
if (distance < closestSpanDistance) {
closestSpanDistance = distance;
closestSpan = spanEl;
}
}
if (!closestSpan) return null;
const rect = closestSpan.getBoundingClientRect();
// If click is to the left, return start; if right, return end
if (clientX < rect.left) {
return Number(closestSpan.dataset.pmStart);
} else {
return Number(closestSpan.dataset.pmEnd);
}
}
/**
* Get selection rectangles for a PM range using DOM-based detection.
*
* @param container - The pages container element
* @param from - Start PM position
* @param to - End PM position
* @param overlayRect - Bounding rect of the selection overlay
* @returns Array of selection rectangles in overlay coordinates
*/
export interface DomSelectionRect {
x: number;
y: number;
width: number;
height: number;
pageIndex: number;
}
/** A rect whose height drops below this after clipping is treated as fully hidden. */
const MIN_VISIBLE_RECT = 0.5;
/**
* Vertically clip a client rect to the enclosing page-fragment table's visible
* box. A table that breaks across a page sits in a `.layout-table` with
* `overflow:hidden` and `height = visibleHeight`; getClientRects() still reports
* the geometry of lines that are visually clipped (off the page / in the
* inter-page gap), so selection highlights must be clipped to the same box.
*
* Uses `.layout-table:not(.layout-nested-table)` because only the page-fragment
* table carries the window clip — the inner nested table has no overflow:hidden.
* Clips vertically only (the gap bug is vertical; horizontal clipping could trim
* change bars on tracked-change tables that set overflow-x: visible).
*
* Returns null when the rect is fully outside the window.
*/
export function clipRectToTableWindow(
spanEl: Element,
rect: {
readonly left: number;
readonly top: number;
readonly right: number;
readonly bottom: number;
}
): { left: number; top: number; right: number; bottom: number } | null {
const clipEl = spanEl.closest('.layout-table:not(.layout-nested-table)');
if (!clipEl) return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
const clip = clipEl.getBoundingClientRect();
const top = Math.max(rect.top, clip.top);
const bottom = Math.min(rect.bottom, clip.bottom);
if (bottom - top <= MIN_VISIBLE_RECT) return null;
return { left: rect.left, top, right: rect.right, bottom };
}
const BLANK_LINE_SELECTION_WIDTH_PX = 4;
function blankLineSliverRect(markerEl: HTMLElement, overlayRect: DOMRect): DomSelectionRect | null {
const lineEl = markerEl.closest('.layout-line') as HTMLElement | null;
const lineBox = (lineEl ?? markerEl).getBoundingClientRect();
const left = markerEl.getBoundingClientRect().left;
const clipped = clipRectToTableWindow(markerEl, {
left,
top: lineBox.top,
right: left + BLANK_LINE_SELECTION_WIDTH_PX,
bottom: lineBox.bottom,
});
if (!clipped) return null;
const pageEl = markerEl.closest('.layout-page') as HTMLElement | null;
return {
x: clipped.left - overlayRect.left,
y: clipped.top - overlayRect.top,
width: clipped.right - clipped.left,
height: clipped.bottom - clipped.top,
pageIndex: pageEl ? Number(pageEl.dataset.pageNumber || 1) - 1 : 0,
};
}
function emptyParagraphRangeFor(emptyRun: HTMLElement): { pmStart: number; pmEnd: number } | null {
const paragraph = emptyRun.closest('.layout-paragraph') as HTMLElement | null;
if (!paragraph) return null;
const pmStart = Number(paragraph.dataset.pmStart);
const pmEnd = Number(paragraph.dataset.pmEnd);
if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd)) return null;
return { pmStart, pmEnd };
}
export function getSelectionRectsFromDom(
container: HTMLElement,
from: number,
to: number,
overlayRect: DOMRect
): DomSelectionRect[] {
const rects: DomSelectionRect[] = [];
// Find all body spans that intersect with the selection. Scoped via
// `findBodyPmSpans` so HF spans (whose PM positions live in a
// separate document) don't bleed selection rects into headers when
// body PM positions happen to overlap. Same fix as #406 / #408 on
// the React-side selection-resolution path.
const spans = findBodyPmSpans(container);
for (const span of Array.from(spans)) {
const spanEl = span as HTMLElement;
const pmStart = Number(spanEl.dataset.pmStart);
const pmEnd = Number(spanEl.dataset.pmEnd);
// Check if span overlaps with selection
if (pmEnd <= from || pmStart >= to) continue;
// Clip each rect to the enclosing split-table's visible window so selecting
// across a page-broken table doesn't scatter boxes into the inter-page gap
// (getClientRects reports geometry for lines hidden by the table's
// overflow:hidden).
const pageEl = spanEl.closest('.layout-page') as HTMLElement | null;
const pageIndex = pageEl ? Number(pageEl.dataset.pageNumber || 1) - 1 : 0;
const pushClipped = (rect: { left: number; top: number; right: number; bottom: number }) => {
const clipped = clipRectToTableWindow(spanEl, rect);
if (!clipped) return;
rects.push({
x: clipped.left - overlayRect.left,
y: clipped.top - overlayRect.top,
width: clipped.right - clipped.left,
height: clipped.bottom - clipped.top,
pageIndex,
});
};
if (pmStart === pmEnd) {
const sliver = blankLineSliverRect(spanEl, overlayRect);
if (sliver) rects.push(sliver);
continue;
}
// Tab runs render as fixed-width spans with no text node — use their box.
if (spanEl.classList.contains('layout-run-tab')) {
pushClipped(spanEl.getBoundingClientRect());
continue;
}
// Text node may be a direct child, or nested inside an <a> for hyperlinks.
let textNode: Text | null = null;
if (spanEl.firstChild?.nodeType === Node.TEXT_NODE) {
textNode = spanEl.firstChild as Text;
} else if (
spanEl.firstChild?.nodeType === Node.ELEMENT_NODE &&
(spanEl.firstChild as HTMLElement).tagName === 'A' &&
spanEl.firstChild.firstChild?.nodeType === Node.TEXT_NODE
) {
textNode = spanEl.firstChild.firstChild as Text;
}
if (!textNode) continue;
const ownerDoc = spanEl.ownerDocument;
if (!ownerDoc) continue;
// Calculate character range within this span
const startChar = Math.max(0, from - pmStart);
const endChar = Math.min(textNode.length, to - pmStart);
if (startChar >= endChar) continue;
// Create range for the selected text
const range = ownerDoc.createRange();
range.setStart(textNode, startChar);
range.setEnd(textNode, endChar);
// Get all client rects (handles line wraps)
for (const clientRect of Array.from(range.getClientRects())) {
pushClipped(clientRect);
}
}
for (const emptyRun of findBodyEmptyRuns(container)) {
const range = emptyParagraphRangeFor(emptyRun);
if (!range || range.pmEnd <= from || range.pmStart >= to) continue;
const sliver = blankLineSliverRect(emptyRun, overlayRect);
if (sliver) rects.push(sliver);
}
return rects;
}
/**
* Get caret position from DOM for a PM position.
*
* @param container - The pages container element
* @param pmPos - ProseMirror position
* @param overlayRect - Bounding rect of the selection overlay
* @returns Caret position in overlay coordinates, or null
*/
export interface DomCaretPosition {
x: number;
y: number;
height: number;
pageIndex: number;
}
/**
* A table fragment clips its content with `overflow:hidden` + a fixed height,
* translating rows for the visible page window. When a row breaks mid-content
* across pages, the painter renders the SAME cell content in both fragments
* with identical `data-pm-start`/`data-pm-end` — only one copy is on-window.
* An element whose box lies outside its `.layout-table` fragment's box is the
* off-page duplicate and must not win the caret lookup (issue #763).
*/
function isClippedOutOfTableFragment(el: HTMLElement, rect: DOMRect): boolean {
// `:not(.layout-nested-table)` because only the page-fragment table carries
// the `overflow:hidden` + window translate; a nested table has no clip, so
// its box would never indicate the off-page duplicate (matches
// `clipRectToTableWindow`).
const tableEl = el.closest('.layout-table:not(.layout-nested-table)') as HTMLElement | null;
if (!tableEl) return false;
const t = tableEl.getBoundingClientRect();
const mid = (rect.top + rect.bottom) / 2;
return mid < t.top - 0.5 || mid > t.bottom + 0.5;
}
/** Build the caret position from a matched span (tab / text / empty run). */
function caretFromSpan(
spanEl: HTMLElement,
pmPos: number,
pmStart: number,
overlayRect: DOMRect
): DomCaretPosition {
const pageEl = spanEl.closest('.layout-page') as HTMLElement | null;
const pageIndex = pageEl ? Number(pageEl.dataset.pageNumber || 1) - 1 : 0;
const lineEl = spanEl.closest('.layout-line');
const lineHeight = lineEl ? (lineEl as HTMLElement).offsetHeight : 16;
const textNode = spanEl.firstChild;
const isTab = spanEl.classList.contains('layout-run-tab');
if (isTab || !textNode || textNode.nodeType !== Node.TEXT_NODE) {
// Tab or no text node — caret at the span's left edge.
const spanRect = spanEl.getBoundingClientRect();
return {
x: spanRect.left - overlayRect.left,
y: spanRect.top - overlayRect.top,
height: lineHeight,
pageIndex,
};
}
const text = textNode as Text;
const charIndex = Math.min(pmPos - pmStart, text.length);
const ownerDoc = spanEl.ownerDocument!;
const range = ownerDoc.createRange();
range.setStart(text, charIndex);
range.setEnd(text, charIndex);
const rangeRect = range.getBoundingClientRect();
// Caret height tracks the run AT the cursor, not the line box: on a line
// with mixed font sizes the line box is as tall as the largest run, but
// the caret should match the font where the insertion point sits, like
// Word (#748). The collapsed range's rect already reports the per-run
// font box (and its top is baseline-aligned), so use it; fall back to the
// line height if the browser returns a zero-height rect.
return {
x: rangeRect.left - overlayRect.left,
y: rangeRect.top - overlayRect.top,
height: rangeRect.height || lineHeight,
pageIndex,
};
}
export function getCaretPositionFromDom(
container: HTMLElement,
pmPos: number,
overlayRect: DOMRect
): DomCaretPosition | null {
// Body-scoped span lookup so HF spans don't mis-resolve the caret
// when a body PM position happens to fall within an HF range.
const spans = findBodyPmSpans(container);
// A position that lands inside a table cell which breaks across pages can
// match the SAME run painted in two fragments. Prefer the on-window copy;
// remember the first match as a fallback if every copy is clipped out.
let fallback: HTMLElement | null = null;
let fallbackStart = 0;
for (const span of Array.from(spans)) {
const spanEl = span as HTMLElement;
const pmStart = Number(spanEl.dataset.pmStart);
const pmEnd = Number(spanEl.dataset.pmEnd);
// Tab spans use an exclusive end to avoid boundary conflicts: a tab at
// [5,6) means position 6 belongs to the next run, not the tab.
const isTab = spanEl.classList.contains('layout-run-tab');
const matches = isTab ? pmPos >= pmStart && pmPos < pmEnd : pmPos >= pmStart && pmPos <= pmEnd;
if (!matches) continue;
const spanRect = spanEl.getBoundingClientRect();
if (isClippedOutOfTableFragment(spanEl, spanRect)) {
if (!fallback) {
fallback = spanEl;
fallbackStart = pmStart;
}
continue;
}
return caretFromSpan(spanEl, pmPos, pmStart, overlayRect);
}
if (fallback) {
return caretFromSpan(fallback, pmPos, fallbackStart, overlayRect);
}
// Check empty paragraphs (prefer an on-window copy here too).
const paragraphs = container.querySelectorAll('.layout-paragraph');
let pFallback: { el: HTMLElement; targetEl: HTMLElement } | null = null;
for (const p of Array.from(paragraphs)) {
const pEl = p as HTMLElement;
const pStart = Number(pEl.dataset.pmStart);
const pEnd = Number(pEl.dataset.pmEnd);
if (pmPos >= pStart && pmPos <= pEnd) {
const emptyRun = pEl.querySelector('.layout-empty-run');
const targetEl = (emptyRun as HTMLElement) || pEl;
const rect = targetEl.getBoundingClientRect();
if (isClippedOutOfTableFragment(pEl, rect)) {
if (!pFallback) pFallback = { el: pEl, targetEl };
continue;
}
return caretFromParagraph(pEl, targetEl, overlayRect);
}
}
if (pFallback) {
return caretFromParagraph(pFallback.el, pFallback.targetEl, overlayRect);
}
return null;
}
/** Caret position for an empty paragraph / its empty-run placeholder. */
function caretFromParagraph(
pEl: HTMLElement,
targetEl: HTMLElement,
overlayRect: DOMRect
): DomCaretPosition {
const rect = targetEl.getBoundingClientRect();
const pageEl = pEl.closest('.layout-page') as HTMLElement | null;
const pageIndex = pageEl ? Number(pageEl.dataset.pageNumber || 1) - 1 : 0;
const lineEl = targetEl.closest('.layout-line') || targetEl;
const lineHeight = (lineEl as HTMLElement).offsetHeight || 16;
return {
x: rect.left - overlayRect.left,
y: rect.top - overlayRect.top,
height: lineHeight,
pageIndex,
};
}