import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { type DomEditSelection } from "./domEditing"; import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; import { useMarqueeGestures } from "./marqueeCommit"; import { MarqueeOverlay } from "./MarqueeOverlay"; import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry"; import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash"; import { useCanvasContextMenuState } from "./useCanvasContextMenuState"; import { type BlockedMoveState, type DomEditGroupPathOffsetCommit, type FocusableDomEditOverlay, type GestureState, type GroupGestureState, focusDomEditOverlayElement, } from "./domEditOverlayGestures"; import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; import { useDomEditNudge } from "./useDomEditNudge"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; import { GridOverlay } from "./GridOverlay"; import type { GestureRecordingState } from "./GestureRecordControl"; import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome"; import { hugRectForElement } from "./domEditOverlayCrop"; import { useCropOverlay } from "../../hooks/useCropOverlay"; import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape"; import { useDomEditCompositionRect } from "./useDomEditCompositionRect"; import { useMountEffect } from "../../hooks/useMountEffect"; import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh"; import { CanvasContextMenu } from "./CanvasContextMenu"; import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder"; import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; // Re-exports for external consumers — preserving existing import paths. export { filterNestedDomEditGroupItems, resolveDomEditCoordinateScale, resolveDomEditGroupOverlayRect, } from "./domEditOverlayGeometry"; export { focusDomEditOverlayElement, hasDomEditRotationChanged, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; interface DomEditOverlayProps { iframeRef: RefObject; activeCompositionPath: string | null; selection: DomEditSelection | null; groupSelections?: DomEditSelection[]; hoverSelection: DomEditSelection | null; allowCanvasMovement?: boolean; onCanvasMouseDown: ( event: React.MouseEvent, options?: PreviewMouseDownOptions, ) => void; onCanvasPointerMove: ( event: React.PointerEvent, options?: { preferClipAncestor?: boolean }, ) => Promise; onCanvasPointerLeave: () => void; onSelectionChange: ( selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }, ) => void; onBlockedMove: (selection: DomEditSelection) => void; onManualDragStart?: () => void; onPathOffsetCommit: ( selection: DomEditSelection, next: { x: number; y: number }, modifiers?: { altKey?: boolean }, ) => Promise | void; onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise | void; onBoxSizeCommit: ( selection: DomEditSelection, next: { width: number; height: number }, offset?: { x: number; y: number }, restore?: () => void, ) => Promise | void; onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void; onStyleCommit?: (property: string, value: string) => Promise | void; gridVisible?: boolean; gridSpacing?: number; recordingState?: GestureRecordingState; onToggleRecording?: () => void; onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void; /** * Delete the selected canvas element. * Wire to handleDomEditElementDelete from useDomEditActionsContext — * same handler the Delete/Backspace hotkey uses. */ onDeleteSelection?: (selection: DomEditSelection) => void; /** * Called with the resolved z-order patch list and the menu action that * produced it (feeds the undo coalesce key). The patch list is tie-aware and * may include sibling elements (see canvasContextMenuZOrder); the live DOM is * NOT yet mutated. Wire to handleDomZIndexReorderCommit from * useDomEditActionsContext. See CanvasContextMenu.tsx module comment. */ onApplyZIndex?: ( selection: DomEditSelection, patches: ZOrderPatch[], action: ZOrderAction, /** Sibling a forward/backward step moved past (pre-mutation render order); * null for front/back. Feeds the timeline z-mirror's crossedKey. */ crossed: HTMLElement | null, ) => void; } // fallow-ignore-next-line complexity export const DomEditOverlay = memo(function DomEditOverlay({ iframeRef, activeCompositionPath, selection, groupSelections = [], hoverSelection, allowCanvasMovement = true, onCanvasMouseDown, onCanvasPointerMove, onCanvasPointerLeave, onSelectionChange, onBlockedMove, gridVisible = false, gridSpacing = 50, onManualDragStart, onPathOffsetCommit, onGroupPathOffsetCommit, onBoxSizeCommit, onRotationCommit, onStyleCommit, onMarqueeSelect, onDeleteSelection, onApplyZIndex, }: DomEditOverlayProps) { const overlayRef = useRef(null); const boxRef = useRef(null); const onMarqueeSelectRef = useRef(onMarqueeSelect); onMarqueeSelectRef.current = onMarqueeSelect; const selectionShapeStyles = readDomEditSelectionShapeStyles(selection); const gestureRef = useRef(null); const groupGestureRef = useRef(null); const blockedMoveRef = useRef(null); const suppressNextBoxClickRef = useRef(false); const suppressNextBoxMouseDownRef = useRef(false); const suppressNextOverlayMouseDownRef = useRef(false); const snapGuidesRef = useRef(null); const rafPausedRef = useRef(false); const selectionRef = useRef(selection); selectionRef.current = selection; // Brief highlight on the sibling a forward/backward z step crossed — drawn // in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash). const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef }); const activeCompositionPathRef = useRef(activeCompositionPath); activeCompositionPathRef.current = activeCompositionPath; const groupSelectionsRef = useRef(groupSelections); groupSelectionsRef.current = groupSelections; const hoverSelectionRef = useRef(hoverSelection); hoverSelectionRef.current = hoverSelection; const onPathOffsetCommitRef = useRef(onPathOffsetCommit); onPathOffsetCommitRef.current = onPathOffsetCommit; const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit); onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit; const onBoxSizeCommitRef = useRef(onBoxSizeCommit); onBoxSizeCommitRef.current = onBoxSizeCommit; const onRotationCommitRef = useRef(onRotationCommit); onRotationCommitRef.current = onRotationCommit; const onStyleCommitRef = useRef(onStyleCommit); onStyleCommitRef.current = onStyleCommit; const onBlockedMoveRef = useRef(onBlockedMove); onBlockedMoveRef.current = onBlockedMove; const onManualDragStartRef = useRef(onManualDragStart); onManualDragStartRef.current = onManualDragStart; const onCanvasPointerMoveRef = useRef(onCanvasPointerMove); onCanvasPointerMoveRef.current = onCanvasPointerMove; const onCanvasPointerLeaveRef = useRef(onCanvasPointerLeave); onCanvasPointerLeaveRef.current = onCanvasPointerLeave; const onSelectionChangeRef = useRef(onSelectionChange); onSelectionChangeRef.current = onSelectionChange; const { overlayRect, overlayRectRef, setOverlayRect, hoverRect, groupOverlayItems, groupOverlayItemsRef, setGroupOverlayItems, childRects, } = useDomEditOverlayRects({ iframeRef, overlayRef, selectionRef, activeCompositionPathRef, groupSelectionsRef, hoverSelectionRef, rafPausedRef, }); const compRect = useDomEditCompositionRect({ iframeRef, overlayRef }); const compRectRef = useRef(compRect); compRectRef.current = compRect; const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({ selection, overlayRect, }); // Inset crops draw their own outline child; other clip shapes keep the raw mirror. const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath; const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath); // Off-canvas element indicators — dashed outlines for elements positioned // outside the composition bounds so users can find them. const offCanvasElementsRef = useRef>(new Map()); const [offCanvasRects, setOffCanvasRects] = useState([]); const offCanvasDirtyRef = useRef(true); const offCanvasSigRef = useRef(""); const offCanvasObserverRef = useRef(null); const offCanvasObservedDocRef = useRef(null); // Positions depend on live iframe layout, not selection — the selected-element // suppression is a render-time filter, so selection/groupSelections stay out // of the geometry walk. useMountEffect(() => startOffCanvasIndicatorRefresh({ iframeRef, overlayRef, compRectRef, activeCompositionPathRef, dirtyRef: offCanvasDirtyRef, sigRef: offCanvasSigRef, observerRef: offCanvasObserverRef, observedDocRef: offCanvasObservedDocRef, elementsRef: offCanvasElementsRef, setRects: setOffCanvasRects, }), ); // Switching compositions may not swap the iframe document (so the observer's // doc-swap detection wouldn't fire) yet changes which elements are off-canvas. // Force a recompute explicitly on comp change. useEffect(() => { offCanvasDirtyRef.current = true; }, [activeCompositionPath]); const gestures = createDomEditOverlayGestureHandlers({ overlayRef, iframeRef, boxRef, selectionRef, hoverSelectionRef, overlayRectRef, groupOverlayItemsRef, gestureRef, groupGestureRef, blockedMoveRef, rafPausedRef, suppressNextBoxClickRef, setOverlayRect, setGroupOverlayItems, onBlockedMoveRef, onManualDragStartRef, onPathOffsetCommitRef, onGroupPathOffsetCommitRef, onBoxSizeCommitRef, onRotationCommitRef, onCanvasPointerMoveRef, onCanvasMouseDown, snapGuidesRef, }); // Arrow-key nudge (1px, Shift = 10px) — commits through the same // path-offset callbacks as a drag, one undo entry per key burst. const { flushNudge } = useDomEditNudge({ selection, groupSelections, allowCanvasMovement, selectionRef, overlayRectRef, groupOverlayItemsRef, gestureRef, groupGestureRef, blockedMoveRef, onManualDragStartRef, onPathOffsetCommitRef, onGroupPathOffsetCommitRef, }); const marquee = useMarqueeGestures({ iframeRef, overlayRef, activeCompositionPathRef, onMarqueeSelectRef, selectionRef, gestures, }); const selectionKey = useMemo(() => { if (!selection) return "none"; return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`; }, [selection]); const groupBounds = useMemo( () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)), [groupOverlayItems], ); const hasGroupSelection = groupSelections.length > 1; const groupCanMove = hasGroupSelection && groupOverlayItems.length > 1 && groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset); const handleOverlayMouseDown = (event: React.MouseEvent) => { if (!allowCanvasMovement) return; if (suppressNextOverlayMouseDownRef.current) { suppressNextOverlayMouseDownRef.current = false; suppressNextBoxMouseDownRef.current = false; suppressNextBoxClickRef.current = false; event.preventDefault(); event.stopPropagation(); return; } const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; // Allow clicks anywhere on the overlay — GSAP-translated elements can // extend beyond the composition rect into the gray zone, and users need // to select/deselect them by clicking there. onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current }); if (event.shiftKey) { suppressNextBoxMouseDownRef.current = true; suppressNextBoxClickRef.current = true; } }; // fallow-ignore-next-line complexity const handleOverlayPointerDown = (event: React.PointerEvent) => { if (!allowCanvasMovement || event.button !== 0) return; if (event.shiftKey) { // Use the already-updated hover selection rather than re-resolving async const candidate = hoverSelectionRef.current; if (!candidate) return; event.preventDefault(); event.stopPropagation(); suppressNextOverlayMouseDownRef.current = true; suppressNextBoxMouseDownRef.current = true; suppressNextBoxClickRef.current = true; onSelectionChangeRef.current(candidate, { additive: true }); return; } const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; // Start marquee if clicking on empty canvas (no element under pointer). // The hover selection is an ASYNC cache: on a fast click (or when the // pointer was already resting over an element) it can still be empty while // an element IS under the pointer — starting a marquee here would swallow // the selection mousedown and the click would silently select nothing. // Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing. if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) { const iframe = iframeRef.current; const freshTarget = iframe ? getPreviewTargetFromPointer( iframe, event.clientX, event.clientY, activeCompositionPathRef.current, ) : null; if (freshTarget) return; const overlayEl = overlayRef.current; if (overlayEl) { const oRect = overlayEl.getBoundingClientRect(); const cx = event.clientX - oRect.left; const cy = event.clientY - oRect.top; const inComp = cx >= compRect.left && cx <= compRect.left + compRect.width && cy >= compRect.top && cy <= compRect.top + compRect.height; if (inComp) { event.preventDefault(); event.stopPropagation(); suppressNextOverlayMouseDownRef.current = true; (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); marquee.marqueeRef.current = { startX: cx, startY: cy, currentX: cx, currentY: cy, pointerId: event.pointerId, pastThreshold: false, }; return; } } } }; const handleBoxClick = (event: React.MouseEvent) => { if (!allowCanvasMovement) return; if (gestureRef.current || groupGestureRef.current) return; if (suppressNextBoxClickRef.current) { suppressNextBoxClickRef.current = false; event.stopPropagation(); return; } onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current }); }; const suppressBoxMouseDown = (e: React.MouseEvent) => { if (!suppressNextBoxMouseDownRef.current) return; suppressNextBoxMouseDownRef.current = false; e.preventDefault(); e.stopPropagation(); }; // Right-click state + handler: select the element under the pointer (if // needed), then open the menu; closes when the selection moves off-target. const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({ selection, selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef, }); return (
{ // A pointer gesture supersedes a pending nudge burst — commit it first // so the gesture's member snapshot starts from the nudged position. flushNudge(); focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay); }} onPointerDown={handleOverlayPointerDown} onMouseDown={handleOverlayMouseDown} onPointerMove={marquee.onPointerMove} onPointerLeave={() => onCanvasPointerLeaveRef.current()} onPointerUp={marquee.onPointerUp} onPointerCancel={marquee.onPointerCancel} onContextMenu={handleContextMenu} > {hoverSelection && hoverRect && compRect.width > 0 && (