"use client";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Ban, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, GripVertical, } from "lucide-react";
import { useEditContext } from "../client/editContext";
import { useSlotContext } from "../views/editorSlotContext";
import { getAllPlaceholders, getComponentById } from "../componentTreeHelper";
import { CANVAS_REFRESH_COMPLETED_EVENT, } from "../page-viewer/pageViewContext";
import { cn } from "../../lib/utils";
import { useReducedMotion } from "../client/hooks/useReducedMotion";
import { clientToOverlay, findBridgeComponentTarget, hostViewportToOverlay, FRAME_OUTSET, measureBridgeComponentRect, measureBridgePlaceholderRect, rectEquals, } from "./overlay/geometry";
import { IFRAME_OVERLAY_BRIDGE_DRAG_GESTURE_EVENT, useIframeOverlay, } from "./overlay/IframeOverlayProvider";
import { useOverlayMeasurement } from "./overlay/useOverlayMeasurement";
import { getAutoScrollStep } from "../../lib/pointerDrag";
import { computePlaceholderZones, isWrapInsertionGap, layoutAwareIndex, placeholderZonesEqual, resolveNearestGap, } from "./overlay/placeholderZones";
import { getBetweenInsertionZoneRect, getDirectionalInsertionHitZoneRect, getDirectionalInsertionZoneRect, getEmptyPlaceholderNeutralZoneRect, InsertionLine, } from "./overlay/InsertionLine";
import { buildComponentDragObject } from "./componentDragObject";
import { buildInsertTargetLabel, formatPlaceholderDisplayName, } from "./insertTargetLabel";
import { getPlaceholderDropZoneColor, } from "../componentFrameColors";
// Toggle to re-enable the sibling "make room" shift preview (host previewGap).
const DRAG_PREVIEW_SHIFT_ENABLED = false;
const COMPONENT_EDGE_DROP_ZONE_RATIO = 0.25;
const COMPONENT_EDGE_DROP_ZONE_MIN_PX = 16;
const COMPONENT_EDGE_DROP_ZONE_MAX_PX = 48;
// Matches the large PlaceholderDropZone control. Component moves keep pointer
// capture in the rendering host, so the button cannot receive DOM hover/drop
// events itself; the gesture resolver must mirror its complete visible bounds.
const PLACEHOLDER_DROPZONE_BUTTON_SIZE = 32;
// Matches the parhelia-drop-zone-overlay-exit CSS keyframe (120ms): keep the JS
// unmount timer in lockstep so no transparent residue lingers after the fade.
const DROP_ZONE_EXIT_ANIMATION_MS = 120;
const DRAG_RETURN_ANIMATION_MS = 240;
const DRAG_CURSOR_DROP_ZONE_SHRINK_PX = 50;
const TEMPLATE_THUMBNAIL_WIDTH_PX = 176;
const TEMPLATE_THUMBNAIL_IMAGE_HEIGHT_PX = 96;
const OFFSCREEN_GUIDE_EDGE_INSET_PX = 18;
const OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX = 24;
// Zones start empty for a frame or two while the first overlay measurement
// runs; only call the drag hopeless after they stay empty this long.
const NO_DROP_HINT_DELAY_MS = 200;
/**
 * Full-canvas drop affordance shown while a component/template is being dragged.
 * It drives the continuous {@link InsertionLine} from two sources:
 *
 * - native HTML5 drags (legacy sidebar/tree sources) via the surface's
 *   dragover/drop, with the surface `pointer-events-auto`; and
 * - host/parent-driven pointer gestures (whole-component grab, palette cards)
 *   via forwarded `dragGesture*` events, with the surface `pointer-events-none`
 *   so it never competes with the host's in-iframe pointer capture.
 *
 * Both paths resolve the nearest gap and commit through the same
 * `droppedInPlaceholder` the discrete "+" dots use.
 */
export function DragInsertionOverlay({ pageViewContext, onActiveTargetChange, }) {
    const editContext = useEditContext();
    const slotContext = useSlotContext();
    const reducedMotion = useReducedMotion();
    const overlay = useIframeOverlay();
    const surfaceRef = useRef(null);
    const [placeholders, setPlaceholders] = useState([]);
    const [active, setActive] = useState(null);
    const [exitingActive, setExitingActive] = useState(null);
    const [guideMetrics, setGuideMetrics] = useState(() => overlay.getMetrics());
    const [gestureActive, setGestureActive] = useState(false);
    const gestureActiveRef = useRef(false);
    // Cursor position in PARENT viewport coords during a pointer gesture. The chip
    // and snapshot render position:fixed at this point (portaled out of the
    // scrolling overlay layer) so they track the cursor regardless of page scroll.
    const [cursorClient, setCursorClient] = useState(null);
    // Live raster of the dragged component (host-captured), shown at the cursor
    // instead of the chip once it arrives.
    const [snapshot, setSnapshot] = useState(null);
    const [returnPreview, setReturnPreview] = useState(null);
    const dragCursorPreviewRef = useRef(null);
    const snapshotRef = useRef(snapshot);
    snapshotRef.current = snapshot;
    const returnPreviewRef = useRef(null);
    returnPreviewRef.current = returnPreview;
    const returnPreviewTimerRef = useRef(null);
    const returnPreviewSequenceRef = useRef(0);
    const nativeDragPointRef = useRef(null);
    const nativeAutoScrollRafRef = useRef(null);
    const activeRef = useRef(null);
    const exitTimerRef = useRef(null);
    const setGestureActiveValue = useCallback((next) => {
        gestureActiveRef.current = next;
        setGestureActive(next);
    }, []);
    useEffect(() => {
        if (!pageViewContext?.page) {
            setPlaceholders([]);
            return;
        }
        setPlaceholders(getAllPlaceholders(pageViewContext.page));
    }, [pageViewContext?.page]);
    const isDragging = !!editContext?.dragObject &&
        !!slotContext?.isActive &&
        editContext.mode === "edit" &&
        !editContext.readonly;
    const zones = useOverlayMeasurement((metrics) => {
        if (!isDragging || !pageViewContext)
            return [];
        return computePlaceholderZones({
            placeholders,
            geometry: pageViewContext.bridgeGeometry,
            metrics,
            dragObject: editContext?.dragObject,
            selectedForInsertion: editContext?.selectedForInsertion,
            insertMode: editContext?.insertMode,
            showLayoutComponents: editContext?.showLayoutComponents,
        });
    }, [
        isDragging,
        pageViewContext,
        pageViewContext?.bridgeGeometry,
        placeholders,
        editContext?.dragObject,
        editContext?.selectedForInsertion,
        editContext?.insertMode,
        editContext?.showLayoutComponents,
    ], {
        initialValue: [],
        equals: placeholderZonesEqual,
        interests: ["viewport", "layout", "adornment-data"],
    });
    // Refs so the imperative gesture handler always reads current values.
    const zonesRef = useRef(zones);
    zonesRef.current = zones;
    const editContextRef = useRef(editContext);
    editContextRef.current = editContext;
    const pageViewContextRef = useRef(pageViewContext);
    pageViewContextRef.current = pageViewContext;
    const overlayRef = useRef(overlay);
    overlayRef.current = overlay;
    // Sibling-shift preview bookkeeping (gesture drags only).
    const movingRef = useRef(null);
    const returnTargetComponentRef = useRef(null);
    const gestureDragObjectRef = useRef(null);
    const canceledGestureHandledRef = useRef(false);
    const lastPreviewKeyRef = useRef(null);
    const originalPlaceholderPreviewActiveRef = useRef(false);
    const lastZoneKeyRef = useRef(null);
    const ownedInsertionZoneRef = useRef(null);
    const reducedMotionRef = useRef(reducedMotion);
    reducedMotionRef.current = reducedMotion;
    const clearExitTimer = useCallback(() => {
        if (exitTimerRef.current !== null) {
            window.clearTimeout(exitTimerRef.current);
            exitTimerRef.current = null;
        }
    }, []);
    const clearExitAnimation = useCallback(() => {
        clearExitTimer();
        setExitingActive(null);
    }, [clearExitTimer]);
    const clearReturnPreviewTimer = useCallback(() => {
        if (returnPreviewTimerRef.current !== null) {
            window.clearTimeout(returnPreviewTimerRef.current);
            returnPreviewTimerRef.current = null;
        }
    }, []);
    const clearReturnPreview = useCallback(() => {
        clearReturnPreviewTimer();
        returnPreviewRef.current = null;
        setReturnPreview(null);
    }, [clearReturnPreviewTimer]);
    const resolveReturnTargetRect = useCallback(() => {
        const component = returnTargetComponentRef.current;
        const pvc = pageViewContextRef.current;
        const geometry = pvc?.bridgeGeometry;
        const surface = surfaceRef.current;
        if (!component || !geometry || !surface)
            return null;
        const measured = measureBridgeComponentRect(geometry, component, overlayRef.current.getMetrics())?.rect;
        if (!measured)
            return null;
        const surfaceRect = surface.getBoundingClientRect();
        return {
            x: surfaceRect.left + measured.x,
            y: surfaceRect.top + measured.y,
            width: Math.max(1, measured.width),
            height: Math.max(1, measured.height),
        };
    }, []);
    const startExitAnimation = useCallback((previous) => {
        if (reducedMotionRef.current || previous.visualMode === "none") {
            clearExitAnimation();
            return;
        }
        clearExitTimer();
        setExitingActive(previous);
        exitTimerRef.current = window.setTimeout(() => {
            exitTimerRef.current = null;
            setExitingActive(null);
        }, DROP_ZONE_EXIT_ANIMATION_MS);
    }, [clearExitAnimation, clearExitTimer]);
    const setActiveInsertion = useCallback((next, { animateExit = true } = {}) => {
        const previous = activeRef.current;
        const previousKey = formatActiveInsertionKey(previous);
        const nextKey = formatActiveInsertionKey(next);
        const nextIsVisible = !!next && next.visualMode !== "none";
        if (nextIsVisible) {
            clearExitAnimation();
        }
        else if (animateExit &&
            previous &&
            previous.visualMode !== "none" &&
            previousKey !== nextKey) {
            startExitAnimation(previous);
        }
        else {
            clearExitAnimation();
        }
        activeRef.current = next;
        setActive(next);
    }, [clearExitAnimation, startExitAnimation]);
    const resolveZoneAtOverlayPoint = useCallback((point) => {
        const geometry = pageViewContextRef.current?.bridgeGeometry;
        if (!geometry)
            return null;
        const zone = resolveNearestGap(point, zonesRef.current);
        if (!zone)
            return null;
        const metrics = overlayRef.current.getMetrics();
        const placeholderRect = measureBridgePlaceholderRect(geometry, zone.placeholder.key, metrics)?.rect;
        if (!placeholderRect)
            return null;
        const visualTarget = resolveInsertionZoneVisualTarget(zone, point, placeholderRect, geometry, metrics, ownedInsertionZoneRef.current);
        return {
            zone,
            placeholderRect,
            visualMode: visualTarget.mode,
            visualAnchor: visualTarget.anchor,
            visualTargetRect: visualTarget.rect,
            visualOwnerComponentId: visualTarget.ownerComponentId,
            zoneHovered: visualTarget.zoneHovered ?? false,
            color: computeDropZoneColor(zone),
            adjustedIndex: layoutAwareIndex(zone.placeholder.components, zone.index),
        };
    }, []);
    const announce = useCallback((target) => {
        const displayPlaceholderName = formatPlaceholderDisplayName(target.zone.description || target.zone.placeholder.name);
        editContextRef.current?.setStatusMessage(_jsxs("div", { children: ["Drop into placeholder", " ", _jsx("span", { className: "font-bold", children: target.zone.placeholder.parentComponent?.name }), " ", "/ ", _jsx("span", { className: "font-bold", children: displayPlaceholderName }), " at position ", _jsx("span", { className: "font-bold", children: target.adjustedIndex + 1 })] }));
    }, []);
    // Ask the host to open a gap (shift siblings) at the resolved zone. Only for
    // pointer gestures, where we know the moving element + its size.
    const sendPreviewGap = useCallback((target) => {
        // Sibling-shift preview is off: per-element transforms don't hold up across
        // Sitecore's varied component rendering. The live snapshot is the preview
        // now; the protocol/host wiring stays for a possible future revisit.
        if (!DRAG_PREVIEW_SHIFT_ENABLED || reducedMotionRef.current)
            return;
        const pvc = pageViewContextRef.current;
        const moving = movingRef.current;
        const geometry = pvc?.bridgeGeometry;
        if (!pvc?.requestBridgePreviewGap || !geometry || !moving)
            return;
        const previewKey = `${target.zone.placeholder.key}#${target.zone.index}`;
        if (previewKey === lastPreviewKeyRef.current)
            return;
        const movingRect = findBridgeComponentTarget(geometry, moving.component)?.rect;
        const gapPx = movingRect
            ? target.zone.orientation === "horizontal"
                ? movingRect.width
                : movingRect.height
            : 0;
        if (!gapPx)
            return;
        lastPreviewKeyRef.current = previewKey;
        pvc.requestBridgePreviewGap({
            siblingElementKeys: target.zone.placeholder.components.map((c) => findBridgeComponentTarget(geometry, c)?.elementKey ?? null),
            fromDomIndex: target.zone.index,
            gapPx,
            axis: target.zone.orientation === "horizontal" ? "x" : "y",
            movingElementKey: moving.elementKey,
        });
    }, []);
    const clearPreviewGap = useCallback(() => {
        if (lastPreviewKeyRef.current === null &&
            !originalPlaceholderPreviewActiveRef.current) {
            return;
        }
        lastPreviewKeyRef.current = null;
        originalPlaceholderPreviewActiveRef.current = false;
        pageViewContextRef.current?.requestBridgePreviewGap?.({ clear: true });
    }, []);
    // Pending "keep the original-position placeholder up" hold after a committed
    // move; holds the clear until the canvas repaints (or the backstop fires).
    const previewGapHoldCleanupRef = useRef(null);
    const cancelPreviewGapHold = useCallback(() => {
        previewGapHoldCleanupRef.current?.();
    }, []);
    // After a committed component move, the grayed-out placeholder the bridge
    // draws over the original position must outlive the drag: the operation
    // resolves when the server accepts it, but the canvas repaints later -
    // clearing at drop leaves the stale component fully visible at its old spot
    // until the refresh lands. Mirror holdInsertingUntilCanvasRefresh
    // (EditorShell): clear on the first canvas refresh, with a timeout backstop,
    // or immediately if the operation did not commit.
    const holdPreviewGapUntilCanvasRefresh = useCallback((commit) => {
        previewGapHoldCleanupRef.current?.();
        const detach = () => {
            if (previewGapHoldCleanupRef.current === detach) {
                previewGapHoldCleanupRef.current = null;
            }
            window.removeEventListener(CANVAS_REFRESH_COMPLETED_EVENT, finish);
            window.clearTimeout(timeoutId);
        };
        const finish = () => {
            detach();
            clearPreviewGap();
        };
        const timeoutId = window.setTimeout(finish, 15000);
        window.addEventListener(CANVAS_REFRESH_COMPLETED_EVENT, finish);
        // Cancel (a new drag owns the preview now): stop watching WITHOUT
        // sending a clear that would wipe the new drag's placeholder.
        previewGapHoldCleanupRef.current = detach;
        const isCurrentHold = () => previewGapHoldCleanupRef.current === detach;
        // Promise.resolve guards non-async doubles (test mocks) of
        // droppedInPlaceholder.
        void Promise.resolve(commit)
            .then((result) => {
            if (result?.committed === false && isCurrentHold())
                finish();
        })
            .catch(() => {
            if (isCurrentHold())
                finish();
        });
    }, [clearPreviewGap]);
    useEffect(() => () => {
        // Detach only: unmount tears down the whole chrome (view switch or the
        // refresh itself re-keying the overlay); an unconditional clear here
        // would race a hold that a remounted overlay no longer tracks.
        previewGapHoldCleanupRef.current?.();
    }, []);
    const startCanceledDragReturnAnimation = useCallback(() => {
        if (reducedMotionRef.current)
            return false;
        const dragObject = gestureDragObjectRef.current ?? editContextRef.current?.dragObject;
        if (!dragObject || dragObject.type !== "component")
            return false;
        const targetRect = resolveReturnTargetRect();
        const previewRect = dragCursorPreviewRef.current?.getBoundingClientRect();
        if (!targetRect || !previewRect)
            return false;
        if (previewRect.width <= 0 && previewRect.height <= 0)
            return false;
        const fromRect = {
            x: previewRect.left,
            y: previewRect.top,
            width: Math.max(1, previewRect.width),
            height: Math.max(1, previewRect.height),
        };
        clearReturnPreviewTimer();
        const preview = {
            id: ++returnPreviewSequenceRef.current,
            from: fromRect,
            to: targetRect,
            dragObject,
            snapshot: snapshotRef.current,
            label: formatDragCursorLabel(dragObject),
        };
        returnPreviewRef.current = preview;
        setReturnPreview(preview);
        returnPreviewTimerRef.current = window.setTimeout(() => {
            returnPreviewTimerRef.current = null;
            returnPreviewRef.current = null;
            setReturnPreview(null);
            clearPreviewGap();
        }, DRAG_RETURN_ANIMATION_MS + 40);
        return true;
    }, [clearPreviewGap, clearReturnPreviewTimer, resolveReturnTargetRect]);
    // Update line/highlight/status/preview only when the target gap changes - not
    // on every pointer move - so dragging stays smooth (the cursor chip still
    // tracks every move via setCursorPoint).
    const applyResolvedZone = useCallback((next) => {
        const nextKey = formatActiveInsertionKey(next);
        if (nextKey === lastZoneKeyRef.current)
            return;
        lastZoneKeyRef.current = nextKey;
        setActiveInsertion(next);
        if (next) {
            ownedInsertionZoneRef.current =
                next.visualMode === "directional" &&
                    next.visualTargetRect &&
                    next.visualOwnerComponentId
                    ? {
                        zoneKey: formatPlaceholderZoneKey(next.zone),
                        anchor: next.visualAnchor,
                        targetRect: next.visualTargetRect,
                        dropZoneRect: getDirectionalInsertionHitZoneRect({
                            zone: next.zone,
                            targetRect: next.visualTargetRect,
                            anchor: next.visualAnchor,
                        }),
                        ownerComponentId: next.visualOwnerComponentId,
                    }
                    : null;
            announce(next);
            sendPreviewGap(next);
        }
        else {
            ownedInsertionZoneRef.current = null;
            editContextRef.current?.setStatusMessage("");
            if (lastPreviewKeyRef.current !== null) {
                clearPreviewGap();
            }
        }
    }, [announce, sendPreviewGap, clearPreviewGap, setActiveInsertion]);
    const resolveNativeDragPoint = useCallback((point) => {
        const surface = surfaceRef.current;
        if (!surface)
            return;
        const overlayPoint = clientToOverlay(point.x, point.y, surface.getBoundingClientRect());
        applyResolvedZone(resolveZoneAtOverlayPoint(overlayPoint));
    }, [applyResolvedZone, resolveZoneAtOverlayPoint]);
    const getNativeAutoScrollStep = useCallback((point) => {
        const iframe = overlayRef.current.iframe;
        if (!iframe)
            return 0;
        const rect = iframe.getBoundingClientRect();
        if (point.x < rect.left || point.x > rect.right)
            return 0;
        return getAutoScrollStep(point.y, rect.top, rect.bottom);
    }, []);
    const stopNativeAutoScroll = useCallback(() => {
        if (nativeAutoScrollRafRef.current !== null) {
            window.cancelAnimationFrame(nativeAutoScrollRafRef.current);
            nativeAutoScrollRafRef.current = null;
        }
    }, []);
    const scrollNativeDragAtEdge = useCallback(() => {
        const point = nativeDragPointRef.current;
        if (!point)
            return false;
        const step = getNativeAutoScrollStep(point);
        if (step === 0)
            return false;
        pageViewContextRef.current?.requestBridgeScrollBy?.({
            y: step,
            behavior: "auto",
        });
        resolveNativeDragPoint(point);
        return true;
    }, [getNativeAutoScrollStep, resolveNativeDragPoint]);
    const runNativeAutoScroll = useCallback(() => {
        nativeAutoScrollRafRef.current = null;
        if (scrollNativeDragAtEdge()) {
            nativeAutoScrollRafRef.current =
                window.requestAnimationFrame(runNativeAutoScroll);
        }
    }, [scrollNativeDragAtEdge]);
    const updateNativeAutoScroll = useCallback(() => {
        const point = nativeDragPointRef.current;
        if (!point || getNativeAutoScrollStep(point) === 0) {
            stopNativeAutoScroll();
            return;
        }
        if (nativeAutoScrollRafRef.current !== null)
            return;
        if (scrollNativeDragAtEdge()) {
            nativeAutoScrollRafRef.current =
                window.requestAnimationFrame(runNativeAutoScroll);
        }
    }, [
        getNativeAutoScrollStep,
        runNativeAutoScroll,
        scrollNativeDragAtEdge,
        stopNativeAutoScroll,
    ]);
    const clearDragPresentation = useCallback((options) => {
        const clearPreview = options?.clearPreview ?? true;
        setActiveInsertion(null, { animateExit: false });
        setGestureActiveValue(false);
        setCursorClient(null);
        setSnapshot(null);
        nativeDragPointRef.current = null;
        stopNativeAutoScroll();
        if (clearPreview) {
            clearPreviewGap();
        }
        lastZoneKeyRef.current = null;
        ownedInsertionZoneRef.current = null;
        movingRef.current = null;
        returnTargetComponentRef.current = null;
        gestureDragObjectRef.current = null;
        editContextRef.current?.setStatusMessage("");
    }, [
        clearPreviewGap,
        setActiveInsertion,
        setGestureActiveValue,
        stopNativeAutoScroll,
    ]);
    const cancelCurrentDrag = useCallback(() => {
        const returnStarted = startCanceledDragReturnAnimation();
        canceledGestureHandledRef.current = true;
        if (gestureActiveRef.current) {
            pageViewContextRef.current?.requestBridgeCancelPointerDrag?.();
        }
        clearDragPresentation({ clearPreview: !returnStarted });
        editContextRef.current?.dragEnd();
    }, [clearDragPresentation, startCanceledDragReturnAnimation]);
    const cancelActiveGesture = useCallback(() => {
        if (!gestureActiveRef.current)
            return;
        cancelCurrentDrag();
    }, [cancelCurrentDrag]);
    useEffect(() => {
        if (!gestureActive)
            return;
        const cancel = () => cancelActiveGesture();
        const cancelWhenHidden = () => {
            if (document.visibilityState === "hidden")
                cancelActiveGesture();
        };
        window.addEventListener("blur", cancel);
        window.addEventListener("pagehide", cancel);
        document.addEventListener("visibilitychange", cancelWhenHidden);
        return () => {
            window.removeEventListener("blur", cancel);
            window.removeEventListener("pagehide", cancel);
            document.removeEventListener("visibilitychange", cancelWhenHidden);
        };
    }, [cancelActiveGesture, gestureActive]);
    useEffect(() => {
        if (!isDragging)
            return;
        const cancelOnEscape = (event) => {
            if (event.key !== "Escape" &&
                event.key !== "Esc" &&
                event.code !== "Escape") {
                return;
            }
            event.preventDefault();
            event.stopPropagation();
            event.stopImmediatePropagation();
            cancelCurrentDrag();
        };
        window.addEventListener("keydown", cancelOnEscape, true);
        return () => {
            window.removeEventListener("keydown", cancelOnEscape, true);
        };
    }, [cancelCurrentDrag, isDragging]);
    // No gap anywhere accepts this drag — for a component move that means it
    // already sits in its only possible position (its own adjacent gaps are
    // no-ops and hidden). Surface that instead of leaving the drag mute: a hint
    // rides the drag cursor (gesture drags) or tops the canvas (native drags),
    // and the status line announces it. Delayed so the initial empty measurement
    // doesn't flash the hint at every drag start.
    const noDropPositionMessage = isDragging
        ? editContext?.dragObject?.type === "component"
            ? "Already in its only possible position — nowhere else to move it"
            : "No compatible placeholder on this page"
        : null;
    const zonesEmptyWhileDragging = isDragging && zones.length === 0;
    const [showNoDropHint, setShowNoDropHint] = useState(false);
    useEffect(() => {
        if (!zonesEmptyWhileDragging) {
            setShowNoDropHint(false);
            return;
        }
        const timer = window.setTimeout(() => setShowNoDropHint(true), NO_DROP_HINT_DELAY_MS);
        return () => window.clearTimeout(timer);
    }, [zonesEmptyWhileDragging]);
    useEffect(() => {
        if (showNoDropHint && noDropPositionMessage) {
            editContextRef.current?.setStatusMessage(_jsx("div", { children: noDropPositionMessage }));
        }
    }, [showNoDropHint, noDropPositionMessage]);
    // Tell the "+" dots which gap is currently targeted so the matching dot can
    // light up - but only while the insertion LINE is actually visible at that gap,
    // so the dot highlight and the line appear together. (The resolved gap is set
    // even when the cursor is far from any edge, with the line hidden.)
    const activeGapKey = active && isInsertionLineVisibleFor(active)
        ? formatPlaceholderZoneKey(active.zone)
        : null;
    useEffect(() => {
        onActiveTargetChange?.(activeGapKey);
    }, [activeGapKey, onActiveTargetChange]);
    useEffect(() => () => onActiveTargetChange?.(null), [onActiveTargetChange]);
    useEffect(() => {
        if (!isDragging)
            return;
        const syncMetrics = (metrics) => {
            setGuideMetrics(metrics);
        };
        const syncMeasurementMetrics = (metrics) => {
            syncMetrics(metrics);
        };
        syncMetrics(overlay.getMetrics());
        const unsubscribeScroll = overlay.subscribeScroll(syncMetrics);
        const unsubscribeMeasurement = overlay.subscribeMeasurement(syncMeasurementMetrics, ["viewport", "layout", "adornment-data"]);
        return () => {
            unsubscribeScroll();
            unsubscribeMeasurement();
        };
    }, [isDragging, overlay]);
    // Host-driven pointer gesture path (whole-component grab).
    useEffect(() => {
        const iframe = overlay.iframe;
        if (!iframe)
            return;
        const onGesture = (e) => {
            const detail = e
                .detail;
            if (detail.phase === "snapshot") {
                setSnapshot({
                    dataUrl: detail.dataUrl,
                    width: detail.width,
                    height: detail.height,
                });
                return;
            }
            const ec = editContextRef.current;
            const pvc = pageViewContextRef.current;
            if (!ec || !pvc?.page)
                return;
            // "host" gestures report in-iframe (host-viewport) coords; "client"
            // gestures (frame-menu grip, parent-driven) report PARENT-client coords.
            const space = detail.space ?? "host";
            const toOverlay = (clientX, clientY) => {
                if (space === "client") {
                    const surface = surfaceRef.current;
                    if (!surface)
                        return null;
                    return clientToOverlay(clientX, clientY, surface.getBoundingClientRect());
                }
                const geometry = pvc.bridgeGeometry;
                if (!geometry)
                    return null;
                return hostViewportToOverlay({ x: clientX, y: clientY }, geometry, overlayRef.current.getMetrics());
            };
            // Position for the fixed (viewport-anchored) chip/snapshot, in PARENT
            // viewport coords so it follows the cursor on screen and never scrolls away
            // with the page. Client-space coords are already parent-client; host-space
            // coords are mapped through the iframe's on-screen rect vs. its content
            // viewport size (absorbs any editor scaling).
            const toClient = (clientX, clientY) => {
                if (space === "client")
                    return { x: clientX, y: clientY };
                const iframe = overlayRef.current.iframe;
                if (!iframe)
                    return null;
                const rect = iframe.getBoundingClientRect();
                const metrics = overlayRef.current.getMetrics();
                const sx = metrics.viewportWidth
                    ? rect.width / metrics.viewportWidth
                    : 1;
                const sy = metrics.viewportHeight
                    ? rect.height / metrics.viewportHeight
                    : 1;
                return { x: rect.left + clientX * sx, y: rect.top + clientY * sy };
            };
            if (detail.phase === "start") {
                clearReturnPreview();
                // A pending post-drop hold must stop watching: the new drag owns the
                // original-position placeholder from here on.
                cancelPreviewGapHold();
                canceledGestureHandledRef.current = false;
                let component;
                let dragObject = detail.dragObject;
                let inMulti = false;
                if (!dragObject) {
                    component = detail.componentId
                        ? getComponentById(detail.componentId, pvc.page)
                        : undefined;
                    if (!component)
                        return;
                    const selection = ec.getSelection();
                    inMulti = selection.length > 1 && selection.includes(component.id);
                    const selectedIds = inMulti ? selection : [component.id];
                    dragObject = buildComponentDragObject(component, selectedIds, pvc.page);
                }
                if (!dragObject)
                    return;
                movingRef.current =
                    component && detail.elementKey
                        ? { elementKey: detail.elementKey, component }
                        : null;
                returnTargetComponentRef.current = component ?? null;
                gestureDragObjectRef.current = dragObject;
                if (dragObject.type === "component" &&
                    (detail.elementKey || detail.componentId)) {
                    if (space === "client") {
                        pvc.requestBridgePreviewGap?.({
                            originalElementKey: detail.elementKey,
                            originalComponentId: detail.componentId,
                            originalComponentName: component?.name ?? detail.componentName ?? dragObject.name,
                        });
                    }
                    originalPlaceholderPreviewActiveRef.current = true;
                }
                setSnapshot(null);
                setGestureActiveValue(true);
                ec.dragStart(dragObject);
                // Select the grabbed component AFTER dragStart so the selection-scroll
                // effect (PageViewerFrame) sees the active drag and skips scrolling it
                // back into view, which would yank the page away from the drag.
                if (component && !inMulti)
                    ec.select([component.id]);
                // Host gestures self-capture the snapshot on threshold-cross. Parent-
                // driven (client-space) drags must ask the host to rasterize the source.
                if (space === "client" && (detail.elementKey || detail.componentId)) {
                    pvc.requestBridgeCaptureSnapshot?.({
                        elementKey: detail.elementKey,
                        componentId: detail.componentId,
                    });
                }
                setCursorClient(toClient(detail.clientX, detail.clientY));
                const point = toOverlay(detail.clientX, detail.clientY);
                applyResolvedZone(point ? resolveZoneAtOverlayPoint(point) : null);
            }
            else if (detail.phase === "move") {
                const client = toClient(detail.clientX, detail.clientY);
                setCursorClient(client);
                // Hovering the minimap mid-drag scrolls the canvas to that region so
                // off-screen drop targets become reachable. Works for both grab modes:
                // the pointer is captured, so the host keeps reporting moves (which we
                // map to parent coords) even after the cursor leaves the iframe for the
                // minimap. When the minimap owns the scroll this frame, skip the canvas
                // zone re-resolve (the cursor is off-canvas).
                if (client && pvc.scrollViaMinimapPoint?.(client.x, client.y)) {
                    return;
                }
                const point = toOverlay(detail.clientX, detail.clientY);
                if (!point)
                    return;
                applyResolvedZone(resolveZoneAtOverlayPoint(point));
            }
            else if (detail.phase === "end") {
                if (detail.canceled) {
                    if (canceledGestureHandledRef.current)
                        return;
                    canceledGestureHandledRef.current = true;
                    const returnStarted = startCanceledDragReturnAnimation();
                    clearDragPresentation({ clearPreview: !returnStarted });
                    ec.dragEnd();
                    return;
                }
                const point = !detail.canceled
                    ? toOverlay(detail.clientX, detail.clientY)
                    : null;
                const target = point ? resolveZoneAtOverlayPoint(point) : null;
                const commitTarget = !detail.canceled && isDropCommitTarget(target) ? target : null;
                // On a committed move, keep the original-position placeholder up past
                // the drag - it is cleared when the canvas refresh lands (see
                // holdPreviewGapUntilCanvasRefresh).
                const holdOriginalPreview = !!commitTarget && originalPlaceholderPreviewActiveRef.current;
                clearDragPresentation({ clearPreview: !holdOriginalPreview });
                if (commitTarget) {
                    const commit = ec.droppedInPlaceholder(commitTarget.zone.placeholder.key, commitTarget.adjustedIndex, undefined, commitTarget.zone.anchor, undefined, commitTarget.zone.position, commitTarget.color);
                    if (holdOriginalPreview) {
                        holdPreviewGapUntilCanvasRefresh(commit);
                    }
                }
                ec.dragEnd();
            }
        };
        iframe.addEventListener(IFRAME_OVERLAY_BRIDGE_DRAG_GESTURE_EVENT, onGesture);
        return () => iframe.removeEventListener(IFRAME_OVERLAY_BRIDGE_DRAG_GESTURE_EVENT, onGesture);
    }, [
        overlay.iframe,
        resolveZoneAtOverlayPoint,
        applyResolvedZone,
        cancelPreviewGapHold,
        clearReturnPreview,
        clearDragPresentation,
        holdPreviewGapUntilCanvasRefresh,
        startCanceledDragReturnAnimation,
    ]);
    // Reset when a drag ends. A pending post-drop hold keeps the original
    // placeholder alive until the canvas refresh, so don't clear the preview
    // out from under it here.
    useEffect(() => {
        if (!isDragging) {
            if (returnPreviewRef.current)
                return;
            clearDragPresentation({
                clearPreview: !previewGapHoldCleanupRef.current,
            });
        }
    }, [isDragging, clearDragPresentation]);
    // Never leave a status message lingering after unmount.
    useEffect(() => {
        return () => {
            stopNativeAutoScroll();
            clearExitTimer();
            clearReturnPreviewTimer();
            editContextRef.current?.setStatusMessage("");
        };
    }, [clearExitTimer, clearReturnPreviewTimer, stopNativeAutoScroll]);
    const offscreenGuides = getOffscreenInsertionGuides({
        zones,
        active,
        metrics: guideMetrics,
    });
    const dragCursorThumbnailUrl = editContext?.dragObject?.type === "template"
        ? editContext.dragObject.thumbnailUrl
        : undefined;
    const dragCursorLabel = editContext?.dragObject
        ? formatDragCursorLabel(editContext.dragObject)
        : undefined;
    const dragCursorShrinkPx = isDropCommitTarget(active)
        ? DRAG_CURSOR_DROP_ZONE_SHRINK_PX
        : 0;
    const dragCursorSnapshotSize = snapshot
        ? snapshotDisplaySize(snapshot, dragCursorShrinkPx)
        : null;
    const templateThumbnailSize = shrinkSizeProportionally({
        width: TEMPLATE_THUMBNAIL_WIDTH_PX,
        height: TEMPLATE_THUMBNAIL_IMAGE_HEIGHT_PX,
    }, dragCursorShrinkPx);
    const templateThumbnailWidth = templateThumbnailSize.width;
    const templateThumbnailImageHeight = templateThumbnailSize.height;
    const dragCursorSizeTransition = reducedMotion
        ? undefined
        : "width 120ms ease-out, height 120ms ease-out";
    const returnPreviewPortal = returnPreview
        ? createPortal(_jsx(ReturningDragPreview, { preview: returnPreview }), document.body)
        : null;
    if (!isDragging || !pageViewContext || !editContext) {
        return returnPreviewPortal;
    }
    const handleDragOver = (e) => {
        if (editContext.mode !== "edit" || editContext.readonly) {
            e.dataTransfer.dropEffect = "none";
            return;
        }
        e.preventDefault();
        e.dataTransfer.dropEffect =
            editContext.dragObject?.type === "template" ? "copy" : "move";
        const surface = surfaceRef.current;
        if (!surface)
            return;
        const point = { x: e.clientX, y: e.clientY };
        nativeDragPointRef.current = point;
        resolveNativeDragPoint(point);
        updateNativeAutoScroll();
    };
    const handleDrop = (e) => {
        e.preventDefault();
        nativeDragPointRef.current = null;
        stopNativeAutoScroll();
        editContext.setStatusMessage("");
        if (editContext.mode !== "edit" || editContext.readonly) {
            setActiveInsertion(null, { animateExit: false });
            return;
        }
        const surface = surfaceRef.current;
        const point = surface
            ? clientToOverlay(e.clientX, e.clientY, surface.getBoundingClientRect())
            : null;
        const target = point ? resolveZoneAtOverlayPoint(point) : null;
        const commitTarget = isDropCommitTarget(target) ? target : null;
        setActiveInsertion(null, { animateExit: false });
        ownedInsertionZoneRef.current = null;
        if (!commitTarget)
            return;
        editContext.droppedInPlaceholder(commitTarget.zone.placeholder.key, commitTarget.adjustedIndex, undefined, commitTarget.zone.anchor, undefined, commitTarget.zone.position, commitTarget.color);
    };
    const handleDragLeave = (e) => {
        const surface = surfaceRef.current;
        const related = e.relatedTarget;
        if (surface && related && surface.contains(related))
            return;
        nativeDragPointRef.current = null;
        stopNativeAutoScroll();
        applyResolvedZone(null);
    };
    return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: surfaceRef, "data-testid": "drag-insertion-surface", className: cn("absolute inset-0", gestureActive ? "pointer-events-none" : "pointer-events-auto"), style: { zIndex: 850 }, onDragOver: gestureActive ? undefined : handleDragOver, onDrop: gestureActive ? undefined : handleDrop, onDragLeave: gestureActive ? undefined : handleDragLeave, children: [active && active.zone.placeholder.parentComponent?.id && (_jsx(ParentComponentOutline, { component: active.zone.placeholder.parentComponent, pageViewContext: pageViewContext, color: active.color, animate: !reducedMotion })), exitingActive && (_jsx(InsertionLine
                    // Constant key: only one exit copy is ever in flight, so reuse the
                    // same node across successive disappearances instead of remounting
                    // (and replaying the exit fade) per gap.
                    , { zone: exitingActive.zone, placeholderRect: exitingActive.placeholderRect, visualMode: exitingActive.visualMode, visualAnchor: exitingActive.visualAnchor, visualTargetRect: exitingActive.visualTargetRect, color: exitingActive.color, label: buildActiveInsertionLabel(exitingActive), animate: !reducedMotion, phase: "exit" }, "exit-active")), active && (_jsx(InsertionLine
                    // Constant key: the bar is a single persistent element that GLIDES
                    // to each new gap (via its left/top/width/height transition) instead
                    // of remounting and replaying the fade-in at every gap - that per-gap
                    // re-fade is the "spots quickly flashing" as the cursor sweeps. The
                    // enter fade now plays once when the bar first appears.
                    , { zone: active.zone, placeholderRect: active.placeholderRect, visualMode: active.visualMode, visualAnchor: active.visualAnchor, visualTargetRect: active.visualTargetRect, color: active.color, label: buildActiveInsertionLabel(active), animate: !reducedMotion, phase: "enter" }, "active-bar")), offscreenGuides.map((guide) => (_jsx(OffscreenInsertionGuidePill, { guide: guide }, guide.direction))), !gestureActive && showNoDropHint && noDropPositionMessage && (
                    // Native (HTML5) drags have no cursor-anchored preview to attach the
                    // hint to, so it tops the canvas instead.
                    _jsxs("div", { "data-testid": "drag-no-drop-banner", className: "bg-neutral-grey-100/90 pointer-events-none absolute top-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-[4px] px-2.5 py-1.5 text-xs leading-none font-medium whitespace-nowrap text-white shadow-md backdrop-blur-sm", style: { zIndex: 900 }, children: [_jsx(Ban, { className: "h-3.5 w-3.5 shrink-0", strokeWidth: 1 }), _jsx("span", { className: "truncate", children: noDropPositionMessage })] }))] }), gestureActive &&
                cursorClient &&
                editContext.dragObject &&
                createPortal(
                // Cursor-anchored: the dragged thing (live snapshot, else a chip). The
                // insertion bar names the target placeholder, so the cursor stays clean.
                _jsxs("div", { ref: dragCursorPreviewRef, "data-testid": "drag-cursor-preview", className: "pointer-events-none fixed flex flex-col items-start gap-1.5", style: {
                        left: cursorClient.x + 14,
                        top: cursorClient.y + 14,
                        zIndex: 99999,
                    }, children: [showNoDropHint && noDropPositionMessage && (
                        // Ride ABOVE the dragged preview so the hint sits on top of the
                        // snapshot/thumbnail image rather than dangling below it.
                        _jsxs("div", { "data-testid": "drag-cursor-no-drop", className: "bg-neutral-grey-100/90 flex items-center gap-1.5 rounded-[4px] px-2 py-1 text-xs leading-none font-medium whitespace-nowrap text-white shadow-md", children: [_jsx(Ban, { className: "h-3.5 w-3.5 shrink-0", strokeWidth: 1 }), _jsx("span", { className: "truncate", children: noDropPositionMessage })] })), snapshot ? (_jsx("div", { "data-testid": "drag-cursor-snapshot", className: "overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black/10", style: { opacity: 0.96 }, children: _jsx("div", { className: "p-1.5", children: _jsx("img", { src: snapshot.dataUrl, alt: "", "aria-hidden": "true", draggable: false, className: "block rounded-sm", style: {
                                        width: dragCursorSnapshotSize?.width,
                                        height: dragCursorSnapshotSize?.height,
                                        objectFit: "contain",
                                        transition: dragCursorSizeTransition,
                                    } }) }) })) : dragCursorThumbnailUrl ? (_jsxs("div", { "data-testid": "drag-cursor-thumbnail", className: "overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black/10", style: {
                                opacity: 0.96,
                                width: templateThumbnailWidth,
                                transition: dragCursorSizeTransition,
                            }, children: [_jsx("div", { "data-testid": "drag-cursor-thumbnail-label", className: "border-border-default text-neutral-grey-100 truncate border-b px-2 py-1 text-xs leading-4 font-medium", title: dragCursorLabel, children: dragCursorLabel }), _jsx("div", { className: "p-1.5", children: _jsx("img", { src: dragCursorThumbnailUrl, alt: "", "aria-hidden": "true", draggable: false, className: "block w-full rounded-sm object-contain", style: {
                                            height: templateThumbnailImageHeight,
                                            transition: dragCursorSizeTransition,
                                        } }) })] })) : (_jsxs("div", { "data-testid": "drag-cursor-chip", className: "bg-component-default flex items-center gap-1 rounded px-2 py-1 text-sm whitespace-nowrap text-white shadow-md", children: [_jsx(GripVertical, { className: "h-3.5 w-3.5", strokeWidth: 1 }), (editContext.dragObject.components?.length ?? 0) > 1
                                    ? `${editContext.dragObject.components.length} components`
                                    : editContext.dragObject.name] }))] }), document.body), returnPreviewPortal] }));
}
function ReturningDragPreview({ preview }) {
    const [atRest, setAtRest] = useState(false);
    useEffect(() => {
        setAtRest(false);
        const timer = window.setTimeout(() => setAtRest(true), 0);
        return () => window.clearTimeout(timer);
    }, [preview.id]);
    const rect = atRest ? preview.to : preview.from;
    return (_jsx("div", { "data-testid": "drag-return-preview", className: "pointer-events-none fixed overflow-hidden", style: {
            left: rect.x,
            top: rect.y,
            width: rect.width,
            height: rect.height,
            zIndex: 99998,
            opacity: atRest ? 0 : 0.96,
            transition: "left 240ms cubic-bezier(0.22, 1, 0.36, 1), top 240ms cubic-bezier(0.22, 1, 0.36, 1), width 240ms cubic-bezier(0.22, 1, 0.36, 1), height 240ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease 80ms",
            willChange: "left, top, width, height, opacity",
        }, children: preview.snapshot ? (_jsx("div", { "data-testid": "drag-return-snapshot", className: "h-full w-full overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black/10", children: _jsx("div", { className: "h-full w-full p-1.5", children: _jsx("img", { src: preview.snapshot.dataUrl, alt: "", "aria-hidden": "true", draggable: false, className: "block h-full w-full rounded-sm object-contain" }) }) })) : (_jsxs("div", { "data-testid": "drag-return-chip", className: "bg-component-default flex h-full w-full items-center justify-center gap-1 rounded px-2 py-1 text-sm whitespace-nowrap text-white shadow-md", children: [_jsx(GripVertical, { className: "h-3.5 w-3.5 shrink-0", strokeWidth: 1 }), _jsx("span", { className: "truncate", children: preview.label })] })) }));
}
function OffscreenInsertionGuidePill({ guide, }) {
    const Icon = offscreenGuideIcons[guide.direction];
    const accent = insertAccentVar[guide.color];
    const transform = guide.direction === "right"
        ? "translate(-100%, -50%)"
        : guide.direction === "left"
            ? "translateY(-50%)"
            : "translate(-50%, -50%)";
    return (_jsxs("div", { "aria-hidden": "true", "data-testid": "drag-offscreen-guide", "data-direction": guide.direction, className: "pointer-events-none absolute flex max-w-[min(20rem,calc(100vw-3rem))] items-center gap-1.5 rounded-[4px] border px-2 py-1 text-[11px] leading-none font-medium whitespace-nowrap text-white shadow-md backdrop-blur-sm", style: {
            left: guide.position.x,
            top: guide.position.y,
            zIndex: 890,
            transform,
            backgroundColor: `color-mix(in srgb, ${accent} 88%, white)`,
            borderColor: `color-mix(in srgb, ${accent} 78%, white)`,
        }, children: [_jsx(Icon, { className: "h-3.5 w-3.5 shrink-0", strokeWidth: 1 }), _jsx("span", { className: "truncate", children: guide.label }), guide.count > 1 && (_jsxs("span", { className: "rounded bg-white/20 px-1 text-[10px]", children: ["+", guide.count - 1] }))] }));
}
const offscreenGuideIcons = {
    up: ChevronUp,
    down: ChevronDown,
    left: ChevronLeft,
    right: ChevronRight,
};
function getOffscreenInsertionGuides({ zones, active, metrics, }) {
    if (zones.length === 0)
        return [];
    const viewport = getOverlayViewportRect(metrics);
    const activeLineVisible = active ? isInsertionLineVisibleFor(active) : false;
    const activeLineVisibleInViewport = activeLineVisible && active
        ? pointInOverlayRect(active.zone.position, viewport)
        : false;
    if (activeLineVisibleInViewport) {
        return [];
    }
    if (zones.some((zone) => pointInOverlayRect(zone.position, viewport))) {
        return [];
    }
    const grouped = buildOffscreenGuideGroups(zones, viewport);
    return ["up", "down", "left", "right"]
        .map((direction) => pickNearestOffscreenGuide(direction, grouped[direction], viewport))
        .filter((guide) => !!guide);
}
function buildOffscreenGuideGroups(zones, viewport) {
    const grouped = createEmptyOffscreenGuideGroups();
    for (const zone of zones) {
        const color = computeDropZoneColor(zone);
        const adjustedIndex = layoutAwareIndex(zone.placeholder.components, zone.index);
        const baseGuide = {
            zone,
            adjustedIndex,
            label: buildInsertTargetLabel({
                placeholder: zone.placeholder,
                description: zone.description,
                index: adjustedIndex,
            }),
            count: 1,
            color,
        };
        if (zone.position.y < viewport.y) {
            grouped.up.push({
                ...baseGuide,
                direction: "up",
                position: {
                    x: clampNumber(zone.position.x, viewport.x + OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX, viewport.x + viewport.width - OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX),
                    y: viewport.y + OFFSCREEN_GUIDE_EDGE_INSET_PX,
                },
            });
        }
        if (zone.position.y > viewport.y + viewport.height) {
            grouped.down.push({
                ...baseGuide,
                direction: "down",
                position: {
                    x: clampNumber(zone.position.x, viewport.x + OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX, viewport.x + viewport.width - OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX),
                    y: viewport.y + viewport.height - OFFSCREEN_GUIDE_EDGE_INSET_PX,
                },
            });
        }
        if (zone.position.x < viewport.x) {
            grouped.left.push({
                ...baseGuide,
                direction: "left",
                position: {
                    x: viewport.x + OFFSCREEN_GUIDE_EDGE_INSET_PX,
                    y: clampNumber(zone.position.y, viewport.y + OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX, viewport.y +
                        viewport.height -
                        OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX),
                },
            });
        }
        if (zone.position.x > viewport.x + viewport.width) {
            grouped.right.push({
                ...baseGuide,
                direction: "right",
                position: {
                    x: viewport.x + viewport.width - OFFSCREEN_GUIDE_EDGE_INSET_PX,
                    y: clampNumber(zone.position.y, viewport.y + OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX, viewport.y +
                        viewport.height -
                        OFFSCREEN_GUIDE_CROSS_AXIS_PADDING_PX),
                },
            });
        }
    }
    return grouped;
}
function createEmptyOffscreenGuideGroups() {
    return {
        up: [],
        down: [],
        left: [],
        right: [],
    };
}
function pickNearestOffscreenGuide(direction, guides, viewport) {
    if (guides.length === 0)
        return null;
    const sorted = [...guides].sort((a, b) => {
        const mainDelta = getOffscreenMainAxisDistance(direction, a, viewport);
        const otherMainDelta = getOffscreenMainAxisDistance(direction, b, viewport);
        if (mainDelta !== otherMainDelta)
            return mainDelta - otherMainDelta;
        const crossCenter = direction === "up" || direction === "down"
            ? viewport.x + viewport.width / 2
            : viewport.y + viewport.height / 2;
        const aCross = direction === "up" || direction === "down"
            ? a.zone.position.x
            : a.zone.position.y;
        const bCross = direction === "up" || direction === "down"
            ? b.zone.position.x
            : b.zone.position.y;
        return Math.abs(aCross - crossCenter) - Math.abs(bCross - crossCenter);
    });
    return {
        ...sorted[0],
        count: guides.length,
    };
}
function getOffscreenMainAxisDistance(direction, guide, viewport) {
    switch (direction) {
        case "up":
            return viewport.y - guide.zone.position.y;
        case "down":
            return guide.zone.position.y - (viewport.y + viewport.height);
        case "left":
            return viewport.x - guide.zone.position.x;
        case "right":
            return guide.zone.position.x - (viewport.x + viewport.width);
    }
}
function getOverlayViewportRect(metrics) {
    const documentScale = metrics.scrollScale * metrics.visualScale;
    return {
        x: metrics.scrollLeft * documentScale,
        y: metrics.scrollTop * documentScale,
        width: metrics.viewportWidth,
        height: metrics.viewportHeight,
    };
}
function clampNumber(value, min, max) {
    if (max < min)
        return min;
    return Math.min(max, Math.max(min, value));
}
// The single drag indicator's label, shown on the insertion bar: the path of
// ANCESTOR COMPONENTS from the page root down to the target placeholder, then
// the 1-based insert position. Walks up the component/placeholder
// back-references (wired by pageModelBuilder) but names only the components -
// the parent components' own placeholders are intermediate plumbing, not shown.
// E.g. "Page ▸ Section 4 ▸ CENTER · 3" or "Page ▸ Main · 2" for a top-level
// placeholder.
function buildActiveInsertionLabel(target) {
    return buildInsertTargetLabel({
        placeholder: target.zone.placeholder,
        description: target.zone.description,
        index: target.adjustedIndex,
    });
}
const insertAccentVar = {
    default: "var(--color-component-default)",
    shared: "var(--color-component-shared)",
    layout: "var(--color-highlight-100)",
};
// A subtle dotted outline around the component that owns the target placeholder,
// so a nested drop reads as "into this component's slot". Renders only when the
// placeholder sits inside a measurable component; top-level placeholders have no
// parent component to outline, so nothing is drawn.
function ParentComponentOutline({ component, pageViewContext, color, animate, }) {
    const measuredRect = useOverlayMeasurement((metrics) => measureBridgeComponentRect(pageViewContext.bridgeGeometry, component, metrics)?.rect ?? null, [component, pageViewContext.bridgeGeometry], {
        initialValue: null,
        equals: rectEquals,
        interests: ["viewport", "layout", "adornment-data"],
        validateBeforePaint: true,
    });
    // Hold the last good rect so a single transient-null measurement frame (mid
    // drag auto-scroll / reflow) doesn't unmount the outline and replay its
    // fade-in - a one-frame blink at the component. Reset when the outlined
    // component changes so we never paint a stale box at the wrong place.
    const lastRectRef = useRef(null);
    const lastComponentIdRef = useRef(component.id);
    if (lastComponentIdRef.current !== component.id) {
        lastComponentIdRef.current = component.id;
        lastRectRef.current = null;
    }
    if (measuredRect)
        lastRectRef.current = measuredRect;
    const rect = measuredRect ?? lastRectRef.current;
    if (!rect)
        return null;
    const accent = insertAccentVar[color];
    return (_jsx("div", { "data-testid": "drag-parent-outline", "data-component-id": component.id, className: cn("pointer-events-none absolute rounded-[6px] border border-dotted", animate && "parhelia-drop-zone-overlay-enter"), style: {
            left: rect.x - FRAME_OUTSET,
            top: rect.y - FRAME_OUTSET,
            width: rect.width + FRAME_OUTSET * 2,
            height: rect.height + FRAME_OUTSET * 2,
            borderColor: `color-mix(in srgb, ${accent} 45%, transparent)`,
            zIndex: 851,
        } }));
}
// Size the floating snapshot from 75% of the component's captured width, capped
// to the viewport.
function snapshotDisplaySize(s, shrinkPx = 0) {
    const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
    const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 800;
    const width = Math.round(Math.min(Math.max(1, s.width * 0.75), viewportWidth * 0.9));
    const aspectHeight = s.width > 0 ? Math.round((width * Math.max(1, s.height)) / s.width) : width;
    const height = Math.round(Math.min(Math.max(1, aspectHeight), viewportHeight * 0.9));
    return shrinkSizeProportionally({ width, height }, shrinkPx);
}
// Shrink both dimensions by ONE scale factor (the larger dimension loses
// `shrinkPx`), preserving aspect ratio. Subtracting the pixels per axis
// squashed extreme aspect ratios: a flat accordion-row capture lost most of
// its height but almost none of its width, so the hover-over-drop-zone shrink
// squeezed it into a sliver.
export function shrinkSizeProportionally(size, shrinkPx) {
    const width = Math.max(1, Math.round(size.width));
    const height = Math.max(1, Math.round(size.height));
    if (shrinkPx <= 0)
        return { width, height };
    const maxDimension = Math.max(width, height);
    const factor = Math.max(1, maxDimension - shrinkPx) / maxDimension;
    return {
        width: Math.max(1, Math.round(width * factor)),
        height: Math.max(1, Math.round(height * factor)),
    };
}
function formatDragCursorLabel(dragObject) {
    const componentCount = dragObject.components?.length ?? 0;
    if (componentCount > 1)
        return `${componentCount} components`;
    return dragObject.name ?? "Component";
}
function formatOverlayRectKey(rect) {
    if (!rect)
        return "none";
    return `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`;
}
// GRANULAR identity: the full state signature, including transient hover state
// and the rounded target rect. Use this for the applyResolvedZone dedup and the
// setActiveInsertion comparison so a hover-only change (zoneHovered) or a 1px
// rect tick still flows through to setActive(next) - that keeps active.zoneHovered
// fresh for isDropCommitTarget and lets the bar reposition. MUST stay granular;
// do NOT reuse it for the React key (see formatActiveInsertionVisualKey).
export function formatActiveInsertionKey(target) {
    if (!target)
        return "none";
    return `${target.zone.placeholder.key}#${target.zone.index}#${target.visualMode}#${target.visualAnchor}#${target.zoneHovered ? "hovered" : "resting"}#${formatOverlayRectKey(target.visualTargetRect)}`;
}
// COARSE identity: the targeted gap + its visual orientation only. This is the
// animation/React-key identity for <InsertionLine>. It deliberately DROPS
// zoneHovered and visualTargetRect so that merely hovering onto the line's hit
// band, or a sub-pixel rect drift, re-renders the SAME node in place (the inner
// transition glides it) instead of remounting it - a remount would replay the
// fade-in enter keyframe = a visible blink/flicker. The key changes only when
// the real insertion target changes, which is exactly when a fresh fade is wanted.
export function formatActiveInsertionVisualKey(target) {
    if (!target)
        return "none";
    return `${target.zone.placeholder.key}#${target.zone.index}#${target.visualMode}#${target.visualAnchor}`;
}
function isDropCommitTarget(target) {
    if (!target)
        return false;
    if (target.zoneHovered)
        return true;
    // Directional edge targets render a clear insertion line even when the final
    // pointer sample is inside the component edge band instead of the 40px band
    // outside it. Treat the visible line as the user's drop intent; hidden/neutral
    // targets remain non-committal unless their own hit zone is hovered.
    return target.visualMode !== "neutral" && isInsertionLineVisibleFor(target);
}
function formatPlaceholderZoneKey(zone) {
    return `${zone.placeholder.key}#${zone.index}`;
}
// Mirrors InsertionLine's own visibility gate: the bar renders unless the mode
// is "none" (cursor not near an edge) or a non-empty "neutral" zone. Used to
// keep the "+" dot highlight in lockstep with the line - the resolved gap is set
// even when the cursor is far from any edge, and the dot must not light up then.
function isInsertionLineVisibleFor(target) {
    if (target.visualMode === "none")
        return false;
    const isEmptyPlaceholder = target.zone.placeholder.components.length === 0;
    if (target.visualMode === "neutral" && !isEmptyPlaceholder)
        return false;
    return true;
}
function computeDropZoneColor(zone) {
    const components = zone.placeholder.components;
    const prev = zone.index >= 1 ? components[zone.index - 1] : null;
    const next = zone.index < components.length ? components[zone.index] : null;
    return getPlaceholderDropZoneColor({
        parentComponent: zone.placeholder.parentComponent,
        prevComponent: prev,
        nextComponent: next,
    });
}
function resolveInsertionZoneVisualTarget(zone, point, placeholderRect, geometry, metrics, ownedZone) {
    const components = zone.placeholder.components;
    const zoneKey = formatPlaceholderZoneKey(zone);
    if (ownedZone?.zoneKey === zoneKey &&
        pointInOverlayRect(point, ownedZone.dropZoneRect)) {
        const visualDropZoneRect = getDirectionalInsertionZoneRect({
            zone,
            targetRect: ownedZone.targetRect,
            anchor: ownedZone.anchor,
        });
        return {
            mode: "directional",
            anchor: ownedZone.anchor,
            rect: ownedZone.targetRect,
            ownerComponentId: ownedZone.ownerComponentId,
            zoneHovered: pointInOverlayRect(point, visualDropZoneRect),
        };
    }
    const previousComponent = zone.index > 0 ? components[zone.index - 1] : null;
    const nextComponent = zone.index < components.length ? components[zone.index] : null;
    const previousRect = previousComponent
        ? measureBridgeComponentRect(geometry, previousComponent, metrics)?.rect
        : null;
    const nextRect = nextComponent
        ? measureBridgeComponentRect(geometry, nextComponent, metrics)?.rect
        : null;
    const afterPrevious = previousRect
        ? {
            anchor: zone.orientation === "horizontal"
                ? "right"
                : "bottom",
            rect: previousRect,
            ownerComponentId: previousComponent?.id,
        }
        : null;
    const beforeNext = nextRect
        ? {
            anchor: zone.orientation === "horizontal"
                ? "left"
                : "top",
            rect: nextRect,
            ownerComponentId: nextComponent?.id,
        }
        : null;
    // A captured component-move gesture bypasses the overlay button's DOM event
    // handlers. Give the visible 32x32 "+" explicit priority here so every pixel
    // of the control drives its hover state and is a valid drop target, even when
    // decorated component bounds overlap or extend past the gap position.
    const buttonHitVisual = resolvePlaceholderButtonHitVisual({
        zone,
        point,
        placeholderRect,
        afterPrevious,
        beforeNext,
    });
    if (buttonHitVisual)
        return buttonHitVisual;
    if (!previousComponent && beforeNext) {
        return resolveSingleEdgeVisual(zone, point, placeholderRect, beforeNext);
    }
    if (!nextComponent && afterPrevious) {
        return resolveSingleEdgeVisual(zone, point, placeholderRect, afterPrevious);
    }
    if (afterPrevious && beforeNext) {
        // A wrap gap (next starts a new row/column of a wrapping grid) has no
        // shared gutter for a "between" band - the min→max cross span would
        // stretch the bar across both rows. Treat the gap as the previous
        // component's trailing edge instead, which is where `zone.position`
        // (and the "+" dot) already sits.
        if (isWrapInsertionGap(zone.orientation, afterPrevious.rect, beforeNext.rect)) {
            return resolveSingleEdgeVisual(zone, point, placeholderRect, afterPrevious);
        }
        const betweenRect = getBetweenInsertionZoneRect({
            zone,
            previousRect: afterPrevious.rect,
            nextRect: beforeNext.rect,
        });
        if (pointInOverlayRect(point, betweenRect)) {
            return {
                mode: "between",
                anchor: zone.anchor,
                rect: betweenRect,
                zoneHovered: true,
            };
        }
        if (pointInOverlayRect(point, afterPrevious.rect)) {
            return pointInDirectionalEdgeBand(point, afterPrevious.rect, afterPrevious.anchor)
                ? { ...afterPrevious, mode: "directional" }
                : {
                    ...resolveHiddenInsertionVisual(zone),
                    ownerComponentId: afterPrevious.ownerComponentId,
                };
        }
        if (pointInOverlayRect(point, beforeNext.rect)) {
            return pointInDirectionalEdgeBand(point, beforeNext.rect, beforeNext.anchor)
                ? { ...beforeNext, mode: "directional" }
                : {
                    ...resolveHiddenInsertionVisual(zone),
                    ownerComponentId: beforeNext.ownerComponentId,
                };
        }
        return resolveNeutralInsertionVisual(zone, placeholderRect, point);
    }
    return resolveNeutralInsertionVisual(zone, placeholderRect, point);
}
function resolvePlaceholderButtonHitVisual({ zone, point, placeholderRect, afterPrevious, beforeNext, }) {
    const halfButton = PLACEHOLDER_DROPZONE_BUTTON_SIZE / 2;
    const buttonRect = {
        x: zone.position.x - halfButton,
        y: zone.position.y - halfButton,
        width: PLACEHOLDER_DROPZONE_BUTTON_SIZE,
        height: PLACEHOLDER_DROPZONE_BUTTON_SIZE,
    };
    if (!pointInOverlayRect(point, buttonRect))
        return null;
    if (afterPrevious && beforeNext) {
        if (!isWrapInsertionGap(zone.orientation, afterPrevious.rect, beforeNext.rect)) {
            return {
                mode: "between",
                anchor: zone.anchor,
                rect: getBetweenInsertionZoneRect({
                    zone,
                    previousRect: afterPrevious.rect,
                    nextRect: beforeNext.rect,
                }),
                zoneHovered: true,
            };
        }
        return { ...afterPrevious, mode: "directional", zoneHovered: true };
    }
    const edge = afterPrevious ?? beforeNext;
    if (edge) {
        return { ...edge, mode: "directional", zoneHovered: true };
    }
    return {
        ...resolveNeutralInsertionVisual(zone, placeholderRect, point),
        zoneHovered: true,
    };
}
// A gap with a single visual owner: a terminal gap (before the first / after
// the last component) or a wrap gap resolved to its previous component. The
// component's edge band and the outside band both light the directional bar;
// anywhere else falls back to neutral/hidden.
function resolveSingleEdgeVisual(zone, point, placeholderRect, edge) {
    if (pointInOverlayRect(point, edge.rect)) {
        return pointInDirectionalEdgeBand(point, edge.rect, edge.anchor)
            ? { ...edge, mode: "directional" }
            : {
                ...resolveHiddenInsertionVisual(zone),
                ownerComponentId: edge.ownerComponentId,
            };
    }
    const outsideEdge = resolveDirectionalOutsideEdgeVisual(zone, point, edge);
    if (outsideEdge)
        return outsideEdge;
    return resolveNeutralInsertionVisual(zone, placeholderRect, point);
}
// An end gap's "+" dot and insertion band hang OUTSIDE the terminal
// component's edge (below the last / above the first component), where the
// component-rect checks above can't match. Without this, the first approach
// from that side resolved "neutral" (nothing lights up) while the same spot
// activated fine once the zone was owned - the pointer had to cross into the
// component's edge band once to arm it. Reuse the owned-zone hit rect so the
// first approach and the hysteresis accept the exact same region.
function resolveDirectionalOutsideEdgeVisual(zone, point, edge) {
    const hitRect = getDirectionalInsertionHitZoneRect({
        zone,
        targetRect: edge.rect,
        anchor: edge.anchor,
    });
    if (!pointInOverlayRect(point, hitRect))
        return null;
    const visualDropZoneRect = getDirectionalInsertionZoneRect({
        zone,
        targetRect: edge.rect,
        anchor: edge.anchor,
    });
    return {
        ...edge,
        mode: "directional",
        zoneHovered: pointInOverlayRect(point, visualDropZoneRect),
    };
}
function resolveNeutralInsertionVisual(zone, placeholderRect, point) {
    const targetRect = zone.targetRect ?? placeholderRect;
    const isEmptyPlaceholder = zone.placeholder.components.length === 0;
    return {
        anchor: zone.anchor,
        rect: targetRect,
        mode: "neutral",
        zoneHovered: isEmptyPlaceholder &&
            pointInOverlayRect(point, getEmptyPlaceholderNeutralZoneRect({ zone, targetRect })),
    };
}
function resolveHiddenInsertionVisual(zone) {
    return {
        anchor: zone.anchor,
        rect: null,
        mode: "none",
    };
}
function pointInOverlayRect(point, rect) {
    return (point.x >= rect.x &&
        point.x <= rect.x + rect.width &&
        point.y >= rect.y &&
        point.y <= rect.y + rect.height);
}
function pointInDirectionalEdgeBand(point, rect, anchor) {
    const length = anchor === "left" || anchor === "right" ? rect.width : rect.height;
    const edgeSize = resolveComponentEdgeDropZoneSize(length);
    switch (anchor) {
        case "top":
            return point.y - rect.y <= edgeSize;
        case "bottom":
            return rect.y + rect.height - point.y <= edgeSize;
        case "left":
            return point.x - rect.x <= edgeSize;
        case "right":
            return rect.x + rect.width - point.x <= edgeSize;
    }
}
function resolveComponentEdgeDropZoneSize(length) {
    const desired = Math.min(COMPONENT_EDGE_DROP_ZONE_MAX_PX, Math.max(COMPONENT_EDGE_DROP_ZONE_MIN_PX, length * COMPONENT_EDGE_DROP_ZONE_RATIO));
    return Math.max(1, Math.min(desired, length / 3));
}
//# sourceMappingURL=DragInsertionOverlay.js.map