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] 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 5dd7e2eb4..1de3f3d2f 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 6dd651d45..de0619427 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 000000000..b427e9706 --- /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 885986831..590e0e055 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 ddfe5b71b..2e121275b 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 f77948fd9..ea70dc90f 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 1a182c6f5..9e46e0932 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 fff052639..ded4b386f 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 129104dfb..2fc295ad4 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 720847048..b068dd8b3 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 bcefba970..0eee5b91c 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 000000000..dc4c151e4 --- /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 000000000..24ab5db94 --- /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 db90fd085..1b445167f 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 f016b11bc..6292dd9c2 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] ?? ""; }