import { useState } from "react"; import { CaptionOverlay } from "../../captions/components/CaptionOverlay"; import { DomEditOverlay } from "../editor/DomEditOverlay"; import { MotionPathOverlay } from "../editor/MotionPathOverlay"; import { SnapToolbar } from "../editor/SnapToolbar"; import { useCompositionDimensions } from "../../hooks/useCompositionDimensions"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_KEYFRAMES_ENABLED, STUDIO_PREVIEW_MANUAL_EDITING_ENABLED, STUDIO_PREVIEW_SELECTION_ENABLED, } from "../editor/manualEditingAvailability"; import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext"; import { useDomEditActionsContext, useDomEditSelectionContext, } from "../../contexts/DomEditContext"; import { readStudioUiPreferences } from "../../utils/studioUiPreferences"; import { readHfId, type DomEditSelection } from "../editor/domEditing"; import { buildStableSelector } from "../editor/domEditingDom"; import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers"; import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps"; import { useCanvasZOrderTimelineMirror } from "./useCanvasZOrderTimelineMirror"; import { runZLaneGesture } from "./zLaneGesture"; import type { BlockPreviewInfo } from "../sidebar/BlocksTab"; import type { GestureRecordingState } from "../editor/GestureRecordControl"; import type { ReactNode } from "react"; export interface PreviewOverlaysProps { shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; recordingState?: GestureRecordingState; onToggleRecording?: () => void; gestureOverlay?: ReactNode; } type ZIndexReorderEntry = { element: HTMLElement; zIndex: number; id?: string; selector?: string; selectorIndex?: number; sourceFile: string; /** Timeline store key — lets the commit update the store zIndex synchronously. */ key?: string; }; /** Can this element be robustly re-targeted for a persisted z change? */ function canTargetZIndexElement( element: HTMLElement, id: string | undefined, selector: string | undefined, ): boolean { return Boolean(id || selector || readHfId(element)); } /** The selected element carries its full selection identity. */ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry { return { element: sel.element, zIndex, id: sel.id ?? undefined, selector: sel.selector, selectorIndex: sel.selectorIndex, sourceFile: sel.sourceFile, key: deriveTimelineStoreKey({ domId: sel.id ?? undefined, selector: sel.selector, selectorIndex: sel.selectorIndex, sourceFile: sel.sourceFile, }), }; } /** * Sibling elements are raw iframe DOM nodes with no selection object: derive a * PatchTarget from the node itself (siblings live in the same document, so they * share the selection's sourceFile). Null when it cannot be robustly targeted * (no id and no selector) — its z stays live-only. */ function siblingZIndexEntry( element: HTMLElement, zIndex: number, sourceFile: string, ): ZIndexReorderEntry | null { const id = element.id || undefined; const selector = buildStableSelector(element); if (!canTargetZIndexElement(element, id, selector)) return null; return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile, key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }), }; } /** Short human-readable label for a dropped sibling, for the console warning below. */ function describeZIndexElement(element: HTMLElement): string { if (element.id) return `#${element.id}`; const firstClass = element.classList.item(0); return firstClass ? `${element.tagName.toLowerCase()}.${firstClass}` : element.tagName.toLowerCase(); } // Resolve z-index patches into commit entries; a sibling with no stable // id/selector can't be written to source, so it is returned as `dropped` for // the revert-on-reload warning (and a live-only style write, so the resolved // stacking order still renders coherently). Exported so tests can drive the // menu → commit path through the same wiring the app uses. export function resolveZIndexEntries( sel: DomEditSelection, patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>, ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } { const entries: ZIndexReorderEntry[] = []; const dropped: Array<{ element: HTMLElement; zIndex: number }> = []; for (const patch of patches) { if (patch.element === sel.element) { entries.push(selectedZIndexEntry(sel, patch.zIndex)); continue; } const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile); if (entry) entries.push(entry); else dropped.push(patch); } return { entries, dropped }; } // fallow-ignore-next-line complexity export function PreviewOverlays({ shouldShowSelectedDomBounds, blockPreview, isGestureRecording, recordingState, onToggleRecording, gestureOverlay, }: PreviewOverlaysProps) { const { activeCompPath, previewIframeRef } = useStudioShellContext(); const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext(); const compositionDimensions = useCompositionDimensions(); const { domEditHoverSelection, domEditSelection, domEditGroupSelections } = useDomEditSelectionContext(); const { handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, handlePreviewCanvasPointerLeave, applyDomSelection, handleBlockedDomMove, handleDomManualDragStart, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomStyleCommit, applyMarqueeSelection, handleDomEditElementDelete, handleDomZIndexReorderCommit, } = useDomEditActionsContext(); const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror(); // fallow-ignore-next-line complexity const [snapPrefs, setSnapPrefs] = useState(() => { const p = readStudioUiPreferences(); return { snapEnabled: p.snapEnabled ?? true, gridVisible: p.gridVisible ?? false, gridSpacing: p.gridSpacing ?? 50, snapToGrid: p.snapToGrid ?? false, }; }); if (blockPreview) { return (