import { onScopeDispose, type Ref } from 'vue' /** * Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or * pointer movement: a mouse drag and the phone's system selection handles both produce a * selectionchange, which is the interaction #4 verified. */ export function tokenIndexOf(node: Node | null): number { if (!node) return -1 const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element) const holder = element?.closest?.('[data-token-index]') if (!holder) return -1 const value = Number((holder as HTMLElement).dataset.tokenIndex) return Number.isInteger(value) ? value : -1 } export interface TextSelectionOptions { /** The element the selection has to start inside. */ container: Ref /** Called with the first and last token index of a non-empty selection inside the reader. */ onSelect: (anchor: number, focus: number) => void } /** * Watches the native selection and reports token indices. A collapsed selection (a plain * click) reports nothing, so clicking a word keeps opening the word panel. */ export function useTextSelection(options: TextSelectionOptions) { let timer: number | undefined let disposed = false function read(): void { if (disposed) return const root = options.container.value const selection = window.getSelection?.() if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return const range = selection.getRangeAt(0) if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return const anchor = tokenIndexOf(range.startContainer) const focus = tokenIndexOf(range.endContainer) if (anchor < 0 || focus < 0) return options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus)) } // The debounce matches the verified spike: it lets the browser finish a drag before the // range is read, and it never blocks scrolling on a touch device. function schedule(): void { if (timer !== undefined) window.clearTimeout(timer) timer = window.setTimeout(() => { timer = undefined; read() }, 100) } function pointerUp(event: PointerEvent): void { // Only a pointerup inside the reader can finish a selection; a click on the panel must not // re-read a range the learner already adjusted there. const root = options.container.value const target = event.target as Node | null if (!root || !target || !root.contains(target)) return if (timer !== undefined) window.clearTimeout(timer) // A pointerup arrives before the browser finalises the range, so the read waits a tick. timer = window.setTimeout(() => { timer = undefined; read() }, 0) } // Both listeners live on the document: the reader body only exists once a chapter is ready, // so a listener bound to the element at setup time would miss every later selection. document.addEventListener('selectionchange', schedule) document.addEventListener('pointerup', pointerUp) onScopeDispose(() => { disposed = true if (timer !== undefined) window.clearTimeout(timer) document.removeEventListener('selectionchange', schedule) document.removeEventListener('pointerup', pointerUp) }) return { /** Drops the native highlight, e.g. when the panel closes. */ clear() { window.getSelection?.()?.removeAllRanges() }, read, } }