// fallow-ignore-file code-duplication import { useCallback, useRef, useState } from "react"; import type { DomEditSelection } from "./domEditing"; import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers"; import { isElementComputedVisible } from "./domEditingElement"; import { coversComposition } from "../../utils/studioPreviewHelpers"; import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry"; import { toVisibleOverlayRect } from "./domEditOverlayGeometry"; interface MarqueeState { startX: number; startY: number; currentX: number; currentY: number; pointerId: number; pastThreshold: boolean; } const MARQUEE_THRESHOLD_PX = 4; interface MarqueeHit { element: HTMLElement; rect: Rect; } /** * Synchronous core of the marquee: the elements whose overlay-space rect * intersects the marquee rect. Uses the SAME `toOverlayRect` basis as the * single-selection / group-selection boxes, so what the marquee highlights * and selects is exactly the box the user sees when they click an element. * Shared by the live candidate highlight (per pointer-move) and the mouse-up * commit. No async source probe — that only happens once, on commit. */ // fallow-ignore-next-line complexity function collectMarqueeHits( rect: Rect, iframe: HTMLIFrameElement, overlayEl: HTMLDivElement, activeCompositionPath: string, ): MarqueeHit[] { const doc = iframe.contentDocument; if (!doc) return []; const root = doc.querySelector("[data-composition-id]") ?? doc.body; const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html"; const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView }); const rootEl = doc.querySelector("[data-composition-id]") ?? doc.documentElement; const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? ""); const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? ""); const viewport = { width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1, height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1, }; const hits: MarqueeHit[] = []; for (const item of items) { const el = item.element; if (!isElementComputedVisible(el)) continue; if (coversComposition(el.getBoundingClientRect(), viewport)) continue; const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el); if (!overlayRect) continue; const r: Rect = { left: overlayRect.left, top: overlayRect.top, width: overlayRect.width, height: overlayRect.height, }; if (!rectsOverlap(rect, r)) continue; hits.push({ element: el, rect: r }); } return hits; } async function runMarqueeIntersection( rect: Rect, iframe: HTMLIFrameElement, overlayEl: HTMLDivElement, activeCompositionPath: string, ): Promise { const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html"; const hits: DomEditSelection[] = []; for (const { element } of collectMarqueeHits(rect, iframe, overlayEl, activeCompositionPath)) { const sel = await resolveDomEditSelection(element, { activeCompositionPath, isMasterView, skipSourceProbe: true, }); if (sel) hits.push(sel); } return hits; } interface MarqueeGesturesDeps { iframeRef: React.RefObject; overlayRef: React.RefObject; activeCompositionPathRef: React.RefObject; onMarqueeSelectRef: React.RefObject< ((selections: DomEditSelection[], additive: boolean) => void) | undefined >; selectionRef: React.RefObject; gestures: { onPointerMove: (event: React.PointerEvent) => void; onPointerUp: (event: React.PointerEvent) => void; clearPointerState: (ref: React.RefObject) => void; }; } // fallow-ignore-next-line complexity export function useMarqueeGestures(deps: MarqueeGesturesDeps) { const marqueeRef = useRef(null); const [marqueeRect, setMarqueeRect] = useState(null); // Live "candidate" highlight: the elements the marquee currently touches, // shown before mouse-up so you can see what you're about to select. The // iframe DOM doesn't mutate during a drag, so a sync intersection per move // is cheap (clean layout → no thrash). const [candidateRects, setCandidateRects] = useState([]); const commitMarquee = useCallback( async ( rect: { left: number; top: number; width: number; height: number }, additive: boolean, ) => { const iframe = deps.iframeRef.current; const overlay = deps.overlayRef.current; if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return; const acp = deps.activeCompositionPathRef.current ?? "index.html"; const hits = await runMarqueeIntersection(rect, iframe, overlay, acp); deps.onMarqueeSelectRef.current(hits, additive); }, [deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef], ); const onPointerMove = useCallback( (event: React.PointerEvent) => { const m = marqueeRef.current; if (m) { const oRect = deps.overlayRef.current?.getBoundingClientRect(); if (!oRect) return; m.currentX = event.clientX - oRect.left; m.currentY = event.clientY - oRect.top; if (!m.pastThreshold) { const dx = m.currentX - m.startX; const dy = m.currentY - m.startY; if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return; m.pastThreshold = true; } const rect: Rect = { left: Math.min(m.startX, m.currentX), top: Math.min(m.startY, m.currentY), width: Math.abs(m.currentX - m.startX), height: Math.abs(m.currentY - m.startY), }; setMarqueeRect(rect); const iframe = deps.iframeRef.current; const overlay = deps.overlayRef.current; if (iframe && overlay) { const acp = deps.activeCompositionPathRef.current ?? "index.html"; setCandidateRects(collectMarqueeHits(rect, iframe, overlay, acp).map((h) => h.rect)); } return; } deps.gestures.onPointerMove(event); }, [deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef], ); const onPointerUp = useCallback( (event: React.PointerEvent) => { const m = marqueeRef.current; if (m) { marqueeRef.current = null; try { (event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId); } catch { /* already released */ } if (m.pastThreshold) { commitMarquee( { left: Math.min(m.startX, m.currentX), top: Math.min(m.startY, m.currentY), width: Math.abs(m.currentX - m.startX), height: Math.abs(m.currentY - m.startY), }, event.shiftKey, ); } else { deps.onMarqueeSelectRef.current?.([], false); } setMarqueeRect(null); setCandidateRects([]); return; } deps.gestures.onPointerUp(event); }, [deps.gestures, commitMarquee, deps.onMarqueeSelectRef], ); const onPointerCancel = useCallback(() => { if (marqueeRef.current) { marqueeRef.current = null; setMarqueeRect(null); setCandidateRects([]); return; } deps.gestures.clearPointerState(deps.selectionRef); }, [deps.gestures, deps.selectionRef]); return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel }; }