Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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<any>(null); | |
| const popupRef = useRef<any>(null); | |
| const [selection, setSelection] = useState<TextSelection | null>(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 }; | |
| } | |