import { useCallback, useEffect, useRef, useState, type RefObject, } from 'react'; import type { InputMethod } from '../types'; import { buildSemanticTargetGraph, createSemanticTargetRange, resolveSemanticTargetAtPoint, type SemanticTarget, } from '../utils/blockTargeting'; /** Inputs for pointer Pinpoint targeting within one rendered document. */ export interface UsePinpointOptions { containerRef: RefObject; inputMethod: InputMethod; /** Disable when toolbar/popover/diff is active */ enabled: boolean; /** Submit a text range through the shared annotation pipeline. */ onSelectRange: (range: Range) => void; /** Handle code block clicks (needs special annotation path) */ onCodeBlockClick: (blockId: string, element: HTMLElement) => void; } /** Live pointer-owned state returned by `usePinpoint`. */ export interface UsePinpointReturn { /** Live target from the canonical semantic graph under the pointer. */ hoverTarget: SemanticTarget | null; /** Release pointer-owned feedback so keyboard navigation can take over. */ clearHover: () => void; } /** * Resolve pointer hover and clicks through the canonical semantic target graph. * * The hook mutates only its transient hover marker and removes it when disabled * or unmounted. */ export function usePinpoint({ containerRef, inputMethod, enabled, onSelectRange, onCodeBlockClick, }: UsePinpointOptions): UsePinpointReturn { const [hoverTarget, setHoverTarget] = useState(null); const hoverElementRef = useRef(null); const isActive = inputMethod === 'pinpoint' && enabled; const clearHover = useCallback(() => { hoverElementRef.current?.removeAttribute('data-pinpoint-hover'); hoverElementRef.current = null; setHoverTarget(null); }, []); // Clear hover when deactivated useEffect(() => { if (!isActive) { clearHover(); } }, [clearHover, isActive]); // Mousemove / touchstart — resolve target and update hover state useEffect(() => { const container = containerRef.current; if (!isActive || !container) return; let graph = buildSemanticTargetGraph(container); let graphIsDirty = false; const liveGraph = () => { if (graphIsDirty) { graph = buildSemanticTargetGraph(container); graphIsDirty = false; } return graph; }; const observer = new MutationObserver(() => { graphIsDirty = true; }); observer.observe(container, { childList: true, subtree: true }); const updateHover = (clientX: number, clientY: number, target: HTMLElement) => { const resolved = resolveSemanticTargetAtPoint( liveGraph(), target, { clientX, clientY }, ); if (resolved) { if (resolved.element !== hoverElementRef.current) { hoverElementRef.current?.removeAttribute('data-pinpoint-hover'); resolved.element.setAttribute('data-pinpoint-hover', ''); hoverElementRef.current = resolved.element; setHoverTarget(resolved); } } else { clearHover(); } }; const handleMouseMove = (e: MouseEvent) => { updateHover(e.clientX, e.clientY, e.target as HTMLElement); }; const handleTouchStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; const touch = e.touches[0]; const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; if (target) updateHover(touch.clientX, touch.clientY, target); }; const handleMouseLeave = () => { clearHover(); }; container.addEventListener('mousemove', handleMouseMove); container.addEventListener('mouseleave', handleMouseLeave); container.addEventListener('touchstart', handleTouchStart, { passive: true }); return () => { observer.disconnect(); hoverElementRef.current?.removeAttribute('data-pinpoint-hover'); hoverElementRef.current = null; container.removeEventListener('mousemove', handleMouseMove); container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('touchstart', handleTouchStart); }; }, [clearHover, isActive, containerRef]); // Click — create Range and trigger web-highlighter useEffect(() => { const container = containerRef.current; if (!isActive || !container) return; const handleClick = (e: MouseEvent) => { const target = e.target as HTMLElement; // A `:::question` card's form controls are the answer UI, never a pin // target: a click on a radio, a checkbox or a text box answers. const inQuestion = !!target.closest?.('fieldset.question-block'); if (inQuestion && target.closest('input, textarea, select')) return; const resolved = resolveSemanticTargetAtPoint( buildSemanticTargetGraph(container), target, { clientX: e.clientX, clientY: e.clientY }, ); if (!resolved) return; // Pinning a choice's text must not also pick the choice: the row is a //