From dae26e72b23a5907d173177a72e1f96bd5483934 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 22:05:15 -0400 Subject: [PATCH 01/23] refactor(studio): extract shared layerOrdering module from LayersPanel --- .../src/components/editor/LayersPanel.tsx | 24 +----- .../src/player/lib/layerOrdering.test.ts | 77 +++++++++++++++++ .../studio/src/player/lib/layerOrdering.ts | 85 +++++++++++++++++++ 3 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 packages/studio/src/player/lib/layerOrdering.test.ts create mode 100644 packages/studio/src/player/lib/layerOrdering.ts diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 15ffedca11..915b81c6a6 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -14,6 +14,7 @@ import { } from "../../utils/studioHelpers"; import { Layers } from "../../icons/SystemIcons"; import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag"; +import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering"; const TAG_ICONS: Record = { video: "Vi", @@ -228,9 +229,7 @@ export const LayersPanel = memo(function LayersPanel() { reordered.splice(toIndex, 0, moved); const existingValues = siblingLayers.map((l) => getElementZIndex(l.element)); - const sorted = [...existingValues].sort((a, b) => b - a); - const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]); - const zValues = hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted; + const zValues = computeReorderZValues(existingValues, fromIndex, toIndex); const entries = reordered.map((layer, i) => ({ element: layer.element, @@ -388,25 +387,6 @@ export const LayersPanel = memo(function LayersPanel() { // ── Pure helpers ────────────────────────────────────────────────────── -// fallow-ignore-next-line complexity -function getElementZIndex(element: HTMLElement): number { - try { - const inline = element.style?.zIndex; - if (inline && inline !== "auto") { - const parsed = parseInt(inline, 10); - if (Number.isFinite(parsed)) return parsed; - } - const win = element.ownerDocument?.defaultView; - if (!win) return 0; - const value = win.getComputedStyle(element).zIndex; - if (value === "auto" || value === "") return 0; - const parsed = parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : 0; - } catch { - return 0; - } -} - // fallow-ignore-next-line complexity export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] { if (layers.length <= 1) return layers; diff --git a/packages/studio/src/player/lib/layerOrdering.test.ts b/packages/studio/src/player/lib/layerOrdering.test.ts new file mode 100644 index 0000000000..cb5f035a33 --- /dev/null +++ b/packages/studio/src/player/lib/layerOrdering.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { computeReorderZValues, getElementZIndex, resolveContextOrder } from "./layerOrdering"; + +function makeElement(zIndex?: string): HTMLElement { + const element = document.createElement("div"); + if (zIndex != null) element.style.zIndex = zIndex; + document.body.appendChild(element); + return element; +} + +describe("getElementZIndex", () => { + it("returns inline z-index when present", () => { + expect(getElementZIndex(makeElement("7"))).toBe(7); + }); + + it("falls back to computed style when inline z-index is not usable", () => { + const element = makeElement(); + element.className = "computed-z"; + const style = document.createElement("style"); + style.textContent = ".computed-z { position: relative; z-index: 12; }"; + document.head.appendChild(style); + + expect(getElementZIndex(element)).toBe(12); + }); + + it("returns zero for auto or missing z-index", () => { + expect(getElementZIndex(makeElement())).toBe(0); + expect(getElementZIndex(makeElement("auto"))).toBe(0); + }); +}); + +describe("computeReorderZValues", () => { + it("preserves distinct existing z-index values and remaps them onto the new order", () => { + expect(computeReorderZValues([1, 8, 3], 0, 2)).toEqual([8, 3, 1]); + }); + + it("renumbers an all-tied group to descending contiguous z-index values", () => { + expect(computeReorderZValues([0, 0, 0], 2, 0)).toEqual([3, 2, 1]); + }); + + it("renumbers the whole group when any existing z-index values are tied", () => { + expect(computeReorderZValues([5, 5, 1], 2, 0)).toEqual([3, 2, 1]); + }); +}); + +describe("resolveContextOrder", () => { + it("sorts a flat sibling group by z-index descending then original order", () => { + const ordered = resolveContextOrder([ + { id: "a", zIndex: 2, parentCompositionId: null, compositionAncestors: ["root"] }, + { id: "b", zIndex: 5, parentCompositionId: null, compositionAncestors: ["root"] }, + { id: "c", zIndex: 5, parentCompositionId: null, compositionAncestors: ["root"] }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(["b", "c", "a"]); + }); + + it("keeps distinct stacking contexts from interleaving", () => { + const ordered = resolveContextOrder([ + { id: "root-low", zIndex: 1, parentCompositionId: null, compositionAncestors: ["root"] }, + { + id: "nested-high", + zIndex: 100, + parentCompositionId: "scene", + compositionAncestors: ["root", "scene"], + }, + { id: "root-top", zIndex: 2, parentCompositionId: null, compositionAncestors: ["root"] }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(["root-top", "root-low", "nested-high"]); + }); + + it("returns an empty list for empty input", () => { + expect(resolveContextOrder([])).toEqual([]); + }); +}); diff --git a/packages/studio/src/player/lib/layerOrdering.ts b/packages/studio/src/player/lib/layerOrdering.ts new file mode 100644 index 0000000000..f016b11bca --- /dev/null +++ b/packages/studio/src/player/lib/layerOrdering.ts @@ -0,0 +1,85 @@ +export interface StackingContextDescriptor { + parentCompositionId: string | null; + compositionAncestors: readonly string[]; + stackingContextId?: string | null; +} + +export interface ContextOrderItem extends StackingContextDescriptor { + zIndex: number; +} + +// fallow-ignore-next-line complexity +export function getElementZIndex(element: HTMLElement): number { + try { + const inline = element.style?.zIndex; + if (inline && inline !== "auto") { + const parsed = parseInt(inline, 10); + if (Number.isFinite(parsed)) return parsed; + } + const win = element.ownerDocument?.defaultView; + if (!win) return 0; + const value = win.getComputedStyle(element).zIndex; + if (value === "auto" || value === "") return 0; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; + } catch { + return 0; + } +} + +export function computeReorderZValues( + existingValues: readonly number[], + fromIndex: number, + toIndex: number, +): number[] { + const reordered = [...existingValues]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + const sorted = [...existingValues].sort((a, b) => b - a); + const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]); + return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted; +} + +// Exported in a later unit when the timeline consumes it; internal-only for now. +function resolveStackingContextKey(item: StackingContextDescriptor): string { + return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? ""; +} + +function resolveStackingContextDepth(item: StackingContextDescriptor): number { + const contextKey = resolveStackingContextKey(item); + if (!contextKey) return 0; + const index = item.compositionAncestors.indexOf(contextKey); + return index >= 0 ? index : 0; +} + +export function resolveContextOrder(items: readonly T[]): T[] { + if (items.length === 0) return []; + + const groups = new Map< + string, + { firstIndex: number; depth: number; entries: Array<{ item: T; index: number }> } + >(); + + items.forEach((item, index) => { + const key = resolveStackingContextKey(item); + const group = groups.get(key); + if (group) { + group.entries.push({ item, index }); + return; + } + groups.set(key, { + firstIndex: index, + depth: resolveStackingContextDepth(item), + entries: [{ item, index }], + }); + }); + + return [...groups.values()] + .sort((a, b) => a.depth - b.depth || a.firstIndex - b.firstIndex) + .flatMap((group) => + group.entries + .sort((a, b) => b.item.zIndex - a.item.zIndex || a.index - b.index) + .map((entry) => entry.item), + ); +} From 070ee91694d341310d8541a3370fcf05827b6e7e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 22:05:59 -0400 Subject: [PATCH 02/23] feat(studio): expose resolved z-index and stacking context on timeline clips --- packages/core/src/runtime/timeline.test.ts | 67 +++++++++++++++++++ packages/core/src/runtime/timeline.ts | 19 ++++++ packages/core/src/runtime/types.ts | 2 + .../hooks/useExpandedTimelineElements.ts | 3 + .../studio/src/player/lib/playbackTypes.ts | 3 + packages/studio/src/player/lib/timelineDOM.ts | 61 +++++++++++++++++ .../studio/src/player/store/playerStore.ts | 8 +++ 7 files changed, 163 insertions(+) diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index f4946afbe6..f1d08dfa71 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -57,9 +57,76 @@ describe("collectRuntimeTimelinePayload", () => { expect(result.clips[0].id).toBe("text-1"); expect(result.clips[0].start).toBe(1); expect(result.clips[0].duration).toBe(3); + expect(result.clips[0].track).toBe(0); expect(result.clips[0].kind).toBe("element"); }); + it("parses inline z-index for timeline clips", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const clip = document.createElement("div"); + clip.id = "layered"; + clip.style.zIndex = "11"; + clip.setAttribute("data-start", "0"); + clip.setAttribute("data-duration", "4"); + root.appendChild(clip); + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips[0].zIndex).toBe(11); + }); + + it("uses zero z-index sentinel when a timeline clip has no inline z-index", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const clip = document.createElement("div"); + clip.id = "auto-layer"; + clip.setAttribute("data-start", "0"); + clip.setAttribute("data-duration", "4"); + root.appendChild(clip); + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips[0].zIndex).toBe(0); + }); + + it("assigns stacking context ids from root and nearest sub-composition", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + + const rootClip = document.createElement("div"); + rootClip.id = "root-layer"; + rootClip.setAttribute("data-start", "0"); + rootClip.setAttribute("data-duration", "5"); + root.appendChild(rootClip); + + const scene = document.createElement("div"); + scene.id = "scene-host"; + scene.setAttribute("data-composition-id", "scene"); + scene.setAttribute("data-start", "0"); + scene.setAttribute("data-duration", "5"); + root.appendChild(scene); + + const nestedClip = document.createElement("div"); + nestedClip.id = "nested-layer"; + nestedClip.setAttribute("data-start", "0"); + nestedClip.setAttribute("data-duration", "2"); + scene.appendChild(nestedClip); + + const result = collectRuntimeTimelinePayload(defaultParams); + const rootLayer = result.clips.find((clip) => clip.id === "root-layer"); + const nestedLayer = result.clips.find((clip) => clip.id === "nested-layer"); + + expect(rootLayer?.stackingContextId).toBe("main"); + expect(nestedLayer?.stackingContextId).toBe("scene"); + }); + it("identifies video clips by tag", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 550690c554..d7ea927f7c 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -32,6 +32,19 @@ function parseElementEndAttr(element: Element): number | null { ); } +function readInlineZIndex(element: Element): number { + try { + const inline = (element as HTMLElement).style?.zIndex; + if (inline && inline !== "auto") { + const parsed = parseInt(inline, 10); + if (Number.isFinite(parsed)) return parsed; + } + return 0; + } catch { + return 0; + } +} + function maxDefinedNumber(...values: Array): number | null { const finite = values.filter((value): value is number => Number.isFinite(value ?? null)); if (finite.length === 0) return null; @@ -434,6 +447,8 @@ export function collectRuntimeTimelinePayload(params: { node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i), 10, ) || 0, + zIndex: readInlineZIndex(node), + stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId, kind, tagName: tag, compositionId: node.getAttribute("data-composition-id"), @@ -545,6 +560,8 @@ export function collectRuntimeTimelinePayload(params: { el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", 10, ) || gsapTrack, + zIndex: readInlineZIndex(el), + stackingContextId: rootCompositionIdForGsap, kind: "element", tagName: el.tagName.toLowerCase(), compositionId: el.getAttribute("data-composition-id"), @@ -602,6 +619,8 @@ export function collectRuntimeTimelinePayload(params: { el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "", 10, ) || overlayTrack, + zIndex: readInlineZIndex(el), + stackingContextId: rootCompositionIdForGsap, kind: "element", tagName: tag, compositionId: el.getAttribute("data-composition-id"), diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index a50efae9cf..c2745d215d 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -51,6 +51,8 @@ export type RuntimeTimelineClip = { start: number; duration: number; track: number; + zIndex: number; + stackingContextId: string | null; kind: "video" | "audio" | "image" | "element" | "composition"; tagName: string | null; compositionId: string | null; diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 8ddbb21503..473fc88d82 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -192,9 +192,12 @@ function domSiblingClips( start: host.start, duration: host.duration, track: host.track, + zIndex: 0, + stackingContextId: host.stackingContextId ?? host.domId ?? host.id ?? null, kind: "element", tagName: null, compositionId: null, + compositionAncestors: host.compositionAncestors ?? [], parentCompositionId: host.id ?? null, compositionSrc: host.compositionSrc ?? null, assetUrl: null, diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index e86b67667b..f4b9a60dc8 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -38,9 +38,12 @@ export interface ClipManifestClip { start: number; duration: number; track: number; + zIndex?: number; + stackingContextId?: string | null; kind: "video" | "audio" | "image" | "element" | "composition"; tagName: string | null; compositionId: string | null; + compositionAncestors?: string[]; parentCompositionId: string | null; compositionSrc: string | null; assetUrl: string | null; diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 952a5016c5..a90faec9d5 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -10,6 +10,7 @@ import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "./playbackTypes"; +import { getElementZIndex } from "./layerOrdering"; import { resolveMediaElement, applyMediaMetadataFromElement, @@ -65,6 +66,40 @@ function resolveClipTag(clip: ClipManifestClip): string { return clip.tagName || clip.kind || "div"; } +function resolveDomCompositionContext( + element: Element, + root: Element | null, +): { + parentCompositionId: string | null; + compositionAncestors: string[]; + stackingContextId: string | null; +} { + const ancestors: string[] = []; + let parentCompositionId: string | null = null; + let cursor = element.parentElement; + while (cursor) { + const compositionId = cursor.getAttribute("data-composition-id"); + if (compositionId) { + ancestors.push(compositionId); + if (!parentCompositionId && cursor !== root) { + parentCompositionId = compositionId; + } + } + cursor = cursor.parentElement; + } + const compositionAncestors = ancestors.reverse(); + return { + parentCompositionId, + compositionAncestors, + stackingContextId: parentCompositionId ?? compositionAncestors[0] ?? null, + }; +} + +function getTimelineElementZIndex(element: Element | null): number | undefined { + if (!element || !("style" in element)) return undefined; + return getElementZIndex(element as HTMLElement); +} + // fallow-ignore-next-line complexity export function createTimelineElementFromManifestClip(params: { clip: ClipManifestClip; @@ -86,6 +121,14 @@ export function createTimelineElementFromManifestClip(params: { let sourceFile: string | undefined; let hfId: string | undefined; + const domContext = hostEl + ? resolveDomCompositionContext(hostEl, doc?.querySelector("[data-composition-id]") ?? null) + : null; + const compositionAncestors = clip.compositionAncestors ?? domContext?.compositionAncestors; + const parentCompositionId = clip.parentCompositionId ?? domContext?.parentCompositionId; + const stackingContextId = + clip.stackingContextId ?? parentCompositionId ?? compositionAncestors?.[0] ?? null; + if (hostEl) { domId = hostEl.id || undefined; hfId = hostEl.getAttribute("data-hf-id") || undefined; @@ -112,6 +155,10 @@ export function createTimelineElementFromManifestClip(params: { start: clip.start, duration: clip.duration, track: clip.track, + zIndex: clip.zIndex ?? getTimelineElementZIndex(hostEl), + stackingContextId, + parentCompositionId, + compositionAncestors, domId, hfId, selector, @@ -206,6 +253,7 @@ export function createImplicitTimelineLayersFromDOM( }); if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue; + const compositionContext = resolveDomCompositionContext(child, rootComp); layers.push({ domId: child.id || undefined, hfId: child.getAttribute("data-hf-id") || undefined, @@ -217,6 +265,10 @@ export function createImplicitTimelineLayersFromDOM( selectorIndex, sourceFile, start: 0, + zIndex: getTimelineElementZIndex(child), + stackingContextId: compositionContext.stackingContextId, + parentCompositionId: compositionContext.parentCompositionId, + compositionAncestors: compositionContext.compositionAncestors, tag: child.tagName.toLowerCase(), timingSource: "implicit", track: maxTrack + 1 + layers.length, @@ -278,6 +330,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel selectorIndex, sourceFile, }); + const compositionContext = resolveDomCompositionContext(el, rootComp); const entry: TimelineElement = { id: identity.id, label, @@ -286,6 +339,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel start, duration: dur, track: isNaN(track) ? 0 : track, + zIndex: getTimelineElementZIndex(el), + stackingContextId: compositionContext.stackingContextId, + parentCompositionId: compositionContext.parentCompositionId, + compositionAncestors: compositionContext.compositionAncestors, domId: el.id || undefined, hfId: el.getAttribute("data-hf-id") || undefined, selector, @@ -406,6 +463,10 @@ export function buildStandaloneRootTimelineElement(params: { start: 0, duration: params.rootDuration, track: 0, + zIndex: 0, + stackingContextId: params.compositionId, + parentCompositionId: null, + compositionAncestors: [params.compositionId], compositionSrc, selector: params.selector, selectorIndex: params.selectorIndex, diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 91d9f58392..0271fad666 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -28,6 +28,14 @@ export interface TimelineElement { start: number; duration: number; track: number; + /** Resolved z-index for stacking-aware timeline ordering. */ + zIndex?: number; + /** Stacking context this element belongs to; root clips use the root composition id. */ + stackingContextId?: string | null; + /** Nearest parent composition context, matching RuntimeTimelineClip. */ + parentCompositionId?: string | null; + /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */ + compositionAncestors?: string[]; domId?: string; /** Stable `data-hf-id` attribute value — used as primary patch target when present */ hfId?: string; From dd980697a20bacf04ec3d4d5a6171c040a78dbca Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 23:35:58 -0400 Subject: [PATCH 03/23] feat(studio): unify timeline vertical reorder with z-index stacking Timeline rows now order by scoped stacking (z-index per stacking context) instead of data-track-index, and dragging a clip up/down commits a targeted z-index change through the same shared path the layers panel uses. Both panels stay consistent and moving a clip actually changes front/back. data-track-index is demoted to time-overlap layout only; no bulk z-index injection (#958 intact). Also restores beat-snapping on keyframe retiming (re-wires snapKeyframePctToBeat, orphaned when keyframe dragging was removed) which surfaced while unifying the model. Extracts pure track-ordering logic to timelineTrackOrder.ts, the stacking reorder commit + deleteSelectedKeyframes to timelineEditingHelpers.ts, to keep StudioApp / the timeline hook / Timeline under the studio 600-LOC cap. U3: scoped stacking row order in the timeline U4: vertical drag commits z-index via the shared reorder commit U8: restore keyframe beat-snap on retime --- packages/studio/src/App.tsx | 16 +- .../src/hooks/timelineEditingHelpers.ts | 95 ++++- .../src/hooks/useTimelineEditing.test.tsx | 348 ++++++++++++++++++ .../studio/src/hooks/useTimelineEditing.ts | 45 ++- .../src/hooks/useTimelineEditingTypes.ts | 12 + .../src/player/components/Timeline.test.ts | 95 +++++ .../studio/src/player/components/Timeline.tsx | 16 +- .../src/player/components/TimelineCanvas.tsx | 5 + .../components/TimelineClipDiamonds.tsx | 49 ++- .../player/components/timelineCallbacks.ts | 6 +- .../player/components/timelineEditing.test.ts | 95 +++++ .../src/player/components/timelineEditing.ts | 118 +++++- .../player/components/timelineTrackOrder.ts | 116 ++++++ .../components/useTimelineClipDrag.test.tsx | 117 ++++++ .../player/components/useTimelineClipDrag.ts | 28 +- .../studio/src/player/lib/layerOrdering.ts | 3 +- 16 files changed, 1110 insertions(+), 54 deletions(-) create mode 100644 packages/studio/src/hooks/useTimelineEditing.test.tsx create mode 100644 packages/studio/src/player/components/timelineTrackOrder.ts create mode 100644 packages/studio/src/player/components/useTimelineClipDrag.test.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5dd7e2eb42..1de3f3d2f4 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -11,6 +11,7 @@ import { usePanelLayout } from "./hooks/usePanelLayout"; import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; +import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; import { useSdkSession } from "./hooks/useSdkSession"; @@ -19,7 +20,7 @@ import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences"; -import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection"; +import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers"; import { useCaptionDetection } from "./hooks/useCaptionDetection"; import { useRenderClipContent } from "./hooks/useRenderClipContent"; import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture"; @@ -140,6 +141,7 @@ export function StudioApp() { }); const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); + const handleDomZIndexReorderCommitRef = useRef(null); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); @@ -188,6 +190,7 @@ export function StudioApp() { isRecordingRef: isGestureRecordingRef, sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, + handleDomZIndexReorderCommitRef, }); const { activeBlockParams, @@ -306,19 +309,12 @@ export function StudioApp() { forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; + handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; - deleteSelectedKeyframesRef.current = () => { - const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); - const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); - if (!a) return; - // Only the active element's keyframes; a stale cross-element selection must not delete here. - for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) { - domEditSession.handleGsapRemoveKeyframe(a.id, p); - } - }; + deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession); useSdkSelectionSync( sdkHandle.session, domEditSession.domEditSelection, diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 6dd651d453..de0619427a 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -1,8 +1,99 @@ -import type { TimelineElement } from "../player/store/playerStore"; +import { type TimelineElement, usePlayerStore } from "../player/store/playerStore"; import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; -import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; +import { + formatTimelineAttributeNumber, + resolveTimelineStackingReorderByTargetTrack, + type TimelineStackingReorderIntent, +} from "../player/components/timelineEditing"; +import { computeReorderZValues, getElementZIndex } from "../player/lib/layerOrdering"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; import type { EditHistoryKind } from "../utils/editHistory"; +import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; + +function isHTMLElement(element: Element | null): element is HTMLElement { + return element != null && element instanceof HTMLElement; +} + +/** + * Resolve a timeline vertical move to a z-index stacking reorder and commit it + * through the shared layers-panel reorder path. Reads live sibling z-index from + * the preview DOM, remaps with the dup-preserving reorder math, and writes only + * z-index (never data-track-index). No-op when the move isn't a reorder or the + * live siblings can't be resolved. Extracted from StudioApp's timeline hook to + * keep it under the studio 600-LOC cap. + */ +export function applyTimelineStackingReorder(input: { + element: TimelineElement; + targetTrack: number; + stackingReorder: TimelineStackingReorderIntent | null | undefined; + timelineElements: readonly TimelineElement[]; + iframe: HTMLIFrameElement | null; + activeCompPath: string | null; + commit: TimelineZIndexReorderCommit | null | undefined; + keyOf: (element: TimelineElement) => string; +}): void { + const intent = + input.stackingReorder ?? + (input.targetTrack !== input.element.track + ? resolveTimelineStackingReorderByTargetTrack({ + element: input.element, + elements: input.timelineElements, + targetTrack: input.targetTrack, + }) + : null); + if (intent == null || intent.fromIndex === intent.toIndex) return; + + const siblingByKey = new Map(input.timelineElements.map((el) => [input.keyOf(el), el])); + const orderedSiblings = intent.siblingKeys + .map((key) => siblingByKey.get(key) ?? null) + .filter((sibling): sibling is TimelineElement => sibling != null); + if (orderedSiblings.length !== intent.siblingKeys.length) return; + + const liveEntries = orderedSiblings + .map((sibling) => ({ sibling, element: findTimelineElementInIframe(input.iframe, sibling) })) + .filter((entry): entry is { sibling: TimelineElement; element: HTMLElement } => + isHTMLElement(entry.element), + ); + if (liveEntries.length !== orderedSiblings.length) return; + + const reordered = [...liveEntries]; + const [moved] = reordered.splice(intent.fromIndex, 1); + if (!moved) return; + reordered.splice(intent.toIndex, 0, moved); + + const existingValues = liveEntries.map((entry) => getElementZIndex(entry.element)); + const zValues = computeReorderZValues(existingValues, intent.fromIndex, intent.toIndex); + input.commit?.( + reordered.map((entry, index) => ({ + element: entry.element, + zIndex: zValues[index] ?? 0, + id: entry.sibling.domId ?? entry.sibling.id, + selector: entry.sibling.selector, + selectorIndex: entry.sibling.selectorIndex, + sourceFile: entry.sibling.sourceFile || input.activeCompPath || "index.html", + })), + ); +} + +/** + * Remove the keyframes currently selected in the player store from the active + * element's GSAP animation. Reads selection lazily so it stays correct when + * invoked from a ref callback. Extracted from StudioApp to keep it under the + * studio 600-LOC cap. + */ +export function deleteSelectedKeyframes(session: { + selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[]; + handleGsapRemoveKeyframe: (animId: string, pct: number) => void; +}): void { + const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); + const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes); + if (!animation) return; + // Only the active element's keyframes; a stale cross-element selection must not delete here. + for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) { + session.handleGsapRemoveKeyframe(animation.id, pct); + } +} // ── Types ── diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx new file mode 100644 index 0000000000..b427e97065 --- /dev/null +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -0,0 +1,348 @@ +// @vitest-environment happy-dom + +import React, { act, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../player"; +import { useElementLifecycleOps } from "./useElementLifecycleOps"; +import { useTimelineEditing } from "./useTimelineEditing"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type ZIndexEntry = { + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; +}; + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function createPreviewIframe( + clips: Array<{ + id: string; + track: number; + style?: string; + }> = [ + { id: "front", track: 0 }, + { id: "back", track: 1 }, + ], +): HTMLIFrameElement { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument; + if (!doc) throw new Error("Expected iframe document"); + doc.body.innerHTML = clips + .map( + (clip) => + `
`, + ) + .join("\n"); + return iframe; +} + +function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement { + return { + id: input.id, + domId: input.id, + hfId: `hf-${input.id}`, + tag: "div", + start: 0, + duration: 2, + track: input.track, + zIndex: input.zIndex, + stackingContextId: "root", + parentCompositionId: null, + compositionAncestors: ["root"], + sourceFile: "index.html", + timingSource: "authored", + }; +} + +function renderTimelineEditingHook(input: { + timelineElements: TimelineElement[]; + iframe: HTMLIFrameElement; + onZIndexCommit: (entries: ZIndexEntry[]) => void; + projectId?: string | null; + writeProjectFile?: (path: string, content: string) => Promise; + recordEdit?: (input: { + label: string; + kind: string; + coalesceKey?: string; + files: Record; + }) => Promise; + reloadPreview?: () => void; +}): { + move: ReturnType["handleTimelineElementMove"]; + unmount: () => void; +} { + let move: ReturnType["handleTimelineElementMove"] | null = null; + + function Harness() { + const commitRef = useRef(input.onZIndexCommit); + commitRef.current = input.onZIndexCommit; + const hook = useTimelineEditing({ + projectId: input.projectId ?? null, + activeCompPath: "index.html", + timelineElements: input.timelineElements, + showToast: vi.fn(), + writeProjectFile: input.writeProjectFile ?? vi.fn(), + recordEdit: input.recordEdit ?? vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + reloadPreview: input.reloadPreview ?? vi.fn(), + previewIframeRef: { current: input.iframe }, + pendingTimelineEditPathRef: { current: new Set() }, + uploadProjectFiles: vi.fn(), + handleDomZIndexReorderCommitRef: commitRef, + }); + move = hook.handleTimelineElementMove; + return null; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + if (!move) throw new Error("Expected hook to expose move handler"); + return { + move, + unmount: () => { + act(() => root.unmount()); + }, + }; +} + +function renderTimelineEditingHookWithLifecycle(input: { + timelineElements: TimelineElement[]; + iframe: HTMLIFrameElement; + commitPositionPatchToHtml: ReturnType Promise>>; +}): { + move: ReturnType["handleTimelineElementMove"]; + unmount: () => void; +} { + let move: ReturnType["handleTimelineElementMove"] | null = null; + + function Harness() { + const lifecycle = useElementLifecycleOps({ + activeCompPath: "index.html", + showToast: vi.fn(), + writeProjectFile: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + editHistory: { recordEdit: vi.fn() }, + projectIdRef: { current: "p1" }, + reloadPreview: vi.fn(), + clearDomSelection: vi.fn(), + commitPositionPatchToHtml: input.commitPositionPatchToHtml, + }); + const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit); + commitRef.current = lifecycle.handleDomZIndexReorderCommit; + const hook = useTimelineEditing({ + projectId: null, + activeCompPath: "index.html", + timelineElements: input.timelineElements, + showToast: vi.fn(), + writeProjectFile: vi.fn(), + recordEdit: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, + reloadPreview: vi.fn(), + previewIframeRef: { current: input.iframe }, + pendingTimelineEditPathRef: { current: new Set() }, + uploadProjectFiles: vi.fn(), + handleDomZIndexReorderCommitRef: commitRef, + }); + move = hook.handleTimelineElementMove; + return null; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + if (!move) throw new Error("Expected hook to expose move handler"); + return { + move, + unmount: () => { + act(() => root.unmount()); + }, + }; +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function requestUrl(input: Parameters[0]): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +async function flushAsyncWork(): Promise { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +describe("useTimelineEditing timeline z-index reorder", () => { + it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { + const iframe = createPreviewIframe([ + { id: "front", track: 0 }, + { id: "middle", track: 1 }, + { id: "back", track: 2 }, + ]); + const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); + const middle = timelineElement({ id: "middle", track: 1, zIndex: 0 }); + const back = timelineElement({ id: "back", track: 2, zIndex: 0 }); + const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [front, middle, back], + iframe, + onZIndexCommit: commit, + }); + + await act(async () => { + await move(back, { start: back.start, track: front.track }); + }); + + const doc = iframe.contentDocument; + if (!doc) throw new Error("Expected iframe document"); + + expect(commit).toHaveBeenCalledTimes(1); + expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ + ["back", 3], + ["front", 2], + ["middle", 1], + ]); + expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2"); + + unmount(); + }); + + it("remaps distinct z-index values onto the reordered sibling group", async () => { + const iframe = createPreviewIframe([ + { id: "front", track: 0, style: "position: relative; z-index: 10" }, + { id: "middle", track: 1, style: "position: relative; z-index: 5" }, + { id: "back", track: 2, style: "position: relative; z-index: 1" }, + ]); + const front = timelineElement({ id: "front", track: 0, zIndex: 10 }); + const middle = timelineElement({ id: "middle", track: 1, zIndex: 5 }); + const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); + const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [front, middle, back], + iframe, + onZIndexCommit: commit, + }); + + await act(async () => { + await move(back, { start: back.start, track: front.track }); + }); + + expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ + ["back", 10], + ["front", 5], + ["middle", 1], + ]); + + unmount(); + }); + + it("uses the shared lifecycle commit so static clips receive position relative", async () => { + const iframe = createPreviewIframe([ + { id: "front", track: 0, style: "position: static" }, + { id: "back", track: 1, style: "position: static" }, + ]); + const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); + const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); + const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const { move, unmount } = renderTimelineEditingHookWithLifecycle({ + timelineElements: [front, back], + iframe, + commitPositionPatchToHtml, + }); + + await act(async () => { + await move(back, { start: back.start, track: front.track }); + await flushAsyncWork(); + }); + + expect(commitPositionPatchToHtml).toHaveBeenCalled(); + expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([ + { type: "inline-style", property: "z-index", value: "2" }, + { type: "inline-style", property: "position", value: "relative" }, + ]); + + unmount(); + }); + + it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => { + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async () => {}); + const reloadPreview = vi.fn(); + const fetchMock = vi.fn( + async ( + input: Parameters[0], + _init?: Parameters[1], + ): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) { + return jsonResponse({ + content: '
', + }); + } + if (url.includes("/api/projects/p1/gsap-mutations/")) { + return jsonResponse({ ok: true }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: commit, + projectId: "p1", + writeProjectFile, + recordEdit, + reloadPreview, + }); + + await act(async () => { + await move(clip, { start: 1.25, track: clip.track }); + }); + + const doc = iframe.contentDocument; + if (!doc) throw new Error("Expected iframe document"); + expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25"); + expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0"); + expect(commit).not.toHaveBeenCalled(); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"'); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"'); + expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index"); + expect( + fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), + ).toBe(true); + + unmount(); + }); +}); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 885986831e..590e0e055d 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -21,6 +21,7 @@ import { resolveDroppedAssetDuration, } from "../utils/studioHelpers"; import { + applyTimelineStackingReorder, buildPatchTarget, patchIframeDomTiming, resolveResizePlaybackStart, @@ -32,6 +33,7 @@ import { scaleGsapPositions, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; +import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; import { useTimelineElementVisibilityEditing, useTimelineTrackVisibilityEditing, @@ -39,6 +41,10 @@ import { import { sdkTimingPersist } from "../utils/sdkCutover"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; +type TimelineMoveUpdates = Pick & { + stackingReorder?: TimelineStackingReorderIntent | null; +}; + // ── Hook ── export function useTimelineEditing({ @@ -56,6 +62,7 @@ export function useTimelineEditing({ isRecordingRef, sdkSession, forceReloadSdkSession, + handleDomZIndexReorderCommitRef, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -115,23 +122,35 @@ export function useTimelineEditing({ // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( // fallow-ignore-next-line complexity - (element: TimelineElement, updates: Pick) => { - patchIframeDomTiming(previewIframeRef.current, element, [ - ["data-start", formatTimelineAttributeNumber(updates.start)], - ["data-track-index", String(updates.track)], - ]); + (element: TimelineElement, updates: TimelineMoveUpdates) => { const targetPath = element.sourceFile || activeCompPath || "index.html"; + const startChanged = updates.start !== element.start; + + if (startChanged) { + patchIframeDomTiming(previewIframeRef.current, element, [ + ["data-start", formatTimelineAttributeNumber(updates.start)], + ]); + } + + applyTimelineStackingReorder({ + element, + targetTrack: updates.track, + stackingReorder: updates.stackingReorder, + timelineElements, + iframe: previewIframeRef.current, + activeCompPath, + commit: handleDomZIndexReorderCommitRef?.current, + keyOf: (el) => el.key ?? el.id, + }); + + if (!startChanged) return; + const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - let patched = applyPatchByTarget(original, target, { + return applyPatchByTarget(original, target, { type: "attribute", property: "start", value: formatTimelineAttributeNumber(updates.start), }); - return applyPatchByTarget(patched, target, { - type: "attribute", - property: "track-index", - value: String(updates.track), - }); }; // Server-path fallback (no SDK session): persist the attr patch, then // shift GSAP tween positions on the server and reload the preview — the @@ -155,7 +174,7 @@ export function useTimelineEditing({ return sdkTimingPersist( element.hfId, targetPath, - { start: updates.start, trackIndex: updates.track }, + { start: updates.start }, sdkSession, { editHistory: { recordEdit }, @@ -183,6 +202,8 @@ export function useTimelineEditing({ writeProjectFile, reloadPreview, domEditSaveTimestampRef, + timelineElements, + handleDomZIndexReorderCommitRef, ], ); diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index ddfe5b71b1..2e121275bd 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -10,6 +10,17 @@ interface RecordEditInput { files: Record; } +export type TimelineZIndexReorderCommit = ( + entries: Array<{ + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; + }>, +) => void; + export interface UseTimelineEditingOptions { projectId: string | null; activeCompPath: string | null; @@ -27,4 +38,5 @@ export interface UseTimelineEditingOptions { sdkSession?: Composition | null; /** Resync the SDK session after a server-authoritative timeline write. */ forceReloadSdkSession?: () => void; + handleDomZIndexReorderCommitRef?: MutableRefObject; } diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index f77948fd94..ea70dc90f7 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -18,9 +18,11 @@ import { shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, } from "./Timeline"; +import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder"; import { RULER_H, TRACK_H } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; +import type { TimelineElement } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -201,6 +203,99 @@ describe("Timeline provider boundary", () => { }); }); +function rowElement(input: { + id: string; + track: number; + zIndex?: number; + start?: number; + duration?: number; + stackingContextId?: string | null; + parentCompositionId?: string | null; + compositionAncestors?: string[]; +}): TimelineElement { + return { + id: input.id, + tag: "div", + start: input.start ?? 0, + duration: input.duration ?? 1, + track: input.track, + zIndex: input.zIndex ?? 0, + stackingContextId: input.stackingContextId ?? "root", + parentCompositionId: input.parentCompositionId ?? null, + compositionAncestors: input.compositionAncestors ?? ["root"], + }; +} + +describe("buildStackingTimelineTracks", () => { + it("keeps no-track-index clips in DOM order when stacking ties", () => { + const tracks = buildStackingTimelineTracks([ + rowElement({ id: "a", track: 0 }), + rowElement({ id: "b", track: 1 }), + rowElement({ id: "c", track: 2 }), + ]); + + expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]); + }); + + it("orders authored track-index rows by stacking order instead of numeric track order", () => { + const tracks = buildStackingTimelineTracks([ + rowElement({ id: "dom-first", track: 2 }), + rowElement({ id: "dom-second", track: 0 }), + ]); + + expect(tracks.map(([track]) => track)).toEqual([2, 0]); + }); + + it("renders explicit z-index rows top-to-front by descending z-index", () => { + const tracks = buildStackingTimelineTracks([ + rowElement({ id: "back", track: 0, zIndex: 1 }), + rowElement({ id: "front", track: 1, zIndex: 10 }), + rowElement({ id: "middle", track: 2, zIndex: 5 }), + ]); + + expect(tracks.map(([track]) => track)).toEqual([1, 2, 0]); + }); + + it("keeps nested sub-composition clips scoped below parent-level clips", () => { + const tracks = buildStackingTimelineTracks([ + rowElement({ id: "root-low", track: 1, zIndex: 1 }), + rowElement({ + id: "nested-high", + track: 0, + zIndex: 100, + stackingContextId: "scene", + parentCompositionId: "scene", + compositionAncestors: ["root", "scene"], + }), + rowElement({ id: "root-front", track: 2, zIndex: 2 }), + ]); + + expect(tracks.map(([track]) => track)).toEqual([2, 1, 0]); + }); + + it("keeps time-overlapping equal-rank clips on separate literal-track rows", () => { + const tracks = buildStackingTimelineTracks([ + rowElement({ id: "first", track: 0, start: 0, duration: 2 }), + rowElement({ id: "second", track: 1, start: 1, duration: 2 }), + ]); + + expect(tracks).toHaveLength(2); + expect( + tracks.map(([track, elements]) => [track, elements.map((element) => element.id)]), + ).toEqual([ + [0, ["first"]], + [1, ["second"]], + ]); + }); +}); + +describe("insertPreviewTrackOrder", () => { + it("preserves top and bottom drag-preview row insertion without numeric resorting", () => { + expect(insertPreviewTrackOrder([5, 2, 0], -1)).toEqual([-1, 5, 2, 0]); + expect(insertPreviewTrackOrder([5, 2, 0], 6)).toEqual([5, 2, 0, 6]); + }); +}); + describe("generateTicks", () => { it("returns empty arrays for duration <= 0", () => { expect(generateTicks(0)).toEqual({ major: [], minor: [] }); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 1a182c6f51..9e46e09320 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -23,6 +23,7 @@ import { import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { ClipContextMenu } from "./ClipContextMenu"; import { TimelineShortcutHint } from "./TimelineShortcutHint"; +import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder"; import { GUTTER, generateTicks, @@ -186,15 +187,7 @@ export const Timeline = memo(function Timeline({ return Number.isFinite(result) ? result : safeDur; }, [rawElements, duration]); - const tracks = useMemo(() => { - const map = new Map(); - for (const el of expandedElements) { - const list = map.get(el.track) ?? []; - list.push(el); - map.set(el.track, list); - } - return Array.from(map.entries()).sort(([a], [b]) => a - b); - }, [expandedElements]); + const tracks = useMemo(() => buildStackingTimelineTracks(expandedElements), [expandedElements]); const trackStyles = useMemo(() => { const map = new Map(); @@ -207,6 +200,8 @@ export const Timeline = memo(function Timeline({ const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]); const trackOrderRef = useRef(trackOrder); trackOrderRef.current = trackOrder; + const expandedElementsRef = useRef(expandedElements); + expandedElementsRef.current = expandedElements; const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); @@ -228,6 +223,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + timelineElementsRef: expandedElementsRef, onMoveElement, onResizeElement, onBlockedEditAttempt, @@ -242,7 +238,7 @@ export const Timeline = memo(function Timeline({ trackOrder.includes(draggedClip.previewTrack) ) return trackOrder; - return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b); + return insertPreviewTrackOrder(trackOrder, draggedClip.previewTrack); }, [draggedClip, trackOrder]); const totalH = getTimelineCanvasHeight(displayTrackOrder.length); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index fff0526396..ded4b386f4 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -401,6 +401,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ pointerOffsetY: e.clientY - rect.top, previewStart: el.start, previewTrack: el.track, + previewStackingReorder: null, snapBeatTime: null, started: false, }); @@ -450,6 +451,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({ clipWidthPx={Math.max(previewElement.duration * pps, 4)} clipHeightPx={TRACK_H - 2 * CLIP_Y} beatsActive={beatStripOnTrack} + beatTimes={beatAnalysis?.beatTimes} + clipStart={previewElement.start} + clipDurationSeconds={previewElement.duration} + pixelsPerSecond={pps} accentColor={clipStyle.accent} isSelected={isSelected} currentPercentage={ diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 129104dfb8..2fc295ad47 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,10 +1,12 @@ import { memo, useRef, useState } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; import { + clampToNeighbors, KEYFRAME_DRAG_THRESHOLD_PX, previewClipPct, resolveKeyframeDrag, } from "../../components/editor/keyframeDrag"; +import { snapKeyframePctToBeat } from "./timelineEditing"; interface KeyframeEntry { percentage: number; @@ -28,6 +30,14 @@ interface TimelineClipDiamondsProps { /** Beat-dot strip is shown on this track → shrink diamonds + drop them into * the bottom half so they clear the strip at the top. */ beatsActive?: boolean; + /** Composition-time beat positions (same source the beat strip renders from). + * When present and `beatsActive`, a dragged keyframe snaps to the nearest beat. */ + beatTimes?: number[]; + /** Clip start / duration (seconds) + pixels-per-second, needed to map a + * dragged keyframe's clip-% to composition time for beat snapping. */ + clipStart?: number; + clipDurationSeconds?: number; + pixelsPerSecond?: number; accentColor: string; isSelected: boolean; currentPercentage: number; @@ -71,6 +81,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ clipWidthPx, clipHeightPx, beatsActive, + beatTimes, + clipStart = 0, + clipDurationSeconds = 0, + pixelsPerSecond = 1, accentColor, isSelected, currentPercentage, @@ -121,6 +135,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const baseOpacity = isSelected ? 0.4 : 0.25; const canDrag = isSelected && !!onMoveKeyframe; + // Snap a dragged keyframe's clip-% to the nearest beat (within ~8px), then + // re-clamp to neighbours so the snap can't cross a sibling keyframe. No-op + // when the beat strip isn't active for this track or no beats are loaded. + const snapClipPctToBeat = (clipPct: number, draggedIndex: number): number => { + if (!beatsActive || !beatTimes || beatTimes.length === 0) return clipPct; + const snapped = snapKeyframePctToBeat( + { start: clipStart, duration: clipDurationSeconds }, + clipPct, + beatTimes, + pixelsPerSecond, + ); + return clampToNeighbors(snapped, sortedClipPcts, draggedIndex); + }; + return (
, + updates: Pick & { + stackingReorder?: TimelineStackingReorderIntent | null; + }, ) => Promise | void; onResizeElement?: ( element: TimelineElement, diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 7208470480..b068dd8b3b 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -10,6 +10,7 @@ import { resolveTimelineAutoScroll, resolveTimelineMove, resolveTimelineResize, + snapKeyframePctToBeat, type TimelinePromptElement, } from "./timelineEditing"; @@ -155,6 +156,69 @@ describe("resolveTimelineMove", () => { ), ).toEqual({ start: 2, track: 2 }); }); + + it("resolves vertical stacking movement within the dragged clip's context siblings", () => { + const result = resolveTimelineMove( + { + start: 0, + track: 1, + duration: 2, + originClientX: 0, + originClientY: 0, + pixelsPerSecond: 100, + trackHeight: 72, + maxStart: 8, + trackOrder: [0, 99, 1], + stackingElement: { + id: "root-back", + track: 1, + zIndex: 1, + stackingContextId: "root", + parentCompositionId: null, + compositionAncestors: ["root"], + }, + stackingElements: [ + { + id: "root-front", + track: 0, + zIndex: 2, + stackingContextId: "root", + parentCompositionId: null, + compositionAncestors: ["root"], + }, + { + id: "nested-row", + track: 99, + zIndex: 100, + stackingContextId: "scene", + parentCompositionId: "scene", + compositionAncestors: ["root", "scene"], + }, + { + id: "root-back", + track: 1, + zIndex: 1, + stackingContextId: "root", + parentCompositionId: null, + compositionAncestors: ["root"], + }, + ], + }, + 0, + -72, + ); + + expect(result).toEqual({ + start: 0, + track: 0, + stackingReorder: { + contextKey: "root", + fromIndex: 1, + toIndex: 0, + siblingKeys: ["root-front", "root-back"], + }, + }); + }); }); describe("hasPatchableTimelineTarget", () => { @@ -613,3 +677,34 @@ describe("buildPromptCopyText", () => { ); }); }); + +describe("snapKeyframePctToBeat", () => { + // el spans 0–10s, so clip-% maps to composition time as pct * 0.1s. + // At pps=100 the snap window is 8 / 100 = 0.08s. + const el = { start: 0, duration: 10 }; + const beats = [2, 5, 8]; + + it("snaps a keyframe within ~8px of a beat exactly onto it", () => { + // pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%. + expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50); + }); + + it("leaves a keyframe unchanged when no beat is within the window", () => { + // pct 55 → 5.5s, 0.5s from the nearest beat → free. + expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55); + }); + + it("is a no-op when there are no beats", () => { + expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5); + expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5); + }); + + it("is a no-op for a zero-duration clip", () => { + expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5); + }); + + it("widens the snap window as zoom (pps) decreases", () => { + // pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%. + expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50); + }); +}); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index bcefba970c..0eee5b91c4 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -1,5 +1,6 @@ import { formatTime } from "../lib/time"; import { roundToCenti } from "../../utils/rounding"; +import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering"; const roundToCentiseconds = roundToCenti; @@ -7,6 +8,88 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** + * A timeline clip described for stacking-order math: its track (timeline row), + * resolved z-index, and stacking-context identity. Structurally satisfied by the + * app's TimelineElement. + */ +export interface TimelineStackingElement { + id: string; + key?: string; + track: number; + zIndex?: number; + stackingContextId?: string | null; + parentCompositionId?: string | null; + compositionAncestors?: string[]; +} + +/** A resolved vertical reorder: move the dragged clip from `fromIndex` to + * `toIndex` within its stacking context's ordered siblings (top = front). */ +export interface TimelineStackingReorderIntent { + contextKey: string; + fromIndex: number; + toIndex: number; + siblingKeys: string[]; +} + +interface TimelineStackingOrderItem { + key: string; + track: number; + zIndex: number; + stackingContextId: string | null; + parentCompositionId: string | null; + compositionAncestors: readonly string[]; +} + +function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem { + return { + key: element.key ?? element.id, + track: element.track, + zIndex: element.zIndex ?? 0, + stackingContextId: element.stackingContextId ?? null, + parentCompositionId: element.parentCompositionId ?? null, + compositionAncestors: element.compositionAncestors ?? [], + }; +} + +/** Ordered siblings of `element` within its own stacking context (z-index desc, + * DOM order tiebreak) — the unit a vertical reorder operates on. */ +function resolveContextSiblings( + element: TimelineStackingElement, + elements: readonly TimelineStackingElement[], +): TimelineStackingOrderItem[] { + const contextKey = resolveStackingContextKey(toStackingOrderItem(element)); + const items = elements + .map(toStackingOrderItem) + .filter((item) => resolveStackingContextKey(item) === contextKey); + return resolveContextOrder(items); +} + +/** + * Resolve the reorder implied by dropping `element` onto `targetTrack` (the track + * of the sibling whose slot it lands in). Returns null when the element has no + * reorderable siblings or the target track matches no sibling. + */ +export function resolveTimelineStackingReorderByTargetTrack(args: { + element: TimelineStackingElement; + elements: readonly TimelineStackingElement[]; + targetTrack: number; +}): TimelineStackingReorderIntent | null { + const orderedSiblings = resolveContextSiblings(args.element, args.elements); + if (orderedSiblings.length <= 1) return null; + const draggedKey = args.element.key ?? args.element.id; + const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey); + if (fromIndex < 0) return null; + const toIndex = orderedSiblings.findIndex((sibling) => sibling.track === args.targetTrack); + if (toIndex < 0) return null; + return { + contextKey: resolveStackingContextKey(toStackingOrderItem(args.element)), + fromIndex, + toIndex, + siblingKeys: orderedSiblings.map((sibling) => sibling.key), + }; +} + const EDGE_TRACK_CREATE_THRESHOLD = 0.55; const AUTO_SCROLL_EDGE_ZONE = 40; const AUTO_SCROLL_MAX_SPEED = 12; @@ -25,6 +108,10 @@ export interface TimelineMoveInput { trackHeight: number; maxStart: number; trackOrder: number[]; + /** When provided, vertical movement is resolved as a z-index stacking reorder + * within `stackingElement`'s context instead of a raw track change. */ + stackingElement?: TimelineStackingElement; + stackingElements?: TimelineStackingElement[]; } export interface TimelineResizeInput { @@ -73,7 +160,7 @@ export function resolveTimelineMove( input: TimelineMoveInput, clientX: number, clientY: number, -): { start: number; track: number } { +): { start: number; track: number; stackingReorder?: TimelineStackingReorderIntent } { const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0); const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0); const deltaTime = @@ -81,6 +168,33 @@ export function resolveTimelineMove( const trackDeltaRaw = (clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1); const deltaTrack = Math.round(trackDeltaRaw); + const nextStart = clamp( + roundToCentiseconds(input.start + deltaTime), + 0, + Math.max(0, input.maxStart), + ); + + // Stacking mode: vertical movement reorders z-index within the dragged clip's + // stacking context (top = front), rather than changing the raw track number. + if (input.stackingElement && input.stackingElements) { + const orderedSiblings = resolveContextSiblings(input.stackingElement, input.stackingElements); + const draggedKey = input.stackingElement.key ?? input.stackingElement.id; + const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey); + if (fromIndex >= 0 && orderedSiblings.length > 1) { + const toIndex = clamp(fromIndex + deltaTrack, 0, orderedSiblings.length - 1); + return { + start: nextStart, + track: orderedSiblings[toIndex]!.track, + stackingReorder: { + contextKey: resolveStackingContextKey(toStackingOrderItem(input.stackingElement)), + fromIndex, + toIndex, + siblingKeys: orderedSiblings.map((sibling) => sibling.key), + }, + }; + } + } + const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track)); const desiredTrackIndex = currentTrackIndex + deltaTrack; const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1)); @@ -106,7 +220,7 @@ export function resolveTimelineMove( } return { - start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)), + start: nextStart, track: nextTrack, }; } diff --git a/packages/studio/src/player/components/timelineTrackOrder.ts b/packages/studio/src/player/components/timelineTrackOrder.ts new file mode 100644 index 0000000000..dc4c151e40 --- /dev/null +++ b/packages/studio/src/player/components/timelineTrackOrder.ts @@ -0,0 +1,116 @@ +import { type TimelineElement } from "../store/playerStore"; +import { + resolveContextOrder, + resolveStackingContextKey, + type ContextOrderItem, +} from "../lib/layerOrdering"; + +/** + * Pure timeline track-ordering logic. Timeline rows are ordered by scoped + * stacking (z-index per stacking context, top = front), with data-track-index + * used only to split time-overlapping clips of equal rank onto separate rows. + * Extracted from Timeline.tsx to keep the component under the studio 600-LOC cap. + */ + +interface TimelineTrackOrderItem extends ContextOrderItem { + key: string; + track: number; + start: number; + duration: number; +} + +function getTimelineElementKey(element: TimelineElement): string { + return element.key ?? element.id; +} + +function toTimelineTrackOrderItem(element: TimelineElement): TimelineTrackOrderItem { + return { + key: getTimelineElementKey(element), + track: element.track, + start: element.start, + duration: element.duration, + zIndex: element.zIndex ?? 0, + stackingContextId: element.stackingContextId ?? null, + parentCompositionId: element.parentCompositionId ?? null, + compositionAncestors: element.compositionAncestors ?? [], + }; +} + +function timelineElementsOverlap( + a: Pick, + b: Pick, +): boolean { + return a.start < b.start + b.duration && b.start < a.start + a.duration; +} + +function trackFrontOrderIndex( + elements: readonly TimelineElement[], + orderIndexByKey: ReadonlyMap, +): number { + let orderIndex = Number.POSITIVE_INFINITY; + for (const element of elements) { + orderIndex = Math.min( + orderIndex, + orderIndexByKey.get(getTimelineElementKey(element)) ?? Number.POSITIVE_INFINITY, + ); + } + return orderIndex; +} + +function hasOverlappingEqualRankElements( + aElements: readonly TimelineElement[], + bElements: readonly TimelineElement[], +): boolean { + for (const a of aElements) { + const aOrderItem = toTimelineTrackOrderItem(a); + const aContextKey = resolveStackingContextKey(aOrderItem); + for (const b of bElements) { + const bOrderItem = toTimelineTrackOrderItem(b); + if (aContextKey !== resolveStackingContextKey(bOrderItem)) continue; + if (aOrderItem.zIndex !== bOrderItem.zIndex) continue; + if (timelineElementsOverlap(a, b)) return true; + } + } + return false; +} + +export function buildStackingTimelineTracks( + elements: readonly TimelineElement[], +): Array<[number, TimelineElement[]]> { + const tracks = new Map(); + for (const element of elements) { + const list = tracks.get(element.track) ?? []; + list.push(element); + tracks.set(element.track, list); + } + + const orderedElements = resolveContextOrder(elements.map(toTimelineTrackOrderItem)); + const orderIndexByKey = new Map(); + orderedElements.forEach((element, index) => { + orderIndexByKey.set(element.key, index); + }); + + return Array.from(tracks.entries()).sort(([aTrack, aElements], [bTrack, bElements]) => { + const aIndex = trackFrontOrderIndex(aElements, orderIndexByKey); + const bIndex = trackFrontOrderIndex(bElements, orderIndexByKey); + if (aIndex !== bIndex) return aIndex - bIndex; + if (hasOverlappingEqualRankElements(aElements, bElements)) return aTrack - bTrack; + const aStart = Math.min(...aElements.map((element) => element.start)); + const bStart = Math.min(...bElements.map((element) => element.start)); + if (aStart !== bStart) return aStart - bStart; + return aTrack - bTrack; + }); +} + +export function insertPreviewTrackOrder( + trackOrder: readonly number[], + previewTrack: number, +): number[] { + if (trackOrder.includes(previewTrack)) return [...trackOrder]; + if (trackOrder.length === 0) return [previewTrack]; + const minTrack = Math.min(...trackOrder); + const maxTrack = Math.max(...trackOrder); + if (previewTrack < minTrack) return [previewTrack, ...trackOrder]; + if (previewTrack > maxTrack) return [...trackOrder, previewTrack]; + return [...trackOrder, previewTrack]; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx new file mode 100644 index 0000000000..24ab5db94a --- /dev/null +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; +import { TRACK_H } from "./timelineLayout"; +import type { DraggedClipState } from "./useTimelineClipDrag"; +import { useTimelineClipDrag } from "./useTimelineClipDrag"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement { + return { + id: input.id, + domId: input.id, + tag: "div", + start: 0, + duration: 2, + track: input.track, + zIndex: input.zIndex, + stackingContextId: "root", + parentCompositionId: null, + compositionAncestors: ["root"], + sourceFile: "index.html", + timingSource: "authored", + }; +} + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.getState().reset(); +}); + +describe("useTimelineClipDrag", () => { + it("passes sibling-scoped stacking intent on vertical drag commit", async () => { + const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); + const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 }); + const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); + const scroll = document.createElement("div"); + document.body.append(scroll); + const onMoveElement = vi.fn(); + let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null; + + function Harness() { + const hook = useTimelineClipDrag({ + scrollRef: { current: scroll }, + ppsRef: { current: 100 }, + durationRef: { current: 10 }, + trackOrderRef: { current: [0, 1, 2] }, + timelineElementsRef: { current: [front, middle, back] }, + onMoveElement, + onResizeElement: vi.fn(), + onBlockedEditAttempt: vi.fn(), + setShowPopover: vi.fn(), + setRangeSelectionRef: { current: vi.fn() }, + }); + setDraggedClip = hook.setDraggedClip; + return null; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + if (!setDraggedClip) throw new Error("Expected drag setter"); + const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip; + + act(() => { + applyDraggedClip({ + element: back, + originClientX: 0, + originClientY: 0, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: 0, + pointerClientY: 0, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: back.start, + previewTrack: back.track, + previewStackingReorder: null, + snapBeatTime: null, + started: false, + }); + }); + + act(() => { + window.dispatchEvent( + new MouseEvent("pointermove", { + bubbles: true, + clientX: 0, + clientY: -2 * TRACK_H, + }), + ); + }); + await act(async () => { + window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + }); + + expect(onMoveElement).toHaveBeenCalledTimes(1); + expect(onMoveElement.mock.calls[0]![1]).toMatchObject({ + start: 0, + track: 0, + stackingReorder: { + fromIndex: 2, + toIndex: 0, + siblingKeys: ["front", "middle", "back"], + }, + }); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index db90fd085b..1b445167fc 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -5,6 +5,7 @@ import { resolveTimelineResize, resolveTimelineAutoScroll, type BlockedTimelineEditIntent, + type TimelineStackingReorderIntent, } from "./timelineEditing"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore"; @@ -83,6 +84,8 @@ export interface DraggedClipState { previewTrack: number; /** Beat time the clip will snap to on drop, for the grid-line highlight. */ snapBeatTime: number | null; + /** Sibling-scoped z-index reorder intent resolved from the vertical drag. */ + previewStackingReorder: TimelineStackingReorderIntent | null; started: boolean; } @@ -110,9 +113,12 @@ interface UseTimelineClipDragInput { ppsRef: React.RefObject; durationRef: React.RefObject; trackOrderRef: React.RefObject; + timelineElementsRef: React.RefObject; onMoveElement?: ( element: TimelineElement, - updates: Pick, + updates: Pick & { + stackingReorder?: TimelineStackingReorderIntent | null; + }, ) => Promise | void; onResizeElement?: ( element: TimelineElement, @@ -129,6 +135,7 @@ export function useTimelineClipDrag({ ppsRef, durationRef, trackOrderRef, + timelineElementsRef, onMoveElement, onResizeElement, onBlockedEditAttempt, @@ -204,6 +211,8 @@ export function useTimelineClipDrag({ trackHeight: TRACK_H, maxStart: Math.max(0, durationRef.current - drag.element.duration), trackOrder: trackOrderRef.current, + stackingElement: drag.element, + stackingElements: timelineElementsRef.current, }, clientX, clientY, @@ -225,10 +234,11 @@ export function useTimelineClipDrag({ pointerClientY: clientY, previewStart: snap.start, previewTrack: nextMove.track, + previewStackingReorder: nextMove.stackingReorder ?? null, snapBeatTime: snap.beat, }; }, - [scrollRef, ppsRef, durationRef, trackOrderRef], + [scrollRef, ppsRef, durationRef, trackOrderRef, timelineElementsRef], ); const stopClipDragAutoScroll = useCallback(() => { @@ -299,6 +309,7 @@ export function useTimelineClipDrag({ }); }; + // fallow-ignore-next-line complexity const handleWindowPointerMove = (e: PointerEvent) => { const drag = draggedClipRef.current; const resize = resizingClipRef.current; @@ -434,6 +445,7 @@ export function useTimelineClipDrag({ syncClipDragAutoScrollRef.current(e.clientX, e.clientY); }; + // fallow-ignore-next-line complexity const handleWindowPointerUp = () => { stopClipDragAutoScrollRef.current(); @@ -492,24 +504,30 @@ export function useTimelineClipDrag({ suppressClickRef.current = true; clearSuppressedClick(); + const hasStackingReorder = + drag.previewStackingReorder != null && + drag.previewStackingReorder.fromIndex !== drag.previewStackingReorder.toIndex; const hasChanged = - drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track; + drag.previewStart !== drag.element.start || + drag.previewTrack !== drag.element.track || + hasStackingReorder; if (!hasChanged) return; updateElement(drag.element.key ?? drag.element.id, { start: drag.previewStart, - track: drag.previewTrack, + ...(hasStackingReorder ? {} : { track: drag.previewTrack }), }); Promise.resolve( onMoveElementRef.current?.(drag.element, { start: drag.previewStart, track: drag.previewTrack, + stackingReorder: drag.previewStackingReorder, }), ).catch((error) => { updateElement(drag.element.key ?? drag.element.id, { start: drag.element.start, - track: drag.element.track, + ...(hasStackingReorder ? {} : { track: drag.element.track }), }); console.error("[Timeline] Failed to persist clip move", error); }); diff --git a/packages/studio/src/player/lib/layerOrdering.ts b/packages/studio/src/player/lib/layerOrdering.ts index f016b11bca..6292dd9c24 100644 --- a/packages/studio/src/player/lib/layerOrdering.ts +++ b/packages/studio/src/player/lib/layerOrdering.ts @@ -41,8 +41,7 @@ export function computeReorderZValues( return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted; } -// Exported in a later unit when the timeline consumes it; internal-only for now. -function resolveStackingContextKey(item: StackingContextDescriptor): string { +export function resolveStackingContextKey(item: StackingContextDescriptor): string { return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? ""; } From 5ce362299c72f7d188adca608ff907b2f9e5eb73 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 23:46:50 -0400 Subject: [PATCH 04/23] refactor(studio): single-source the timeline stacking key + guard audio reorder The element stacking key (element.key ?? id) was recomputed in four places (reorder-intent generation, row ordering, the commit-time sibling lookup via a threaded keyOf param, and resolveTimelineMove). Any drift would silently break the sibling lookup and no-op the reorder. Route all of them through the existing getTimelineElementIdentity owner, share one toStackingOrderItem mapper between row ordering and reorder intent, and drop the keyOf parameter. Also enforce the audio side-effect invariant in the single mutation owner (applyTimelineStackingReorder): dragging an audio clip has no visual layer to restack, so it never writes z-index. Covered by a new hook test. --- .../src/hooks/timelineEditingHelpers.ts | 16 ++++++--- .../src/hooks/useTimelineEditing.test.tsx | 32 +++++++++++++++-- .../studio/src/hooks/useTimelineEditing.ts | 1 - .../src/player/components/timelineEditing.ts | 14 ++++---- .../player/components/timelineTrackOrder.ts | 36 ++++++------------- .../src/player/lib/timelineElementHelpers.ts | 2 +- 6 files changed, 60 insertions(+), 41 deletions(-) diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index de0619427a..3f81db5651 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -6,6 +6,7 @@ import { type TimelineStackingReorderIntent, } from "../player/components/timelineEditing"; import { computeReorderZValues, getElementZIndex } from "../player/lib/layerOrdering"; +import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -19,9 +20,10 @@ function isHTMLElement(element: Element | null): element is HTMLElement { * Resolve a timeline vertical move to a z-index stacking reorder and commit it * through the shared layers-panel reorder path. Reads live sibling z-index from * the preview DOM, remaps with the dup-preserving reorder math, and writes only - * z-index (never data-track-index). No-op when the move isn't a reorder or the - * live siblings can't be resolved. Extracted from StudioApp's timeline hook to - * keep it under the studio 600-LOC cap. + * z-index (never data-track-index). No-op when the move isn't a reorder, the + * dragged clip is audio (no visual layer to restack), or the live siblings can't + * be resolved. Extracted from StudioApp's timeline hook to keep it under the + * studio 600-LOC cap. */ export function applyTimelineStackingReorder(input: { element: TimelineElement; @@ -31,8 +33,10 @@ export function applyTimelineStackingReorder(input: { iframe: HTMLIFrameElement | null; activeCompPath: string | null; commit: TimelineZIndexReorderCommit | null | undefined; - keyOf: (element: TimelineElement) => string; }): void { + // Audio has no visual stacking; a vertical drag on it must never write z-index. + if (input.element.tag === "audio") return; + const intent = input.stackingReorder ?? (input.targetTrack !== input.element.track @@ -44,7 +48,9 @@ export function applyTimelineStackingReorder(input: { : null); if (intent == null || intent.fromIndex === intent.toIndex) return; - const siblingByKey = new Map(input.timelineElements.map((el) => [input.keyOf(el), el])); + const siblingByKey = new Map( + input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]), + ); const orderedSiblings = intent.siblingKeys .map((key) => siblingByKey.get(key) ?? null) .filter((sibling): sibling is TimelineElement => sibling != null); diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index b427e97065..3990efbcbb 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -49,12 +49,17 @@ function createPreviewIframe( return iframe; } -function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement { +function timelineElement(input: { + id: string; + track: number; + zIndex: number; + tag?: string; +}): TimelineElement { return { id: input.id, domId: input.id, hfId: `hf-${input.id}`, - tag: "div", + tag: input.tag ?? "div", start: 0, duration: 2, track: input.track, @@ -235,6 +240,29 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); + it("never writes z-index when the dragged clip is audio (no visual layer)", async () => { + const iframe = createPreviewIframe([ + { id: "front", track: 0 }, + { id: "music", track: 1 }, + ]); + const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); + const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" }); + const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [front, music], + iframe, + onZIndexCommit: commit, + }); + + await act(async () => { + await move(music, { start: music.start, track: front.track }); + }); + + expect(commit).not.toHaveBeenCalled(); + + unmount(); + }); + it("remaps distinct z-index values onto the reordered sibling group", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 10" }, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 590e0e055d..fed47ffbc5 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -140,7 +140,6 @@ export function useTimelineEditing({ iframe: previewIframeRef.current, activeCompPath, commit: handleDomZIndexReorderCommitRef?.current, - keyOf: (el) => el.key ?? el.id, }); if (!startChanged) return; diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 0eee5b91c4..16f6ce7a8f 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -1,6 +1,7 @@ import { formatTime } from "../lib/time"; import { roundToCenti } from "../../utils/rounding"; import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; const roundToCentiseconds = roundToCenti; @@ -32,7 +33,7 @@ export interface TimelineStackingReorderIntent { siblingKeys: string[]; } -interface TimelineStackingOrderItem { +export interface TimelineStackingOrderItem { key: string; track: number; zIndex: number; @@ -41,9 +42,9 @@ interface TimelineStackingOrderItem { compositionAncestors: readonly string[]; } -function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem { +export function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem { return { - key: element.key ?? element.id, + key: getTimelineElementIdentity(element), track: element.track, zIndex: element.zIndex ?? 0, stackingContextId: element.stackingContextId ?? null, @@ -77,8 +78,9 @@ export function resolveTimelineStackingReorderByTargetTrack(args: { }): TimelineStackingReorderIntent | null { const orderedSiblings = resolveContextSiblings(args.element, args.elements); if (orderedSiblings.length <= 1) return null; - const draggedKey = args.element.key ?? args.element.id; - const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey); + const fromIndex = orderedSiblings.findIndex( + (sibling) => sibling.key === getTimelineElementIdentity(args.element), + ); if (fromIndex < 0) return null; const toIndex = orderedSiblings.findIndex((sibling) => sibling.track === args.targetTrack); if (toIndex < 0) return null; @@ -178,7 +180,7 @@ export function resolveTimelineMove( // stacking context (top = front), rather than changing the raw track number. if (input.stackingElement && input.stackingElements) { const orderedSiblings = resolveContextSiblings(input.stackingElement, input.stackingElements); - const draggedKey = input.stackingElement.key ?? input.stackingElement.id; + const draggedKey = getTimelineElementIdentity(input.stackingElement); const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey); if (fromIndex >= 0 && orderedSiblings.length > 1) { const toIndex = clamp(fromIndex + deltaTrack, 0, orderedSiblings.length - 1); diff --git a/packages/studio/src/player/components/timelineTrackOrder.ts b/packages/studio/src/player/components/timelineTrackOrder.ts index dc4c151e40..f678e35ebe 100644 --- a/packages/studio/src/player/components/timelineTrackOrder.ts +++ b/packages/studio/src/player/components/timelineTrackOrder.ts @@ -1,39 +1,23 @@ import { type TimelineElement } from "../store/playerStore"; -import { - resolveContextOrder, - resolveStackingContextKey, - type ContextOrderItem, -} from "../lib/layerOrdering"; +import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineEditing"; /** * Pure timeline track-ordering logic. Timeline rows are ordered by scoped * stacking (z-index per stacking context, top = front), with data-track-index * used only to split time-overlapping clips of equal rank onto separate rows. * Extracted from Timeline.tsx to keep the component under the studio 600-LOC cap. + * + * Key derivation and stacking-descriptor mapping are owned by timelineEditing so + * the row order here and the reorder intent there interpret every element the + * same way. */ -interface TimelineTrackOrderItem extends ContextOrderItem { - key: string; - track: number; - start: number; - duration: number; -} - -function getTimelineElementKey(element: TimelineElement): string { - return element.key ?? element.id; -} +type TimelineTrackOrderItem = TimelineStackingOrderItem & { start: number; duration: number }; function toTimelineTrackOrderItem(element: TimelineElement): TimelineTrackOrderItem { - return { - key: getTimelineElementKey(element), - track: element.track, - start: element.start, - duration: element.duration, - zIndex: element.zIndex ?? 0, - stackingContextId: element.stackingContextId ?? null, - parentCompositionId: element.parentCompositionId ?? null, - compositionAncestors: element.compositionAncestors ?? [], - }; + return { ...toStackingOrderItem(element), start: element.start, duration: element.duration }; } function timelineElementsOverlap( @@ -51,7 +35,7 @@ function trackFrontOrderIndex( for (const element of elements) { orderIndex = Math.min( orderIndex, - orderIndexByKey.get(getTimelineElementKey(element)) ?? Number.POSITIVE_INFINITY, + orderIndexByKey.get(getTimelineElementIdentity(element)) ?? Number.POSITIVE_INFINITY, ); } return orderIndex; diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index 74780f429e..f582530821 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -245,7 +245,7 @@ export function buildTimelineElementIdentity(params: { return { id, key }; } -export function getTimelineElementIdentity(element: TimelineElement): string { +export function getTimelineElementIdentity(element: { key?: string | null; id: string }): string { return element.key ?? element.id; } From 03f8089e523980105f22b31161628b75c521ed82 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 00:38:52 -0400 Subject: [PATCH 05/23] fix(studio): capture effective (computed) z-index for timeline ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createTimelineElementFromManifestClip used `clip.zIndex ?? computed`, but the runtime reports inline-only z-index (0 for CSS-rule authored z-index), and `0 ?? x` keeps the 0 — so every CSS-styled clip collapsed to a z=0 tie. The timeline then ordered rows by DOM position instead of true stacking, and the first vertical drag renumbered the whole tie group, clobbering the author's z-index. Prefer the effective computed read from the live element (the same read the reorder commit uses); fall back to the runtime value only when the element isn't live. --- .../studio/src/player/lib/timelineDOM.test.ts | 34 +++++++++++++++++++ packages/studio/src/player/lib/timelineDOM.ts | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index 378462459b..7d29b70e5c 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -102,6 +102,40 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => { expect(element.hidden).toBe(true); }); + + it("captures the effective z-index from the live element, not the runtime inline-only value", () => { + // The runtime reports inline-only z-index (0 for CSS-rule authored z-index), + // which must NOT override the live element's effective z-index — otherwise + // the timeline collapses every CSS-styled clip to a z=0 tie and mis-orders. + const doc = makeDoc(` +
+
+
+ `); + const hostEl = doc.getElementById("hero"); + + const element = createTimelineElementFromManifestClip({ + clip: { + id: "hero", + label: "Hero", + kind: "element", + tagName: "div", + start: 0, + duration: 5, + track: 0, + zIndex: 0, + compositionId: null, + parentCompositionId: null, + compositionSrc: null, + assetUrl: null, + }, + fallbackIndex: 0, + doc, + hostEl, + }); + + expect(element.zIndex).toBe(30); + }); }); describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => { diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index a90faec9d5..9689310979 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -155,7 +155,11 @@ export function createTimelineElementFromManifestClip(params: { start: clip.start, duration: clip.duration, track: clip.track, - zIndex: clip.zIndex ?? getTimelineElementZIndex(hostEl), + // Prefer the effective (computed) z-index read from the live element — the + // same read the reorder commit uses — so CSS-rule z-index (not just inline) + // is captured. clip.zIndex from the runtime is inline-only (0 for CSS rules), + // so it can only serve as a fallback when the element isn't live. + zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0, stackingContextId, parentCompositionId, compositionAncestors, From e5ed5125293c0d85aae5db5482563f8dfe44ffa4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 12:42:36 -0400 Subject: [PATCH 06/23] feat(studio): timeline track = stacking layer (NLE-style layering) Re-architect the timeline row model from data-track-index rows to stacking layers. Rows represent stacking layers per context: explicit-z clips merge onto one track when they share a z and don't overlap in time; auto-z clips stay one row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a stable layer id, not data-track-index. Vertical drag always writes z-index, never track: drop onto a layer joins it (same z), between layers interpolates a new z, past the ends creates a new front/back layer. data-track-index is never rewritten; #958 holds. Adds hasExplicitZIndex capture (computed z != auto). L1: hasExplicitZIndex on the element model L2: buildStackingTimelineLayers (layer-based rows) L3: layer-aware vertical drag (join / interpolate / new-extreme) --- packages/studio/src/App.tsx | 1 + .../src/components/StudioPreviewArea.tsx | 4 + .../src/contexts/TimelineEditContext.tsx | 1 + .../src/hooks/timelineEditingHelpers.ts | 73 +++-- .../src/hooks/useTimelineEditing.test.tsx | 80 +++-- .../src/player/components/Timeline.test.ts | 95 ------ .../studio/src/player/components/Timeline.tsx | 27 +- .../src/player/components/TimelineCanvas.tsx | 48 ++- .../player/components/timelineCallbacks.ts | 1 + .../src/player/components/timelineDragDrop.ts | 22 +- .../player/components/timelineEditing.test.ts | 79 +++-- .../src/player/components/timelineEditing.ts | 141 +++------ .../components/timelineLayerDrag.test.ts | 102 +++++++ .../player/components/timelineLayerDrag.ts | 287 ++++++++++++++++++ .../src/player/components/timelineStacking.ts | 49 +++ .../components/timelineTrackOrder.test.ts | 121 ++++++++ .../player/components/timelineTrackOrder.ts | 221 +++++++++----- .../components/useTimelineClipDrag.test.tsx | 15 +- .../player/components/useTimelineClipDrag.ts | 27 +- .../player/hooks/useTimelinePlayer.test.ts | 5 +- .../studio/src/player/lib/layerOrdering.ts | 14 + .../studio/src/player/lib/timelineDOM.test.ts | 69 +++++ packages/studio/src/player/lib/timelineDOM.ts | 21 +- .../studio/src/player/store/playerStore.ts | 7 +- 24 files changed, 1081 insertions(+), 429 deletions(-) create mode 100644 packages/studio/src/player/components/timelineLayerDrag.test.ts create mode 100644 packages/studio/src/player/components/timelineLayerDrag.ts create mode 100644 packages/studio/src/player/components/timelineStacking.ts create mode 100644 packages/studio/src/player/components/timelineTrackOrder.test.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1de3f3d2f4..b82f59b79b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -525,6 +525,7 @@ export function StudioApp() { handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden} + handleToggleElementHidden={timelineEditing.handleToggleElementHidden} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} handleRazorSplit={timelineEditing.handleRazorSplit} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 69fb581509..66c6c31b30 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -59,6 +59,7 @@ export interface StudioPreviewAreaProps { updates: Pick, ) => Promise | void; handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; + handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; @@ -85,6 +86,7 @@ export function StudioPreviewArea({ handleTimelineElementMove, handleTimelineElementResize, handleToggleTrackHidden, + handleToggleElementHidden, handleBlockedTimelineEdit, handleTimelineElementSplit, handleRazorSplit, @@ -182,6 +184,7 @@ export function StudioPreviewArea({ onMoveElement: handleTimelineElementMove, onResizeElement: handleTimelineElementResize, onToggleTrackHidden: handleToggleTrackHidden, + onToggleElementHidden: handleToggleElementHidden, onBlockedEditAttempt: handleBlockedTimelineEdit, onSplitElement: handleTimelineElementSplit, onRazorSplit: handleRazorSplit, @@ -282,6 +285,7 @@ export function StudioPreviewArea({ handleTimelineElementMove, handleTimelineElementResize, handleToggleTrackHidden, + handleToggleElementHidden, handleBlockedTimelineEdit, handleTimelineElementSplit, handleRazorSplit, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index b64be14800..7f075d7838 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -33,6 +33,7 @@ export function TimelineEditProvider({ value.onMoveElement, value.onResizeElement, value.onToggleTrackHidden, + value.onToggleElementHidden, value.onBlockedEditAttempt, value.onSplitElement, value.onRazorSplit, diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 3f81db5651..6e14c32b50 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -2,10 +2,9 @@ import { type TimelineElement, usePlayerStore } from "../player/store/playerStor import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; import { formatTimelineAttributeNumber, - resolveTimelineStackingReorderByTargetTrack, type TimelineStackingReorderIntent, } from "../player/components/timelineEditing"; -import { computeReorderZValues, getElementZIndex } from "../player/lib/layerOrdering"; +import { getElementZIndex } from "../player/lib/layerOrdering"; import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; @@ -37,49 +36,45 @@ export function applyTimelineStackingReorder(input: { // Audio has no visual stacking; a vertical drag on it must never write z-index. if (input.element.tag === "audio") return; - const intent = - input.stackingReorder ?? - (input.targetTrack !== input.element.track - ? resolveTimelineStackingReorderByTargetTrack({ - element: input.element, - elements: input.timelineElements, - targetTrack: input.targetTrack, - }) - : null); - if (intent == null || intent.fromIndex === intent.toIndex) return; + const intent = input.stackingReorder ?? null; + if (intent == null || intent.zIndexChanges.length === 0) return; const siblingByKey = new Map( input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]), ); - const orderedSiblings = intent.siblingKeys - .map((key) => siblingByKey.get(key) ?? null) - .filter((sibling): sibling is TimelineElement => sibling != null); - if (orderedSiblings.length !== intent.siblingKeys.length) return; + const commitEntries: Array<{ + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; + key: string; + }> = []; - const liveEntries = orderedSiblings - .map((sibling) => ({ sibling, element: findTimelineElementInIframe(input.iframe, sibling) })) - .filter((entry): entry is { sibling: TimelineElement; element: HTMLElement } => - isHTMLElement(entry.element), - ); - if (liveEntries.length !== orderedSiblings.length) return; - - const reordered = [...liveEntries]; - const [moved] = reordered.splice(intent.fromIndex, 1); - if (!moved) return; - reordered.splice(intent.toIndex, 0, moved); + for (const change of intent.zIndexChanges) { + const sibling = siblingByKey.get(change.key); + if (!sibling) return; + const element = findTimelineElementInIframe(input.iframe, sibling); + if (!isHTMLElement(element)) return; + if (getElementZIndex(element) === change.zIndex) continue; + commitEntries.push({ + element, + zIndex: change.zIndex, + id: sibling.domId ?? sibling.id, + selector: sibling.selector, + selectorIndex: sibling.selectorIndex, + sourceFile: sibling.sourceFile || input.activeCompPath || "index.html", + key: getTimelineElementIdentity(sibling), + }); + } - const existingValues = liveEntries.map((entry) => getElementZIndex(entry.element)); - const zValues = computeReorderZValues(existingValues, intent.fromIndex, intent.toIndex); - input.commit?.( - reordered.map((entry, index) => ({ - element: entry.element, - zIndex: zValues[index] ?? 0, - id: entry.sibling.domId ?? entry.sibling.id, - selector: entry.sibling.selector, - selectorIndex: entry.sibling.selectorIndex, - sourceFile: entry.sibling.sourceFile || input.activeCompPath || "index.html", - })), - ); + if (commitEntries.length === 0) return; + input.commit?.(commitEntries); + const store = usePlayerStore.getState(); + for (const entry of commitEntries) { + store.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true }); + } } /** diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index 3990efbcbb..b26460fcb9 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -208,22 +208,28 @@ async function flushAsyncWork(): Promise { describe("useTimelineEditing timeline z-index reorder", () => { it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { const iframe = createPreviewIframe([ - { id: "front", track: 0 }, - { id: "middle", track: 1 }, - { id: "back", track: 2 }, + { id: "front", track: 0, style: "position: relative; z-index: 10" }, + { id: "back", track: 2, style: "position: relative; z-index: 1" }, ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); - const middle = timelineElement({ id: "middle", track: 1, zIndex: 0 }); - const back = timelineElement({ id: "back", track: 2, zIndex: 0 }); + const front = timelineElement({ id: "front", track: 0, zIndex: 10 }); + const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [front, middle, back], + timelineElements: [front, back], iframe, onZIndexCommit: commit, }); await act(async () => { - await move(back, { start: back.start, track: front.track }); + await move(back, { + start: back.start, + track: back.track, + stackingReorder: { + contextKey: "root", + placement: { type: "onto", layerId: "layer-front" }, + zIndexChanges: [{ key: "back", zIndex: 10 }], + }, + }); }); const doc = iframe.contentDocument; @@ -231,9 +237,7 @@ describe("useTimelineEditing timeline z-index reorder", () => { expect(commit).toHaveBeenCalledTimes(1); expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ - ["back", 3], - ["front", 2], - ["middle", 1], + ["back", 10], ]); expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2"); @@ -255,7 +259,15 @@ describe("useTimelineEditing timeline z-index reorder", () => { }); await act(async () => { - await move(music, { start: music.start, track: front.track }); + await move(music, { + start: music.start, + track: music.track, + stackingReorder: { + contextKey: "root", + placement: { type: "onto", layerId: "layer-front" }, + zIndexChanges: [{ key: "music", zIndex: 2 }], + }, + }); }); expect(commit).not.toHaveBeenCalled(); @@ -263,30 +275,40 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); - it("remaps distinct z-index values onto the reordered sibling group", async () => { + it("commits only the minimum z-index changes resolved by the timeline drag", async () => { const iframe = createPreviewIframe([ - { id: "front", track: 0, style: "position: relative; z-index: 10" }, - { id: "middle", track: 1, style: "position: relative; z-index: 5" }, - { id: "back", track: 2, style: "position: relative; z-index: 1" }, + { id: "front", track: 0, style: "position: relative; z-index: 2" }, + { id: "back", track: 1, style: "position: relative; z-index: 1" }, + { id: "dragged", track: 2, style: "position: relative; z-index: 0" }, ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 10 }); - const middle = timelineElement({ id: "middle", track: 1, zIndex: 5 }); - const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); + const front = timelineElement({ id: "front", track: 0, zIndex: 2 }); + const back = timelineElement({ id: "back", track: 1, zIndex: 1 }); + const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 }); const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [front, middle, back], + timelineElements: [front, back, dragged], iframe, onZIndexCommit: commit, }); await act(async () => { - await move(back, { start: back.start, track: front.track }); + await move(dragged, { + start: dragged.start, + track: dragged.track, + stackingReorder: { + contextKey: "root", + placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" }, + zIndexChanges: [ + { key: "dragged", zIndex: 2 }, + { key: "front", zIndex: 3 }, + ], + }, + }); }); expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ - ["back", 10], - ["front", 5], - ["middle", 1], + ["dragged", 2], + ["front", 3], ]); unmount(); @@ -307,7 +329,15 @@ describe("useTimelineEditing timeline z-index reorder", () => { }); await act(async () => { - await move(back, { start: back.start, track: front.track }); + await move(back, { + start: back.start, + track: back.track, + stackingReorder: { + contextKey: "root", + placement: { type: "above", layerId: "front" }, + zIndexChanges: [{ key: "back", zIndex: 2 }], + }, + }); await flushAsyncWork(); }); diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index ea70dc90f7..f77948fd94 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -18,11 +18,9 @@ import { shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, } from "./Timeline"; -import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder"; import { RULER_H, TRACK_H } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; -import type { TimelineElement } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -203,99 +201,6 @@ describe("Timeline provider boundary", () => { }); }); -function rowElement(input: { - id: string; - track: number; - zIndex?: number; - start?: number; - duration?: number; - stackingContextId?: string | null; - parentCompositionId?: string | null; - compositionAncestors?: string[]; -}): TimelineElement { - return { - id: input.id, - tag: "div", - start: input.start ?? 0, - duration: input.duration ?? 1, - track: input.track, - zIndex: input.zIndex ?? 0, - stackingContextId: input.stackingContextId ?? "root", - parentCompositionId: input.parentCompositionId ?? null, - compositionAncestors: input.compositionAncestors ?? ["root"], - }; -} - -describe("buildStackingTimelineTracks", () => { - it("keeps no-track-index clips in DOM order when stacking ties", () => { - const tracks = buildStackingTimelineTracks([ - rowElement({ id: "a", track: 0 }), - rowElement({ id: "b", track: 1 }), - rowElement({ id: "c", track: 2 }), - ]); - - expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]); - }); - - it("orders authored track-index rows by stacking order instead of numeric track order", () => { - const tracks = buildStackingTimelineTracks([ - rowElement({ id: "dom-first", track: 2 }), - rowElement({ id: "dom-second", track: 0 }), - ]); - - expect(tracks.map(([track]) => track)).toEqual([2, 0]); - }); - - it("renders explicit z-index rows top-to-front by descending z-index", () => { - const tracks = buildStackingTimelineTracks([ - rowElement({ id: "back", track: 0, zIndex: 1 }), - rowElement({ id: "front", track: 1, zIndex: 10 }), - rowElement({ id: "middle", track: 2, zIndex: 5 }), - ]); - - expect(tracks.map(([track]) => track)).toEqual([1, 2, 0]); - }); - - it("keeps nested sub-composition clips scoped below parent-level clips", () => { - const tracks = buildStackingTimelineTracks([ - rowElement({ id: "root-low", track: 1, zIndex: 1 }), - rowElement({ - id: "nested-high", - track: 0, - zIndex: 100, - stackingContextId: "scene", - parentCompositionId: "scene", - compositionAncestors: ["root", "scene"], - }), - rowElement({ id: "root-front", track: 2, zIndex: 2 }), - ]); - - expect(tracks.map(([track]) => track)).toEqual([2, 1, 0]); - }); - - it("keeps time-overlapping equal-rank clips on separate literal-track rows", () => { - const tracks = buildStackingTimelineTracks([ - rowElement({ id: "first", track: 0, start: 0, duration: 2 }), - rowElement({ id: "second", track: 1, start: 1, duration: 2 }), - ]); - - expect(tracks).toHaveLength(2); - expect( - tracks.map(([track, elements]) => [track, elements.map((element) => element.id)]), - ).toEqual([ - [0, ["first"]], - [1, ["second"]], - ]); - }); -}); - -describe("insertPreviewTrackOrder", () => { - it("preserves top and bottom drag-preview row insertion without numeric resorting", () => { - expect(insertPreviewTrackOrder([5, 2, 0], -1)).toEqual([-1, 5, 2, 0]); - expect(insertPreviewTrackOrder([5, 2, 0], 6)).toEqual([5, 2, 0, 6]); - }); -}); - describe("generateTicks", () => { it("returns empty arrays for duration <= 0", () => { expect(generateTicks(0)).toEqual({ major: [], minor: [] }); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 9e46e09320..8a2c526e75 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -23,7 +23,7 @@ import { import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { ClipContextMenu } from "./ClipContextMenu"; import { TimelineShortcutHint } from "./TimelineShortcutHint"; -import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder"; +import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder"; import { GUTTER, generateTicks, @@ -187,19 +187,24 @@ export const Timeline = memo(function Timeline({ return Number.isFinite(result) ? result : safeDur; }, [rawElements, duration]); - const tracks = useMemo(() => buildStackingTimelineTracks(expandedElements), [expandedElements]); + const tracks = useMemo( + () => buildStackingTimelineLayers(expandedElements).rows, + [expandedElements], + ); const trackStyles = useMemo(() => { - const map = new Map(); - for (const [trackNum, els] of tracks) { - map.set(trackNum, getTrackStyle(els[0]?.tag ?? "")); + const map = new Map(); + for (const layer of tracks) { + map.set(layer.id, getTrackStyle(layer.elements[0]?.tag ?? "")); } return map; }, [tracks]); - const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]); + const trackOrder = useMemo(() => tracks.map((layer) => layer.id), [tracks]); const trackOrderRef = useRef(trackOrder); trackOrderRef.current = trackOrder; + const timelineLayersRef = useRef(tracks); + timelineLayersRef.current = tracks; const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; @@ -223,6 +228,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + timelineLayersRef, timelineElementsRef: expandedElementsRef, onMoveElement, onResizeElement, @@ -235,10 +241,14 @@ export const Timeline = memo(function Timeline({ if ( !draggedClip?.started || trackOrder.length === 0 || - trackOrder.includes(draggedClip.previewTrack) + trackOrder.includes(draggedClip.previewLayerId) ) return trackOrder; - return insertPreviewTrackOrder(trackOrder, draggedClip.previewTrack); + return insertPreviewTrackOrder( + trackOrder, + draggedClip.previewLayerId, + draggedClip.previewLayerIndex, + ); }, [draggedClip, trackOrder]); const totalH = getTimelineCanvasHeight(displayTrackOrder.length); @@ -369,6 +379,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + timelineLayersRef, onFileDrop, onAssetDrop, onBlockDrop, diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index ded4b386f4..3d87f7580d 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -20,6 +20,7 @@ import { } from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TrackVisualStyle } from "./timelineIcons"; +import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; @@ -47,10 +48,10 @@ interface TimelineCanvasProps { majorTickInterval: number; rangeSelection: TimelineRangeSelection | null; theme: TimelineTheme; - displayTrackOrder: number[]; - trackOrder: number[]; - tracks: [number, TimelineElement[]][]; - trackStyles: Map; + displayTrackOrder: TimelineLayerId[]; + trackOrder: TimelineLayerId[]; + tracks: StackingTimelineLayer[]; + trackStyles: Map; selectedElementId: string | null; hoveredClip: string | null; draggedClip: DraggedClipState | null; @@ -141,8 +142,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onContextMenuClip, beatAnalysis, }: TimelineCanvasProps) { - const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } = - useTimelineEditContextOptional(); + const { + onResizeElement, + onMoveElement, + onToggleTrackHidden, + onToggleElementHidden, + onRazorSplit, + onRazorSplitAll, + } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -198,13 +205,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ { // fallow-ignore-next-line complexity - displayTrackOrder.map((trackNum) => { - const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; - const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); + displayTrackOrder.map((layerId, rowIndex) => { + const layer = tracks.find((item) => item.id === layerId) ?? null; + const els = layer?.elements ?? []; + const ts = trackStyles.get(layerId) ?? getTrackStyle(""); const isPendingTrack = - draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; - const rowBackground = - displayTrackOrder.indexOf(trackNum) % 2 === 0 ? theme.rowBackground : "#0D0E12"; + draggedClip?.started === true && !trackOrder.includes(layerId) && els.length === 0; + const rowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12"; + const rowTrack = layer?.placementTrack ?? els[0]?.track ?? 0; // 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. @@ -216,7 +224,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); return (
-
-
- {/* Faint beat lines in every track's background (behind the clips); + {dropIndicator?.kind === "line" && ( + + )} + {/* 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; - 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); - return ( - { - 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={(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, - 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; + {/* 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; + 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); + return ( + { + 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={(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); - setDraggedClip({ + setResizingClip({ element: el, + edge, 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, - previewLayerId: layerId, - previewLayerIndex: rowIndex, - previewStackingReorder: null, - snapBeatTime: null, + previewDuration: el.duration, + previewPlaybackStart: el.playbackStart, started: false, }); - syncClipDragAutoScroll(e.clientX, e.clientY); - } - } - onClick={(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); + }} + 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, + previewLayerId: layerId, + previewLayerIndex: rowIndex, + previewStackingReorder: null, + snapBeatTime: null, + started: false, + }); + syncClipDragAutoScroll(e.clientX, e.clientY); } - return; } - const nextElement = isSelected ? null : el; - setSelectedElementId(nextElement ? elementKey : null); - onSelectElement?.(nextElement); - }} - onDoubleClick={(e) => { - e.stopPropagation(); - if (suppressClickRef.current) return; - if (isComposition && onDrillDown) onDrillDown(el); - }} - > - {renderClipChildren(previewElement, clipStyle)} - {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( - 0 - ? ((currentTime - previewElement.start) / previewElement.duration) * - 100 - : 0 + onClick={(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; } - elementId={elementKey} - selectedKeyframes={selectedKeyframes} - onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} - onShiftClickKeyframe={onShiftClickKeyframe} - onContextMenuKeyframe={onContextMenuKeyframe} - onMoveKeyframe={onMoveKeyframe} - suppressClickRef={suppressClickRef} - /> - )} - - ); - }) - } + const nextElement = isSelected ? null : el; + setSelectedElementId(nextElement ? elementKey : null); + onSelectElement?.(nextElement); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + if (suppressClickRef.current) return; + if (isComposition && onDrillDown) onDrillDown(el); + }} + > + {renderClipChildren(previewElement, clipStyle)} + {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} + /> + )} +
+ ); + }) + } +
- + ); }) } - {/* Drag ghost */} {activeDraggedElement && activeDraggedPosition && ( -
- {}} - onHoverEnd={() => {}} - onResizeStart={() => {}} - onClick={() => {}} - onDoubleClick={() => {}} - > - {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))} - -
+ {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))} + )} {/* Range highlight */} diff --git a/packages/studio/src/player/components/TimelineDragGhost.tsx b/packages/studio/src/player/components/TimelineDragGhost.tsx new file mode 100644 index 0000000000..771bddb65d --- /dev/null +++ b/packages/studio/src/player/components/TimelineDragGhost.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { TimelineClip } from "./TimelineClip"; +import { getTimelineEditCapabilities } from "./timelineEditing"; +import { CLIP_Y, TRACK_H } from "./timelineLayout"; +import type { TimelineTheme } from "./timelineTheme"; +import type { TimelineElement } from "../store/playerStore"; + +interface TimelineDragGhostProps { + element: TimelineElement; + position: { left: number; top: number }; + pps: number; + selectedElementId: string | null; + hasCustomContent: boolean; + theme: TimelineTheme; + children: ReactNode; +} + +export function TimelineDragGhost({ + element, + position, + pps, + selectedElementId, + hasCustomContent, + theme, + children, +}: TimelineDragGhostProps) { + return ( +
+ {}} + onHoverEnd={() => {}} + onResizeStart={() => {}} + onClick={() => {}} + onDoubleClick={() => {}} + > + {children} + +
+ ); +} diff --git a/packages/studio/src/player/components/TimelineDropInsertionLine.tsx b/packages/studio/src/player/components/TimelineDropInsertionLine.tsx new file mode 100644 index 0000000000..8ca3414ea5 --- /dev/null +++ b/packages/studio/src/player/components/TimelineDropInsertionLine.tsx @@ -0,0 +1,32 @@ +interface TimelineDropInsertionLineProps { + edge: "top" | "bottom"; + accentColor: string; +} + +export function TimelineDropInsertionLine({ edge, accentColor }: TimelineDropInsertionLineProps) { + return ( +
+ +
+ ); +} diff --git a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx new file mode 100644 index 0000000000..6f1a40753b --- /dev/null +++ b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx @@ -0,0 +1,93 @@ +import type { TimelineTheme } from "./timelineTheme"; +import { GUTTER } from "./timelineLayout"; +import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; + +const TIMELINE_LAYER_GROUP_HEADER_H = 18; + +export function shouldShowTimelineLayerGroupHeader( + contextKey: string, + previousContextKey: string, +): boolean { + return contextKey !== "" && contextKey !== previousContextKey; +} + +export function getTimelineLayerGroupHeaderTotalHeight( + layerOrder: readonly TimelineLayerId[], + layers: readonly StackingTimelineLayer[], +): number { + const layerById = new Map(); + for (const layer of layers) layerById.set(layer.id, layer); + + let previousContextKey = ""; + let count = 0; + for (const layerId of layerOrder) { + const contextKey = layerById.get(layerId)?.contextKey ?? ""; + if (shouldShowTimelineLayerGroupHeader(contextKey, previousContextKey)) count += 1; + previousContextKey = contextKey; + } + return count * TIMELINE_LAYER_GROUP_HEADER_H; +} + +interface TimelineLayerGroupHeaderProps { + contextKey: string; + trackContentWidth: number; + theme: TimelineTheme; + accentColor: string; +} + +export function TimelineLayerGroupHeader({ + contextKey, + trackContentWidth, + theme, + accentColor, +}: TimelineLayerGroupHeaderProps) { + return ( +
+
+
+ +
+
+ + Inside: {contextKey} + +
+
+
+ ); +} diff --git a/packages/studio/src/player/components/TimelineLayerGutter.tsx b/packages/studio/src/player/components/TimelineLayerGutter.tsx new file mode 100644 index 0000000000..435df303fd --- /dev/null +++ b/packages/studio/src/player/components/TimelineLayerGutter.tsx @@ -0,0 +1,61 @@ +import { Eye, EyeSlash } from "@phosphor-icons/react"; +import { Music } from "../../icons/SystemIcons"; +import type { TimelineTheme } from "./timelineTheme"; +import { GUTTER } from "./timelineLayout"; + +interface TimelineLayerGutterProps { + isAudio: boolean; + isTrackHidden: boolean; + rowTrack: number; + theme: TimelineTheme; + onToggleHidden: () => void; +} + +export function TimelineLayerGutter({ + isAudio, + isTrackHidden, + rowTrack, + theme, + onToggleHidden, +}: TimelineLayerGutterProps) { + return ( +
+ {isAudio && ( +
+ ); +} diff --git a/packages/studio/src/player/components/timelineDropIndicator.test.ts b/packages/studio/src/player/components/timelineDropIndicator.test.ts new file mode 100644 index 0000000000..48b98a7871 --- /dev/null +++ b/packages/studio/src/player/components/timelineDropIndicator.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineLayerDropPlacement } from "./timelineStacking"; +import { resolveTimelineDropIndicator } from "./timelineDropIndicator"; + +const layerOrder = ["front", "middle", "back"]; + +describe("resolveTimelineDropIndicator", () => { + it("highlights only the row targeted by an onto placement", () => { + const placement: TimelineLayerDropPlacement = { type: "onto", layerId: "middle" }; + + expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toBeNull(); + expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toEqual({ + kind: "onto", + }); + }); + + it("renders a single insertion line at the bottom edge of the before row", () => { + const placement: TimelineLayerDropPlacement = { + type: "between", + beforeLayerId: "front", + afterLayerId: "middle", + }; + + expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({ + kind: "line", + edge: "bottom", + }); + expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toBeNull(); + }); + + it("renders above placements on the target row top edge", () => { + const placement: TimelineLayerDropPlacement = { type: "above", layerId: "front" }; + + expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({ + kind: "line", + edge: "top", + }); + }); + + it("renders below placements on the target row bottom edge", () => { + const placement: TimelineLayerDropPlacement = { type: "below", layerId: "back" }; + + expect(resolveTimelineDropIndicator({ placement, layerId: "back", layerOrder })).toEqual({ + kind: "line", + edge: "bottom", + }); + }); +}); diff --git a/packages/studio/src/player/components/timelineDropIndicator.ts b/packages/studio/src/player/components/timelineDropIndicator.ts new file mode 100644 index 0000000000..011d05fc3e --- /dev/null +++ b/packages/studio/src/player/components/timelineDropIndicator.ts @@ -0,0 +1,33 @@ +import type { TimelineLayerDropPlacement } from "./timelineStacking"; +import type { TimelineLayerId } from "./timelineTrackOrder"; + +export type TimelineDropIndicator = { kind: "onto" } | { kind: "line"; edge: "top" | "bottom" }; + +interface ResolveTimelineDropIndicatorInput { + placement: TimelineLayerDropPlacement | null; + layerId: TimelineLayerId; + layerOrder: readonly TimelineLayerId[]; +} + +export function resolveTimelineDropIndicator({ + placement, + layerId, + layerOrder, +}: ResolveTimelineDropIndicatorInput): TimelineDropIndicator | null { + if (!placement || !layerOrder.includes(layerId)) return null; + + switch (placement.type) { + case "onto": + return placement.layerId === layerId ? { kind: "onto" } : null; + case "between": + if (placement.beforeLayerId === layerId) return { kind: "line", edge: "bottom" }; + if (!layerOrder.includes(placement.beforeLayerId) && placement.afterLayerId === layerId) { + return { kind: "line", edge: "top" }; + } + return null; + case "above": + return placement.layerId === layerId ? { kind: "line", edge: "top" } : null; + case "below": + return placement.layerId === layerId ? { kind: "line", edge: "bottom" } : null; + } +} From ed8bf475d327951a63c61880e31e1d1f9121ea6e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 15:33:56 -0400 Subject: [PATCH 10/23] feat(studio): lane-model timeline (non-overlapping clips share a row) Rework the timeline row model from one-row-per-stacking-layer to NLE "lanes": a row holds a time-sequence of non-overlapping clips, ordered top = front. Stacking only matters between clips that overlap in time. - Lane packing: per stacking context, sort clips by effective z-index desc (DOM order tiebreak) and greedily pack each onto the first lane whose members don't overlap it in time; else open a new lane. Lane's representative z is the max member z. Audio stays one clip per lane. - Lane-aware drag: dropping onto a lane joins it (clip takes the lane's z) when there's no time-conflict; a conflicting drop is rejected and the preview snaps to the nearest valid lane or a new-lane insertion. Between/above/below still create a new lane at that stacking level. - Overlap checks use the drag-preview start/duration, so conflict is judged by where the clip lands, not where it started. Only explicit drags write z-index; authored z is preserved and data-track-index is never rewritten. --- .../player/components/timelineEditing.test.ts | 8 +- .../src/player/components/timelineEditing.ts | 7 +- .../components/timelineLayerDrag.test.ts | 70 ++++- .../player/components/timelineLayerDrag.ts | 242 +++++++++++++++--- .../src/player/components/timelineStacking.ts | 2 + .../components/timelineTrackOrder.test.ts | 34 ++- .../player/components/timelineTrackOrder.ts | 59 +++-- .../components/useTimelineClipDrag.test.tsx | 185 ++++++++----- 8 files changed, 449 insertions(+), 158 deletions(-) diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 312b216cd2..dfada1656b 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -158,7 +158,7 @@ describe("resolveTimelineMove", () => { ).toEqual({ start: 2, track: 2 }); }); - it("resolves vertical stacking movement as a layer join without changing data-track-index", () => { + it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => { const stackingElements = [ { id: "root-front", @@ -209,12 +209,12 @@ describe("resolveTimelineMove", () => { expect(result).toEqual({ start: 0, track: 1, - previewLayerId: layers[0]!.id, + previewLayerId: `preview:root-back:above:${layers[0]!.id}`, previewLayerIndex: 0, stackingReorder: { contextKey: "root", - placement: { type: "onto", layerId: layers[0]!.id }, - zIndexChanges: [{ key: "root-back", zIndex: 2 }], + placement: { type: "above", layerId: layers[0]!.id }, + zIndexChanges: [{ key: "root-back", zIndex: 3 }], }, }); }); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 1a6320787d..c7293d90f8 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -110,10 +110,15 @@ export function resolveTimelineMove( // Stacking mode: vertical movement writes z-index only. The authored // data-track-index is preserved even when the pointer crosses rows. if (input.stackingElement) { + const stackingElement = { + ...input.stackingElement, + start: nextStart, + duration: input.duration, + }; const layerMove = input.timelineLayers && input.layerOrder ? resolveTimelineLayerStackingMove({ - element: input.stackingElement, + element: stackingElement, layers: input.timelineLayers, layerOrder: input.layerOrder, trackDeltaRaw, diff --git a/packages/studio/src/player/components/timelineLayerDrag.test.ts b/packages/studio/src/player/components/timelineLayerDrag.test.ts index d000e1c378..4d129b6ff4 100644 --- a/packages/studio/src/player/components/timelineLayerDrag.test.ts +++ b/packages/studio/src/player/components/timelineLayerDrag.test.ts @@ -1,14 +1,23 @@ import { describe, expect, it } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import type { StackingTimelineLayer } from "./timelineTrackOrder"; -import { resolveTimelineLayerZIndexChanges } from "./timelineLayerDrag"; +import { + resolveTimelineLayerStackingMove, + resolveTimelineLayerZIndexChanges, +} from "./timelineLayerDrag"; -function element(input: { id: string; zIndex: number; tag?: string }): TimelineElement { +function element(input: { + id: string; + zIndex: number; + tag?: string; + start?: number; + duration?: number; +}): TimelineElement { return { id: input.id, tag: input.tag ?? "div", - start: 0, - duration: 1, + start: input.start ?? 0, + duration: input.duration ?? 1, track: 0, zIndex: input.zIndex, hasExplicitZIndex: true, @@ -18,30 +27,48 @@ function element(input: { id: string; zIndex: number; tag?: string }): TimelineE }; } -function layer(id: string, zIndex: number): StackingTimelineLayer { +function layer( + id: string, + zIndex: number, + elements: TimelineElement[] = [element({ id, zIndex })], +): StackingTimelineLayer { return { id, kind: "visual", contextKey: "root", zIndex, placementTrack: 0, - elements: [element({ id, zIndex })], + elements, }; } describe("resolveTimelineLayerZIndexChanges", () => { - it("joins an existing layer by assigning the dragged clip that layer's z-index", () => { - const dragged = element({ id: "dragged", zIndex: 1 }); + it("joins an existing lane by assigning the dragged clip that lane's z-index", () => { + const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 }); + const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 }); expect( resolveTimelineLayerZIndexChanges({ element: dragged, - layers: [layer("front", 10), layer("back", 1)], + layers: [layer("front", 10, [front]), layer("back", 1)], placement: { type: "onto", layerId: "front" }, })?.zIndexChanges, ).toEqual([{ key: "dragged", zIndex: 10 }]); }); + it("rejects an onto-lane join when the dragged clip would overlap that lane", () => { + const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 }); + const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 }); + + expect( + resolveTimelineLayerZIndexChanges({ + element: dragged, + layers: [layer("front", 10, [front]), layer("back", 1)], + placement: { type: "onto", layerId: "front" }, + }), + ).toBeNull(); + }); + it("interpolates a new integer z-index strictly between neighboring layers", () => { const dragged = element({ id: "dragged", zIndex: 1 }); @@ -100,3 +127,28 @@ describe("resolveTimelineLayerZIndexChanges", () => { ).toBeNull(); }); }); + +describe("resolveTimelineLayerStackingMove", () => { + it("snaps a blocked onto-lane drop to the nearest new lane instead of committing the conflict", () => { + const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 }); + const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 }); + const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])]; + + expect( + resolveTimelineLayerStackingMove({ + element: dragged, + layers, + layerOrder: layers.map((item) => item.id), + trackDeltaRaw: -1, + }), + ).toEqual({ + previewLayerId: "preview:dragged:above:front", + previewLayerIndex: 0, + stackingReorder: { + contextKey: "root", + placement: { type: "above", layerId: "front" }, + zIndexChanges: [{ key: "dragged", zIndex: 11 }], + }, + }); + }); +}); diff --git a/packages/studio/src/player/components/timelineLayerDrag.ts b/packages/studio/src/player/components/timelineLayerDrag.ts index d87bf7ad2c..f718fb5428 100644 --- a/packages/studio/src/player/components/timelineLayerDrag.ts +++ b/packages/studio/src/player/components/timelineLayerDrag.ts @@ -1,6 +1,10 @@ import { resolveStackingContextKey } from "../lib/layerOrdering"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; +import { + timelineElementsOverlap, + type StackingTimelineLayer, + type TimelineLayerId, +} from "./timelineTrackOrder"; import { toStackingOrderItem, type TimelineLayerDropPlacement, @@ -25,6 +29,18 @@ function layerContainsElement(layer: StackingTimelineLayer, key: string): boolea return layer.elements.some((element) => getTimelineElementIdentity(element) === key); } +function layerConflictsWithElement( + layer: StackingTimelineLayer, + element: TimelineStackingElement, + draggedKey: string, +): boolean { + return layer.elements.some( + (candidate) => + getTimelineElementIdentity(candidate) !== draggedKey && + timelineElementsOverlap(candidate, element), + ); +} + function addElementChange( changes: TimelineStackingZIndexChange[], element: TimelineStackingElement, @@ -161,6 +177,77 @@ function resolveBetweenChanges(input: { return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes; } +function resolveOntoChanges(input: { + element: TimelineStackingElement; + layers: readonly StackingTimelineLayer[]; + layerId: string; +}): TimelineStackingZIndexChange[] | null { + const target = findLayer(input.layers, input.layerId); + if (!target) return null; + if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) { + return null; + } + return resolvePlacementZIndexChanges({ + element: input.element, + targetZIndex: target.zIndex, + }); +} + +function resolveEdgeChanges(input: { + element: TimelineStackingElement; + layers: readonly StackingTimelineLayer[]; + layerId: string; + offset: number; +}): TimelineStackingZIndexChange[] | null { + const target = findLayer(input.layers, input.layerId); + if (!target) return null; + return resolvePlacementZIndexChanges({ + element: input.element, + targetZIndex: target.zIndex + input.offset, + }); +} + +function resolvePlacementChanges(input: { + element: TimelineStackingElement; + layers: readonly StackingTimelineLayer[]; + placement: TimelineLayerDropPlacement; +}): TimelineStackingZIndexChange[] | null { + switch (input.placement.type) { + case "onto": + return resolveOntoChanges({ + element: input.element, + layers: input.layers, + layerId: input.placement.layerId, + }); + case "above": + return resolveEdgeChanges({ + element: input.element, + layers: input.layers, + layerId: input.placement.layerId, + offset: 1, + }); + case "below": + return resolveEdgeChanges({ + element: input.element, + layers: input.layers, + layerId: input.placement.layerId, + offset: -1, + }); + case "between": { + const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId); + const afterLayer = findLayer(input.layers, input.placement.afterLayerId); + return beforeLayer && afterLayer + ? resolveBetweenChanges({ + element: input.element, + layers: input.layers, + beforeLayer, + afterLayer, + }) + : null; + } + } +} + export function resolveTimelineLayerZIndexChanges(input: { element: TimelineStackingElement; layers: readonly StackingTimelineLayer[]; @@ -169,37 +256,13 @@ export function resolveTimelineLayerZIndexChanges(input: { if (isAudioElement(input.element)) return null; const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element)); const layers = getContextLayers(input.layers, contextKey); - let changes: TimelineStackingZIndexChange[] = []; - - if (input.placement.type === "onto") { - const target = findLayer(layers, input.placement.layerId); - if (!target) return null; - changes = resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: target.zIndex, - }); - } else if (input.placement.type === "above") { - const target = findLayer(layers, input.placement.layerId); - if (!target) return null; - changes = resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: target.zIndex + 1, - }); - } else if (input.placement.type === "below") { - const target = findLayer(layers, input.placement.layerId); - if (!target) return null; - changes = resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: target.zIndex - 1, - }); - } else { - const beforeLayer = findLayer(layers, input.placement.beforeLayerId); - const afterLayer = findLayer(layers, input.placement.afterLayerId); - if (!beforeLayer || !afterLayer) return null; - changes = resolveBetweenChanges({ element: input.element, layers, beforeLayer, afterLayer }); - } + const changes = resolvePlacementChanges({ + element: input.element, + layers, + placement: input.placement, + }); - return changes.length > 0 + return changes && changes.length > 0 ? { contextKey, placement: input.placement, zIndexChanges: changes } : null; } @@ -233,6 +296,111 @@ function resolveDragPlacement( : null; } +function buildInsertionPlacement( + layers: readonly StackingTimelineLayer[], + insertionIndex: number, +): TimelineLayerDropPlacement | null { + const first = layers[0]; + const last = layers[layers.length - 1]; + if (!first || !last) return null; + if (insertionIndex <= 0) return { type: "above", layerId: first.id }; + if (insertionIndex >= layers.length) return { type: "below", layerId: last.id }; + const before = layers[insertionIndex - 1]; + const after = layers[insertionIndex]; + return before && after + ? { type: "between", beforeLayerId: before.id, afterLayerId: after.id } + : null; +} + +interface ValidPlacementCandidate { + placement: TimelineLayerDropPlacement; + position: number; + kind: "onto" | "insert"; +} + +function comparePlacementCandidates(input: { + targetPosition: number; + currentIndex: number; + a: ValidPlacementCandidate; + b: ValidPlacementCandidate; +}): number { + const distanceA = Math.abs(input.a.position - input.targetPosition); + const distanceB = Math.abs(input.b.position - input.targetPosition); + if (distanceA !== distanceB) return distanceA - distanceB; + + const direction = Math.sign(input.targetPosition - input.currentIndex); + if (direction !== 0) { + const biasA = + direction < 0 + ? input.a.position <= input.targetPosition + : input.a.position >= input.targetPosition; + const biasB = + direction < 0 + ? input.b.position <= input.targetPosition + : input.b.position >= input.targetPosition; + if (biasA !== biasB) return biasA ? -1 : 1; + } + + if (input.a.kind !== input.b.kind) return input.a.kind === "onto" ? -1 : 1; + return input.a.position - input.b.position; +} + +function resolveNearestValidPlacement(input: { + layers: readonly StackingTimelineLayer[]; + element: TimelineStackingElement; + draggedKey: string; + targetPosition: number; + currentIndex: number; +}): TimelineLayerDropPlacement | null { + const candidates: ValidPlacementCandidate[] = []; + + input.layers.forEach((layer, index) => { + if (!layerConflictsWithElement(layer, input.element, input.draggedKey)) { + candidates.push({ + placement: { type: "onto", layerId: layer.id }, + position: index, + kind: "onto", + }); + } + }); + + for (let insertionIndex = 0; insertionIndex <= input.layers.length; insertionIndex += 1) { + const placement = buildInsertionPlacement(input.layers, insertionIndex); + if (!placement) continue; + candidates.push({ + placement, + position: insertionIndex - 0.5, + kind: "insert", + }); + } + + return ( + candidates.sort((a, b) => + comparePlacementCandidates({ + targetPosition: input.targetPosition, + currentIndex: input.currentIndex, + a, + b, + }), + )[0]?.placement ?? null + ); +} + +function resolveLaneAwareDragPlacement(input: { + layers: readonly StackingTimelineLayer[]; + element: TimelineStackingElement; + draggedKey: string; + placement: TimelineLayerDropPlacement; + targetPosition: number; + currentIndex: number; +}): TimelineLayerDropPlacement | null { + if (input.placement.type !== "onto") return input.placement; + const target = findLayer(input.layers, input.placement.layerId); + if (!target) return null; + if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement; + return resolveNearestValidPlacement(input); +} + function getPreviewLayerId( draggedKey: string, placement: TimelineLayerDropPlacement, @@ -275,7 +443,17 @@ export function resolveTimelineLayerStackingMove(input: { const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey)); if (currentIndex < 0) return null; - const placement = resolveDragPlacement(contextLayers, currentIndex + input.trackDeltaRaw); + const targetPosition = currentIndex + input.trackDeltaRaw; + const rawPlacement = resolveDragPlacement(contextLayers, targetPosition); + if (!rawPlacement) return null; + const placement = resolveLaneAwareDragPlacement({ + layers: contextLayers, + element: input.element, + draggedKey, + placement: rawPlacement, + targetPosition, + currentIndex, + }); if (!placement) return null; return { previewLayerId: getPreviewLayerId(draggedKey, placement), diff --git a/packages/studio/src/player/components/timelineStacking.ts b/packages/studio/src/player/components/timelineStacking.ts index 5235cca19c..af667b1dc9 100644 --- a/packages/studio/src/player/components/timelineStacking.ts +++ b/packages/studio/src/player/components/timelineStacking.ts @@ -4,6 +4,8 @@ export interface TimelineStackingElement { id: string; key?: string; tag?: string; + start: number; + duration: number; track: number; zIndex?: number; stackingContextId?: string | null; diff --git a/packages/studio/src/player/components/timelineTrackOrder.test.ts b/packages/studio/src/player/components/timelineTrackOrder.test.ts index 56d40ea1b5..e84cd431de 100644 --- a/packages/studio/src/player/components/timelineTrackOrder.test.ts +++ b/packages/studio/src/player/components/timelineTrackOrder.test.ts @@ -34,31 +34,41 @@ function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): stri } describe("buildStackingTimelineLayers", () => { - it("merges explicit same-z clips in one context when they do not overlap in time", () => { + it("packs non-overlapping clips into the same lane even when their z-index differs", () => { const result = buildStackingTimelineLayers([ - rowElement({ id: "a", zIndex: 5, start: 0, duration: 1 }), - rowElement({ id: "b", zIndex: 5, start: 1, duration: 1 }), + rowElement({ id: "back", zIndex: 1, start: 0, duration: 1 }), + rowElement({ id: "front", zIndex: 10, start: 1, duration: 1 }), ]); - expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]); + expect(rowIds(result.visualLayers)).toEqual([["back", "front"]]); + expect(result.visualLayers[0]?.zIndex).toBe(10); }); - it("splits explicit same-z clips in one context when they overlap in time", () => { + it("splits clips into separate lanes when they overlap in time", () => { const result = buildStackingTimelineLayers([ - rowElement({ id: "a", track: 2, zIndex: 5, start: 0, duration: 2 }), - rowElement({ id: "b", track: 0, zIndex: 5, start: 1, duration: 2 }), + rowElement({ id: "front", zIndex: 10, start: 0, duration: 2 }), + rowElement({ id: "back", zIndex: 1, start: 1, duration: 2 }), ]); - expect(rowIds(result.visualLayers)).toEqual([["b"], ["a"]]); + expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]); }); - it("keeps auto-z clips in their own rows even when their computed z-index ties", () => { + it("uses DOM order to break stacking ties before lane packing", () => { const result = buildStackingTimelineLayers([ - rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false }), - rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false }), + rowElement({ id: "first", track: 2, zIndex: 5, start: 0, duration: 2 }), + rowElement({ id: "second", track: 0, zIndex: 5, start: 1, duration: 2 }), ]); - expect(rowIds(result.visualLayers)).toEqual([["a"], ["b"]]); + expect(rowIds(result.visualLayers)).toEqual([["first"], ["second"]]); + }); + + it("packs auto-z clips by time instead of forcing one row per clip", () => { + const result = buildStackingTimelineLayers([ + rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false, start: 0, duration: 1 }), + rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false, start: 1, duration: 1 }), + ]); + + expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]); }); it("does not merge equal z-index clips across stacking contexts", () => { diff --git a/packages/studio/src/player/components/timelineTrackOrder.ts b/packages/studio/src/player/components/timelineTrackOrder.ts index dcbfa0068a..43e0014f3c 100644 --- a/packages/studio/src/player/components/timelineTrackOrder.ts +++ b/packages/studio/src/player/components/timelineTrackOrder.ts @@ -24,13 +24,10 @@ type TimelineLayerOrderItem = TimelineStackingOrderItem & { start: number; duration: number; index: number; - hasExplicitZIndex: boolean; element: TimelineElement; }; -type BuildLayer = StackingTimelineLayer & { - hasExplicitZIndex: boolean; -}; +type BuildLayer = Omit; function toTimelineLayerOrderItem(element: TimelineElement, index: number): TimelineLayerOrderItem { return { @@ -38,12 +35,11 @@ function toTimelineLayerOrderItem(element: TimelineElement, index: number): Time start: element.start, duration: element.duration, index, - hasExplicitZIndex: element.hasExplicitZIndex === true, element, }; } -function timelineElementsOverlap( +export function timelineElementsOverlap( a: Pick, b: Pick, ): boolean { @@ -52,13 +48,10 @@ function timelineElementsOverlap( function compareLayerItems(a: TimelineLayerOrderItem, b: TimelineLayerOrderItem): number { if (a.zIndex !== b.zIndex) return b.zIndex - a.zIndex; - if (a.hasExplicitZIndex && b.hasExplicitZIndex && a.track !== b.track) { - return a.track - b.track; - } return a.index - b.index; } -function buildLayerId( +function buildElementLayerId( prefix: string, contextKey: string, element: TimelineElement, @@ -66,6 +59,15 @@ function buildLayerId( return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`; } +function buildLaneId( + prefix: string, + contextKey: string, + elements: TimelineElement[], +): TimelineLayerId { + const memberKey = elements.map(getTimelineElementIdentity).sort().join("|"); + return `${prefix}:${contextKey}:${memberKey}`; +} + function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] { const keys: string[] = []; for (const item of resolveContextOrder(items)) { @@ -77,14 +79,16 @@ function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string function canJoinLayer(layer: BuildLayer, item: TimelineLayerOrderItem): boolean { return ( - layer.hasExplicitZIndex && - item.hasExplicitZIndex && layer.contextKey === resolveStackingContextKey(item) && - layer.zIndex === item.zIndex && layer.elements.every((element) => !timelineElementsOverlap(element, item.element)) ); } +function compareElementsByStart(a: TimelineElement, b: TimelineElement): number { + if (a.start !== b.start) return a.start - b.start; + return getTimelineElementIdentity(a).localeCompare(getTimelineElementIdentity(b)); +} + function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] { const byContext = new Map(); for (const item of items) { @@ -99,42 +103,37 @@ function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): Stackin const contextRows: BuildLayer[] = []; const contextItems = [...(byContext.get(contextKey) ?? [])].sort(compareLayerItems); for (const item of contextItems) { - if (!item.hasExplicitZIndex) { - contextRows.push({ - id: buildLayerId("auto", contextKey, item.element), - kind: "visual", - contextKey, - zIndex: item.zIndex, - placementTrack: item.element.track, - elements: [item.element], - hasExplicitZIndex: false, - }); - continue; - } - const existing = contextRows.find((row) => canJoinLayer(row, item)); if (existing) { existing.elements.push(item.element); + existing.zIndex = Math.max(existing.zIndex, item.zIndex); continue; } contextRows.push({ - id: buildLayerId("layer", contextKey, item.element), kind: "visual", contextKey, zIndex: item.zIndex, placementTrack: item.element.track, elements: [item.element], - hasExplicitZIndex: true, }); } - rows.push(...contextRows); + rows.push( + ...contextRows.map((row) => { + const elements = [...row.elements].sort(compareElementsByStart); + return { + ...row, + id: buildLaneId("lane", contextKey, elements), + elements, + }; + }), + ); } return rows; } function buildAudioLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] { return items.map((item) => ({ - id: buildLayerId("audio", resolveStackingContextKey(item), item.element), + id: buildElementLayerId("audio", resolveStackingContextKey(item), item.element), kind: "audio", contextKey: resolveStackingContextKey(item), zIndex: item.zIndex, diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index 6f0a63640c..efe4840da3 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -34,89 +34,134 @@ afterEach(() => { usePlayerStore.getState().reset(); }); +function renderDragHarness(elements: TimelineElement[]) { + const layers = buildStackingTimelineLayers(elements).rows; + const scroll = document.createElement("div"); + document.body.append(scroll); + const onMoveElement = vi.fn(); + let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null; + + function Harness() { + const hook = useTimelineClipDrag({ + scrollRef: { current: scroll }, + ppsRef: { current: 100 }, + durationRef: { current: 10 }, + trackOrderRef: { current: layers.map((layer) => layer.id) }, + timelineLayersRef: { current: layers }, + timelineElementsRef: { current: elements }, + onMoveElement, + onResizeElement: vi.fn(), + onBlockedEditAttempt: vi.fn(), + setShowPopover: vi.fn(), + setRangeSelectionRef: { current: vi.fn() }, + }); + setDraggedClip = hook.setDraggedClip; + return null; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + if (!setDraggedClip) throw new Error("Expected drag setter"); + const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip; + + return { + layers, + onMoveElement, + startDrag(element: TimelineElement, layerIndex: number) { + act(() => { + applyDraggedClip({ + element, + originClientX: 0, + originClientY: 0, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: 0, + pointerClientY: 0, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: element.start, + previewTrack: element.track, + previewLayerId: layers[layerIndex]!.id, + previewLayerIndex: layerIndex, + previewStackingReorder: null, + snapBeatTime: null, + started: false, + }); + }); + }, + movePointer(clientX: number, clientY: number) { + act(() => { + window.dispatchEvent( + new MouseEvent("pointermove", { + bubbles: true, + clientX, + clientY, + }), + ); + }); + }, + async dropPointer() { + await act(async () => { + window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + }); + }, + unmount() { + act(() => root.unmount()); + }, + }; +} + describe("useTimelineClipDrag", () => { - it("passes sibling-scoped stacking intent on vertical drag commit", async () => { + it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => { const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 }); const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); - const layers = buildStackingTimelineLayers([front, middle, back]).rows; - const scroll = document.createElement("div"); - document.body.append(scroll); - const onMoveElement = vi.fn(); - let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null; - - function Harness() { - const hook = useTimelineClipDrag({ - scrollRef: { current: scroll }, - ppsRef: { current: 100 }, - durationRef: { current: 10 }, - trackOrderRef: { current: layers.map((layer) => layer.id) }, - timelineLayersRef: { current: layers }, - timelineElementsRef: { current: [front, middle, back] }, - onMoveElement, - onResizeElement: vi.fn(), - onBlockedEditAttempt: vi.fn(), - setShowPopover: vi.fn(), - setRangeSelectionRef: { current: vi.fn() }, - }); - setDraggedClip = hook.setDraggedClip; - return null; - } - - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render(); - }); - if (!setDraggedClip) throw new Error("Expected drag setter"); - const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip; - - act(() => { - applyDraggedClip({ - element: back, - originClientX: 0, - originClientY: 0, - originScrollLeft: 0, - originScrollTop: 0, - pointerClientX: 0, - pointerClientY: 0, - pointerOffsetX: 0, - pointerOffsetY: 0, - previewStart: back.start, - previewTrack: back.track, - previewLayerId: layers[2]!.id, - previewLayerIndex: 2, - previewStackingReorder: null, - snapBeatTime: null, - started: false, - }); - }); + const harness = renderDragHarness([front, middle, back]); - act(() => { - window.dispatchEvent( - new MouseEvent("pointermove", { - bubbles: true, - clientX: 0, - clientY: -2 * TRACK_H, - }), - ); - }); - await act(async () => { - window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); - }); + harness.startDrag(back, 2); + harness.movePointer(0, -2 * TRACK_H); + await harness.dropPointer(); - expect(onMoveElement).toHaveBeenCalledTimes(1); - expect(onMoveElement.mock.calls[0]![1]).toMatchObject({ + expect(harness.onMoveElement).toHaveBeenCalledTimes(1); + expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({ start: 0, track: 2, stackingReorder: { contextKey: "root", - placement: { type: "onto", layerId: layers[0]!.id }, + placement: { type: "above", layerId: harness.layers[0]!.id }, + zIndexChanges: [{ key: "back", zIndex: 4 }], + }, + }); + + harness.unmount(); + }); + + it("uses the preview start when resolving whether a dragged clip can join a lane", async () => { + const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); + const back = timelineElement({ id: "back", track: 1, zIndex: 1 }); + back.start = 0; + front.start = 0; + const harness = renderDragHarness([front, back]); + + harness.startDrag(back, 1); + harness.movePointer(200, -TRACK_H); + await harness.dropPointer(); + + expect(harness.onMoveElement).toHaveBeenCalledTimes(1); + expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({ + start: 2, + track: 1, + stackingReorder: { + contextKey: "root", + placement: { type: "onto", layerId: harness.layers[0]!.id }, zIndexChanges: [{ key: "back", zIndex: 3 }], }, }); - act(() => root.unmount()); + harness.unmount(); }); }); From 5e3ca6ae63d3c6484a4e5cde0eea4e3987f8236a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 17:06:02 -0400 Subject: [PATCH 11/23] feat(studio): drag a clip past the video end to extend its duration Dragging a timeline clip (or its right resize edge) past the end of the video now extends the composition duration on drop, instead of clamping the clip at the current end. Extend-only and undoable. - Relax the move/resize horizontal clamps that pinned a clip's end at the current duration; effectiveDuration now folds in the active drag/resize preview so the ruler and track width grow live as you drag past the end. - On drop, extend the root composition data-duration (and the store) when the clip's new end exceeds it, via a shared extendRootDurationInSource helper extracted from the block installer (now the single owner of that logic). An extending edit routes through the server persist path since the SDK setTiming op can't express the root composition's own duration. --- .../src/hooks/timelineEditingHelpers.test.ts | 22 +++- .../src/hooks/timelineEditingHelpers.ts | 51 +++++++- .../src/hooks/useTimelineEditing.test.tsx | 111 +++++++++++++++++- .../studio/src/hooks/useTimelineEditing.ts | 37 ++---- .../studio/src/player/components/Timeline.tsx | 28 +++-- .../components/useTimelineClipDrag.test.tsx | 70 ++++++++++- .../player/components/useTimelineClipDrag.ts | 10 +- packages/studio/src/utils/blockInstaller.ts | 16 +-- .../studio/src/utils/rootDuration.test.ts | 34 ++++++ packages/studio/src/utils/rootDuration.ts | 17 +++ 10 files changed, 327 insertions(+), 69 deletions(-) create mode 100644 packages/studio/src/utils/rootDuration.test.ts create mode 100644 packages/studio/src/utils/rootDuration.ts diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index e120fe2d02..5af7db7373 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -1,7 +1,12 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from "vitest"; -import { applyTimelineStackingReorder } from "./timelineEditingHelpers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers"; import type { TimelineElement } from "../player/store/playerStore"; +import { usePlayerStore } from "../player/store/playerStore"; + +afterEach(() => { + usePlayerStore.getState().reset(); +}); function makeIframeWith(html: string): HTMLIFrameElement { const iframe = document.createElement("iframe"); @@ -86,3 +91,16 @@ describe("applyTimelineStackingReorder", () => { expect(commit).not.toHaveBeenCalled(); }); }); + +describe("extendRootDurationIfNeeded", () => { + it("extends the player duration only when the new end is larger", () => { + usePlayerStore.getState().setDuration(4); + + expect(extendRootDurationIfNeeded(5)).toBe(true); + expect(usePlayerStore.getState().duration).toBe(5); + + expect(extendRootDurationIfNeeded(5)).toBe(false); + expect(extendRootDurationIfNeeded(3)).toBe(false); + expect(usePlayerStore.getState().duration).toBe(5); + }); +}); diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 2dfe41b81f..a47fe92289 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -10,6 +10,7 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; import type { EditHistoryKind } from "../utils/editHistory"; import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; +import { extendRootDurationInSource } from "../utils/rootDuration"; function isHTMLElement(element: Element | null): element is HTMLElement { if (!element) return false; @@ -114,6 +115,13 @@ export function deleteSelectedKeyframes(session: { } } +export function extendRootDurationIfNeeded(newEnd: number): boolean { + const store = usePlayerStore.getState(); + if (newEnd <= store.duration) return false; + store.setDuration(newEnd); + return true; +} + // ── Types ── export interface RecordEditInput { @@ -183,7 +191,7 @@ export function patchIframeDomTiming( } // fallow-ignore-next-line complexity -export function resolveResizePlaybackStart( +function resolveResizePlaybackStart( original: string, target: PatchTarget, element: TimelineElement, @@ -209,6 +217,47 @@ export function resolveResizePlaybackStart( }; } +export function buildTimelineMoveTimingPatch( + original: string, + target: PatchTarget, + start: number, + duration: number, +): string { + const patched = applyPatchByTarget(original, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(start), + }); + return extendRootDurationInSource(patched, start + duration); +} + +export function buildTimelineResizeTimingPatch( + original: string, + target: PatchTarget, + element: TimelineElement, + updates: Pick, +): string { + const pbs = resolveResizePlaybackStart(original, target, element, updates); + let patched = applyPatchByTarget(original, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "duration", + value: formatTimelineAttributeNumber(updates.duration), + }); + if (pbs) { + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: pbs.attrName, + value: formatTimelineAttributeNumber(pbs.value), + }); + } + return extendRootDurationInSource(patched, updates.start + updates.duration); +} + export interface PersistTimelineEditInput { projectId: string; element: TimelineElement; diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index b26460fcb9..bb159900f6 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -2,8 +2,9 @@ import React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; +import { openComposition } from "@hyperframes/sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { TimelineElement } from "../player"; +import { usePlayerStore, type TimelineElement } from "../player"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { useTimelineEditing } from "./useTimelineEditing"; @@ -20,6 +21,7 @@ type ZIndexEntry = { afterEach(() => { document.body.innerHTML = ""; + usePlayerStore.getState().reset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -85,11 +87,15 @@ function renderTimelineEditingHook(input: { files: Record; }) => Promise; reloadPreview?: () => void; + sdkSession?: Awaited> | null; + forceReloadSdkSession?: () => void; }): { move: ReturnType["handleTimelineElementMove"]; + resize: ReturnType["handleTimelineElementResize"]; unmount: () => void; } { let move: ReturnType["handleTimelineElementMove"] | null = null; + let resize: ReturnType["handleTimelineElementResize"] | null = null; function Harness() { const commitRef = useRef(input.onZIndexCommit); @@ -106,9 +112,12 @@ function renderTimelineEditingHook(input: { previewIframeRef: { current: input.iframe }, pendingTimelineEditPathRef: { current: new Set() }, uploadProjectFiles: vi.fn(), + sdkSession: input.sdkSession, + forceReloadSdkSession: input.forceReloadSdkSession, handleDomZIndexReorderCommitRef: commitRef, }); move = hook.handleTimelineElementMove; + resize = hook.handleTimelineElementResize; return null; } @@ -120,8 +129,10 @@ function renderTimelineEditingHook(input: { }); if (!move) throw new Error("Expected hook to expose move handler"); + if (!resize) throw new Error("Expected hook to expose resize handler"); return { move, + resize, unmount: () => { act(() => root.unmount()); }, @@ -206,6 +217,104 @@ async function flushAsyncWork(): Promise { } describe("useTimelineEditing timeline z-index reorder", () => { + it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => { + const source = [ + `
`, + `
`, + `
`, + ].join("\n"); + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const sdkSession = await openComposition(source); + const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async () => {}); + const forceReloadSdkSession = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + usePlayerStore.getState().setDuration(4); + const { move, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn(), + projectId: "p1", + writeProjectFile, + recordEdit, + sdkSession, + forceReloadSdkSession, + }); + + await act(async () => { + await move(clip, { start: 3, track: clip.track }); + }); + + expect(setTimingSpy).not.toHaveBeenCalled(); + expect(writeProjectFile.mock.calls[0]![1]).toContain( + 'data-composition-id="main" data-duration="5"', + ); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"'); + expect(usePlayerStore.getState().duration).toBe(5); + expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => { + const source = [ + `
`, + `
`, + `
`, + ].join("\n"); + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); + const sdkSession = await openComposition(source); + const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const recordEdit = vi.fn(async () => {}); + const forceReloadSdkSession = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]): Promise => { + const url = requestUrl(input); + if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source }); + if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + usePlayerStore.getState().setDuration(4); + const { resize, unmount } = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn(), + projectId: "p1", + writeProjectFile, + recordEdit, + sdkSession, + forceReloadSdkSession, + }); + + await act(async () => { + await resize(clip, { start: 0, duration: 5, playbackStart: undefined }); + }); + + expect(setTimingSpy).not.toHaveBeenCalled(); + expect(writeProjectFile.mock.calls[0]![1]).toContain( + 'data-composition-id="main" data-duration="5"', + ); + expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">'); + expect(usePlayerStore.getState().duration).toBe(5); + expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); + + unmount(); + }); + it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 10" }, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index fed47ffbc5..835cfa470b 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -24,13 +24,14 @@ import { applyTimelineStackingReorder, buildPatchTarget, patchIframeDomTiming, - resolveResizePlaybackStart, persistTimelineEdit, readFileContent, - applyPatchByTarget, formatTimelineAttributeNumber, shiftGsapPositions, scaleGsapPositions, + extendRootDurationIfNeeded, + buildTimelineMoveTimingPatch, + buildTimelineResizeTimingPatch, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; @@ -145,11 +146,7 @@ export function useTimelineEditing({ if (!startChanged) return; const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - return applyPatchByTarget(original, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(updates.start), - }); + return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration); }; // Server-path fallback (no SDK session): persist the attr patch, then // shift GSAP tween positions on the server and reload the preview — the @@ -169,7 +166,8 @@ export function useTimelineEditing({ } return reloadPreview(); }); - if (sdkSession && element.hfId) { + const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); + if (sdkSession && element.hfId && !needsExtension) { return sdkTimingPersist( element.hfId, targetPath, @@ -230,25 +228,7 @@ export function useTimelineEditing({ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); const targetPath = element.sourceFile || activeCompPath || "index.html"; const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - const pbs = resolveResizePlaybackStart(original, target, element, updates); - let patched = applyPatchByTarget(original, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(updates.start), - }); - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: "duration", - value: formatTimelineAttributeNumber(updates.duration), - }); - if (pbs) { - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: pbs.attrName, - value: formatTimelineAttributeNumber(pbs.value), - }); - } - return patched; + return buildTimelineResizeTimingPatch(original, target, element, updates); }; // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). // The second clause fires because trimming the start of a clip that has a @@ -265,6 +245,7 @@ export function useTimelineEditing({ const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; const timingChanged = updates.start !== element.start || updates.duration !== element.duration; + const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration); const resizeFallback = () => enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => { const pid = projectIdRef.current; @@ -283,7 +264,7 @@ export function useTimelineEditing({ } return reloadPreview(); }); - if (sdkSession && element.hfId && !hasPbsAdjustment) { + if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) { return sdkTimingPersist( element.hfId, targetPath, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index a43b00f867..91a4b6fa8f 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -180,14 +180,6 @@ export const Timeline = memo(function Timeline({ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); }); - const effectiveDuration = useMemo(() => { - const safeDur = Number.isFinite(duration) ? duration : 0; - if (rawElements.length === 0) return safeDur; - const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration)); - const result = Math.max(safeDur, maxEnd); - return Number.isFinite(result) ? result : safeDur; - }, [rawElements, duration]); - const tracks = useMemo( () => buildStackingTimelineLayers(expandedElements).rows, [expandedElements], @@ -210,8 +202,7 @@ export const Timeline = memo(function Timeline({ expandedElementsRef.current = expandedElements; const ppsRef = useRef(100); - const durationRef = useRef(effectiveDuration); - durationRef.current = effectiveDuration; + const durationRef = useRef(Number.isFinite(duration) ? duration : 0); // Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null); @@ -227,7 +218,6 @@ export const Timeline = memo(function Timeline({ } = useTimelineClipDrag({ scrollRef, ppsRef, - durationRef, trackOrderRef, timelineLayersRef, timelineElementsRef: expandedElementsRef, @@ -238,6 +228,22 @@ export const Timeline = memo(function Timeline({ setRangeSelectionRef, }); + const effectiveDuration = useMemo(() => { + const safeDur = Number.isFinite(duration) ? duration : 0; + let maxEnd = safeDur; + if (rawElements.length > 0) { + maxEnd = Math.max(maxEnd, ...rawElements.map((el) => el.start + el.duration)); + } + if (draggedClip?.started) { + maxEnd = Math.max(maxEnd, draggedClip.previewStart + draggedClip.element.duration); + } + if (resizingClip?.started) { + maxEnd = Math.max(maxEnd, resizingClip.previewStart + resizingClip.previewDuration); + } + return Number.isFinite(maxEnd) ? maxEnd : safeDur; + }, [rawElements, duration, draggedClip, resizingClip]); + durationRef.current = effectiveDuration; + const displayTrackOrder = useMemo(() => { if ( !draggedClip?.started || diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index efe4840da3..58d113ba57 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -7,18 +7,26 @@ import type { TimelineElement } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore"; import { TRACK_H } from "./timelineLayout"; import { buildStackingTimelineLayers } from "./timelineTrackOrder"; -import type { DraggedClipState } from "./useTimelineClipDrag"; +import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement { +function timelineElement(input: { + id: string; + track: number; + zIndex: number; + start?: number; + duration?: number; + sourceDuration?: number; +}): TimelineElement { return { id: input.id, domId: input.id, tag: "div", - start: 0, - duration: 2, + start: input.start ?? 0, + duration: input.duration ?? 2, + sourceDuration: input.sourceDuration, track: input.track, zIndex: input.zIndex, stackingContextId: "root", @@ -39,23 +47,25 @@ function renderDragHarness(elements: TimelineElement[]) { const scroll = document.createElement("div"); document.body.append(scroll); const onMoveElement = vi.fn(); + const onResizeElement = vi.fn(); let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null; + let setResizingClip: ((state: ResizingClipState | null) => void) | null = null; function Harness() { const hook = useTimelineClipDrag({ scrollRef: { current: scroll }, ppsRef: { current: 100 }, - durationRef: { current: 10 }, trackOrderRef: { current: layers.map((layer) => layer.id) }, timelineLayersRef: { current: layers }, timelineElementsRef: { current: elements }, onMoveElement, - onResizeElement: vi.fn(), + onResizeElement, onBlockedEditAttempt: vi.fn(), setShowPopover: vi.fn(), setRangeSelectionRef: { current: vi.fn() }, }); setDraggedClip = hook.setDraggedClip; + setResizingClip = hook.setResizingClip; return null; } @@ -66,11 +76,14 @@ function renderDragHarness(elements: TimelineElement[]) { root.render(); }); if (!setDraggedClip) throw new Error("Expected drag setter"); + if (!setResizingClip) throw new Error("Expected resize setter"); const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip; + const applyResizingClip: (state: ResizingClipState | null) => void = setResizingClip; return { layers, onMoveElement, + onResizeElement, startDrag(element: TimelineElement, layerIndex: number) { act(() => { applyDraggedClip({ @@ -93,6 +106,19 @@ function renderDragHarness(elements: TimelineElement[]) { }); }); }, + startResize(element: TimelineElement, edge: "start" | "end") { + act(() => { + applyResizingClip({ + element, + edge, + originClientX: 0, + previewStart: element.start, + previewDuration: element.duration, + previewPlaybackStart: element.playbackStart, + started: false, + }); + }); + }, movePointer(clientX: number, clientY: number) { act(() => { window.dispatchEvent( @@ -116,6 +142,38 @@ function renderDragHarness(elements: TimelineElement[]) { } describe("useTimelineClipDrag", () => { + it("allows moving a clip past the current composition duration", async () => { + const clip = timelineElement({ id: "clip", track: 0, zIndex: 1 }); + const harness = renderDragHarness([clip]); + + harness.startDrag(clip, 0); + harness.movePointer(1100, 0); + await harness.dropPointer(); + + expect(harness.onMoveElement).toHaveBeenCalledWith( + clip, + expect.objectContaining({ start: 11 }), + ); + + harness.unmount(); + }); + + it("allows right-edge resize past the current composition duration", async () => { + const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 }); + const harness = renderDragHarness([clip]); + + harness.startResize(clip, "end"); + harness.movePointer(400, 0); + await harness.dropPointer(); + + expect(harness.onResizeElement).toHaveBeenCalledWith( + clip, + expect.objectContaining({ start: 6, duration: 6 }), + ); + + harness.unmount(); + }); + it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => { const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 6e45e05ee7..f7c51d8958 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -114,7 +114,6 @@ export interface BlockedClipState { interface UseTimelineClipDragInput { scrollRef: React.RefObject; ppsRef: React.RefObject; - durationRef: React.RefObject; trackOrderRef: React.RefObject; timelineLayersRef: React.RefObject; timelineElementsRef: React.RefObject; @@ -137,7 +136,6 @@ interface UseTimelineClipDragInput { export function useTimelineClipDrag({ scrollRef, ppsRef, - durationRef, trackOrderRef, timelineLayersRef, timelineElementsRef, @@ -214,7 +212,7 @@ export function useTimelineClipDrag({ currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop, pixelsPerSecond: ppsRef.current, trackHeight: TRACK_H, - maxStart: Math.max(0, durationRef.current - drag.element.duration), + maxStart: Number.POSITIVE_INFINITY, trackOrder: timelineLayersRef.current.map((layer) => layer.placementTrack), layerOrder: trackOrderRef.current, timelineLayers: timelineLayersRef.current, @@ -232,7 +230,7 @@ export function useTimelineClipDrag({ drag.element.duration, beatTimesRef.current, ppsRef.current, - durationRef.current, + Number.POSITIVE_INFINITY, ); return { ...drag, @@ -247,7 +245,7 @@ export function useTimelineClipDrag({ snapBeatTime: snap.beat, }; }, - [scrollRef, ppsRef, durationRef, trackOrderRef, timelineLayersRef, timelineElementsRef], + [scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef], ); const stopClipDragAutoScroll = useCallback(() => { @@ -342,7 +340,7 @@ export function useTimelineClipDrag({ const normalizedTag = resize.element.tag.toLowerCase(); const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video"; const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1); - const maxEnd = Math.min(durationRef.current, resize.element.start + sourceRemaining); + const maxEnd = resize.element.start + sourceRemaining; let nextResize = resolveTimelineResize( { start: resize.element.start, diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index c2ca2bbab9..daf636a7f1 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -8,6 +8,7 @@ import { collectHtmlIds } from "./studioHelpers"; import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { saveProjectFilesWithHistory } from "./studioFileHistory"; import type { EditHistoryKind } from "./editHistory"; +import { extendRootDurationInSource } from "./rootDuration"; function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number { try { @@ -163,20 +164,7 @@ export async function addBlockToProject( ].join("\n"); let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); - - const newEnd = start + duration; - const rootDurMatch = patchedContent.match( - /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/, - ); - if (rootDurMatch) { - const rootDur = parseFloat(rootDurMatch[2]!); - if (newEnd > rootDur) { - patchedContent = patchedContent.replace( - rootDurMatch[0], - `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`, - ); - } - } + patchedContent = extendRootDurationInSource(patchedContent, start + duration); await saveProjectFilesWithHistory({ projectId, diff --git a/packages/studio/src/utils/rootDuration.test.ts b/packages/studio/src/utils/rootDuration.test.ts new file mode 100644 index 0000000000..795941ce54 --- /dev/null +++ b/packages/studio/src/utils/rootDuration.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { extendRootDurationInSource } from "./rootDuration"; + +describe("extendRootDurationInSource", () => { + it("extends data-duration when the new end is bigger than the root duration", () => { + const source = [ + `
`, + `
`, + `
`, + ].join("\n"); + + expect(extendRootDurationInSource(source, 5.25)).toContain( + `data-composition-id="main" data-duration="5.25"`, + ); + }); + + it("does nothing when the new end is smaller than or equal to the root duration", () => { + const source = `
`; + + expect(extendRootDurationInSource(source, 5)).toBe(source); + expect(extendRootDurationInSource(source, 6)).toBe(source); + }); + + it("leaves non-root data-duration attributes untouched by the extension", () => { + const source = [ + `
`, + `
`, + ].join("\n"); + const patched = extendRootDurationInSource(source, 7); + + expect(patched).toContain(`
`); + expect(patched).toContain(`
`); + }); +}); diff --git a/packages/studio/src/utils/rootDuration.ts b/packages/studio/src/utils/rootDuration.ts new file mode 100644 index 0000000000..0a9350425e --- /dev/null +++ b/packages/studio/src/utils/rootDuration.ts @@ -0,0 +1,17 @@ +import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; + +export function extendRootDurationInSource(source: string, newEnd: number): string { + const rootDurMatch = source.match( + /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/, + ); + if (rootDurMatch) { + const rootDur = parseFloat(rootDurMatch[2]!); + if (newEnd > rootDur) { + return source.replace( + rootDurMatch[0], + `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`, + ); + } + } + return source; +} From 6b4870246ce7d9c4f259c1f714c1467c97c0df29 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 17:44:48 -0400 Subject: [PATCH 12/23] fix(studio): dropping an overlapping clip on a lane restacks it, not a no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vertical drag onto a lane whose clips overlap the dragged clip in time used to resolve to the "nearest valid lane", which included the clip's own lane — so a small drag snapped straight back and felt like the editor refused the move. Overlapping clips can't share a row (a row is 1-D in time), but the drag should still restack, never reject. Now a conflicting onto-drop converts to an edge insertion adjacent to the target by drag direction (up -> above, down -> below): the clip lands on its own lane just in front of / behind the target, with its time unchanged. Non-overlapping drops still join the lane. Deletes the nearest-valid-placement search (~90 lines) this replaces. --- .../components/timelineLayerDrag.test.ts | 50 +++++++++- .../player/components/timelineLayerDrag.ts | 98 ++----------------- 2 files changed, 55 insertions(+), 93 deletions(-) diff --git a/packages/studio/src/player/components/timelineLayerDrag.test.ts b/packages/studio/src/player/components/timelineLayerDrag.test.ts index 4d129b6ff4..b325095a21 100644 --- a/packages/studio/src/player/components/timelineLayerDrag.test.ts +++ b/packages/studio/src/player/components/timelineLayerDrag.test.ts @@ -129,7 +129,7 @@ describe("resolveTimelineLayerZIndexChanges", () => { }); describe("resolveTimelineLayerStackingMove", () => { - it("snaps a blocked onto-lane drop to the nearest new lane instead of committing the conflict", () => { + it("places an upward onto-lane drop above the overlapping target lane", () => { const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 }); const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 }); const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])]; @@ -139,7 +139,7 @@ describe("resolveTimelineLayerStackingMove", () => { element: dragged, layers, layerOrder: layers.map((item) => item.id), - trackDeltaRaw: -1, + trackDeltaRaw: -0.8, }), ).toEqual({ previewLayerId: "preview:dragged:above:front", @@ -151,4 +151,50 @@ describe("resolveTimelineLayerStackingMove", () => { }, }); }); + + it("places a downward onto-lane drop below the overlapping target lane", () => { + const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 }); + const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 }); + const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])]; + + expect( + resolveTimelineLayerStackingMove({ + element: dragged, + layers, + layerOrder: layers.map((item) => item.id), + trackDeltaRaw: 0.8, + }), + ).toEqual({ + previewLayerId: "preview:dragged:below:back", + previewLayerIndex: 2, + stackingReorder: { + contextKey: "root", + placement: { type: "below", layerId: "back" }, + zIndexChanges: [{ key: "dragged", zIndex: 0 }], + }, + }); + }); + + it("keeps a non-overlapping onto-lane drop joined to the target lane", () => { + const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 }); + const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 }); + const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])]; + + expect( + resolveTimelineLayerStackingMove({ + element: dragged, + layers, + layerOrder: layers.map((item) => item.id), + trackDeltaRaw: -0.8, + }), + ).toEqual({ + previewLayerId: "front", + previewLayerIndex: 0, + stackingReorder: { + contextKey: "root", + placement: { type: "onto", layerId: "front" }, + zIndexChanges: [{ key: "dragged", zIndex: 10 }], + }, + }); + }); }); diff --git a/packages/studio/src/player/components/timelineLayerDrag.ts b/packages/studio/src/player/components/timelineLayerDrag.ts index f718fb5428..75e4d4c1bc 100644 --- a/packages/studio/src/player/components/timelineLayerDrag.ts +++ b/packages/studio/src/player/components/timelineLayerDrag.ts @@ -296,96 +296,6 @@ function resolveDragPlacement( : null; } -function buildInsertionPlacement( - layers: readonly StackingTimelineLayer[], - insertionIndex: number, -): TimelineLayerDropPlacement | null { - const first = layers[0]; - const last = layers[layers.length - 1]; - if (!first || !last) return null; - if (insertionIndex <= 0) return { type: "above", layerId: first.id }; - if (insertionIndex >= layers.length) return { type: "below", layerId: last.id }; - const before = layers[insertionIndex - 1]; - const after = layers[insertionIndex]; - return before && after - ? { type: "between", beforeLayerId: before.id, afterLayerId: after.id } - : null; -} - -interface ValidPlacementCandidate { - placement: TimelineLayerDropPlacement; - position: number; - kind: "onto" | "insert"; -} - -function comparePlacementCandidates(input: { - targetPosition: number; - currentIndex: number; - a: ValidPlacementCandidate; - b: ValidPlacementCandidate; -}): number { - const distanceA = Math.abs(input.a.position - input.targetPosition); - const distanceB = Math.abs(input.b.position - input.targetPosition); - if (distanceA !== distanceB) return distanceA - distanceB; - - const direction = Math.sign(input.targetPosition - input.currentIndex); - if (direction !== 0) { - const biasA = - direction < 0 - ? input.a.position <= input.targetPosition - : input.a.position >= input.targetPosition; - const biasB = - direction < 0 - ? input.b.position <= input.targetPosition - : input.b.position >= input.targetPosition; - if (biasA !== biasB) return biasA ? -1 : 1; - } - - if (input.a.kind !== input.b.kind) return input.a.kind === "onto" ? -1 : 1; - return input.a.position - input.b.position; -} - -function resolveNearestValidPlacement(input: { - layers: readonly StackingTimelineLayer[]; - element: TimelineStackingElement; - draggedKey: string; - targetPosition: number; - currentIndex: number; -}): TimelineLayerDropPlacement | null { - const candidates: ValidPlacementCandidate[] = []; - - input.layers.forEach((layer, index) => { - if (!layerConflictsWithElement(layer, input.element, input.draggedKey)) { - candidates.push({ - placement: { type: "onto", layerId: layer.id }, - position: index, - kind: "onto", - }); - } - }); - - for (let insertionIndex = 0; insertionIndex <= input.layers.length; insertionIndex += 1) { - const placement = buildInsertionPlacement(input.layers, insertionIndex); - if (!placement) continue; - candidates.push({ - placement, - position: insertionIndex - 0.5, - kind: "insert", - }); - } - - return ( - candidates.sort((a, b) => - comparePlacementCandidates({ - targetPosition: input.targetPosition, - currentIndex: input.currentIndex, - a, - b, - }), - )[0]?.placement ?? null - ); -} - function resolveLaneAwareDragPlacement(input: { layers: readonly StackingTimelineLayer[]; element: TimelineStackingElement; @@ -398,7 +308,13 @@ function resolveLaneAwareDragPlacement(input: { const target = findLayer(input.layers, input.placement.layerId); if (!target) return null; if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement; - return resolveNearestValidPlacement(input); + if (input.targetPosition < input.currentIndex) { + return { type: "above", layerId: target.id }; + } + if (input.targetPosition > input.currentIndex) { + return { type: "below", layerId: target.id }; + } + return input.placement; } function getPreviewLayerId( From d7e7fca8ec93a894ec76404ecc8026a5168d7b60 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 19:38:53 -0400 Subject: [PATCH 13/23] feat(studio): magnetic snapping to clip edges, playhead, and bounds Timeline moves and resizes previously snapped only to the beat grid; they now also snap the dragged edge to other clips' start/end, the playhead, and the composition bounds, whichever is nearest within the existing threshold. Beats fold in as one target kind, so beat snapping is unchanged. Snap logic lives in a new pure timelineSnapTargets module with unit tests; a snapped edge shows a guide line (beat highlight, else a thin accent line) for both drag and resize. --- .../src/player/components/BeatStrip.tsx | 22 ++- .../src/player/components/TimelineCanvas.tsx | 15 +- .../components/timelineSnapTargets.test.ts | 121 ++++++++++++ .../player/components/timelineSnapTargets.ts | 163 ++++++++++++++++ .../components/useTimelineClipDrag.test.tsx | 4 + .../player/components/useTimelineClipDrag.ts | 176 +++++++----------- 6 files changed, 385 insertions(+), 116 deletions(-) create mode 100644 packages/studio/src/player/components/timelineSnapTargets.test.ts create mode 100644 packages/studio/src/player/components/timelineSnapTargets.ts diff --git a/packages/studio/src/player/components/BeatStrip.tsx b/packages/studio/src/player/components/BeatStrip.tsx index dc78e58fa3..0caa0e3263 100644 --- a/packages/studio/src/player/components/BeatStrip.tsx +++ b/packages/studio/src/player/components/BeatStrip.tsx @@ -28,13 +28,17 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({ beatTimes: number[] | undefined; beatStrengths: number[] | undefined; pps: number; - /** Beat time a dragged clip will snap to — drawn as a bright neon line. */ + /** Snap guide time — drawn as a bright line even when it is not a beat. */ highlightTime?: number | null; }) { - if (!beatTimes || beatsTooDense(beatTimes, pps)) return null; + const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null; + const highlightIsBeat = + highlightTime != null && + visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true; + if (!visibleBeatTimes && highlightTime == null) return null; return (
- {beatTimes.map((t, i) => { + {visibleBeatTimes?.map((t, i) => { const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3; const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2); const opacity = isHighlight ? 1 : 0.06 + strength * 0.16; @@ -52,6 +56,18 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({ /> ); })} + {highlightTime != null && !highlightIsBeat && ( +
+ )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 0c292abeac..196678b3fd 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -120,7 +120,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ selectedElementId, hoveredClip, draggedClip, - resizingClip: _resizingClip, + resizingClip, blockedClipRef, suppressClickRef, scrollRef, @@ -158,6 +158,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onRazorSplitAll, } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); + const activeSnapGuideTime = draggedClip?.started + ? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime) + : resizingClip?.started + ? resizingClip.snapGuideTime + : null; const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = draggedClip?.started === true && draggedElement @@ -312,12 +317,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({ )} {/* Faint beat lines in every track's background (behind the clips); - the active move-snap target is highlighted. */} + the active 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. */} @@ -394,6 +399,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({ previewStart: el.start, previewDuration: el.duration, previewPlaybackStart: el.playbackStart, + snapGuideTime: null, + snapGuideKind: null, started: false, }); }} @@ -452,6 +459,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({ previewLayerIndex: rowIndex, previewStackingReorder: null, snapBeatTime: null, + snapGuideTime: null, + snapGuideKind: null, started: false, }); syncClipDragAutoScroll(e.clientX, e.clientY); diff --git a/packages/studio/src/player/components/timelineSnapTargets.test.ts b/packages/studio/src/player/components/timelineSnapTargets.test.ts new file mode 100644 index 0000000000..534dded58f --- /dev/null +++ b/packages/studio/src/player/components/timelineSnapTargets.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { + buildTimelineSnapTargets, + snapEdgesToTargets, + snapResizeEdgeToTargets, +} from "./timelineSnapTargets"; + +function timelineElement(input: { + id: string; + key?: string; + start: number; + duration: number; +}): TimelineElement { + return { + id: input.id, + key: input.key, + tag: "div", + start: input.start, + duration: input.duration, + track: 0, + }; +} + +describe("buildTimelineSnapTargets", () => { + it("excludes the dragged clip's own edges", () => { + const dragged = timelineElement({ + id: "dragged-id", + key: "dragged-key", + start: 1, + duration: 2, + }); + const other = timelineElement({ id: "other", start: 4, duration: 2 }); + + const targets = buildTimelineSnapTargets({ + elements: [dragged, other], + draggedKey: "dragged-key", + playhead: 8, + compDuration: 10, + beats: [1.5], + }); + + const times = targets.map((target) => target.time); + expect(times).not.toContain(1); + expect(times).not.toContain(3); + expect(times).toContain(4); + expect(times).toContain(6); + }); + + it("dedupes near-equal times from different sources", () => { + const dragged = timelineElement({ id: "dragged", start: 2, duration: 2 }); + const other = timelineElement({ id: "other", start: 0.0004, duration: 10 }); + + const targets = buildTimelineSnapTargets({ + elements: [dragged, other], + draggedKey: "dragged", + playhead: 5, + compDuration: 10, + beats: [0.0002, 10.0002], + }); + + expect(targets.filter((target) => Math.abs(target.time) < 0.001)).toHaveLength(1); + expect(targets.filter((target) => Math.abs(target.time - 10) < 0.001)).toHaveLength(1); + }); +}); + +describe("snapEdgesToTargets", () => { + it("snaps the start edge to another clip's end", () => { + const snap = snapEdgesToTargets(3.95, 2, [{ time: 4, kind: "edge" }], 100); + + expect(snap).toEqual({ start: 4, snapTime: 4, snapKind: "edge" }); + }); + + it("snaps the end edge to another clip's start", () => { + const snap = snapEdgesToTargets(2.96, 2, [{ time: 5, kind: "edge" }], 100); + + expect(snap).toEqual({ start: 3, snapTime: 5, snapKind: "edge" }); + }); + + it("snaps to the playhead", () => { + const snap = snapEdgesToTargets(2.94, 1, [{ time: 3, kind: "playhead" }], 100); + + expect(snap).toEqual({ start: 3, snapTime: 3, snapKind: "playhead" }); + }); + + it("snaps the start edge to the lower composition bound", () => { + const snap = snapEdgesToTargets(0.04, 1, [{ time: 0, kind: "bound" }], 100); + + expect(snap).toEqual({ start: 0, snapTime: 0, snapKind: "bound" }); + }); + + it("snaps the end edge to the upper composition bound", () => { + const snap = snapEdgesToTargets(7.96, 2, [{ time: 10, kind: "bound" }], 100); + + expect(snap).toEqual({ start: 8, snapTime: 10, snapKind: "bound" }); + }); + + it("does not snap when targets are beyond the pixel threshold", () => { + const snap = snapEdgesToTargets(3.9, 1, [{ time: 4, kind: "edge" }], 100); + + expect(snap).toEqual({ start: 3.9, snapTime: null, snapKind: null }); + }); +}); + +describe("snapResizeEdgeToTargets", () => { + it("does not apply end-edge snaps past maxEnd or below minDuration", () => { + expect( + snapResizeEdgeToTargets("end", 4, 2.95, [{ time: 7.01, kind: "edge" }], 100, { + minDuration: 0.05, + maxEnd: 7, + }), + ).toEqual({ start: 4, duration: 2.95, snapTime: null, snapKind: null }); + + expect( + snapResizeEdgeToTargets("end", 4, 0.1, [{ time: 4.03, kind: "edge" }], 100, { + minDuration: 0.05, + maxEnd: 10, + }), + ).toEqual({ start: 4, duration: 0.1, snapTime: null, snapKind: null }); + }); +}); diff --git a/packages/studio/src/player/components/timelineSnapTargets.ts b/packages/studio/src/player/components/timelineSnapTargets.ts new file mode 100644 index 0000000000..10bdfa9635 --- /dev/null +++ b/packages/studio/src/player/components/timelineSnapTargets.ts @@ -0,0 +1,163 @@ +import type { TimelineElement } from "../store/playerStore"; + +export type TimelineSnapKind = "beat" | "edge" | "playhead" | "bound"; + +export interface TimelineSnapTarget { + time: number; + kind: TimelineSnapKind; +} + +interface NearestSnap { + target: TimelineSnapTarget; + distance: number; +} + +const SNAP_PX = 8; +const DEDUPE_EPSILON_SECONDS = 0.001; +const ROUND_FACTOR = 1000; +const KIND_PRIORITY: Record = { + bound: 0, + playhead: 1, + edge: 2, + beat: 3, +}; + +function roundToMillis(value: number): number { + return Math.round(value * ROUND_FACTOR) / ROUND_FACTOR; +} + +function addTarget(targets: TimelineSnapTarget[], candidate: TimelineSnapTarget) { + if (!Number.isFinite(candidate.time)) return; + const existingIndex = targets.findIndex( + (target) => Math.abs(target.time - candidate.time) < DEDUPE_EPSILON_SECONDS, + ); + if (existingIndex === -1) { + targets.push(candidate); + return; + } + + const existing = targets[existingIndex]; + if (!existing || KIND_PRIORITY[candidate.kind] >= KIND_PRIORITY[existing.kind]) return; + targets[existingIndex] = candidate; +} + +export function buildTimelineSnapTargets(input: { + elements: TimelineElement[]; + draggedKey: string; + playhead: number; + compDuration: number; + beats: number[]; +}): TimelineSnapTarget[] { + const targets: TimelineSnapTarget[] = []; + + addTarget(targets, { time: 0, kind: "bound" }); + addTarget(targets, { time: Math.max(0, input.compDuration), kind: "bound" }); + addTarget(targets, { time: Math.max(0, input.playhead), kind: "playhead" }); + + for (const element of input.elements) { + const elementKey = element.key ?? element.id; + if (elementKey === input.draggedKey || element.id === input.draggedKey) continue; + addTarget(targets, { time: element.start, kind: "edge" }); + addTarget(targets, { time: element.start + element.duration, kind: "edge" }); + } + + for (const beat of input.beats) { + addTarget(targets, { time: beat, kind: "beat" }); + } + + return targets.sort((a, b) => a.time - b.time || KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind]); +} + +function nearestSnap( + time: number, + targets: TimelineSnapTarget[], + thresholdSeconds: number, +): NearestSnap | null { + let best: NearestSnap | null = null; + let bestDistance = thresholdSeconds; + for (const target of targets) { + if (target.time === time) continue; + const distance = Math.abs(target.time - time); + if (distance < bestDistance) { + bestDistance = distance; + best = { target, distance }; + } + } + return best; +} + +export function snapEdgesToTargets( + start: number, + duration: number, + targets: TimelineSnapTarget[], + pixelsPerSecond: number, + options?: { maxStart?: number }, +): { start: number; snapTime: number | null; snapKind: TimelineSnapKind | null } { + const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1); + const startSnap = nearestSnap(start, targets, thresholdSeconds); + const endSnap = nearestSnap(start + duration, targets, thresholdSeconds); + + let candidate = start; + let snapTarget: TimelineSnapTarget | null = null; + if (startSnap && (!endSnap || startSnap.distance <= endSnap.distance)) { + candidate = startSnap.target.time; + snapTarget = startSnap.target; + } else if (endSnap) { + candidate = endSnap.target.time - duration; + snapTarget = endSnap.target; + } + + const maxStart = options?.maxStart ?? Number.POSITIVE_INFINITY; + const upperStart = Number.isFinite(maxStart) ? Math.max(0, maxStart) : Number.POSITIVE_INFINITY; + const clamped = Math.max(0, Math.min(upperStart, roundToMillis(candidate))); + if (snapTarget && Math.abs(clamped - candidate) > 1e-6) { + return { start: clamped, snapTime: null, snapKind: null }; + } + return { + start: clamped, + snapTime: snapTarget?.time ?? null, + snapKind: snapTarget?.kind ?? null, + }; +} + +export function snapResizeEdgeToTargets( + edge: "start" | "end", + start: number, + duration: number, + targets: TimelineSnapTarget[], + pixelsPerSecond: number, + limits: { minDuration: number; maxEnd: number; maxLeftDelta?: number }, +): { start: number; duration: number; snapTime: number | null; snapKind: TimelineSnapKind | null } { + const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1); + + if (edge === "end") { + const snap = nearestSnap(start + duration, targets, thresholdSeconds); + if (!snap) return { start, duration, snapTime: null, snapKind: null }; + const snappedDuration = roundToMillis(snap.target.time - start); + if (snap.target.time > limits.maxEnd + 1e-6 || snappedDuration < limits.minDuration) { + return { start, duration, snapTime: null, snapKind: null }; + } + return { + start, + duration: snappedDuration, + snapTime: snap.target.time, + snapKind: snap.target.kind, + }; + } + + const snap = nearestSnap(start, targets, thresholdSeconds); + if (!snap) return { start, duration, snapTime: null, snapKind: null }; + const snappedStart = roundToMillis(snap.target.time); + const delta = start - snappedStart; + const snappedDuration = roundToMillis(duration + delta); + const maxLeftDelta = limits.maxLeftDelta ?? Number.POSITIVE_INFINITY; + if (snappedStart < 0 || delta > maxLeftDelta + 1e-6 || snappedDuration < limits.minDuration) { + return { start, duration, snapTime: null, snapKind: null }; + } + return { + start: snappedStart, + duration: snappedDuration, + snapTime: snap.target.time, + snapKind: snap.target.kind, + }; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index 58d113ba57..0c71c0ffbf 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -102,6 +102,8 @@ function renderDragHarness(elements: TimelineElement[]) { previewLayerIndex: layerIndex, previewStackingReorder: null, snapBeatTime: null, + snapGuideTime: null, + snapGuideKind: null, started: false, }); }); @@ -115,6 +117,8 @@ function renderDragHarness(elements: TimelineElement[]) { previewStart: element.start, previewDuration: element.duration, previewPlaybackStart: element.playbackStart, + snapGuideTime: null, + snapGuideKind: null, started: false, }); }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index f7c51d8958..1c8aabeb3c 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -13,63 +13,15 @@ import { TRACK_H } from "./timelineLayout"; import { isMusicTrack } from "../../utils/timelineInspector"; import { mergeUserBeats } from "../../utils/beatEditing"; import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder"; +import { + buildTimelineSnapTargets, + snapEdgesToTargets, + snapResizeEdgeToTargets, + type TimelineSnapKind, +} from "./timelineSnapTargets"; -const BEAT_SNAP_PX = 8; const EMPTY_BEAT_TIMES: number[] = []; -function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: number): number { - let best = time; - let bestDist = thresholdSecs; - for (const bt of beatTimes) { - const d = Math.abs(bt - time); - if (d < bestDist) { - bestDist = d; - best = bt; - } - } - return best; -} - -/** - * Snap a moved clip so whichever edge (start or end) is nearest a beat lands on - * it, keeping the duration fixed. Returns the (clamped) start plus the beat time - * it snapped to (for the grid-line highlight), or `beat: null` when no edge is - * within threshold. - */ -function snapMoveStartToBeat( - start: number, - duration: number, - beatTimes: number[], - pixelsPerSecond: number, - timelineDuration: number, -): { start: number; beat: number | null } { - if (beatTimes.length === 0) return { start, beat: null }; - const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1); - const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs); - const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs); - const startMoved = snappedStart !== start; - const endMoved = snappedEnd !== start + duration; - - let candidate = start; - let beat: number | null = null; - if ( - startMoved && - (!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration))) - ) { - candidate = snappedStart; - beat = snappedStart; - } else if (endMoved) { - candidate = snappedEnd - duration; - beat = snappedEnd; - } - - const maxStart = Math.max(0, timelineDuration - duration); - const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000)); - // If clamping pulled the clip off the snap target, drop the highlight. - if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null; - return { start: clamped, beat }; -} - /* ── Shared state types ─────────────────────────────────────────── */ export interface DraggedClipState { element: TimelineElement; @@ -87,6 +39,8 @@ export interface DraggedClipState { previewLayerIndex: number; /** Beat time the clip will snap to on drop, for the grid-line highlight. */ snapBeatTime: number | null; + snapGuideTime: number | null; + snapGuideKind: TimelineSnapKind | null; /** Sibling-scoped z-index reorder intent resolved from the vertical drag. */ previewStackingReorder: TimelineStackingReorderIntent | null; started: boolean; @@ -99,6 +53,8 @@ export interface ResizingClipState { previewStart: number; previewDuration: number; previewPlaybackStart?: number; + snapGuideTime: number | null; + snapGuideKind: TimelineSnapKind | null; started: boolean; } @@ -149,6 +105,8 @@ export function useTimelineClipDrag({ const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES); const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES); const beatEdits = usePlayerStore((s) => s.beatEdits); + const playhead = usePlayerStore((s) => s.currentTime); + const compositionDuration = usePlayerStore((s) => s.duration); const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0); const musicPlaybackStart = usePlayerStore( (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0, @@ -176,6 +134,22 @@ export function useTimelineClipDrag({ const beatTimesRef = useRef([]); beatTimesRef.current = adjustedBeatTimes; + const playheadRef = useRef(0); + playheadRef.current = playhead; + const compositionDurationRef = useRef(0); + compositionDurationRef.current = compositionDuration; + + const buildSnapTargets = useCallback( + (element: TimelineElement) => + buildTimelineSnapTargets({ + elements: timelineElementsRef.current, + draggedKey: element.key ?? element.id, + playhead: playheadRef.current, + compDuration: compositionDurationRef.current, + beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current, + }), + [timelineElementsRef], + ); const [draggedClip, setDraggedClip] = useState(null); const draggedClipRef = useRef(null); @@ -222,16 +196,13 @@ export function useTimelineClipDrag({ clientX, clientY, ); - // The music track defines the beats, so it must not snap to itself. - const snap = isMusicTrack(drag.element) - ? { start: nextMove.start, beat: null } - : snapMoveStartToBeat( - nextMove.start, - drag.element.duration, - beatTimesRef.current, - ppsRef.current, - Number.POSITIVE_INFINITY, - ); + const snap = snapEdgesToTargets( + nextMove.start, + drag.element.duration, + buildSnapTargets(drag.element), + ppsRef.current, + { maxStart: Number.POSITIVE_INFINITY }, + ); return { ...drag, started: true, @@ -242,10 +213,12 @@ export function useTimelineClipDrag({ previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId, previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex, previewStackingReorder: nextMove.stackingReorder ?? null, - snapBeatTime: snap.beat, + snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null, + snapGuideTime: snap.snapTime, + snapGuideKind: snap.snapKind, }; }, - [scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef], + [scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef, buildSnapTargets], ); const stopClipDragAutoScroll = useCallback(() => { @@ -359,51 +332,32 @@ export function useTimelineClipDrag({ e.clientX, ); - // Snap edge to beat grid when beat analysis is available. The snap must - // stay inside the same limits resolveTimelineResize enforces, or it would - // push the edge past the available source media / composition end. - // The music track defines the beats, so it must not snap to itself. - const beatTimes = beatTimesRef.current; - if (beatTimes.length > 0 && !isMusicTrack(resize.element)) { - const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1); - if (resize.edge === "end") { - const edgeTime = nextResize.start + nextResize.duration; - const snapped = snapToNearestBeat(edgeTime, beatTimes, snapSecs); - // Stay within [start+minDuration, maxEnd] so the snap can't create a - // degenerate clip or run past the source/composition limit. - const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000; - if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) { - nextResize = { ...nextResize, duration: snappedDuration }; - } - } else { - const snapped = snapToNearestBeat(nextResize.start, beatTimes, snapSecs); - const delta = nextResize.start - snapped; // >0 when snapping left - // Leftward snap reveals more source; cap so playbackStart can't go < 0. - const maxLeftDelta = - nextResize.playbackStart != null + const snap = snapResizeEdgeToTargets( + resize.edge, + nextResize.start, + nextResize.duration, + buildSnapTargets(resize.element), + ppsRef.current, + { + minDuration: 0.05, + maxEnd, + maxLeftDelta: + resize.edge === "start" && nextResize.playbackStart != null ? nextResize.playbackStart / playbackRate - : Number.POSITIVE_INFINITY; - // Also require the resulting duration to stay >= minDuration so a - // rightward snap (delta < 0) can't collapse the clip to zero/negative. - const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000; - if ( - snapped !== nextResize.start && - snapped >= 0 && - delta <= maxLeftDelta + 1e-6 && - snappedDuration >= 0.05 - ) { - nextResize = { - ...nextResize, - start: snapped, - duration: snappedDuration, - playbackStart: - nextResize.playbackStart != null - ? Math.round( - Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000, - ) / 1000 - : undefined, - }; - } + : Number.POSITIVE_INFINITY, + }, + ); + if (snap.snapTime != null) { + const unsnappedStart = nextResize.start; + nextResize = { ...nextResize, start: snap.start, duration: snap.duration }; + if (resize.edge === "start" && nextResize.playbackStart != null) { + const delta = unsnappedStart - snap.start; + nextResize = { + ...nextResize, + playbackStart: + Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) / + 1000, + }; } } @@ -415,6 +369,8 @@ export function useTimelineClipDrag({ previewStart: nextResize.start, previewDuration: nextResize.duration, previewPlaybackStart: nextResize.playbackStart, + snapGuideTime: snap.snapTime, + snapGuideKind: snap.snapKind, } : prev, ); From 580676bbf46d2d6c5a39df0ccb6860407521adda Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 19:42:03 -0400 Subject: [PATCH 14/23] fix(studio): freeze timeline zoom during extend-drag so clips don't jump Dragging a clip past the video end fed back on itself: the growing preview length shrank the fit-to-width zoom, which remapped the pointer, moved the clip, and grew the length again (visible jumping). Split the committed basis duration (drives zoom) from the displayed effective duration (adds the live preview for ruler + width). Zoom holds fixed through the gesture and the extra length scrolls; it re-fits once on drop. Duration math extracted to pure tested helpers in timelineLayout. --- .../studio/src/player/components/Timeline.tsx | 40 +++++++++++-------- .../player/components/timelineLayout.test.ts | 34 ++++++++++++++++ .../src/player/components/timelineLayout.ts | 31 ++++++++++++++ 3 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 packages/studio/src/player/components/timelineLayout.test.ts diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 91a4b6fa8f..88b6a78631 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -30,6 +30,8 @@ import { generateTicks, getTimelineCanvasHeight, shouldShowTimelineShortcutHint, + computeTimelineBasisDuration, + computeTimelineEffectiveDuration, } from "./timelineLayout"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; @@ -228,20 +230,25 @@ export const Timeline = memo(function Timeline({ setRangeSelectionRef, }); - const effectiveDuration = useMemo(() => { - const safeDur = Number.isFinite(duration) ? duration : 0; - let maxEnd = safeDur; - if (rawElements.length > 0) { - maxEnd = Math.max(maxEnd, ...rawElements.map((el) => el.start + el.duration)); - } - if (draggedClip?.started) { - maxEnd = Math.max(maxEnd, draggedClip.previewStart + draggedClip.element.duration); - } - if (resizingClip?.started) { - maxEnd = Math.max(maxEnd, resizingClip.previewStart + resizingClip.previewDuration); - } - return Number.isFinite(maxEnd) ? maxEnd : safeDur; - }, [rawElements, duration, draggedClip, resizingClip]); + // basisDuration drives the zoom (committed, no live preview); effectiveDuration + // adds the active drag/resize preview so the ruler/width follow a past-end drag. + // See timelineLayout for why the split prevents jump-during-extend. + const basisDuration = useMemo( + () => + computeTimelineBasisDuration( + duration, + rawElements.map((el) => el.start + el.duration), + ), + [rawElements, duration], + ); + const effectiveDuration = useMemo( + () => + computeTimelineEffectiveDuration(basisDuration, [ + draggedClip?.started ? draggedClip.previewStart + draggedClip.element.duration : null, + resizingClip?.started ? resizingClip.previewStart + resizingClip.previewDuration : null, + ]), + [basisDuration, draggedClip, resizingClip], + ); durationRef.current = effectiveDuration; const displayTrackOrder = useMemo(() => { @@ -273,9 +280,10 @@ export const Timeline = memo(function Timeline({ const selectedElementRef = useRef(selectedElement); selectedElementRef.current = selectedElement; + // Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom. const fitPps = - viewportWidth > GUTTER && effectiveDuration > 0 - ? (viewportWidth - GUTTER - 2) / effectiveDuration + viewportWidth > GUTTER && basisDuration > 0 + ? (viewportWidth - GUTTER - 2) / basisDuration : 100; const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent); ppsRef.current = pps; diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts new file mode 100644 index 0000000000..98f8217ebd --- /dev/null +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout"; + +describe("computeTimelineBasisDuration", () => { + it("uses the root duration when it exceeds every clip end", () => { + expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12); + }); + + it("grows to the furthest committed clip end past the root duration", () => { + expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18); + }); + + it("falls back to the root duration with no clips / non-finite ends", () => { + expect(computeTimelineBasisDuration(10, [])).toBe(10); + expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0); + }); +}); + +describe("computeTimelineEffectiveDuration", () => { + it("returns the basis when there is no active preview", () => { + expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12); + }); + + it("extends to a drag/resize preview end beyond the basis", () => { + expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20); + expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16); + }); + + it("never shrinks below the basis for a preview inside the current length", () => { + // The invariant behind the jump fix: the basis (which drives zoom) is + // independent of the preview, and a smaller preview end can't reduce it. + expect(computeTimelineEffectiveDuration(12, [8])).toBe(12); + }); +}); diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 9944d3c95e..69f372cb6f 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -9,6 +9,37 @@ export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; const TIMELINE_SCROLL_BUFFER = 20; +/* ── Timeline duration ─────────────────────────────────────────────── */ + +// Committed timeline length: root duration or the furthest committed clip end, +// with NO live drag/resize preview. This drives the zoom (fit-to-width pps) so +// the pixels-per-second mapping stays fixed while you drag — otherwise a clip +// dragged past the end grows the duration, shrinks pps, and jumps under the +// pointer (a positive-feedback loop). The zoom re-fits once on drop. +export function computeTimelineBasisDuration( + rootDuration: number, + clipEnds: readonly number[], +): number { + const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0; + if (clipEnds.length === 0) return safeDur; + const maxEnd = Math.max(safeDur, ...clipEnds); + return Number.isFinite(maxEnd) ? maxEnd : safeDur; +} + +// Displayed length: the basis plus any active drag/resize preview end, so the +// ruler and track width grow to follow a clip dragged past the current end +// (with the zoom held fixed, the extra length becomes scrollable content). +export function computeTimelineEffectiveDuration( + basisDuration: number, + previewEnds: readonly (number | null)[], +): number { + let maxEnd = basisDuration; + for (const end of previewEnds) { + if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end); + } + return maxEnd; +} + /* ── Tick generation ──────────────────────────────────────────────── */ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number { const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; From 9f6c20e4826e1304114dcdf8f963da6d75ef0cc2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 20:36:19 -0400 Subject: [PATCH 15/23] perf(studio): grow composition duration live on extend, no preview remount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extending a clip past the video end used to force the server-fallback path that fully remounts the preview iframe (the SDK fast path can't express the root composition's data-duration, and the runtime bakes+drops data-duration at load so it can't be patched live). On a large comp that remount is a visible hitch. Add a runtime control-bridge action set-root-duration -> clock.setDuration, so the studio can grow the transport length in place. On an extend the studio now posts it (and patches the clip's own timing live) instead of reloading; it only reloads when a GSAP source rewrite actually happened (the gsap-mutation endpoints now report a mutated flag). Non-animated extends — the common case — commit as fast as a normal edit. Verified: bridge dispatch + studio no-reload/post-message paths unit- tested; core/studio/studio-server typecheck + suites green; the built runtime artifact carries the handler; E2E confirms the extend no longer remounts the preview and still persists. --- packages/core/src/runtime/bridge.test.ts | 8 ++ packages/core/src/runtime/bridge.ts | 2 + packages/core/src/runtime/init.ts | 26 +++++++ packages/core/src/runtime/types.ts | 2 + .../studio-server/src/routes/files.test.ts | 28 +++++++ packages/studio-server/src/routes/files.ts | 14 ++++ .../src/hooks/timelineEditingHelpers.ts | 74 +++++++++++++++++-- .../src/hooks/useTimelineEditing.test.tsx | 38 +++++++++- .../studio/src/hooks/useTimelineEditing.ts | 67 +++++++++-------- 9 files changed, 221 insertions(+), 38 deletions(-) diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 4ab0f9053c..7e58cdfcec 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -16,6 +16,7 @@ function createMockDeps() { onSetPlaybackRate: vi.fn(), onSetColorGrading: vi.fn(), onSetColorGradingCompare: vi.fn(), + onSetRootDuration: vi.fn(), onEnablePickMode: vi.fn(), onDisablePickMode: vi.fn(), }; @@ -155,6 +156,13 @@ describe("installRuntimeControlBridge", () => { expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1); }); + it("dispatches set-root-duration command with numeric seconds", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("set-root-duration", { durationSeconds: "18.5" })); + expect(deps.onSetRootDuration).toHaveBeenCalledWith(18.5); + }); + it("dispatches set-color-grading command with target and grading payload", () => { const deps = createMockDeps(); const handler = installRuntimeControlBridge(deps); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index 6430cdeb9a..44597243a5 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -14,6 +14,7 @@ type BridgeDeps = { onSetNativeMediaSyncDisabled: (disabled: boolean) => void; onSetWebAudioMediaDisabled: (disabled: boolean) => void; onSetPlaybackRate: (rate: number) => void; + onSetRootDuration: (durationSeconds: number) => void; onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void; onSetColorGradingCompare: ( target: HfColorGradingTarget | string | null, @@ -53,6 +54,7 @@ const CONTROL_HANDLERS: Record = { "set-web-audio-media-disabled": (data, deps) => deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)), "set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)), + "set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)), "set-color-grading": (data, deps) => deps.onSetColorGrading(data.target ?? null, data.grading ?? null), "set-color-grading-compare": (data, deps) => diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a55984df76..2002c31023 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1852,6 +1852,7 @@ export function initSandboxRuntimeModular(): void { // transport tick. A plain count misses same-count swaps (one sub-comp unloads // as another loads), so the signature keys on id+tag in document order. let clipTreeSignature = ""; + let liveRootDurationOverrideSeconds = 0; const computeClipTreeSignature = (): string => { let sig = ""; for (const el of document.querySelectorAll("[data-start]")) { @@ -1904,6 +1905,30 @@ export function initSandboxRuntimeModular(): void { scheduleRootStageLayoutDiagnostics(); }; + const finitePositiveDuration = (value: number): number => + Number.isFinite(value) && value > 0 ? value : 0; + + const growRootDurationLive = (durationSeconds: number) => { + const nextDuration = finitePositiveDuration(Number(durationSeconds)); + if (nextDuration <= 0) return; + const rootEl = resolveRootCompositionElement(); + const rootAttrDuration = finitePositiveDuration( + Number.parseFloat(rootEl?.getAttribute("data-duration") ?? ""), + ); + const currentDuration = Math.max( + liveRootDurationOverrideSeconds, + finitePositiveDuration(clock.getDuration()), + rootAttrDuration, + ); + if (nextDuration <= currentDuration) return; + + liveRootDurationOverrideSeconds = nextDuration; + rootEl?.setAttribute("data-duration", String(nextDuration)); + clock.setDuration(nextDuration); + postTimeline(); + postState(true); + }; + const runAdapters = (method: "discover" | "pause" | "play", timeSeconds = 0) => { for (const adapter of state.deterministicAdapters) { try { @@ -2193,6 +2218,7 @@ export function initSandboxRuntimeModular(): void { if (state.transportClock) state.transportClock.setRate(state.playbackRate); applyWebAudioRate(); }, + onSetRootDuration: growRootDurationLive, onSetColorGrading: (target, grading) => { colorGrading.setGrading(target, grading); }, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index c2745d215d..b2b6648149 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -18,6 +18,7 @@ export type RuntimeBridgeControlAction = | "set-media-output-muted" | "set-native-media-sync-disabled" | "set-web-audio-media-disabled" + | "set-root-duration" | "stop-media" | "flash-elements"; @@ -28,6 +29,7 @@ export type RuntimeBridgeControlMessage = { frame?: number; muted?: boolean; volume?: number; + durationSeconds?: number; disabled?: boolean; playbackRate?: number; target?: HfColorGradingTarget | string | null; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 766c15b147..dfdcdcb9f8 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -227,18 +227,46 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas }); const result = (await res.json()) as { ok: boolean; + mutated?: boolean; after: string; parsed: { animations: Array<{ fromProperties?: Record }> }; }; expect(res.status).toBe(200); expect(result.ok).toBe(true); + expect(result.mutated).toBe(true); expect(result.after).toContain("opacity: 0.2"); expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2); // x unchanged expect(result.parsed.animations[0].fromProperties?.x).toBe(-50); }); + it("reports no GSAP mutation when shifting positions in a file with no GSAP script", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "shift-positions", + targetSelector: "#box", + delta: 1, + }), + }); + const result = (await res.json()) as { + ok?: boolean; + changed?: boolean; + mutated?: boolean; + }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.changed).toBe(false); + expect(result.mutated).toBe(false); + }); + it("consolidate-position-writes leaves exactly one position write per selector", async () => { const projectDir = createProjectDir(); const CORRUPTED = `