Operand

do no harem.

gram: docs

> ./packages/react/src/components/DocxEditor/hooks/usePagesPointer.ts

/**
* Pointer-routing hook for PagedEditor.
*
* Owns every mouse path that lands on the visible pages: cursor placement,
* drag-to-select (with cell-selection promotion when the drag crosses a
* table boundary), table column / row / right-edge resize handles, the
* floating "+" row/column insert button, image clicks, hyperlink and
* header/footer double-clicks, word and paragraph multi-click selection,
* and the right-click → host context-menu callback.
*
* Lots of state. Most lives in refs because the handlers run from window
* listeners (handleMouseMove, handleMouseUp) where stale-closure traps
* would be lethal — refs are read at event time, not capture time.
*
* `dragExtendRef` is the trampoline that lets `useDragAutoScroll`'s
* auto-extend callback reach `getPositionFromMouse` without the two
* forming a closure cycle. The trampoline is assigned after the hook's
* `useCallback`s so the wire-up sees the latest `getPositionFromMouse`
* identity on every render.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { NodeSelection, TextSelection } from 'prosemirror-state';
import { CellSelection } from 'prosemirror-tables';
import type { EditorView } from 'prosemirror-view';
import type { CaretPosition, SelectionRect } from '@eigenpal/docx-editor-core/layout-bridge';
import {
clickToPosition,
clickToPositionDom,
detectTableInsertHover,
hitTestFragment,
hitTestTableCell,
TABLE_INSERT_HIDE_DELAY_MS as TABLE_INSERT_HIDE_DELAY,
} from '@eigenpal/docx-editor-core/layout-bridge';
import type { FlowBlock, Layout, Measure } from '@eigenpal/docx-editor-core/layout-engine';
import { addColumnRight, addRowBelow } from '@eigenpal/docx-editor-core/prosemirror';
import {
captureInlinePositionEmu,
findImageElement as coreFindImageElement,
hitTestImage,
} from '@eigenpal/docx-editor-core/layout-painter';
import type { WrapType } from '@eigenpal/docx-editor-core/docx/wrapTypes';
import { findWordBoundaries } from '@eigenpal/docx-editor-core/utils';
import type { HiddenProseMirrorRef } from '../HiddenProseMirror';
import type { ImageSelectionInfo } from '../overlays/ImageSelectionOverlay';
import { useDragAutoScroll } from '../../../hooks/useDragAutoScroll';
import { useTableResizeState } from './useTableResizeState';
import {
createCellDragTracker,
findCellPosFromPmPos as coreFindCellPosFromPmPos,
} from '@eigenpal/docx-editor-core/prosemirror/cellDragSelection';
interface TableInsertButtonState {
type: 'row' | 'column';
/** Pixel position relative to viewport container */
x: number;
y: number;
/** PM position inside target cell (to set selection before dispatching) */
cellPmPos: number;
}
interface ImageInfo {
pos: number;
wrapType: WrapType;
cssFloat?: 'left' | 'right' | 'none' | null;
inlinePositionEmu?: { horizontalEmu: number; verticalEmu: number };
}
export interface UsePagesPointerOptions {
pagesContainerRef: React.RefObject<HTMLDivElement | null>;
hiddenPMRef: React.RefObject<HiddenProseMirrorRef | null>;
/**
* Active HF EditorView lookup — when `hfEditMode` is truthy, every
* gesture (single-click, drag, multi-click, image-select, hyperlink) is
* routed through this view instead of the body PM. Without it the hook
* stays single-surface and only the body PM receives input.
*/
getHfView?: () => EditorView | null;
layout: Layout | null;
blocks: FlowBlock[];
measures: Measure[];
zoom: number;
readOnly: boolean;
hfEditMode?: 'header' | 'footer' | null;
onBodyClick?: () => void;
onContextMenu?: (data: {
x: number;
y: number;
hasSelection: boolean;
image?: ImageInfo | null;
}) => void;
onHyperlinkClick?: (data: {
href: string;
displayText: string;
tooltip?: string;
position: { top: number; left: number };
}) => void;
onHeaderFooterDoubleClick?: (position: 'header' | 'footer', pageNumber?: number) => void;
setSelectedImageInfo: React.Dispatch<React.SetStateAction<ImageSelectionInfo | null>>;
setSelectionRects: React.Dispatch<React.SetStateAction<SelectionRect[]>>;
setCaretPosition: React.Dispatch<React.SetStateAction<CaretPosition | null>>;
buildImageSelectionInfo: (el: HTMLElement, pmPos: number) => ImageSelectionInfo;
setIsFocused: React.Dispatch<React.SetStateAction<boolean>>;
scrollToPositionImpl: (pmPos: number, forParaIdScroll?: boolean) => void;
}
export interface UsePagesPointerReturn {
handlePagesMouseDown: (e: React.MouseEvent) => void;
handlePagesMouseMove: (e: React.MouseEvent) => void;
handlePagesClick: (e: React.MouseEvent) => void;
handlePagesContextMenu: (e: React.MouseEvent) => void;
handleTableInsertClick: (e: React.MouseEvent) => void;
tableInsertButton: TableInsertButtonState | null;
/** Cancel a pending delayed-hide so hovering the button keeps it visible. */
clearTableInsertTimer: () => void;
/** Hide the button immediately (used by the button's onMouseLeave). */
hideTableInsertButton: () => void;
getPositionFromMouse: (clientX: number, clientY: number) => number | null;
}
/**
* Minimal surface every pointer gesture needs from "the PM the user is
* editing." Body PM (`HiddenProseMirrorRef`) and HF PM (raw `EditorView`)
* both project into this shape. Routing through `activeSurface()` keeps
* the handler body single-pipeline: drag, multi-click, image-select,
* hyperlink, table-cell selection all flow through whichever PM is
* active without the handler caring which one.
*/
interface ActivePmSurface {
getView(): EditorView | null;
setSelection(anchor: number, head?: number): void;
setNodeSelection(pos: number): void;
setCellSelection(anchorCellPos: number, headCellPos: number): void;
focus(): void;
}
function wrapEditorViewAsSurface(view: EditorView): ActivePmSurface {
return {
getView: () => view,
setSelection(anchor, head) {
const headPos = head ?? anchor;
try {
const $a = view.state.doc.resolve(anchor);
const $h = view.state.doc.resolve(headPos);
view.dispatch(view.state.tr.setSelection(TextSelection.between($a, $h)));
} catch {
// Out-of-range — fall back to start of doc.
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 0)));
}
},
setNodeSelection(pos) {
try {
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
} catch {
// Position may not be a valid node anchor.
}
},
setCellSelection(anchorCellPos, headCellPos) {
try {
const $a = view.state.doc.resolve(anchorCellPos);
const $h = view.state.doc.resolve(headCellPos);
view.dispatch(view.state.tr.setSelection(new CellSelection($a, $h)));
} catch {
// Not inside a table — ignore.
}
},
focus() {
view.focus();
},
};
}
export function usePagesPointer(opts: UsePagesPointerOptions): UsePagesPointerReturn {
const {
pagesContainerRef,
hiddenPMRef,
getHfView,
layout,
blocks,
measures,
zoom,
readOnly,
hfEditMode,
onBodyClick,
onContextMenu,
onHyperlinkClick,
onHeaderFooterDoubleClick,
setSelectedImageInfo,
setSelectionRects,
setCaretPosition,
buildImageSelectionInfo,
setIsFocused,
scrollToPositionImpl,
} = opts;
// Drag selection state
const isDraggingRef = useRef(false);
const dragAnchorRef = useRef<number | null>(null);
// Table resize state machine (column-between, row, right-edge handles).
// `getActiveHfView` lets the hook dispatch column/row commits on the HF
// EditorView when the handle lives inside `.layout-page-header/footer`,
// not on the body PM (which would corrupt the body doc with stray
// colWidth changes at out-of-range positions).
const tableResize = useTableResizeState({ hiddenPMRef, getActiveHfView: getHfView });
// Cell-drag selection state machine (shared with Vue via core).
const cellDragRef = useRef(createCellDragTracker());
// Table insert button state + delayed-hide timer
const [tableInsertButton, setTableInsertButton] = useState<TableInsertButtonState | null>(null);
const tableInsertHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTableInsertTimer = useCallback(() => {
if (tableInsertHideTimerRef.current) {
clearTimeout(tableInsertHideTimerRef.current);
tableInsertHideTimerRef.current = null;
}
}, []);
useEffect(() => {
return () => {
if (tableInsertHideTimerRef.current) clearTimeout(tableInsertHideTimerRef.current);
};
}, []);
// Trampoline so useDragAutoScroll's callback can reach getPositionFromMouse
// without forming a closure cycle. Assigned every render below.
const dragExtendRef = useRef<(cx: number, cy: number) => void>(() => {});
const dragAutoScrollCallbackRef = useCallback((cx: number, cy: number) => {
dragExtendRef.current(cx, cy);
}, []);
const { updateMousePosition: updateDragScroll, stopAutoScroll: stopDragAutoScroll } =
useDragAutoScroll({
pagesContainerRef,
onScrollExtendSelection: dragAutoScrollCallbackRef,
});
/**
* Convert mouse coords to a PM position. DOM-based mapping first
* (handles transforms, zoom, line-wraps); falls back to geometry hit
* tests when the DOM doesn't resolve (e.g. clicks above/below content).
*/
const getPositionFromMouse = useCallback(
(clientX: number, clientY: number): number | null => {
if (!pagesContainerRef.current || !layout) return null;
const domPos = clickToPositionDom(pagesContainerRef.current, clientX, clientY, zoom);
if (domPos !== null) return domPos;
// In HF edit mode, the geometry-based fallback below uses BODY blocks/
// measures — wrong coord space for HF clicks. If clickToPositionDom
// couldn't pin a span, find the nearest HF data-pm-start span at the
// same y so drag-select doesn't ping-pong between HF and body coords
// mid-drag. Returning null is safer than returning a body pos.
if (hfEditMode) {
const els = window.document.elementsFromPoint(clientX, clientY);
const hfHost = els.find(
(el) =>
(el as HTMLElement).closest('.layout-page-header') ||
(el as HTMLElement).closest('.layout-page-footer')
) as HTMLElement | undefined;
if (!hfHost) return null;
// Walk every painted span in the HF host; pick the closest by horizontal
// distance on the same line (within span vertical bounds).
const host = hfHost.closest('.layout-page-header') ?? hfHost.closest('.layout-page-footer');
if (!host) return null;
const spans = Array.from(
host.querySelectorAll<HTMLElement>('span[data-pm-start][data-pm-end]')
);
let best: { pos: number; dist: number } | null = null;
for (const span of spans) {
const r = span.getBoundingClientRect();
if (clientY < r.top - 4 || clientY > r.bottom + 4) continue;
const pmStart = Number(span.dataset.pmStart);
const pmEnd = Number(span.dataset.pmEnd);
if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd)) continue;
// Snap to span edge nearest the cursor.
let pos: number;
let dist: number;
if (clientX < r.left) {
pos = pmStart;
dist = r.left - clientX;
} else if (clientX > r.right) {
pos = pmEnd;
dist = clientX - r.right;
} else {
const ratio = (clientX - r.left) / Math.max(1, r.width);
pos = pmStart + Math.round(ratio * (pmEnd - pmStart));
dist = 0;
}
if (!best || dist < best.dist) best = { pos, dist };
}
return best?.pos ?? null;
}
const pageElements = pagesContainerRef.current.querySelectorAll('.layout-page');
let clickedPageIndex = -1;
let pageRect: DOMRect | null = null;
for (let i = 0; i < pageElements.length; i++) {
const pageEl = pageElements[i];
const rect = pageEl.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
clickedPageIndex = i;
pageRect = rect;
break;
}
}
if (clickedPageIndex < 0 || !pageRect) return null;
const pageX = (clientX - pageRect.left) / zoom;
const pageY = (clientY - pageRect.top) / zoom;
const page = layout.pages[clickedPageIndex];
if (!page) return null;
const pageHit = { pageIndex: clickedPageIndex, page, pageY };
const fragmentHit = hitTestFragment(pageHit, blocks, measures, { x: pageX, y: pageY });
if (!fragmentHit) return null;
if (fragmentHit.fragment.kind === 'table') {
const tableCellHit = hitTestTableCell(pageHit, blocks, measures, {
x: pageX,
y: pageY,
});
return clickToPosition(fragmentHit, tableCellHit);
}
return clickToPosition(fragmentHit);
},
[layout, blocks, measures, zoom, hfEditMode, pagesContainerRef]
);
/**
* Walk up from a PM position to find the enclosing tableCell / tableHeader.
* Returns the cell's `before(d)` so CellSelection.create can resolve via
* cellAround() internally.
*/
// Build the active surface for whichever PM the user is editing — HF view
// when `hfEditMode` is set AND `getHfView` resolves, body PM otherwise.
// Holding it as a function (not a value) lets the closure see the latest
// `hfEditMode` on each gesture without rebuilding handler callbacks.
const activeSurface = useCallback((): ActivePmSurface | null => {
if (hfEditMode && getHfView) {
const hfView = getHfView();
if (hfView) return wrapEditorViewAsSurface(hfView);
}
return hiddenPMRef.current;
}, [hfEditMode, getHfView, hiddenPMRef]);
const findCellPosFromPmPos = useCallback(
(pmPos: number): number | null => {
const view = activeSurface()?.getView();
return view ? coreFindCellPosFromPmPos(view, pmPos) : null;
},
[activeSurface]
);
const handlePagesMouseDown = useCallback(
(e: React.MouseEvent) => {
const surface = activeSurface();
if (!surface) return;
// Right-click: stop Firefox from resetting selection, but skip our routing.
if (e.button === 2) {
e.preventDefault();
return;
}
if (e.button !== 0) return;
// Any mousedown hides the floating table-insert button.
setTableInsertButton(null);
clearTableInsertTimer();
// Prevent native hyperlink navigation but let the rest of the handler
// run so cursor placement / drag-selection still work. The popup is
// shown on click (mouseup) instead.
const anchorEl = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;
if (anchorEl) e.preventDefault();
if (readOnly) return;
const target = e.target as HTMLElement;
// HF edit mode: clicks outside the painted header/footer exit the HF
// editor — that's the only HF-specific carve-out. Everything else
// (table resize, image select, drag, multi-click) flows through the
// unified pipeline below via `surface`, which auto-routes to the HF
// EditorView when `hfEditMode` is set.
if (hfEditMode) {
const isInHfArea =
target.closest('.layout-page-header') ||
target.closest('.layout-page-footer') ||
target.closest('.hf-inline-editor');
if (!isInHfArea && onBodyClick) {
e.preventDefault();
e.stopPropagation();
onBodyClick();
return;
}
} else {
// Normal mode: single-click on H/F area is a no-op (matches Word —
// don't yank the body caret to position 0). Double-click (`e.detail === 2`)
// falls through to the dblclick branch below where HF edit mode engages.
const isInHfArea =
target.closest('.layout-page-header') || target.closest('.layout-page-footer');
if (isInHfArea && e.detail !== 2) {
e.preventDefault();
return;
}
}
// Table resize handles (column-between, row, right-edge). Body OR
// header tables — `tableResize.tryStartFromMouseDown` doesn't care
// which document the cells belong to, only that the click landed on
// a `.layout-table-*-handle`.
if (tableResize.tryStartFromMouseDown(target, e)) return;
// Image click → NodeSelection on the active doc.
const imageEl = coreFindImageElement(target);
if (imageEl) {
e.preventDefault();
e.stopPropagation();
const pmStart = imageEl.dataset.pmStart;
if (pmStart !== undefined) {
const pos = parseInt(pmStart, 10);
surface.setNodeSelection(pos);
setSelectedImageInfo(buildImageSelectionInfo(imageEl, pos));
setSelectionRects([]);
setCaretPosition(null);
}
surface.focus();
if (!hfEditMode) setIsFocused(true);
return;
}
// Click outside an image clears the image selection.
setSelectedImageInfo(null);
e.preventDefault();
const pmPos = getPositionFromMouse(e.clientX, e.clientY);
if (pmPos !== null) {
// Track for potential text-drag → cell-drag promotion.
cellDragRef.current.begin(findCellPosFromPmPos(pmPos));
isDraggingRef.current = true;
dragAnchorRef.current = pmPos;
surface.setSelection(pmPos);
} else {
// Click outside content — move cursor to end of active doc.
cellDragRef.current.begin(null);
const view = surface.getView();
if (view) {
const endPos = Math.max(0, view.state.doc.content.size - 1);
surface.setSelection(endPos);
dragAnchorRef.current = endPos;
isDraggingRef.current = true;
}
}
surface.focus();
if (!hfEditMode) setIsFocused(true);
},
[
activeSurface,
readOnly,
hfEditMode,
onBodyClick,
getPositionFromMouse,
findCellPosFromPmPos,
tableResize,
clearTableInsertTimer,
setSelectedImageInfo,
setSelectionRects,
setCaretPosition,
buildImageSelectionInfo,
setIsFocused,
]
);
// Re-wire the drag trampoline every render so it sees the latest
// `getPositionFromMouse` closure + the latest active surface.
dragExtendRef.current = (cx: number, cy: number) => {
if (!isDraggingRef.current || dragAnchorRef.current === null) return;
const surface = activeSurface();
if (!surface) return;
const pmPos = getPositionFromMouse(cx, cy);
if (pmPos === null) return;
surface.setSelection(dragAnchorRef.current, pmPos);
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
// Table resize drag — one of column / row / right-edge updates the
// visual handle and the tentative width / height.
if (tableResize.handleMouseMoveUpdate(e)) return;
if (!isDraggingRef.current || dragAnchorRef.current === null) return;
const surface = activeSurface();
if (!surface || !pagesContainerRef.current) return;
updateDragScroll(e.clientX, e.clientY);
const pmPos = getPositionFromMouse(e.clientX, e.clientY);
if (pmPos === null) return;
// A drag that crosses cell boundaries is promoted to a CellSelection;
// when it handles the move, skip the text-selection update.
const view = surface.getView();
if (view && cellDragRef.current.update(view, pmPos, e.clientX)) return;
// Regular text-selection drag (outside tables, or inside a single cell).
const anchor = dragAnchorRef.current;
surface.setSelection(anchor, pmPos);
},
[
activeSurface,
getPositionFromMouse,
findCellPosFromPmPos,
updateDragScroll,
tableResize,
pagesContainerRef,
]
);
const handleMouseUp = useCallback(() => {
// Resize commit (column / row / right-edge) takes priority.
if (tableResize.tryCommit()) return;
isDraggingRef.current = false;
cellDragRef.current.end();
stopDragAutoScroll();
// Keep dragAnchorRef for potential shift-click extension.
}, [stopDragAutoScroll, tableResize]);
// Global mousemove / mouseup listeners — drag selection escapes the
// pagesContainer once you mouse out of it, so the listeners must live on
// window.
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
const handlePagesMouseMove = useCallback(
(e: React.MouseEvent) => {
// Skip during drags / resizes.
if (
readOnly ||
isDraggingRef.current ||
cellDragRef.current.isCellDragging ||
tableResize.isAnyResizeActive()
)
return;
const pagesEl = pagesContainerRef.current;
if (!pagesEl) return;
const hit = detectTableInsertHover({
mouseX: e.clientX,
mouseY: e.clientY,
pagesContainer: pagesEl,
target: e.target as HTMLElement,
hfEditMode: hfEditMode ?? null,
});
if (!hit) {
// Brief moves between cells flicker the button; schedule a delayed
// hide instead of clearing immediately. detectTableInsertHover
// returns null for both "no nearby table" and "near table but not
// over a row/column"; both deserve the same delayed-hide UX.
if (!tableInsertHideTimerRef.current) {
tableInsertHideTimerRef.current = setTimeout(() => {
setTableInsertButton(null);
tableInsertHideTimerRef.current = null;
}, TABLE_INSERT_HIDE_DELAY);
}
return;
}
const viewportEl = pagesEl.parentElement;
if (!viewportEl) return;
const viewportRect = viewportEl.getBoundingClientRect();
setTableInsertButton({
type: hit.type,
x: hit.clientX - viewportRect.left,
y: hit.clientY - viewportRect.top,
cellPmPos: hit.cellPmPos,
});
clearTableInsertTimer();
},
[readOnly, clearTableInsertTimer, hfEditMode, pagesContainerRef]
);
const handleTableInsertClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!tableInsertButton) return;
const surface = activeSurface();
const view = surface?.getView();
if (!surface || !view) return;
const { type, cellPmPos } = tableInsertButton;
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, cellPmPos + 1));
view.dispatch(tr);
if (type === 'row') {
addRowBelow(view.state, view.dispatch);
} else {
addColumnRight(view.state, view.dispatch);
}
setTableInsertButton(null);
surface.focus();
},
[tableInsertButton, activeSurface]
);
const handlePagesClick = useCallback(
(e: React.MouseEvent) => {
const surface = activeSurface();
// Hyperlink: bookmark anchor (#name) or external href.
const anchorEl = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;
if (anchorEl && surface) {
e.preventDefault();
const href = anchorEl.getAttribute('href') || '';
const view = surface.getView();
if (href.startsWith('#')) {
const bookmarkName = href.substring(1);
if (bookmarkName && view) {
let targetPos: number | null = null;
view.state.doc.descendants((node, pos) => {
if (targetPos !== null) return false;
if (node.type.name === 'paragraph') {
const bookmarks = node.attrs.bookmarks as
| Array<{ id: number; name: string }>
| undefined;
if (bookmarks?.some((b) => b.name === bookmarkName)) {
targetPos = pos;
return false;
}
}
});
if (targetPos !== null) {
scrollToPositionImpl(targetPos);
surface.setSelection(targetPos + 1);
}
}
} else if (onHyperlinkClick) {
// External hyperlink — show popup unless this is a drag-to-select.
const hasRangeSelection = view && view.state.selection.from !== view.state.selection.to;
if (!hasRangeSelection) {
const displayText = anchorEl.textContent || '';
const tooltip = anchorEl.getAttribute('title') || undefined;
const root = anchorEl.closest('.ep-root.paged-editor') as HTMLElement | null;
if (root) {
const rootRect = root.getBoundingClientRect();
const linkRect = anchorEl.getBoundingClientRect();
const position = {
top: linkRect.bottom - rootRect.top + 4,
left: linkRect.left - rootRect.left,
};
onHyperlinkClick({ href, displayText, tooltip, position });
}
}
}
return;
}
// Double-click on header/footer area → enter HF editing mode. Only
// fires when NOT already in HF mode — once engaged, the dblclick falls
// through to the word-select / cell-select branches below.
if (e.detail === 2 && !hfEditMode && onHeaderFooterDoubleClick) {
const target = e.target as HTMLElement;
const headerEl = target.closest('.layout-page-header');
const footerEl = target.closest('.layout-page-footer');
if (headerEl || footerEl) {
const pageEl = target.closest('[data-page-number]') as HTMLElement | null;
const pageNum = pageEl ? Number(pageEl.dataset.pageNumber) : 1;
e.preventDefault();
e.stopPropagation();
onHeaderFooterDoubleClick(headerEl ? 'header' : 'footer', pageNum);
return;
}
}
if (!surface) return;
const view = surface.getView();
if (!view) return;
// Double-click: cell selection if inside a table, otherwise word selection.
if (e.detail === 2) {
const pmPos = getPositionFromMouse(e.clientX, e.clientY);
if (pmPos !== null) {
const cellPos = findCellPosFromPmPos(pmPos);
if (cellPos !== null) {
e.preventDefault();
e.stopPropagation();
surface.setCellSelection(cellPos, cellPos);
return;
}
const { doc } = view.state;
const $pos = doc.resolve(pmPos);
const parent = $pos.parent;
if (parent.isTextblock) {
const text = parent.textContent;
const offset = $pos.parentOffset;
const [start, end] = findWordBoundaries(text, offset);
const absStart = $pos.start() + start;
const absEnd = $pos.start() + end;
if (absStart < absEnd) surface.setSelection(absStart, absEnd);
}
}
}
// Triple-click: paragraph selection.
if (e.detail === 3) {
const pmPos = getPositionFromMouse(e.clientX, e.clientY);
if (pmPos !== null) {
const $pos = view.state.doc.resolve(pmPos);
surface.setSelection($pos.start($pos.depth), $pos.end($pos.depth));
}
}
},
[
activeSurface,
hfEditMode,
getPositionFromMouse,
onHeaderFooterDoubleClick,
onHyperlinkClick,
findCellPosFromPmPos,
scrollToPositionImpl,
]
);
const handlePagesContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!onContextMenu) return;
e.preventDefault();
const surface = activeSurface();
const view = surface?.getView();
if (!surface || !view) return;
// Two routes land here. The cheap one — right-clicking a non-selected
// image — surfaces the image element as e.target and we walk up. The
// harder one is when PM already has a NodeSelection on the image
// (because the user clicked it once first): PM mounts a selection
// overlay that swallows pointer events, so e.target lands on the
// overlay, not on .layout-page-floating-image etc. Fall through to
// the current selection in that case.
const readImageNodeAt = (pos: number): ImageInfo | null => {
const node = view.state.doc.nodeAt(pos);
if (!node || node.type.name !== 'image') return null;
const wrapType = (node.attrs.wrapType as WrapType | undefined) ?? 'inline';
const cssFloat = node.attrs.cssFloat as ImageInfo['cssFloat'];
return { pos, wrapType, cssFloat };
};
let imageInfo: ImageInfo | null = null;
const hit = hitTestImage(e.target);
if (hit) {
imageInfo = readImageNodeAt(hit.pos);
if (imageInfo) {
imageInfo.inlinePositionEmu = captureInlinePositionEmu(hit.imageEl, zoom);
}
}
if (!imageInfo) {
const sel = view.state.selection;
if (sel instanceof NodeSelection && sel.node.type.name === 'image') {
imageInfo = readImageNodeAt(sel.from);
if (imageInfo) {
const inlineEl = pagesContainerRef.current?.querySelector(
`.layout-run-image[data-pm-start="${sel.from}"]`
) as HTMLElement | null;
if (inlineEl) {
imageInfo.inlinePositionEmu = captureInlinePositionEmu(inlineEl, zoom);
}
}
}
}
const { from, to } = view.state.selection;
const pmPos = getPositionFromMouse(e.clientX, e.clientY);
// Right-click inside an existing range keeps the selection; otherwise
// move cursor to the right-click position.
if (pmPos !== null && (from === to || pmPos < from || pmPos > to)) {
surface.setSelection(pmPos);
surface.focus();
if (!hfEditMode) setIsFocused(true);
}
const hasSelection = view.state.selection.from !== view.state.selection.to;
onContextMenu({ x: e.clientX, y: e.clientY, hasSelection, image: imageInfo });
},
// `zoom` is read inside captureInlinePositionEmu to convert post-
// transform px deltas back to authored space.
[
activeSurface,
hfEditMode,
onContextMenu,
getPositionFromMouse,
zoom,
pagesContainerRef,
setIsFocused,
]
);
const hideTableInsertButton = useCallback(() => setTableInsertButton(null), []);
return {
handlePagesMouseDown,
handlePagesMouseMove,
handlePagesClick,
handlePagesContextMenu,
handleTableInsertClick,
tableInsertButton,
clearTableInsertTimer,
hideTableInsertButton,
getPositionFromMouse,
};
}