diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index e96e35e470..c6eb57fc1c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,13 @@ "packages/studio/src/hooks/gsapRuntimePreview.ts", // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. + "packages/studio/src/player/components/TimelineLanes.tsx", + "packages/studio/src/player/components/useTimelineStackingSync.ts", + "packages/studio/src/player/components/useTimelineGeometry.ts", + "packages/studio/src/player/components/useTimelineEditPinning.ts", + "packages/studio/src/hooks/useTimelineEditingDrops.ts", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/nle/NLEContext.tsx", "packages/studio/src/components/nle/PreviewPane.tsx", "packages/studio/src/components/nle/AssetPreviewOverlay.tsx", @@ -439,6 +446,12 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useTimelineEditing.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useTimelineEditingDrops.ts", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/utils/timelineAssetDrop.ts", @@ -618,6 +631,10 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); + // removed by studio-dnd/pr22 when the final config lands. + "packages/studio/src/player/components/useTimelineGeometry.ts", + "packages/studio/src/player/components/useTimelineStackingSync.ts", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/components/editor/domEditOverlayGeometry.ts", diff --git a/packages/studio/src/hooks/useTimelineEditingDrops.ts b/packages/studio/src/hooks/useTimelineEditingDrops.ts new file mode 100644 index 0000000000..2305779b4d --- /dev/null +++ b/packages/studio/src/hooks/useTimelineEditingDrops.ts @@ -0,0 +1,232 @@ +// Timeline asset/file drop + add-at-playhead handlers, extracted from +// useTimelineEditing so that hook stays a wiring shell. Called unconditionally as +// a sub-hook to preserve the parent's hook call order. +import { useCallback } from "react"; +import type { MutableRefObject } from "react"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; +import { resolveZoneDropPlacement } from "../player/components/timelineCollision"; +import { isAudioTimelineElement } from "../utils/timelineInspector"; +import { + buildTimelineAssetId, + extendCompositionDurationIfNeeded, + buildTimelineAssetInsertHtml, + buildTimelineFileDropPlacements, + fitTimelineAssetGeometry, + getTimelineAssetKind, + insertTimelineAssetIntoSource, + resolveTimelineAssetCompositionSize, + resolveTimelineAssetSrc, +} from "../utils/timelineAssetDrop"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { + collectHtmlIds, + resolveDroppedAssetDuration, + resolveDroppedAssetDimensions, +} from "../utils/studioHelpers"; +import { generateId } from "../utils/generateId"; +import { formatTimelineAttributeNumber, readFileContent } from "./timelineEditingHelpers"; +import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; + +export interface UseTimelineEditingDropsDeps extends Pick< + UseTimelineEditingOptions, + | "activeCompPath" + | "timelineElements" + | "showToast" + | "writeProjectFile" + | "recordEdit" + | "domEditSaveTimestampRef" + | "reloadPreview" + | "uploadProjectFiles" + | "isRecordingRef" + | "forceReloadSdkSession" +> { + projectIdRef: MutableRefObject; +} + +export function useTimelineEditingDrops(deps: UseTimelineEditingDropsDeps) { + const { + projectIdRef, + activeCompPath, + timelineElements, + showToast, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + reloadPreview, + uploadProjectFiles, + isRecordingRef, + forceReloadSdkSession, + } = deps; + + // fallow-ignore-next-line complexity + const handleTimelineAssetDrop = useCallback( + // fallow-ignore-next-line complexity + async ( + assetPath: string, + placement: Pick, + durationOverride?: number, + ) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } + const pid = projectIdRef.current; + if (!pid) throw new Error("No active project"); + const kind = getTimelineAssetKind(assetPath); + if (!kind) { + showToast("Only image, video, and audio assets can be dropped onto the timeline."); + return; + } + const targetPath = activeCompPath || "index.html"; + try { + const originalContent = await readFileContent(pid, targetPath); + const normalizedStart = Number(formatTimelineAttributeNumber(placement.start)); + const duration = + Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0 + ? durationOverride + : await resolveDroppedAssetDuration(pid, assetPath, kind); + const normalizedDuration = Number(formatTimelineAttributeNumber(duration)); + const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent)); + const hfId = `hf-${generateId()}`; + const compSize = resolveTimelineAssetCompositionSize(originalContent); + const natural = + kind === "audio" ? null : await resolveDroppedAssetDimensions(pid, assetPath, kind); + const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath); + + const resolvedTargetPath = targetPath || "index.html"; + const relevantElements = timelineElements.filter( + (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath, + ); + const newElementZIndex = Math.max(1, relevantElements.length + 1); + + // Zone- and overlap-aware track: run the new clip through the same pure + // drop decision a clip DRAG uses (visual lanes top / audio lanes bottom, + // no same-track time overlap — relocate to the nearest free lane in the + // kind zone, else a fresh track). Covers OS file drops, asset-panel + // drops, and playhead adds alike, so adding an asset behaves exactly + // like moving one. + let resolvedTrack = placement.track; + if (relevantElements.length > 0) { + const order = [...new Set(relevantElements.map((te) => te.track))].sort((a, b) => a - b); + const audioTracks = new Set( + relevantElements.filter(isAudioTimelineElement).map((te) => te.track), + ); + const zoned = resolveZoneDropPlacement({ + order, + audioTracks, + elements: relevantElements, + desiredTrack: placement.track, + deliberateInsertRow: null, + start: normalizedStart, + duration: normalizedDuration, + dragKey: newId, + isAudio: kind === "audio", + }); + // A needsInsert result means every lane in the kind zone is occupied at + // this time — place on a fresh track; display normalization zones it. + resolvedTrack = zoned.insertRow != null ? Math.max(...order) + 1 : zoned.track; + } + + const patchedContent = extendCompositionDurationIfNeeded( + insertTimelineAssetIntoSource( + originalContent, + buildTimelineAssetInsertHtml({ + id: newId, + hfId, + assetPath: resolvedAssetSrc, + kind, + start: normalizedStart, + duration: normalizedDuration, + track: resolvedTrack, + zIndex: newElementZIndex, + geometry: fitTimelineAssetGeometry(natural, compSize), + }), + ), + normalizedStart + normalizedDuration, + ); + + domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: pid, + label: "Add timeline asset", + kind: "timeline", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit, + }); + + forceReloadSdkSession?.(); + reloadPreview(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to drop asset onto timeline"; + showToast(message); + } + }, + [ + activeCompPath, + recordEdit, + showToast, + timelineElements, + writeProjectFile, + domEditSaveTimestampRef, + reloadPreview, + isRecordingRef, + forceReloadSdkSession, + projectIdRef, + ], + ); + // fallow-ignore-next-line complexity + const handleTimelineFileDrop = useCallback( + // fallow-ignore-next-line complexity + async (files: File[], placement?: Pick) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } + const pid = projectIdRef.current; + if (!pid) return; + const uploaded = await uploadProjectFiles(files, "assets"); + if (uploaded.length === 0) return; + const durations: number[] = []; + for (const assetPath of uploaded) { + const kind = getTimelineAssetKind(assetPath); + const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0; + durations.push(Number(formatTimelineAttributeNumber(duration))); + } + const placements = buildTimelineFileDropPlacements( + placement ?? { start: 0, track: 0 }, + durations, + ); + for (const [index, assetPath] of uploaded.entries()) { + await handleTimelineAssetDrop( + assetPath, + placements[index] ?? placements[0], + durations[index], + ); + } + }, + [handleTimelineAssetDrop, uploadProjectFiles, isRecordingRef, showToast, projectIdRef], + ); + + const handleAddAssetAtPlayhead = useCallback( + async (assetPath: string) => { + const pid = projectIdRef.current; + if (!pid) return; + const kind = getTimelineAssetKind(assetPath); + if (!kind) { + showToast("Only image, video, and audio assets can be added to the timeline."); + return; + } + const start = usePlayerStore.getState().currentTime; + const duration = await resolveDroppedAssetDuration(pid, assetPath, kind); + // Add on track 0 at the playhead (no bump — placing onto an occupied track is fine). + await handleTimelineAssetDrop(assetPath, { start, track: 0 }, duration); + }, + [handleTimelineAssetDrop, showToast, projectIdRef], + ); + + return { handleTimelineAssetDrop, handleTimelineFileDrop, handleAddAssetAtPlayhead }; +} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx new file mode 100644 index 0000000000..70ca13c7f3 --- /dev/null +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -0,0 +1,486 @@ +import { type ReactNode } from "react"; +import { Eye, EyeSlash } from "@phosphor-icons/react"; +import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; +import { TimelineClip } from "./TimelineClip"; +import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; +import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; +import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; +import type { TimelineTheme } from "./timelineTheme"; +import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; +import { + usePlayerStore, + type TimelineElement, + type KeyframeCacheEntry, +} from "../store/playerStore"; +import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; +import { + isMultiDragPassenger, + multiDragPassengerOffsetPx, + type MultiDragPreviewInput, +} from "./timelineMultiDragPreview"; +import type { TrackVisualStyle } from "./timelineIcons"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; +import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; +import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; +import { Music } from "../../icons/SystemIcons"; +import { renderClipChildren } from "./timelineClipChildren"; + +/** + * Props shared by the scroll container ({@link TimelineCanvas}) and the lane + * renderer below. TimelineCanvas passes these straight through via spread, so + * they are declared once here and both prop types compose from this base — no + * duplicated prop list. + */ +export interface TimelineLaneBaseProps { + pps: number; + trackContentWidth: number; + theme: TimelineTheme; + displayTrackOrder: number[]; + trackOrder: number[]; + tracks: [number, TimelineElement[]][]; + trackStyles: Map; + selectedElementId: string | null; + selectedElementIds: Set; + hoveredClip: string | null; + draggedClip: DraggedClipState | null; + blockedClipRef: React.RefObject; + suppressClickRef: React.RefObject; + scrollRef: React.RefObject; + renderClipContent?: ( + element: TimelineElement, + style: { clip: string; label: string }, + ) => ReactNode; + renderClipOverlay?: (element: TimelineElement) => ReactNode; + onDrillDown?: (element: TimelineElement) => void; + onSelectElement?: (element: TimelineElement | null) => void; + setHoveredClip: (key: string | null) => void; + setShowPopover: (v: boolean) => void; + setRangeSelection: (v: null) => void; + setResizingClip: (v: ResizingClipState | null) => void; + setDraggedClip: (v: DraggedClipState | null) => void; + setSelectedElementId: (id: string | null) => void; + syncClipDragAutoScroll: (x: number, y: number) => void; + shiftClickClipRef: React.RefObject<{ + element: TimelineElement; + anchorX: number; + anchorY: number; + } | null>; + getPreviewElement: (element: TimelineElement) => TimelineElement; + getTrackStyle: (tag: string) => TrackVisualStyle; + keyframeCache?: Map; + selectedKeyframes: Set; + currentTime: number; + onClickKeyframe?: (element: TimelineElement, percentage: number) => void; + onShiftClickKeyframe?: (elementId: string, percentage: number) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onMoveKeyframe?: ( + elementId: string, + fromClipPercentage: number, + toClipPercentage: number, + ) => void; + onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; + beatAnalysis?: MusicBeatAnalysis | null; +} + +interface TimelineLanesProps extends TimelineLaneBaseProps { + /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ + draggedElement: TimelineElement | null; + multiDragPreview: MultiDragPreviewInput | null; + onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onResizeElement: TimelineEditCallbacks["onResizeElement"]; + onMoveElement: TimelineEditCallbacks["onMoveElement"]; + onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; + onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; +} + +export function TimelineLanes({ + pps, + trackContentWidth, + theme, + displayTrackOrder, + trackOrder, + tracks, + trackStyles, + selectedElementId, + selectedElementIds, + hoveredClip, + draggedClip, + draggedElement, + multiDragPreview, + blockedClipRef, + suppressClickRef, + scrollRef, + renderClipContent, + renderClipOverlay, + onDrillDown, + onSelectElement, + setHoveredClip, + setShowPopover, + setRangeSelection, + setResizingClip, + setDraggedClip, + setSelectedElementId, + syncClipDragAutoScroll, + shiftClickClipRef, + getPreviewElement, + getTrackStyle, + keyframeCache, + selectedKeyframes, + currentTime, + onClickKeyframe, + onShiftClickKeyframe, + onContextMenuKeyframe, + onMoveKeyframe, + onContextMenuClip, + beatAnalysis, + onToggleTrackHidden, + onResizeElement, + onMoveElement, + onRazorSplit, + onRazorSplitAll, +}: TimelineLanesProps) { + return ( + <> + { + // NOTE (deliberate no-virtualization): lanes and their clips render via a + // plain `.map()` inside the scroll container rather than a windowing/virtualized + // list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is + // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap + // in a virtualizer if editorial workflows ever push very high clip counts. + // fallow-ignore-next-line complexity + displayTrackOrder.map((trackNum) => { + const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; + const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); + const isPendingTrack = + draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; + // All lanes use the same uniform color — no alternating stripes. + const rowBackground = theme.rowBackground; + // The beat-dot strip occupies the top of this track's lane (active track, + // or the music track when nothing is selected). When shown, keyframe + // diamonds shrink + drop to the bottom half so they don't collide with it. + const beatStripOnTrack = + (beatAnalysis?.beatTimes?.length ?? 0) >= 2 && + (selectedElementId + ? els.some((e) => (e.key ?? e.id) === selectedElementId) + : els.some(isMusicTrack)); + const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); + const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); + return ( +
+
+ {isAudioTrack && ( +
+
+ {/* Faint beat lines in every track's background (behind the clips); + the active move-snap target is highlighted. */} + + {/* Beat dots on the active track (the one holding the selection), + falling back to the music track when nothing is selected. */} + {beatStripOnTrack && ( + + )} + {isPendingTrack && ( +
+ New track +
+ )} + { + // fallow-ignore-next-line complexity + els.map((el) => { + const clipStyle = getTrackStyle(el.tag); + const elementKey = el.key ?? el.id; + const capabilities = getTimelineEditCapabilities(el); + const isSelected = + selectedElementId === elementKey || selectedElementIds.has(elementKey); + const isComposition = !!el.compositionSrc; + // elementKey (el.key ?? el.id) is already unique per clip; do NOT + // fold in the map index, or a splice/reorder remounts every clip + // at/after the change (DOM flash, drag interruption). + const clipKey = elementKey; + const isDraggingClip = + draggedClip?.started === true && + (draggedElement?.key ?? draggedElement?.id) === elementKey; + if (isDraggingClip) return null; + const previewElement = getPreviewElement(el); + // Passenger of a live multi-drag: slide by the SAME formation + // delta (the grabbed clip's group-clamped delta) via a + // compositor transform on a same-geometry wrapper (absolute + // inset-0 → identical offset parent, so the clip's own + // left/top are preserved), plus the ghost's elevated z/opacity. + const isPassenger = + multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview); + const passengerOffsetPx = isPassenger + ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview) + : 0; + const clip = ( + { + e.preventDefault(); + onContextMenuClip?.(e, el); + }} + el={previewElement} + pps={pps} + clipY={CLIP_Y} + isSelected={isSelected} + isHovered={hoveredClip === clipKey} + isDragging={false} + hasCustomContent={!!renderClipContent} + capabilities={capabilities} + theme={theme} + isComposition={isComposition} + onHoverStart={() => setHoveredClip(clipKey)} + onHoverEnd={() => setHoveredClip(null)} + onResizeStart={ + // fallow-ignore-next-line complexity + (edge, e) => { + if (e.button !== 0 || e.shiftKey || !onResizeElement) return; + if (edge === "start" && !capabilities.canTrimStart) return; + if (edge === "end" && !capabilities.canTrimEnd) return; + e.stopPropagation(); + blockedClipRef.current = null; + setShowPopover(false); + setRangeSelection(null); + setResizingClip({ + element: el, + edge, + originClientX: e.clientX, + originScrollLeft: scrollRef.current?.scrollLeft ?? 0, + previewStart: el.start, + previewDuration: el.duration, + previewPlaybackStart: el.playbackStart, + started: false, + }); + } + } + onPointerDown={ + // fallow-ignore-next-line complexity + (e) => { + if (e.button !== 0) return; + if (usePlayerStore.getState().activeTool === "razor") return; + if (e.shiftKey) { + shiftClickClipRef.current = { + element: el, + anchorX: e.clientX, + anchorY: e.clientY, + }; + return; + } + const target = e.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const blockedIntent = resolveBlockedTimelineEditIntent({ + width: rect.width, + offsetX: e.clientX - rect.left, + handleWidth: CLIP_HANDLE_W, + capabilities, + }); + if ( + blockedIntent && + ((blockedIntent === "move" && onMoveElement) || + (blockedIntent !== "move" && onResizeElement)) + ) { + blockedClipRef.current = { + element: el, + intent: blockedIntent, + originClientX: e.clientX, + originClientY: e.clientY, + started: false, + }; + return; + } + if (!onMoveElement || !capabilities.canMove) return; + blockedClipRef.current = null; + setShowPopover(false); + setRangeSelection(null); + setDraggedClip({ + element: el, + originClientX: e.clientX, + originClientY: e.clientY, + originScrollLeft: scrollRef.current?.scrollLeft ?? 0, + originScrollTop: scrollRef.current?.scrollTop ?? 0, + pointerClientX: e.clientX, + pointerClientY: e.clientY, + pointerOffsetX: e.clientX - rect.left, + pointerOffsetY: e.clientY - rect.top, + previewStart: el.start, + previewTrack: el.track, + insertRow: null, + snapTime: null, + snapType: null, + started: false, + }); + syncClipDragAutoScroll(e.clientX, e.clientY); + } + } + onClick={ + // fallow-ignore-next-line complexity + (e) => { + e.stopPropagation(); + if (suppressClickRef.current) return; + const { activeTool } = usePlayerStore.getState(); + if (activeTool === "razor" && onRazorSplit) { + const clipRect = ( + e.currentTarget as HTMLElement + ).getBoundingClientRect(); + const clickOffsetX = e.clientX - clipRect.left; + const splitTime = previewElement.start + clickOffsetX / pps; + const clampedTime = Math.max( + previewElement.start + SPLIT_BOUNDARY_EPSILON_S, + Math.min( + previewElement.start + + previewElement.duration - + SPLIT_BOUNDARY_EPSILON_S, + splitTime, + ), + ); + if (e.shiftKey && onRazorSplitAll) { + onRazorSplitAll(clampedTime); + } else { + onRazorSplit(el, clampedTime); + } + return; + } + // Plain click single-selects: drop any marquee multi-selection. + // Only a click on the PRIMARY selection toggles it off — a click + // on a marquee-selected clip narrows the selection to that clip. + const hadMultiSelection = selectedElementIds.size > 0; + usePlayerStore.getState().clearSelectedElementIds(); + const nextElement = + selectedElementId === elementKey && !hadMultiSelection ? null : el; + setSelectedElementId(nextElement ? elementKey : null); + onSelectElement?.(nextElement); + } + } + onDoubleClick={(e) => { + e.stopPropagation(); + if (suppressClickRef.current) return; + if (isComposition && onDrillDown) onDrillDown(el); + }} + > + {renderClipChildren( + previewElement, + clipStyle, + renderClipContent, + renderClipOverlay, + )} + {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( + 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * + 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} + onShiftClickKeyframe={onShiftClickKeyframe} + onContextMenuKeyframe={onContextMenuKeyframe} + onMoveKeyframe={onMoveKeyframe} + suppressClickRef={suppressClickRef} + /> + )} + + ); + if (!isPassenger) return clip; + return ( +
+ {clip} +
+ ); + }) + } +
+
+ ); + }) + } + + ); +} diff --git a/packages/studio/src/player/components/timelineMarquee.test.ts b/packages/studio/src/player/components/timelineMarquee.test.ts new file mode 100644 index 0000000000..b185841b23 --- /dev/null +++ b/packages/studio/src/player/components/timelineMarquee.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { + MARQUEE_DRAG_THRESHOLD_PX, + isMarqueeDrag, + isTimelineRulerPress, + getMarqueeRect, + getTimelineClipRect, + computeMarqueeSelection, +} from "./timelineMarquee"; +import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout"; + +describe("isTimelineRulerPress", () => { + const rectTop = 500; // scroll container's viewport top + + it("treats a press inside the ruler band as a ruler press (unscrolled)", () => { + expect(isTimelineRulerPress(rectTop, rectTop)).toBe(true); + expect(isTimelineRulerPress(rectTop + RULER_H - 1, rectTop)).toBe(true); + }); + + it("treats a press below the ruler band as a body press", () => { + expect(isTimelineRulerPress(rectTop + RULER_H, rectTop)).toBe(false); + expect(isTimelineRulerPress(rectTop + RULER_H + 100, rectTop)).toBe(false); + }); + + it("stays a ruler press when the body is scrolled down (sticky ruler)", () => { + // The ruler is position:sticky — scrolled down, its VISUAL band is still + // the top RULER_H px of the container. Content-space math + // (clientY - rectTop + scrollTop) would report y = 10 + 300 = 310 ≥ RULER_H + // and misclassify this as a body/marquee press; viewport-space math must + // still classify it as a ruler press regardless of scrollTop. + const scrollTop = 300; + const clientY = rectTop + 10; // visually on the stuck ruler + const contentSpaceY = clientY - rectTop + scrollTop; + expect(contentSpaceY).toBeGreaterThanOrEqual(RULER_H); // the old, broken signal + expect(isTimelineRulerPress(clientY, rectTop)).toBe(true); + }); + + it("honors a custom ruler height", () => { + expect(isTimelineRulerPress(rectTop + 30, rectTop, 32)).toBe(true); + expect(isTimelineRulerPress(rectTop + 33, rectTop, 32)).toBe(false); + }); +}); + +describe("isMarqueeDrag", () => { + it("treats sub-threshold movement as a click, not a drag", () => { + expect(isMarqueeDrag(100, 50, 100, 50)).toBe(false); + expect( + isMarqueeDrag( + 100, + 50, + 100 + MARQUEE_DRAG_THRESHOLD_PX - 1, + 50 + MARQUEE_DRAG_THRESHOLD_PX - 1, + ), + ).toBe(false); + }); + + it("becomes a drag once either axis passes the threshold", () => { + expect(isMarqueeDrag(100, 50, 100 + MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true); + expect(isMarqueeDrag(100, 50, 100, 50 + MARQUEE_DRAG_THRESHOLD_PX)).toBe(true); + expect(isMarqueeDrag(100, 50, 100 - MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true); + }); +}); + +describe("getMarqueeRect", () => { + it("builds a rect from origin to current for a down-right drag", () => { + expect(getMarqueeRect(10, 20, 110, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + }); + + it("normalizes negative drags (up-left) into a positive rect", () => { + expect(getMarqueeRect(110, 70, 10, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + }); + + it("normalizes mixed-direction drags (down-left / up-right)", () => { + expect(getMarqueeRect(110, 20, 10, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + expect(getMarqueeRect(10, 70, 110, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + }); + + it("yields a zero-size rect when the pointer has not moved", () => { + expect(getMarqueeRect(42, 42, 42, 42)).toEqual({ left: 42, top: 42, width: 0, height: 0 }); + }); +}); + +describe("getTimelineClipRect", () => { + const trackOrder = [0, 2, 5]; + + it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => { + const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100); + expect(rect).toEqual({ + left: GUTTER + 200, + top: getTimelineRowTop(1) + CLIP_Y, + width: 300, + height: TRACK_H - CLIP_Y * 2, + }); + }); + + it("places the first visible track below the ruler + top breathing pad", () => { + const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50); + expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y); + expect(rect?.left).toBe(GUTTER); + }); + + it("uses the row index in trackOrder, not the raw track number", () => { + const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50); + expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y); + }); + + it("enforces the 4px minimum rendered width", () => { + const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10); + expect(rect?.width).toBe(4); + }); + + it("returns null for a track that is not displayed or an invalid pps", () => { + expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull(); + expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull(); + expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull(); + }); +}); + +describe("computeMarqueeSelection", () => { + // Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100. + const trackOrder = [0, 1]; + const pps = 100; + const clips = [ + { id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0 + { id: "b", start: 2, duration: 1, track: 0 }, // x [232,332], row 0 + { id: "c", start: 0.5, duration: 1, track: 1 }, // x [82,182], row 1 + ]; + const row0Top = getTimelineRowTop(0) + CLIP_Y; + const row1Top = getTimelineRowTop(1) + CLIP_Y; + + it("selects only the clips the marquee rect intersects", () => { + const marquee = { left: GUTTER, top: row0Top, width: 50, height: 10 }; + const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee }); + expect(ids).toEqual(new Set(["a"])); + expect(primaryId).toBe("a"); + }); + + it("selects across tracks when the rect spans multiple rows", () => { + const marquee = { left: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 }; + const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee }); + expect(ids).toEqual(new Set(["a", "c"])); + }); + + it("excludes clips outside the rect horizontally", () => { + const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 }; + const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee }); + expect(ids).toEqual(new Set()); + }); + + it("returns null primaryId and keeps the base when nothing is hit (additive)", () => { + const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 }; + const { ids, primaryId } = computeMarqueeSelection({ + clips, + trackOrder, + pps, + marquee, + baseSelection: ["b"], + }); + expect(ids).toEqual(new Set(["b"])); + expect(primaryId).toBeNull(); + }); + + it("unions additive base selection with new hits; primary comes from the marquee", () => { + const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 }; + const { ids, primaryId } = computeMarqueeSelection({ + clips, + trackOrder, + pps, + marquee, + baseSelection: ["b"], + }); + expect(ids).toEqual(new Set(["b", "c"])); + expect(primaryId).toBe("c"); + }); + + it("shrinking the rect live drops clips it no longer covers", () => { + const wide = { left: GUTTER, top: row0Top, width: 320, height: 10 }; + const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 }; + expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual( + new Set(["a", "b"]), + ); + expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual( + new Set(["a"]), + ); + }); + + it("ignores clips on hidden/undisplayed tracks", () => { + const marquee = { left: 0, top: 0, width: 10000, height: 10000 }; + const { ids } = computeMarqueeSelection({ + clips: [{ id: "x", start: 0, duration: 1, track: 7 }], + trackOrder, + pps, + marquee, + }); + expect(ids).toEqual(new Set()); + }); +}); diff --git a/packages/studio/src/player/components/timelineMarquee.ts b/packages/studio/src/player/components/timelineMarquee.ts new file mode 100644 index 0000000000..718dd86594 --- /dev/null +++ b/packages/studio/src/player/components/timelineMarquee.ts @@ -0,0 +1,112 @@ +import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout"; +import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry"; + +/** Pointer must travel at least this far (either axis) before a pointerdown on + * the empty timeline body becomes a marquee drag instead of a plain click. */ +export const MARQUEE_DRAG_THRESHOLD_PX = 4; + +/** Minimum rendered clip width, mirrors TimelineClip's `Math.max(w, 4)`. */ +const MIN_CLIP_W = 4; + +export interface MarqueeClipInput { + id: string; + start: number; + duration: number; + track: number; +} + +/** + * Ruler-vs-body decision for a pointerdown on the timeline scroll container. + * + * The ruler is `position: sticky; top: 0` — once the body is scrolled down its + * VISUAL position stays pinned to the container top while its LAYOUT position + * scrolls away. The hit test must therefore use VIEWPORT-space y (clientY + * relative to the scroll container's bounding rect), NOT content-space y + * (clientY - rect.top + scrollTop), which misclassifies a press on the stuck + * ruler as a body/marquee press whenever scrollTop > 0. + */ +export function isTimelineRulerPress( + clientY: number, + scrollRectTop: number, + rulerHeight: number = RULER_H, +): boolean { + return clientY - scrollRectTop < rulerHeight; +} + +export function isMarqueeDrag( + originX: number, + originY: number, + currentX: number, + currentY: number, + threshold: number = MARQUEE_DRAG_THRESHOLD_PX, +): boolean { + return Math.abs(currentX - originX) >= threshold || Math.abs(currentY - originY) >= threshold; +} + +/** Normalized marquee rect (canvas/content coordinates) from the drag origin and + * the current pointer — handles drags in any direction (negative deltas). */ +export function getMarqueeRect( + originX: number, + originY: number, + currentX: number, + currentY: number, +): Rect { + return { + left: Math.min(originX, currentX), + top: Math.min(originY, currentY), + width: Math.abs(currentX - originX), + height: Math.abs(currentY - originY), + }; +} + +/** + * A clip's rendered rect in canvas/content coordinates (the same space the + * marquee rect lives in): x from GUTTER + start * pps, y from the clip's row + * index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y). + * Returns null when the clip's track is not currently displayed. + */ +export function getTimelineClipRect( + clip: Pick, + trackOrder: number[], + pps: number, +): Rect | null { + const row = trackOrder.indexOf(clip.track); + if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null; + return { + left: GUTTER + clip.start * pps, + top: getTimelineRowTop(row) + CLIP_Y, + width: Math.max(clip.duration * pps, MIN_CLIP_W), + height: TRACK_H - CLIP_Y * 2, + }; +} + +export interface MarqueeSelectionResult { + /** Every clip id the marquee currently covers (plus the additive base). */ + ids: Set; + /** The last marquee-hit clip in element order — the primary selection. + * Null when the marquee covers nothing new (caller keeps its current primary). */ + primaryId: string | null; +} + +/** + * Live marquee selection: every clip whose rendered rect intersects the marquee. + * `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId. + */ +export function computeMarqueeSelection(input: { + clips: MarqueeClipInput[]; + trackOrder: number[]; + pps: number; + marquee: Rect; + baseSelection?: Iterable; +}): MarqueeSelectionResult { + const ids = new Set(input.baseSelection ?? []); + let primaryId: string | null = null; + for (const clip of input.clips) { + const rect = getTimelineClipRect(clip, input.trackOrder, input.pps); + if (rect && rectsOverlap(rect, input.marquee)) { + ids.add(clip.id); + primaryId = clip.id; + } + } + return { ids, primaryId }; +} diff --git a/packages/studio/src/player/components/useTimelineEditPinning.ts b/packages/studio/src/player/components/useTimelineEditPinning.ts new file mode 100644 index 0000000000..7c2cb6df19 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineEditPinning.ts @@ -0,0 +1,109 @@ +import { useCallback, useMemo, useRef, type RefObject } from "react"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineDropCallbacks, TimelineEditCallbacks } from "./timelineCallbacks"; + +interface UseTimelineEditPinningInput { + ppsRef: RefObject; + fitPpsRef: RefObject; + onMoveElement: TimelineEditCallbacks["onMoveElement"]; + onMoveElements: TimelineEditCallbacks["onMoveElements"]; + onResizeElement: TimelineEditCallbacks["onResizeElement"]; + onFileDrop: TimelineDropCallbacks["onFileDrop"]; + onAssetDrop: TimelineDropCallbacks["onAssetDrop"]; + onBlockDrop: TimelineDropCallbacks["onBlockDrop"]; +} + +// Wrap every mutating timeline edit so the zoom pins to the current on-screen +// scale right before the edit commits — the reload the edit triggers then keeps +// the current scale instead of rescaling every clip (the blink-fix symptom). +// Each wrapper forwards its args unchanged and preserves the original's absence +// (an unset callback stays unset → the timeline's own fallbacks kick in). +export function useTimelineEditPinning({ + ppsRef, + fitPpsRef, + onMoveElement, + onMoveElements, + onResizeElement, + onFileDrop, + onAssetDrop, + onBlockDrop, +}: UseTimelineEditPinningInput) { + const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom); + // Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a + // duration change from that edit (drops/moves/deletes recompute the fit basis) + // stops rescaling every clip. `pinTimelineZoom` is a no-op once already pinned + // (or after a manual zoom), so the user's own zoom is never clobbered; Fit + // re-fits. Reads refs at call time for the latest scale. + const pinZoomBeforeEdit = useCallback(() => { + pinTimelineZoom(ppsRef.current, fitPpsRef.current); + }, [pinTimelineZoom, ppsRef, fitPpsRef]); + + // Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep + const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null); + + const pinnedOnMoveElement = useMemo( + () => + onMoveElement && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onMoveElement(...args); + }), + [onMoveElement, pinZoomBeforeEdit], + ); + const pinnedOnMoveElements = useMemo( + () => + onMoveElements && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onMoveElements(...args); + }), + [onMoveElements, pinZoomBeforeEdit], + ); + const pinnedOnResizeElement = useMemo( + () => + onResizeElement && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onResizeElement(...args); + }), + [onResizeElement, pinZoomBeforeEdit], + ); + const pinnedOnFileDrop = useMemo( + () => + onFileDrop && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onFileDrop(...args); + }), + [onFileDrop, pinZoomBeforeEdit], + ); + const pinnedOnAssetDrop = useMemo( + () => + onAssetDrop && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onAssetDrop(...args); + }), + [onAssetDrop, pinZoomBeforeEdit], + ); + const pinnedOnBlockDrop = useMemo( + () => + onBlockDrop && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onBlockDrop(...args); + }), + [onBlockDrop, pinZoomBeforeEdit], + ); + + return { + pinZoomBeforeEdit, + setRangeSelectionRef, + pinnedOnMoveElement, + pinnedOnMoveElements, + pinnedOnResizeElement, + pinnedOnFileDrop, + pinnedOnAssetDrop, + pinnedOnBlockDrop, + }; +} diff --git a/packages/studio/src/player/components/useTimelineGeometry.ts b/packages/studio/src/player/components/useTimelineGeometry.ts new file mode 100644 index 0000000000..fb18810902 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineGeometry.ts @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useRef, type RefObject } from "react"; +import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore"; +import { getTimelinePixelsPerSecond } from "./timelineZoom"; +import { + DRAG_EXTEND_MARGIN_PX, + getTimelineDisplayContentWidth, + getTimelineFitPps, +} from "./timelineLayout"; +import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag"; + +interface UseTimelineGeometryInput { + viewportWidth: number; + effectiveDuration: number; + zoomMode: ZoomMode; + manualZoomPercent: number; + ppsRef: RefObject; + fitPpsRef: RefObject; + draggedClip: DraggedClipState | null; + resizingClip: ResizingClipState | null; + expandedElements: TimelineElement[]; + isDragging: RefObject; + scrollRef: RefObject; + lastScrollLeftRef: RefObject; +} + +// Derive the timeline's horizontal geometry from the viewport, zoom, and any live +// drag/resize preview: the pixels-per-second scale, the rendered content width +// (CapCut-style over-extension while dragging/trimming), and the version key that +// re-triggers dependent effects after an edit re-derives the elements. +export function useTimelineGeometry({ + viewportWidth, + effectiveDuration, + zoomMode, + manualZoomPercent, + ppsRef, + fitPpsRef, + draggedClip, + resizingClip, + expandedElements, + isDragging, + scrollRef, + lastScrollLeftRef, +}: UseTimelineGeometryInput) { + // Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short + // comps show a 60s ruler with usable empty space (see getTimelineFitPps). + const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration); + const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent); + ppsRef.current = pps; + const trackContentWidth = Math.max(0, effectiveDuration * pps); + // Drag-to-extend: while a clip is dragged, keep the rendered extent a margin + // past the ghost's end. Holding the pointer in the right edge zone then keeps + // auto-scroll stepping (scrollWidth grows with the ghost), so the timeline + // extends at auto-scroll pace — placing a clip farther than the timeline + // currently shows. Growth is bounded per frame by AUTO_SCROLL_MAX_SPEED (no + // fling); leaving the edge zone stops it; the extra width collapses when the + // drag ends (the composition itself only grows on commit, content-driven). + const dragGhostEndPx = draggedClip?.started + ? (draggedClip.previewStart + draggedClip.element.duration) * pps + DRAG_EXTEND_MARGIN_PX + : 0; + // Trim-to-extend: same mechanic for a right-edge RESIZE — the rendered extent + // tracks the trim preview's end so the edge auto-scroll zone always has room + // to keep stepping while the trim grows past the current timeline width. + const resizeGhostEndPx = resizingClip?.started + ? (resizingClip.previewStart + resizingClip.previewDuration) * pps + DRAG_EXTEND_MARGIN_PX + : 0; + // The timeline canvas always fills at least the viewport width AND the + // MIN_TIMELINE_EXTENT_S floor: the ruler + empty track lanes keep going into + // the space instead of leaving dead black — CapCut-style. Only the RENDERED + // extent grows; clip positions/durations are untouched. + const displayContentWidth = getTimelineDisplayContentWidth({ + trackContentWidth, + viewportWidth, + pps, + dragGhostEndPx, + resizeGhostEndPx, + }); + const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration; + const clipStateVersion = useMemo( + () => + expandedElements + .map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`) + .join("|"), + [expandedElements], + ); + const zoomModeRef = useRef(zoomMode); + zoomModeRef.current = zoomMode; + const manualZoomPercentRef = useRef(manualZoomPercent); + manualZoomPercentRef.current = manualZoomPercent; + fitPpsRef.current = fitPps; + + // Restore the horizontal scroll offset after an edit re-derives the elements + // (clipStateVersion changes) so the reload doesn't jump the view. Only in manual + // (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and + // never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout + // so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep: + // re-running on a mode flip is a no-op thanks to the guard. + useEffect(() => { + if (zoomMode !== "manual" || isDragging.current) return; + const el = scrollRef.current; + const target = lastScrollLeftRef.current; + if (!el || target <= 0) return; + const raf = requestAnimationFrame(() => { + const max = Math.max(0, el.scrollWidth - el.clientWidth); + const next = Math.min(target, max); + if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next; + }); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clipStateVersion, zoomMode]); + // Publish the live scale so edit handlers OUTSIDE (the keyboard-delete + // path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry. + // In a useEffect (not the render body) so React-18 concurrent replay — Suspense + // retry, transitions, StrictMode double-invoke — can't double-publish. The write is + // idempotent (same pps/fitPps → same fields), so this is behavior-preserving; the + // effect placement is just the strictly-correct shape. + useEffect(() => { + usePlayerStore.getState().setTimelineScale(pps, fitPps); + }, [pps, fitPps]); + + return { + pps, + fitPps, + displayContentWidth, + displayDuration, + clipStateVersion, + zoomModeRef, + manualZoomPercentRef, + }; +} diff --git a/packages/studio/src/player/components/useTimelineStackingSync.ts b/packages/studio/src/player/components/useTimelineStackingSync.ts new file mode 100644 index 0000000000..7f2ec35dad --- /dev/null +++ b/packages/studio/src/player/components/useTimelineStackingSync.ts @@ -0,0 +1,83 @@ +import { useCallback, type RefObject } from "react"; +import type { TimelineElement } from "../store/playerStore"; +import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { findElementForSelection } from "../../components/editor/domEditingElement"; +import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder"; +import type { StackingPatch } from "./timelineStackingSync"; + +interface UseTimelineStackingSyncInput { + expandedElementsRef: RefObject; +} + +// Lane ↔ stacking unification (research/STAGE3-NEEDED-WIRING.md). Provision the +// two deps commitDraggedClipMove accepts so a lane-change drag also patches the +// edited clip's z-index. Both read the SAME preview iframe + z-order persist path +// the canvas right-click menu / LayersPanel use, so a timeline lane move and a +// menu z-edit produce one shared inline-style commit shape. Optional contexts: +// outside the NLE (standalone ) these are null ⇒ deps undefined ⇒ the +// commit's z-sync is a no-op (backward compatible). +export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) { + const domEditActions = useDomEditActionsContextOptional(); + const shell = useStudioShellContextOptional(); + const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null; + const handleDomZIndexReorderCommit = domEditActions?.handleDomZIndexReorderCommit; + const zSyncActiveCompPath = shell?.activeCompPath ?? null; + + // Resolve a TimelineElement to its live iframe HTMLElement via the same + // hfId ?? id ?? selector[selectorIndex] resolver the timeline's DOM patches use. + const resolveIframeElement = useCallback( + (el: TimelineElement): HTMLElement | null => { + const doc = zSyncPreviewIframeRef?.current?.contentDocument ?? null; + if (!doc) return null; + return findElementForSelection( + doc, + { + hfId: el.hfId, + id: el.domId ?? el.id, + selector: el.selector, + selectorIndex: el.selectorIndex, + sourceFile: el.sourceFile, + }, + zSyncActiveCompPath, + ); + }, + [zSyncPreviewIframeRef, zSyncActiveCompPath], + ); + + const readClipZIndex = useCallback( + (el: TimelineElement): number => { + const node = resolveIframeElement(el); + return node ? readEffectiveZIndex(node) : 0; + }, + [resolveIframeElement], + ); + + const applyStackingPatches = useCallback( + (patches: StackingPatch[]) => { + if (!handleDomZIndexReorderCommit) return; + const entries = patches.flatMap((p) => { + const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key); + const node = el && resolveIframeElement(el); + if (!el || !node) return []; + return [ + { + element: node, + zIndex: p.zIndex, + id: el.domId ?? el.id, + selector: el.selector, + selectorIndex: el.selectorIndex, + sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html", + }, + ]; + }); + if (entries.length) handleDomZIndexReorderCommit(entries); + }, + [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef], + ); + + // Engage the z-sync only when the persist path is present (inside the NLE). + const zSyncEnabled = Boolean(handleDomZIndexReorderCommit && zSyncPreviewIframeRef); + + return { readClipZIndex, applyStackingPatches, zSyncEnabled }; +}