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.
58 lines
3.1 KiB
TypeScript
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;
|
|
}
|