import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; export type TextSelection = { text: string; x: number; y: number }; // Expand a DOM range outward so its ends land on whitespace boundaries, i.e. // snap a partial selection to whole words (punctuation stays attached to its // word). Operates in-place; only touches text nodes (nodeType 3). function expandRangeToWords(range: any) { const startNode: any = range.startContainer; if (startNode && startNode.nodeType === 3) { const text: string = startNode.textContent || ""; let s = range.startOffset; while (s > 0 && !/\s/.test(text[s - 1])) s--; range.setStart(startNode, s); } const endNode: any = range.endContainer; if (endNode && endNode.nodeType === 3) { const text: string = endNode.textContent || ""; let e = range.endOffset; while (e < text.length && !/\s/.test(text[e])) e++; range.setEnd(endNode, e); } } // Web-only: tracks the current text selection inside `containerRef` and exposes // its page position so a floating action (e.g. "Review this passage") can be // placed next to it. No-op on native, where arbitrary text selection isn't // available. Pass `enabled=false` to pause tracking (e.g. after submission). export function useTextSelection(enabled: boolean) { const containerRef = useRef(null); const popupRef = useRef(null); const [selection, setSelection] = useState(null); useEffect(() => { if (Platform.OS !== "web" || !enabled) { setSelection(null); return; } const onMouseUp = (e: any) => { // Ignore clicks inside the floating popup so it doesn't clear the selection. if (popupRef.current && popupRef.current.contains(e.target)) return; const w: any = window; const sel = w.getSelection ? w.getSelection() : null; if (!sel || sel.isCollapsed || sel.rangeCount === 0) { setSelection(null); return; } const range = sel.getRangeAt(0); const container = containerRef.current; if (container && !container.contains(range.commonAncestorContainer)) { setSelection(null); return; } // Snap the selection to whole words and reflect it in the visible highlight. expandRangeToWords(range); try { sel.removeAllRanges(); sel.addRange(range); } catch { // Some browsers guard against programmatic selection changes; ignore. } const text = String(sel.toString()).trim(); if (!text) { setSelection(null); return; } const rect = range.getBoundingClientRect(); setSelection({ text, x: rect.left + w.scrollX, y: rect.bottom + w.scrollY + 4, }); }; const doc: any = document; doc.addEventListener("mouseup", onMouseUp); return () => doc.removeEventListener("mouseup", onMouseUp); }, [enabled]); const clearBrowserSelection = () => { if (Platform.OS === "web") { const w: any = window; w.getSelection?.().removeAllRanges(); } setSelection(null); }; return { selection, setSelection, containerRef, popupRef, clearBrowserSelection }; }