/** * RAF-driven hook that tracks overlay, hover, and group rects from the iframe DOM. * Runs a requestAnimationFrame loop and writes React state only when rects change. */ import { useRef, useState, type RefObject } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { type DomEditSelection, findElementForSelection } from "./domEditing"; import { type GroupOverlayItem, type OverlayRect, type ResolvedElementRef, groupOverlayItemsEqual, isElementVisibleForOverlay, rectsEqual, resolveElementForOverlay, selectionCacheKey, toOverlayRect, } from "./domEditOverlayGeometry"; function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!rectsEqual(a[i]!, b[i]!)) return false; } return true; } interface UseDomEditOverlayRectsOptions { iframeRef: RefObject; overlayRef: RefObject; selectionRef: RefObject; activeCompositionPathRef: RefObject; groupSelectionsRef: RefObject; hoverSelectionRef: RefObject; rafPausedRef: RefObject; } interface UseDomEditOverlayRectsResult { overlayRect: OverlayRect | null; overlayRectRef: RefObject; setOverlayRect: (next: OverlayRect | null) => void; hoverRect: OverlayRect | null; hoverRectRef: RefObject; setHoverRect: (next: OverlayRect | null) => void; groupOverlayItems: GroupOverlayItem[]; groupOverlayItemsRef: RefObject; setGroupOverlayItems: (next: GroupOverlayItem[]) => void; childRects: OverlayRect[]; } export function useDomEditOverlayRects({ iframeRef, overlayRef, selectionRef, activeCompositionPathRef, groupSelectionsRef, hoverSelectionRef, rafPausedRef, }: UseDomEditOverlayRectsOptions): UseDomEditOverlayRectsResult { const [overlayRect, setOverlayRectState] = useState(null); const [hoverRect, setHoverRectState] = useState(null); const [groupOverlayItems, setGroupOverlayItemsState] = useState([]); const [childRects, setChildRectsState] = useState([]); const overlayRectRef = useRef(null); const hoverRectRef = useRef(null); const groupOverlayItemsRef = useRef([]); const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null); const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null); const resolvedGroupElementRef = useRef>(new Map()); const childRectsRef = useRef([]); const setOverlayRect = (next: OverlayRect | null) => { if (rectsEqual(overlayRectRef.current, next)) return; overlayRectRef.current = next; setOverlayRectState(next); }; const setHoverRect = (next: OverlayRect | null) => { if (rectsEqual(hoverRectRef.current, next)) return; hoverRectRef.current = next; setHoverRectState(next); }; const setGroupOverlayItems = (next: GroupOverlayItem[]) => { if (groupOverlayItemsEqual(groupOverlayItemsRef.current, next)) return; groupOverlayItemsRef.current = next; setGroupOverlayItemsState(next); }; const resolveGroupElement = (doc: Document, sel: DomEditSelection) => { const key = selectionCacheKey(sel); const cached = resolvedGroupElementRef.current.get(key); if (cached?.isConnected && cached.ownerDocument === doc) return cached; const next = findElementForSelection(doc, sel, activeCompositionPathRef.current); if (next) { resolvedGroupElementRef.current.set(key, next); } else { resolvedGroupElementRef.current.delete(key); } return next; }; useMountEffect(() => { let frame = 0; const clearAll = () => { setOverlayRect(null); setHoverRect(null); setGroupOverlayItems([]); }; const update = () => { frame = requestAnimationFrame(update); if (rafPausedRef.current) { if (childRectsRef.current.length > 0) { childRectsRef.current = []; setChildRectsState([]); } return; } const sel = selectionRef.current; const iframe = iframeRef.current; const overlayEl = overlayRef.current; if (!iframe || !overlayEl) { resolvedElementRef.current = null; resolvedHoverElementRef.current = null; resolvedGroupElementRef.current.clear(); clearAll(); return; } const doc = iframe.contentDocument; if (!doc) { resolvedElementRef.current = null; resolvedHoverElementRef.current = null; resolvedGroupElementRef.current.clear(); clearAll(); return; } if (sel) { const el = resolveElementForOverlay( doc, sel, activeCompositionPathRef.current, resolvedElementRef as ResolvedElementRef, ); // An explicitly-selected element's overlay must track it whenever it's laid // out and not display:none/visibility:hidden/opacity:0 — use basic visibility, // NOT the occlusion heuristic. Occlusion (isElementVisibleInPreview) treats any // opacity:1 ancestor as an opaque cover even when it paints nothing (e.g. a // backgroundless full-bleed scene above a subcomposition), which would wrongly // hide the selection box. Occlusion stays for hover, where a false hide is cheap. if (el && isElementVisibleForOverlay(el)) { const nextRect = toOverlayRect(overlayEl, iframe, el); setOverlayRect(nextRect); const descendants = el.querySelectorAll("*"); if (descendants.length > 0 && descendants.length <= 60) { const nextChildRects: OverlayRect[] = []; for (let i = 0; i < descendants.length; i++) { const child = descendants[i] as HTMLElement; if (!child.getBoundingClientRect) continue; const r = toOverlayRect(overlayEl, iframe, child); if (r && r.width > 2 && r.height > 2) nextChildRects.push(r); } if (!childRectsEqual(childRectsRef.current, nextChildRects)) { childRectsRef.current = nextChildRects; setChildRectsState(nextChildRects); } } else if (childRectsRef.current.length > 0) { childRectsRef.current = []; setChildRectsState([]); } } else { setOverlayRect(null); if (childRectsRef.current.length > 0) { childRectsRef.current = []; setChildRectsState([]); } } } else { resolvedElementRef.current = null; setOverlayRect(null); if (childRectsRef.current.length > 0) { childRectsRef.current = []; setChildRectsState([]); } } const group = groupSelectionsRef.current; if (group.length > 0) { const nextGroupItems: GroupOverlayItem[] = []; const liveGroupKeys = new Set(); for (const groupSelection of group) { const key = selectionCacheKey(groupSelection); liveGroupKeys.add(key); const el = resolveGroupElement(doc, groupSelection); const rect = el ? toOverlayRect(overlayEl, iframe, el) : null; if (el && rect) nextGroupItems.push({ key, selection: groupSelection, element: el, rect }); } for (const key of resolvedGroupElementRef.current.keys()) { if (!liveGroupKeys.has(key)) resolvedGroupElementRef.current.delete(key); } setGroupOverlayItems(nextGroupItems); } else { resolvedGroupElementRef.current.clear(); setGroupOverlayItems([]); } const hoverSel = hoverSelectionRef.current; const hoverMatchesSelection = Boolean( sel && hoverSel && selectionCacheKey(sel) === selectionCacheKey(hoverSel), ); const hoverMatchesGroup = Boolean( hoverSel && group.some((entry) => selectionCacheKey(entry) === selectionCacheKey(hoverSel)), ); if (!hoverSel || hoverMatchesSelection || hoverMatchesGroup) { resolvedHoverElementRef.current = null; setHoverRect(null); return; } const hoverEl = resolveElementForOverlay( doc, hoverSel, activeCompositionPathRef.current, resolvedHoverElementRef as ResolvedElementRef, ); if (!hoverEl) { setHoverRect(null); return; } setHoverRect(toOverlayRect(overlayEl, iframe, hoverEl)); }; frame = requestAnimationFrame(update); return () => cancelAnimationFrame(frame); }); return { overlayRect, overlayRectRef, setOverlayRect, hoverRect, hoverRectRef, setHoverRect, groupOverlayItems, groupOverlayItemsRef, setGroupOverlayItems, childRects, }; }