Files
siftlode/frontend/src/lib/textSelection.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +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. */
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;
}