champ-ed / client /hooks /useTextSelection.ts
MalikS-343
squash
cbfe36d
Raw
History Blame Contribute Delete
3.23 kB
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 };
}