// docs/DESIGN.md "Text stays selectable": a click that ends a text selection does nothing. The // mouseup of a drag-select fires a click on whatever the drag started in, so a title that opens its // video on click would play it the moment the reader finished selecting its words to copy them. // A plain click collapses any selection on mousedown, so a non-empty selection that touches the // element at click time can only be the one this gesture just made. /** True when this click ended a selection: the page's selection is non-empty and starts or ends * inside the clicked element. Takes the EVENT, not the element, because a keyboard click (Enter / * Space, `detail` 0) ends no selection — it has no pointer — so a title whose words are still * selected must open on Enter (E28 review H2). */ export function endsTextSelection(e: { currentTarget: Element; detail: number }): boolean { if (e.detail === 0) return false; const el = e.currentTarget; const sel = typeof window === "undefined" ? null : window.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) return false; return ( (!!sel.anchorNode && el.contains(sel.anchorNode)) || (!!sel.focusNode && el.contains(sel.focusNode)) ); } // The rule's other half: a press that travelled before its release was a drag (across a row's empty // padding, say, where nothing selects), not a click, so it opens nothing either. /** How far, in px, the pointer may move between press and release and still count as a click. */ const CLICK_SLOP = 6; /** True when a click's pointer moved past the slop since its press at `from`. A keyboard click * (`detail` 0) carries no pointer, so it never did. */ export function movedPastSlop( from: { x: number; y: number } | null, e: { detail: number; clientX: number; clientY: number }, ): boolean { if (!from || e.detail === 0) return false; return Math.abs(e.clientX - from.x) > CLICK_SLOP || Math.abs(e.clientY - from.y) > CLICK_SLOP; } // The copy half: a right-click on selected words is a request for the browser's own menu (Copy, // Search, Translate), not the object's. An app menu there would leave Ctrl+C as the only way to copy // what the reader just selected. Only ON the selection: a right-click beside it (the row's picture, // its padding) still means the object. /** True when the page's selection is non-empty and the point (x, y) over `target` lies on it. Where * the engine lays out ranges (a browser), "on it" is inside one of the selection's line boxes; where * it does not (jsdom), it is `target` touching the selection. */ export function pointOnSelection(target: EventTarget | null, x: number, y: number): boolean { const sel = typeof window === "undefined" ? null : window.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim() || !(target instanceof Node)) return false; for (let i = 0; i < sel.rangeCount; i++) { const range = sel.getRangeAt(i); if (!range.intersectsNode(target)) continue; if (typeof range.getClientRects !== "function") return true; for (const r of range.getClientRects()) if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true; } return false; }