Files
siftlode/frontend/src/lib/textSelection.ts
T
peter 0b1d905866 fix(ui): Enter on a selectable title opens it even with its words selected
endsTextSelection takes the click event and exempts a keyboard click (detail 0): Enter / Space
ends no selection, so a title selected and then tabbed to opened nothing. All three callers
(FocusSafeButton, the Plex caption, the video card title) go through it.
2026-09-27 14:55:50 +02:00

58 lines
3.1 KiB
TypeScript

// 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;
}