diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1de3f3d2f..b82f59b79 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 69fb58150..66c6c31b3 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 b64be1480..7f075d783 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 3f81db565..6e14c32b5 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; + 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 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", - })), - ); + 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 3990efbcb..b26460fcb 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 ea70dc90f..f77948fd9 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 9e46e0932..8a2c526e7 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 ded4b386f..3d87f7580 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 (