import { batch, ObservableHint, observable, type OpaqueObject } from "@legendapp/state"; import { type DeepMaybeObservable, type MaybeObservable, type ReadonlyObservable, createDebounceFn, createThrottleFn, } from "@usels/core"; import { type ConfigurableWindow, defaultDocument, resolveWindowSource, } from "@shared/configurable"; import { createEventListener } from "../../browser/useEventListener/core"; export interface UseTextSelectionOptions extends ConfigurableWindow { /** Throttle selectionchange handler in ms. Mutually exclusive with debounce. */ throttle?: MaybeObservable; /** Debounce selectionchange handler in ms. Mutually exclusive with throttle. */ debounce?: MaybeObservable; } export interface UseTextSelectionReturn { /** Selected text content */ text$: ReadonlyObservable; /** Bounding rectangles of selected ranges */ rects$: ReadonlyObservable; /** Selection ranges */ ranges$: ReadonlyObservable; /** Current Selection object */ selection$: ReadonlyObservable | null>; } function getRangesFromSelection(selection: Selection): Range[] { const rangeCount = selection.rangeCount ?? 0; return Array.from({ length: rangeCount }, (_, i) => selection.getRangeAt(i)); } function opaqueArray(arr: T[]): OpaqueObject<{ items: T[] }> { return ObservableHint.opaque({ items: arr }) as OpaqueObject<{ items: T[] }>; } /** * Framework-agnostic reactive text-selection tracker. Listens to * `selectionchange` on the document and exposes the current selection's text, * ranges, rects (lazily computed), and raw `Selection` object. */ /*@__NO_SIDE_EFFECTS__*/ export function createTextSelection( options?: DeepMaybeObservable ): UseTextSelectionReturn { const opts$ = observable(options); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window field type varies by hint map const window$ = resolveWindowSource(opts$.window as any); // Derive document from resolved window; fall back to defaultDocument. const doc$ = observable | null>(() => { const doc = window$.get()?.document ?? defaultDocument; return doc ? (ObservableHint.opaque(doc) as OpaqueObject) : null; }); const text$ = observable(""); const rangesBox$ = observable>( ObservableHint.opaque({ items: [] }) as OpaqueObject<{ items: Range[] }> ); const selection$ = observable | null>(null); const invoke = () => { const win = window$.peek(); if (!win) return; const sel = win.getSelection(); if (sel) { const r = getRangesFromSelection(sel); batch(() => { selection$.set(ObservableHint.opaque(sel)); text$.set(sel.toString()); rangesBox$.set(opaqueArray(r)); }); } else { batch(() => { selection$.set(null); text$.set(""); rangesBox$.set(opaqueArray([])); }); } }; // Mount-time selection: throttle/debounce/plain decided once at creation // time (the wrapper function itself is swapped at mount and kept stable). const raw = opts$.peek(); const throttleMs = raw?.throttle; const debounceMs = raw?.debounce; let handler: (...args: unknown[]) => unknown = invoke; if (throttleMs !== undefined) { handler = createThrottleFn(invoke, throttleMs).throttledFn as typeof handler; } else if (debounceMs !== undefined) { handler = createDebounceFn(invoke, debounceMs).debouncedFn as typeof handler; } createEventListener(doc$, "selectionchange", handler as () => void, { passive: true }); // Lazy computed — getBoundingClientRect runs only when rects$ is accessed. const rects$ = observable(() => (rangesBox$.get()?.items ?? []).map((range) => range.getBoundingClientRect()) ); const ranges$ = observable(() => rangesBox$.get()?.items ?? []); return { text$: text$ as ReadonlyObservable, rects$: rects$ as ReadonlyObservable, ranges$: ranges$ as ReadonlyObservable, selection$: selection$ as ReadonlyObservable | null>, }; }