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)
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:25 -04:00
parent 03f8089e52
commit e5ed512529
24 changed files with 1078 additions and 426 deletions
+1
View File
@@ -525,6 +525,7 @@ export function StudioApp() {
handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden} handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit} handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -59,6 +59,7 @@ export interface StudioPreviewAreaProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">, updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void; ) => Promise<void> | void;
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void; handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void; handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void; handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
handleTimelineElementMove, handleTimelineElementMove,
handleTimelineElementResize, handleTimelineElementResize,
handleToggleTrackHidden, handleToggleTrackHidden,
handleToggleElementHidden,
handleBlockedTimelineEdit, handleBlockedTimelineEdit,
handleTimelineElementSplit, handleTimelineElementSplit,
handleRazorSplit, handleRazorSplit,
@@ -182,6 +184,7 @@ export function StudioPreviewArea({
onMoveElement: handleTimelineElementMove, onMoveElement: handleTimelineElementMove,
onResizeElement: handleTimelineElementResize, onResizeElement: handleTimelineElementResize,
onToggleTrackHidden: handleToggleTrackHidden, onToggleTrackHidden: handleToggleTrackHidden,
onToggleElementHidden: handleToggleElementHidden,
onBlockedEditAttempt: handleBlockedTimelineEdit, onBlockedEditAttempt: handleBlockedTimelineEdit,
onSplitElement: handleTimelineElementSplit, onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit, onRazorSplit: handleRazorSplit,
@@ -282,6 +285,7 @@ export function StudioPreviewArea({
handleTimelineElementMove, handleTimelineElementMove,
handleTimelineElementResize, handleTimelineElementResize,
handleToggleTrackHidden, handleToggleTrackHidden,
handleToggleElementHidden,
handleBlockedTimelineEdit, handleBlockedTimelineEdit,
handleTimelineElementSplit, handleTimelineElementSplit,
handleRazorSplit, handleRazorSplit,
@@ -33,6 +33,7 @@ export function TimelineEditProvider({
value.onMoveElement, value.onMoveElement,
value.onResizeElement, value.onResizeElement,
value.onToggleTrackHidden, value.onToggleTrackHidden,
value.onToggleElementHidden,
value.onBlockedEditAttempt, value.onBlockedEditAttempt,
value.onSplitElement, value.onSplitElement,
value.onRazorSplit, value.onRazorSplit,
@@ -2,10 +2,9 @@ import { type TimelineElement, usePlayerStore } from "../player/store/playerStor
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { import {
formatTimelineAttributeNumber, formatTimelineAttributeNumber,
resolveTimelineStackingReorderByTargetTrack,
type TimelineStackingReorderIntent, type TimelineStackingReorderIntent,
} from "../player/components/timelineEditing"; } from "../player/components/timelineEditing";
import { computeReorderZValues, getElementZIndex } from "../player/lib/layerOrdering"; import { getElementZIndex } from "../player/lib/layerOrdering";
import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers"; import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; 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. // Audio has no visual stacking; a vertical drag on it must never write z-index.
if (input.element.tag === "audio") return; if (input.element.tag === "audio") return;
const intent = const intent = input.stackingReorder ?? null;
input.stackingReorder ?? if (intent == null || intent.zIndexChanges.length === 0) return;
(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( const siblingByKey = new Map(
input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]), input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
); );
const orderedSiblings = intent.siblingKeys const commitEntries: Array<{
.map((key) => siblingByKey.get(key) ?? null) element: HTMLElement;
.filter((sibling): sibling is TimelineElement => sibling != null); zIndex: number;
if (orderedSiblings.length !== intent.siblingKeys.length) return; id?: string;
selector?: string;
selectorIndex?: number;
sourceFile: string;
key: string;
}> = [];
const liveEntries = orderedSiblings for (const change of intent.zIndexChanges) {
.map((sibling) => ({ sibling, element: findTimelineElementInIframe(input.iframe, sibling) })) const sibling = siblingByKey.get(change.key);
.filter((entry): entry is { sibling: TimelineElement; element: HTMLElement } => if (!sibling) return;
isHTMLElement(entry.element), const element = findTimelineElementInIframe(input.iframe, sibling);
); if (!isHTMLElement(element)) return;
if (liveEntries.length !== orderedSiblings.length) 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]; if (commitEntries.length === 0) return;
const [moved] = reordered.splice(intent.fromIndex, 1); input.commit?.(commitEntries);
if (!moved) return; const store = usePlayerStore.getState();
reordered.splice(intent.toIndex, 0, moved); for (const entry of commitEntries) {
store.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
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",
})),
);
} }
/** /**
@@ -208,22 +208,28 @@ async function flushAsyncWork(): Promise<void> {
describe("useTimelineEditing timeline z-index reorder", () => { describe("useTimelineEditing timeline z-index reorder", () => {
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
const iframe = createPreviewIframe([ const iframe = createPreviewIframe([
{ id: "front", track: 0 }, { id: "front", track: 0, style: "position: relative; z-index: 10" },
{ id: "middle", track: 1 }, { id: "back", track: 2, style: "position: relative; z-index: 1" },
{ id: "back", track: 2 },
]); ]);
const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); const front = timelineElement({ id: "front", track: 0, zIndex: 10 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 0 }); const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
const back = timelineElement({ id: "back", track: 2, zIndex: 0 });
const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
const { move, unmount } = renderTimelineEditingHook({ const { move, unmount } = renderTimelineEditingHook({
timelineElements: [front, middle, back], timelineElements: [front, back],
iframe, iframe,
onZIndexCommit: commit, onZIndexCommit: commit,
}); });
await act(async () => { 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; const doc = iframe.contentDocument;
@@ -231,9 +237,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
expect(commit).toHaveBeenCalledTimes(1); expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
["back", 3], ["back", 10],
["front", 2],
["middle", 1],
]); ]);
expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2"); expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2");
@@ -255,7 +259,15 @@ describe("useTimelineEditing timeline z-index reorder", () => {
}); });
await act(async () => { 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(); expect(commit).not.toHaveBeenCalled();
@@ -263,30 +275,40 @@ describe("useTimelineEditing timeline z-index reorder", () => {
unmount(); 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([ const iframe = createPreviewIframe([
{ id: "front", track: 0, style: "position: relative; z-index: 10" }, { id: "front", track: 0, style: "position: relative; z-index: 2" },
{ id: "middle", track: 1, style: "position: relative; z-index: 5" }, { id: "back", track: 1, style: "position: relative; z-index: 1" },
{ id: "back", track: 2, 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 front = timelineElement({ id: "front", track: 0, zIndex: 2 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 5 }); const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 });
const commit = vi.fn<(entries: ZIndexEntry[]) => void>(); const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
const { move, unmount } = renderTimelineEditingHook({ const { move, unmount } = renderTimelineEditingHook({
timelineElements: [front, middle, back], timelineElements: [front, back, dragged],
iframe, iframe,
onZIndexCommit: commit, onZIndexCommit: commit,
}); });
await act(async () => { 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([ expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
["back", 10], ["dragged", 2],
["front", 5], ["front", 3],
["middle", 1],
]); ]);
unmount(); unmount();
@@ -307,7 +329,15 @@ describe("useTimelineEditing timeline z-index reorder", () => {
}); });
await act(async () => { 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(); await flushAsyncWork();
}); });
@@ -18,11 +18,9 @@ import {
shouldHandleTimelineDeleteKey, shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline, shouldAutoScrollTimeline,
} from "./Timeline"; } from "./Timeline";
import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder";
import { RULER_H, TRACK_H } from "./timelineLayout"; import { RULER_H, TRACK_H } from "./timelineLayout";
import { formatTime } from "../lib/time"; import { formatTime } from "../lib/time";
import { usePlayerStore } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
globalThis.IS_REACT_ACT_ENVIRONMENT = true; 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", () => { describe("generateTicks", () => {
it("returns empty arrays for duration <= 0", () => { it("returns empty arrays for duration <= 0", () => {
expect(generateTicks(0)).toEqual({ major: [], minor: [] }); expect(generateTicks(0)).toEqual({ major: [], minor: [] });
@@ -23,7 +23,7 @@ import {
import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu"; import { ClipContextMenu } from "./ClipContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint"; import { TimelineShortcutHint } from "./TimelineShortcutHint";
import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder"; import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
import { import {
GUTTER, GUTTER,
generateTicks, generateTicks,
@@ -187,19 +187,24 @@ export const Timeline = memo(function Timeline({
return Number.isFinite(result) ? result : safeDur; return Number.isFinite(result) ? result : safeDur;
}, [rawElements, duration]); }, [rawElements, duration]);
const tracks = useMemo(() => buildStackingTimelineTracks(expandedElements), [expandedElements]); const tracks = useMemo(
() => buildStackingTimelineLayers(expandedElements).rows,
[expandedElements],
);
const trackStyles = useMemo(() => { const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>(); const map = new Map<string, TrackVisualStyle>();
for (const [trackNum, els] of tracks) { for (const layer of tracks) {
map.set(trackNum, getTrackStyle(els[0]?.tag ?? "")); map.set(layer.id, getTrackStyle(layer.elements[0]?.tag ?? ""));
} }
return map; return map;
}, [tracks]); }, [tracks]);
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]); const trackOrder = useMemo(() => tracks.map((layer) => layer.id), [tracks]);
const trackOrderRef = useRef(trackOrder); const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder; trackOrderRef.current = trackOrder;
const timelineLayersRef = useRef(tracks);
timelineLayersRef.current = tracks;
const expandedElementsRef = useRef(expandedElements); const expandedElementsRef = useRef(expandedElements);
expandedElementsRef.current = expandedElements; expandedElementsRef.current = expandedElements;
@@ -223,6 +228,7 @@ export const Timeline = memo(function Timeline({
ppsRef, ppsRef,
durationRef, durationRef,
trackOrderRef, trackOrderRef,
timelineLayersRef,
timelineElementsRef: expandedElementsRef, timelineElementsRef: expandedElementsRef,
onMoveElement, onMoveElement,
onResizeElement, onResizeElement,
@@ -235,10 +241,14 @@ export const Timeline = memo(function Timeline({
if ( if (
!draggedClip?.started || !draggedClip?.started ||
trackOrder.length === 0 || trackOrder.length === 0 ||
trackOrder.includes(draggedClip.previewTrack) trackOrder.includes(draggedClip.previewLayerId)
) )
return trackOrder; return trackOrder;
return insertPreviewTrackOrder(trackOrder, draggedClip.previewTrack); return insertPreviewTrackOrder(
trackOrder,
draggedClip.previewLayerId,
draggedClip.previewLayerIndex,
);
}, [draggedClip, trackOrder]); }, [draggedClip, trackOrder]);
const totalH = getTimelineCanvasHeight(displayTrackOrder.length); const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
@@ -369,6 +379,7 @@ export const Timeline = memo(function Timeline({
ppsRef, ppsRef,
durationRef, durationRef,
trackOrderRef, trackOrderRef,
timelineLayersRef,
onFileDrop, onFileDrop,
onAssetDrop, onAssetDrop,
onBlockDrop, onBlockDrop,
@@ -20,6 +20,7 @@ import {
} from "../store/playerStore"; } from "../store/playerStore";
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
import type { TrackVisualStyle } from "./timelineIcons"; import type { TrackVisualStyle } from "./timelineIcons";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
@@ -47,10 +48,10 @@ interface TimelineCanvasProps {
majorTickInterval: number; majorTickInterval: number;
rangeSelection: TimelineRangeSelection | null; rangeSelection: TimelineRangeSelection | null;
theme: TimelineTheme; theme: TimelineTheme;
displayTrackOrder: number[]; displayTrackOrder: TimelineLayerId[];
trackOrder: number[]; trackOrder: TimelineLayerId[];
tracks: [number, TimelineElement[]][]; tracks: StackingTimelineLayer[];
trackStyles: Map<number, TrackVisualStyle>; trackStyles: Map<TimelineLayerId, TrackVisualStyle>;
selectedElementId: string | null; selectedElementId: string | null;
hoveredClip: string | null; hoveredClip: string | null;
draggedClip: DraggedClipState | null; draggedClip: DraggedClipState | null;
@@ -141,8 +142,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({
onContextMenuClip, onContextMenuClip,
beatAnalysis, beatAnalysis,
}: TimelineCanvasProps) { }: TimelineCanvasProps) {
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } = const {
useTimelineEditContextOptional(); onResizeElement,
onMoveElement,
onToggleTrackHidden,
onToggleElementHidden,
onRazorSplit,
onRazorSplitAll,
} = useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging); const beatDragging = usePlayerStore((s) => s.beatDragging);
const draggedElement = draggedClip?.element ?? null; const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement = const activeDraggedElement =
@@ -198,13 +205,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({
{ {
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
displayTrackOrder.map((trackNum) => { displayTrackOrder.map((layerId, rowIndex) => {
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const layer = tracks.find((item) => item.id === layerId) ?? null;
const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const els = layer?.elements ?? [];
const ts = trackStyles.get(layerId) ?? getTrackStyle("");
const isPendingTrack = const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; draggedClip?.started === true && !trackOrder.includes(layerId) && els.length === 0;
const rowBackground = const rowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12";
displayTrackOrder.indexOf(trackNum) % 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, // 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 // 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. // 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); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
return ( return (
<div <div
key={trackNum} key={layerId}
className="relative flex" className="relative flex"
style={{ style={{
height: TRACK_H, height: TRACK_H,
@@ -234,8 +242,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
> >
<button <button
type="button" type="button"
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`} aria-label={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`} title={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${ className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isTrackHidden isTrackHidden
? "text-[#3CE6AC] hover:text-white" ? "text-[#3CE6AC] hover:text-white"
@@ -246,7 +254,13 @@ export const TimelineCanvas = memo(function TimelineCanvas({
}} }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void onToggleTrackHidden?.(trackNum, !isTrackHidden); if (onToggleElementHidden && els.length > 0) {
for (const element of els) {
void onToggleElementHidden(element.key ?? element.id, !isTrackHidden);
}
return;
}
void onToggleTrackHidden?.(rowTrack, !isTrackHidden);
}} }}
> >
{isTrackHidden ? ( {isTrackHidden ? (
@@ -401,6 +415,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
pointerOffsetY: e.clientY - rect.top, pointerOffsetY: e.clientY - rect.top,
previewStart: el.start, previewStart: el.start,
previewTrack: el.track, previewTrack: el.track,
previewLayerId: layerId,
previewLayerIndex: rowIndex,
previewStackingReorder: null, previewStackingReorder: null,
snapBeatTime: null, snapBeatTime: null,
started: false, started: false,
@@ -35,6 +35,7 @@ export interface TimelineEditCallbacks {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">, updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void; ) => Promise<void> | void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void; onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void; onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void; onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -2,12 +2,14 @@ import { useCallback, useState, type RefObject } from "react";
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout"; import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks"; import type { TimelineDropCallbacks } from "./timelineCallbacks";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
scrollRef: RefObject<HTMLDivElement | null>; scrollRef: RefObject<HTMLDivElement | null>;
ppsRef: RefObject<number>; ppsRef: RefObject<number>;
durationRef: RefObject<number>; durationRef: RefObject<number>;
trackOrderRef: RefObject<number[]>; trackOrderRef: RefObject<TimelineLayerId[]>;
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
} }
export function useTimelineAssetDrop({ export function useTimelineAssetDrop({
@@ -15,6 +17,7 @@ export function useTimelineAssetDrop({
ppsRef, ppsRef,
durationRef, durationRef,
trackOrderRef, trackOrderRef,
timelineLayersRef,
onFileDrop, onFileDrop,
onAssetDrop, onAssetDrop,
onBlockDrop, onBlockDrop,
@@ -39,6 +42,10 @@ export function useTimelineAssetDrop({
setIsDragOver(false); setIsDragOver(false);
const scroll = scrollRef.current; const scroll = scrollRef.current;
const rect = scroll?.getBoundingClientRect(); const rect = scroll?.getBoundingClientRect();
const layerById = new Map(timelineLayersRef.current.map((layer) => [layer.id, layer]));
const trackOrder = trackOrderRef.current
.map((id) => layerById.get(id)?.placementTrack)
.filter((track): track is number => track != null);
const dropInput = { const dropInput = {
rectLeft: rect?.left ?? 0, rectLeft: rect?.left ?? 0,
rectTop: rect?.top ?? 0, rectTop: rect?.top ?? 0,
@@ -47,7 +54,7 @@ export function useTimelineAssetDrop({
pixelsPerSecond: ppsRef.current, pixelsPerSecond: ppsRef.current,
duration: durationRef.current, duration: durationRef.current,
trackHeight: TRACK_H, trackHeight: TRACK_H,
trackOrder: trackOrderRef.current, trackOrder,
}; };
if (onFileDrop && e.dataTransfer.files.length > 0) { if (onFileDrop && e.dataTransfer.files.length > 0) {
void onFileDrop( void onFileDrop(
@@ -84,7 +91,16 @@ export function useTimelineAssetDrop({
} }
} }
}, },
[onAssetDrop, onBlockDrop, onFileDrop, scrollRef, ppsRef, durationRef, trackOrderRef], [
onAssetDrop,
onBlockDrop,
onFileDrop,
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
timelineLayersRef,
],
); );
return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop }; return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };
@@ -13,6 +13,7 @@ import {
snapKeyframePctToBeat, snapKeyframePctToBeat,
type TimelinePromptElement, type TimelinePromptElement,
} from "./timelineEditing"; } from "./timelineEditing";
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
describe("resolveTimelineMove", () => { describe("resolveTimelineMove", () => {
it("moves timing based on horizontal drag and snaps to centiseconds", () => { it("moves timing based on horizontal drag and snaps to centiseconds", () => {
@@ -157,7 +158,34 @@ describe("resolveTimelineMove", () => {
).toEqual({ start: 2, track: 2 }); ).toEqual({ start: 2, track: 2 });
}); });
it("resolves vertical stacking movement within the dragged clip's context siblings", () => { it("resolves vertical stacking movement as a layer join without changing data-track-index", () => {
const stackingElements = [
{
id: "root-front",
tag: "div",
start: 0,
duration: 2,
track: 0,
zIndex: 2,
hasExplicitZIndex: true,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
{
id: "root-back",
tag: "div",
start: 0,
duration: 2,
track: 1,
zIndex: 1,
hasExplicitZIndex: true,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
];
const layers = buildStackingTimelineLayers(stackingElements).rows;
const result = resolveTimelineMove( const result = resolveTimelineMove(
{ {
start: 0, start: 0,
@@ -168,41 +196,11 @@ describe("resolveTimelineMove", () => {
pixelsPerSecond: 100, pixelsPerSecond: 100,
trackHeight: 72, trackHeight: 72,
maxStart: 8, maxStart: 8,
trackOrder: [0, 99, 1], trackOrder: [0, 1],
stackingElement: { layerOrder: layers.map((layer) => layer.id),
id: "root-back", timelineLayers: layers,
track: 1, stackingElement: stackingElements[1],
zIndex: 1, stackingElements,
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, 0,
-72, -72,
@@ -210,12 +208,13 @@ describe("resolveTimelineMove", () => {
expect(result).toEqual({ expect(result).toEqual({
start: 0, start: 0,
track: 0, track: 1,
previewLayerId: layers[0]!.id,
previewLayerIndex: 0,
stackingReorder: { stackingReorder: {
contextKey: "root", contextKey: "root",
fromIndex: 1, placement: { type: "onto", layerId: layers[0]!.id },
toIndex: 0, zIndexChanges: [{ key: "root-back", zIndex: 2 }],
siblingKeys: ["root-front", "root-back"],
}, },
}); });
}); });
@@ -1,7 +1,13 @@
import { formatTime } from "../lib/time"; import { formatTime } from "../lib/time";
import { roundToCenti } from "../../utils/rounding"; import { roundToCenti } from "../../utils/rounding";
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering"; import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
export {
type TimelineStackingElement,
type TimelineStackingReorderIntent,
} from "./timelineStacking";
const roundToCentiseconds = roundToCenti; const roundToCentiseconds = roundToCenti;
@@ -9,89 +15,6 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); 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[];
}
export interface TimelineStackingOrderItem {
key: string;
track: number;
zIndex: number;
stackingContextId: string | null;
parentCompositionId: string | null;
compositionAncestors: readonly string[];
}
export function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem {
return {
key: getTimelineElementIdentity(element),
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 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;
return {
contextKey: resolveStackingContextKey(toStackingOrderItem(args.element)),
fromIndex,
toIndex,
siblingKeys: orderedSiblings.map((sibling) => sibling.key),
};
}
const EDGE_TRACK_CREATE_THRESHOLD = 0.55; const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
const AUTO_SCROLL_EDGE_ZONE = 40; const AUTO_SCROLL_EDGE_ZONE = 40;
const AUTO_SCROLL_MAX_SPEED = 12; const AUTO_SCROLL_MAX_SPEED = 12;
@@ -110,6 +33,8 @@ export interface TimelineMoveInput {
trackHeight: number; trackHeight: number;
maxStart: number; maxStart: number;
trackOrder: number[]; trackOrder: number[];
layerOrder?: TimelineLayerId[];
timelineLayers?: StackingTimelineLayer[];
/** When provided, vertical movement is resolved as a z-index stacking reorder /** When provided, vertical movement is resolved as a z-index stacking reorder
* within `stackingElement`'s context instead of a raw track change. */ * within `stackingElement`'s context instead of a raw track change. */
stackingElement?: TimelineStackingElement; stackingElement?: TimelineStackingElement;
@@ -162,7 +87,13 @@ export function resolveTimelineMove(
input: TimelineMoveInput, input: TimelineMoveInput,
clientX: number, clientX: number,
clientY: number, clientY: number,
): { start: number; track: number; stackingReorder?: TimelineStackingReorderIntent } { ): {
start: number;
track: number;
previewLayerId?: TimelineLayerId;
previewLayerIndex?: number;
stackingReorder?: TimelineStackingReorderIntent | null;
} {
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0); const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0); const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
const deltaTime = const deltaTime =
@@ -176,25 +107,25 @@ export function resolveTimelineMove(
Math.max(0, input.maxStart), Math.max(0, input.maxStart),
); );
// Stacking mode: vertical movement reorders z-index within the dragged clip's // Stacking mode: vertical movement writes z-index only. The authored
// stacking context (top = front), rather than changing the raw track number. // data-track-index is preserved even when the pointer crosses rows.
if (input.stackingElement && input.stackingElements) { if (input.stackingElement) {
const orderedSiblings = resolveContextSiblings(input.stackingElement, input.stackingElements); const layerMove =
const draggedKey = getTimelineElementIdentity(input.stackingElement); input.timelineLayers && input.layerOrder
const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey); ? resolveTimelineLayerStackingMove({
if (fromIndex >= 0 && orderedSiblings.length > 1) { element: input.stackingElement,
const toIndex = clamp(fromIndex + deltaTrack, 0, orderedSiblings.length - 1); layers: input.timelineLayers,
return { layerOrder: input.layerOrder,
start: nextStart, trackDeltaRaw,
track: orderedSiblings[toIndex]!.track, })
stackingReorder: { : null;
contextKey: resolveStackingContextKey(toStackingOrderItem(input.stackingElement)), return {
fromIndex, start: nextStart,
toIndex, track: input.track,
siblingKeys: orderedSiblings.map((sibling) => sibling.key), previewLayerId: layerMove?.previewLayerId,
}, previewLayerIndex: layerMove?.previewLayerIndex,
}; stackingReorder: layerMove?.stackingReorder ?? null,
} };
} }
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track)); const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { StackingTimelineLayer } from "./timelineTrackOrder";
import { resolveTimelineLayerZIndexChanges } from "./timelineLayerDrag";
function element(input: { id: string; zIndex: number; tag?: string }): TimelineElement {
return {
id: input.id,
tag: input.tag ?? "div",
start: 0,
duration: 1,
track: 0,
zIndex: input.zIndex,
hasExplicitZIndex: true,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
};
}
function layer(id: string, zIndex: number): StackingTimelineLayer {
return {
id,
kind: "visual",
contextKey: "root",
zIndex,
placementTrack: 0,
elements: [element({ id, zIndex })],
};
}
describe("resolveTimelineLayerZIndexChanges", () => {
it("joins an existing layer by assigning the dragged clip that layer's z-index", () => {
const dragged = element({ id: "dragged", zIndex: 1 });
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers: [layer("front", 10), layer("back", 1)],
placement: { type: "onto", layerId: "front" },
})?.zIndexChanges,
).toEqual([{ key: "dragged", zIndex: 10 }]);
});
it("interpolates a new integer z-index strictly between neighboring layers", () => {
const dragged = element({ id: "dragged", zIndex: 1 });
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers: [layer("front", 10), layer("back", 4)],
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
})?.zIndexChanges,
).toEqual([{ key: "dragged", zIndex: 7 }]);
});
it("renumbers the minimum sibling set when adjacent layers leave no integer gap", () => {
const dragged = element({ id: "dragged", zIndex: 0 });
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers: [layer("front", 2), layer("back", 1), layer("lower", 0)],
placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
})?.zIndexChanges,
).toEqual([
{ key: "dragged", zIndex: 2 },
{ key: "front", zIndex: 3 },
]);
});
it("assigns new extreme z-index values above the top and below the bottom layer", () => {
const dragged = element({ id: "dragged", zIndex: 0 });
const layers = [layer("front", 10), layer("back", -2)];
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers,
placement: { type: "above", layerId: "front" },
})?.zIndexChanges,
).toEqual([{ key: "dragged", zIndex: 11 }]);
expect(
resolveTimelineLayerZIndexChanges({
element: dragged,
layers,
placement: { type: "below", layerId: "back" },
})?.zIndexChanges,
).toEqual([{ key: "dragged", zIndex: -3 }]);
});
it("does not resolve stacking z-index changes for audio clips", () => {
expect(
resolveTimelineLayerZIndexChanges({
element: element({ id: "music", zIndex: 0, tag: "audio" }),
layers: [layer("front", 10)],
placement: { type: "onto", layerId: "front" },
}),
).toBeNull();
});
});
@@ -0,0 +1,287 @@
import { resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import {
toStackingOrderItem,
type TimelineLayerDropPlacement,
type TimelineStackingElement,
type TimelineStackingReorderIntent,
type TimelineStackingZIndexChange,
} from "./timelineStacking";
const ONTO_ROW_THRESHOLD = 0.35;
export interface TimelineLayerStackingMoveResolution {
previewLayerId: TimelineLayerId;
previewLayerIndex: number;
stackingReorder: TimelineStackingReorderIntent | null;
}
function isAudioElement(element: TimelineStackingElement): boolean {
return element.tag?.toLowerCase() === "audio";
}
function layerContainsElement(layer: StackingTimelineLayer, key: string): boolean {
return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
}
function addElementChange(
changes: TimelineStackingZIndexChange[],
key: string,
currentZIndex: number,
zIndex: number,
): void {
if (currentZIndex !== zIndex) changes.push({ key, zIndex });
}
function addLayerChanges(
changes: TimelineStackingZIndexChange[],
layer: StackingTimelineLayer,
zIndex: number,
excludedKey: string,
): number {
let count = 0;
for (const element of layer.elements) {
const key = getTimelineElementIdentity(element);
if (key === excludedKey) continue;
const before = changes.length;
addElementChange(changes, key, element.zIndex ?? 0, zIndex);
if (changes.length > before) count += 1;
}
return count;
}
function getContextLayers(
layers: readonly StackingTimelineLayer[],
contextKey: string,
): StackingTimelineLayer[] {
return layers.filter((layer) => layer.kind === "visual" && layer.contextKey === contextKey);
}
function findLayer(
layers: readonly StackingTimelineLayer[],
id: string,
): StackingTimelineLayer | null {
return layers.find((layer) => layer.id === id) ?? null;
}
function resolvePlacementZIndexChanges(input: {
element: TimelineStackingElement;
targetZIndex: number;
}): TimelineStackingZIndexChange[] {
const changes: TimelineStackingZIndexChange[] = [];
addElementChange(
changes,
getTimelineElementIdentity(input.element),
input.element.zIndex ?? 0,
input.targetZIndex,
);
return changes;
}
function buildPushUpCandidate(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
beforeIndex: number;
bottomZIndex: number;
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
const draggedKey = getTimelineElementIdentity(input.element);
const draggedZIndex = input.bottomZIndex + 1;
const changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: draggedZIndex,
});
let siblingChanges = 0;
let requiredZIndex = draggedZIndex + 1;
for (let index = input.beforeIndex; index >= 0; index -= 1) {
const layer = input.layers[index];
if (!layer) continue;
const nextZIndex = layer.zIndex >= requiredZIndex ? layer.zIndex : requiredZIndex;
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
requiredZIndex = nextZIndex + 1;
}
return { changes, siblingChanges };
}
function buildPushDownCandidate(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
afterIndex: number;
topZIndex: number;
}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } {
const draggedKey = getTimelineElementIdentity(input.element);
const draggedZIndex = input.topZIndex - 1;
const changes = resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: draggedZIndex,
});
let siblingChanges = 0;
let requiredZIndex = draggedZIndex - 1;
for (let index = input.afterIndex; index < input.layers.length; index += 1) {
const layer = input.layers[index];
if (!layer) continue;
const nextZIndex = layer.zIndex <= requiredZIndex ? layer.zIndex : requiredZIndex;
siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey);
requiredZIndex = nextZIndex - 1;
}
return { changes, siblingChanges };
}
function resolveBetweenChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
beforeLayer: StackingTimelineLayer;
afterLayer: StackingTimelineLayer;
}): TimelineStackingZIndexChange[] {
const topZIndex = input.beforeLayer.zIndex;
const bottomZIndex = input.afterLayer.zIndex;
if (topZIndex - bottomZIndex > 1) {
return resolvePlacementZIndexChanges({
element: input.element,
targetZIndex: Math.floor((topZIndex + bottomZIndex) / 2),
});
}
const beforeIndex = input.layers.findIndex((layer) => layer.id === input.beforeLayer.id);
const afterIndex = input.layers.findIndex((layer) => layer.id === input.afterLayer.id);
const pushUp = buildPushUpCandidate({
element: input.element,
layers: input.layers,
beforeIndex,
bottomZIndex,
});
const pushDown = buildPushDownCandidate({
element: input.element,
layers: input.layers,
afterIndex,
topZIndex,
});
return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
}
export function resolveTimelineLayerZIndexChanges(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
placement: TimelineLayerDropPlacement;
}): TimelineStackingReorderIntent | null {
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 });
}
return changes.length > 0
? { contextKey, placement: input.placement, zIndexChanges: changes }
: null;
}
// fallow-ignore-next-line complexity
function resolveDragPlacement(
layers: readonly StackingTimelineLayer[],
targetPosition: number,
): TimelineLayerDropPlacement | null {
const first = layers[0];
const last = layers[layers.length - 1];
if (!first || !last) return null;
if (targetPosition < -ONTO_ROW_THRESHOLD) return { type: "above", layerId: first.id };
if (targetPosition > layers.length - 1 + ONTO_ROW_THRESHOLD) {
return { type: "below", layerId: last.id };
}
const nearestIndex = Math.round(targetPosition);
const nearest = layers[nearestIndex];
if (nearest && Math.abs(targetPosition - nearestIndex) <= ONTO_ROW_THRESHOLD) {
return { type: "onto", layerId: nearest.id };
}
const insertionIndex = Math.max(0, Math.min(layers.length, Math.ceil(targetPosition)));
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;
}
function getPreviewLayerId(
draggedKey: string,
placement: TimelineLayerDropPlacement,
): TimelineLayerId {
if (placement.type === "onto") return placement.layerId;
if (placement.type === "between") {
return `preview:${draggedKey}:between:${placement.beforeLayerId}:${placement.afterLayerId}`;
}
return `preview:${draggedKey}:${placement.type}:${placement.layerId}`;
}
function getPreviewLayerIndex(
layerOrder: readonly TimelineLayerId[],
placement: TimelineLayerDropPlacement,
): number {
if (placement.type === "onto") return Math.max(0, layerOrder.indexOf(placement.layerId));
if (placement.type === "between") {
return Math.max(0, layerOrder.indexOf(placement.afterLayerId));
}
const targetIndex = Math.max(0, layerOrder.indexOf(placement.layerId));
return placement.type === "above" ? targetIndex : targetIndex + 1;
}
export function resolveTimelineLayerStackingMove(input: {
element: TimelineStackingElement;
layers: readonly StackingTimelineLayer[];
layerOrder: readonly TimelineLayerId[];
trackDeltaRaw: number;
}): TimelineLayerStackingMoveResolution | null {
if (isAudioElement(input.element)) return null;
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
const layerById = new Map(input.layers.map((layer) => [layer.id, layer]));
const contextLayers = input.layerOrder
.map((id) => layerById.get(id) ?? null)
.filter(
(layer): layer is StackingTimelineLayer =>
layer != null && layer.kind === "visual" && layer.contextKey === contextKey,
);
const draggedKey = getTimelineElementIdentity(input.element);
const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
if (currentIndex < 0) return null;
const placement = resolveDragPlacement(contextLayers, currentIndex + input.trackDeltaRaw);
if (!placement) return null;
return {
previewLayerId: getPreviewLayerId(draggedKey, placement),
previewLayerIndex: getPreviewLayerIndex(input.layerOrder, placement),
stackingReorder: resolveTimelineLayerZIndexChanges({
element: input.element,
layers: contextLayers,
placement,
}),
};
}
@@ -0,0 +1,49 @@
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
export interface TimelineStackingElement {
id: string;
key?: string;
tag?: string;
track: number;
zIndex?: number;
stackingContextId?: string | null;
parentCompositionId?: string | null;
compositionAncestors?: string[];
}
export interface TimelineStackingOrderItem {
key: string;
track: number;
zIndex: number;
stackingContextId: string | null;
parentCompositionId: string | null;
compositionAncestors: readonly string[];
}
export type TimelineLayerDropPlacement =
| { type: "onto"; layerId: string }
| { type: "between"; beforeLayerId: string; afterLayerId: string }
| { type: "above"; layerId: string }
| { type: "below"; layerId: string };
export interface TimelineStackingZIndexChange {
key: string;
zIndex: number;
}
export interface TimelineStackingReorderIntent {
contextKey: string;
placement: TimelineLayerDropPlacement;
zIndexChanges: TimelineStackingZIndexChange[];
}
export function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem {
return {
key: getTimelineElementIdentity(element),
track: element.track,
zIndex: element.zIndex ?? 0,
stackingContextId: element.stackingContextId ?? null,
parentCompositionId: element.parentCompositionId ?? null,
compositionAncestors: element.compositionAncestors ?? [],
};
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
// fallow-ignore-next-line complexity
function rowElement(input: {
id: string;
track?: number;
zIndex?: number;
hasExplicitZIndex?: boolean;
start?: number;
duration?: number;
tag?: string;
stackingContextId?: string | null;
parentCompositionId?: string | null;
compositionAncestors?: string[];
}): TimelineElement {
return {
id: input.id,
tag: input.tag ?? "div",
start: input.start ?? 0,
duration: input.duration ?? 1,
track: input.track ?? 0,
zIndex: input.zIndex ?? 0,
hasExplicitZIndex: input.hasExplicitZIndex ?? true,
stackingContextId: input.stackingContextId ?? "root",
parentCompositionId: input.parentCompositionId ?? null,
compositionAncestors: input.compositionAncestors ?? ["root"],
};
}
function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): string[][] {
return rows.map((row) => row.elements.map((element) => element.id));
}
describe("buildStackingTimelineLayers", () => {
it("merges explicit same-z clips in one context when they do not overlap in time", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", zIndex: 5, start: 0, duration: 1 }),
rowElement({ id: "b", zIndex: 5, start: 1, duration: 1 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]);
});
it("splits explicit same-z clips in one context 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 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["b"], ["a"]]);
});
it("keeps auto-z clips in their own rows even when their computed z-index ties", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false }),
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false }),
]);
expect(rowIds(result.visualLayers)).toEqual([["a"], ["b"]]);
});
it("does not merge equal z-index clips across stacking contexts", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "root", zIndex: 4, start: 0, duration: 1 }),
rowElement({
id: "nested",
zIndex: 4,
start: 1,
duration: 1,
stackingContextId: "scene",
parentCompositionId: "scene",
compositionAncestors: ["root", "scene"],
}),
]);
expect(rowIds(result.visualLayers)).toEqual([["root"], ["nested"]]);
});
it("returns audio clips as separate bottom rows without merging them into z layers", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "front", zIndex: 10 }),
rowElement({ id: "music-a", tag: "audio", track: 4 }),
rowElement({ id: "music-b", tag: "audio", track: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"]]);
expect(rowIds(result.audioLayers)).toEqual([["music-a"], ["music-b"]]);
expect(rowIds(result.rows)).toEqual([["front"], ["music-a"], ["music-b"]]);
});
it("orders rows by descending z-index with auto-z clips ranked at computed zero", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "auto-a", zIndex: 0, hasExplicitZIndex: false }),
rowElement({ id: "back", zIndex: -1 }),
rowElement({ id: "front", zIndex: 10 }),
rowElement({ id: "auto-b", zIndex: 0, hasExplicitZIndex: false }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"], ["auto-a"], ["auto-b"], ["back"]]);
});
it("keeps a row key stable when a clip's z-index changes but membership does not", () => {
const before = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 1 })]);
const after = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 20 })]);
expect(after.visualLayers[0]?.id).toBe(before.visualLayers[0]?.id);
});
});
describe("insertPreviewTrackOrder", () => {
it("inserts preview layer ids by target row index", () => {
expect(insertPreviewTrackOrder(["a", "b", "c"], "preview", 1)).toEqual([
"a",
"preview",
"b",
"c",
]);
});
});
@@ -1,23 +1,46 @@
import { type TimelineElement } from "../store/playerStore"; import { type TimelineElement } from "../store/playerStore";
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering"; import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineEditing"; import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineStacking";
/** export type TimelineLayerId = string;
* 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.
*/
type TimelineTrackOrderItem = TimelineStackingOrderItem & { start: number; duration: number }; export interface StackingTimelineLayer {
id: TimelineLayerId;
kind: "visual" | "audio";
contextKey: string;
zIndex: number;
placementTrack: number;
elements: TimelineElement[];
}
function toTimelineTrackOrderItem(element: TimelineElement): TimelineTrackOrderItem { export interface StackingTimelineLayerGroups {
return { ...toStackingOrderItem(element), start: element.start, duration: element.duration }; visualLayers: StackingTimelineLayer[];
audioLayers: StackingTimelineLayer[];
rows: StackingTimelineLayer[];
}
type TimelineLayerOrderItem = TimelineStackingOrderItem & {
start: number;
duration: number;
index: number;
hasExplicitZIndex: boolean;
element: TimelineElement;
};
type BuildLayer = StackingTimelineLayer & {
hasExplicitZIndex: boolean;
};
function toTimelineLayerOrderItem(element: TimelineElement, index: number): TimelineLayerOrderItem {
return {
...toStackingOrderItem(element),
start: element.start,
duration: element.duration,
index,
hasExplicitZIndex: element.hasExplicitZIndex === true,
element,
};
} }
function timelineElementsOverlap( function timelineElementsOverlap(
@@ -27,74 +50,120 @@ function timelineElementsOverlap(
return a.start < b.start + b.duration && b.start < a.start + a.duration; return a.start < b.start + b.duration && b.start < a.start + a.duration;
} }
function trackFrontOrderIndex( function compareLayerItems(a: TimelineLayerOrderItem, b: TimelineLayerOrderItem): number {
elements: readonly TimelineElement[], if (a.zIndex !== b.zIndex) return b.zIndex - a.zIndex;
orderIndexByKey: ReadonlyMap<string, number>, if (a.hasExplicitZIndex && b.hasExplicitZIndex && a.track !== b.track) {
): number { return a.track - b.track;
let orderIndex = Number.POSITIVE_INFINITY;
for (const element of elements) {
orderIndex = Math.min(
orderIndex,
orderIndexByKey.get(getTimelineElementIdentity(element)) ?? Number.POSITIVE_INFINITY,
);
} }
return orderIndex; return a.index - b.index;
} }
function hasOverlappingEqualRankElements( function buildLayerId(
aElements: readonly TimelineElement[], prefix: string,
bElements: readonly TimelineElement[], contextKey: string,
): boolean { element: TimelineElement,
for (const a of aElements) { ): TimelineLayerId {
const aOrderItem = toTimelineTrackOrderItem(a); return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`;
const aContextKey = resolveStackingContextKey(aOrderItem); }
for (const b of bElements) {
const bOrderItem = toTimelineTrackOrderItem(b); function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] {
if (aContextKey !== resolveStackingContextKey(bOrderItem)) continue; const keys: string[] = [];
if (aOrderItem.zIndex !== bOrderItem.zIndex) continue; for (const item of resolveContextOrder(items)) {
if (timelineElementsOverlap(a, b)) return true; const key = resolveStackingContextKey(item);
if (!keys.includes(key)) keys.push(key);
}
return keys;
}
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 buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
const byContext = new Map<string, TimelineLayerOrderItem[]>();
for (const item of items) {
const key = resolveStackingContextKey(item);
const list = byContext.get(key);
if (list) list.push(item);
else byContext.set(key, [item]);
}
const rows: StackingTimelineLayer[] = [];
for (const contextKey of getOrderedContextKeys(items)) {
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);
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);
} }
return false; return rows;
} }
export function buildStackingTimelineTracks( function buildAudioLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
return items.map((item) => ({
id: buildLayerId("audio", resolveStackingContextKey(item), item.element),
kind: "audio",
contextKey: resolveStackingContextKey(item),
zIndex: item.zIndex,
placementTrack: item.element.track,
elements: [item.element],
}));
}
export function buildStackingTimelineLayers(
elements: readonly TimelineElement[], elements: readonly TimelineElement[],
): Array<[number, TimelineElement[]]> { ): StackingTimelineLayerGroups {
const tracks = new Map<number, TimelineElement[]>(); const items = elements.map(toTimelineLayerOrderItem);
for (const element of elements) { const visualItems = items.filter((item) => item.element.tag !== "audio");
const list = tracks.get(element.track) ?? []; const audioItems = items.filter((item) => item.element.tag === "audio");
list.push(element); const visualLayers = buildVisualLayerRows(visualItems);
tracks.set(element.track, list); const audioLayers = buildAudioLayerRows(audioItems);
} return {
visualLayers,
const orderedElements = resolveContextOrder(elements.map(toTimelineTrackOrderItem)); audioLayers,
const orderIndexByKey = new Map<string, number>(); rows: [...visualLayers, ...audioLayers],
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( export function insertPreviewTrackOrder(
trackOrder: readonly number[], layerOrder: readonly TimelineLayerId[],
previewTrack: number, previewLayerId: TimelineLayerId,
): number[] { previewIndex: number,
if (trackOrder.includes(previewTrack)) return [...trackOrder]; ): TimelineLayerId[] {
if (trackOrder.length === 0) return [previewTrack]; if (layerOrder.includes(previewLayerId)) return [...layerOrder];
const minTrack = Math.min(...trackOrder); const index = Math.max(0, Math.min(layerOrder.length, Math.round(previewIndex)));
const maxTrack = Math.max(...trackOrder); return [...layerOrder.slice(0, index), previewLayerId, ...layerOrder.slice(index)];
if (previewTrack < minTrack) return [previewTrack, ...trackOrder];
if (previewTrack > maxTrack) return [...trackOrder, previewTrack];
return [...trackOrder, previewTrack];
} }
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout"; import { TRACK_H } from "./timelineLayout";
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
import type { DraggedClipState } from "./useTimelineClipDrag"; import type { DraggedClipState } from "./useTimelineClipDrag";
import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { useTimelineClipDrag } from "./useTimelineClipDrag";
@@ -38,6 +39,7 @@ describe("useTimelineClipDrag", () => {
const front = timelineElement({ id: "front", track: 0, zIndex: 3 }); const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 }); const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
const layers = buildStackingTimelineLayers([front, middle, back]).rows;
const scroll = document.createElement("div"); const scroll = document.createElement("div");
document.body.append(scroll); document.body.append(scroll);
const onMoveElement = vi.fn(); const onMoveElement = vi.fn();
@@ -48,7 +50,8 @@ describe("useTimelineClipDrag", () => {
scrollRef: { current: scroll }, scrollRef: { current: scroll },
ppsRef: { current: 100 }, ppsRef: { current: 100 },
durationRef: { current: 10 }, durationRef: { current: 10 },
trackOrderRef: { current: [0, 1, 2] }, trackOrderRef: { current: layers.map((layer) => layer.id) },
timelineLayersRef: { current: layers },
timelineElementsRef: { current: [front, middle, back] }, timelineElementsRef: { current: [front, middle, back] },
onMoveElement, onMoveElement,
onResizeElement: vi.fn(), onResizeElement: vi.fn(),
@@ -82,6 +85,8 @@ describe("useTimelineClipDrag", () => {
pointerOffsetY: 0, pointerOffsetY: 0,
previewStart: back.start, previewStart: back.start,
previewTrack: back.track, previewTrack: back.track,
previewLayerId: layers[2]!.id,
previewLayerIndex: 2,
previewStackingReorder: null, previewStackingReorder: null,
snapBeatTime: null, snapBeatTime: null,
started: false, started: false,
@@ -104,11 +109,11 @@ describe("useTimelineClipDrag", () => {
expect(onMoveElement).toHaveBeenCalledTimes(1); expect(onMoveElement).toHaveBeenCalledTimes(1);
expect(onMoveElement.mock.calls[0]![1]).toMatchObject({ expect(onMoveElement.mock.calls[0]![1]).toMatchObject({
start: 0, start: 0,
track: 0, track: 2,
stackingReorder: { stackingReorder: {
fromIndex: 2, contextKey: "root",
toIndex: 0, placement: { type: "onto", layerId: layers[0]!.id },
siblingKeys: ["front", "middle", "back"], zIndexChanges: [{ key: "back", zIndex: 3 }],
}, },
}); });
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout"; import { TRACK_H } from "./timelineLayout";
import { isMusicTrack } from "../../utils/timelineInspector"; import { isMusicTrack } from "../../utils/timelineInspector";
import { mergeUserBeats } from "../../utils/beatEditing"; import { mergeUserBeats } from "../../utils/beatEditing";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
const BEAT_SNAP_PX = 8; const BEAT_SNAP_PX = 8;
const EMPTY_BEAT_TIMES: number[] = []; const EMPTY_BEAT_TIMES: number[] = [];
@@ -82,6 +83,8 @@ export interface DraggedClipState {
pointerOffsetY: number; pointerOffsetY: number;
previewStart: number; previewStart: number;
previewTrack: number; previewTrack: number;
previewLayerId: TimelineLayerId;
previewLayerIndex: number;
/** Beat time the clip will snap to on drop, for the grid-line highlight. */ /** Beat time the clip will snap to on drop, for the grid-line highlight. */
snapBeatTime: number | null; snapBeatTime: number | null;
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */ /** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
@@ -112,7 +115,8 @@ interface UseTimelineClipDragInput {
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
ppsRef: React.RefObject<number>; ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>; durationRef: React.RefObject<number>;
trackOrderRef: React.RefObject<number[]>; trackOrderRef: React.RefObject<TimelineLayerId[]>;
timelineLayersRef: React.RefObject<StackingTimelineLayer[]>;
timelineElementsRef: React.RefObject<TimelineElement[]>; timelineElementsRef: React.RefObject<TimelineElement[]>;
onMoveElement?: ( onMoveElement?: (
element: TimelineElement, element: TimelineElement,
@@ -135,6 +139,7 @@ export function useTimelineClipDrag({
ppsRef, ppsRef,
durationRef, durationRef,
trackOrderRef, trackOrderRef,
timelineLayersRef,
timelineElementsRef, timelineElementsRef,
onMoveElement, onMoveElement,
onResizeElement, onResizeElement,
@@ -210,7 +215,9 @@ export function useTimelineClipDrag({
pixelsPerSecond: ppsRef.current, pixelsPerSecond: ppsRef.current,
trackHeight: TRACK_H, trackHeight: TRACK_H,
maxStart: Math.max(0, durationRef.current - drag.element.duration), maxStart: Math.max(0, durationRef.current - drag.element.duration),
trackOrder: trackOrderRef.current, trackOrder: timelineLayersRef.current.map((layer) => layer.placementTrack),
layerOrder: trackOrderRef.current,
timelineLayers: timelineLayersRef.current,
stackingElement: drag.element, stackingElement: drag.element,
stackingElements: timelineElementsRef.current, stackingElements: timelineElementsRef.current,
}, },
@@ -234,11 +241,13 @@ export function useTimelineClipDrag({
pointerClientY: clientY, pointerClientY: clientY,
previewStart: snap.start, previewStart: snap.start,
previewTrack: nextMove.track, previewTrack: nextMove.track,
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
previewStackingReorder: nextMove.stackingReorder ?? null, previewStackingReorder: nextMove.stackingReorder ?? null,
snapBeatTime: snap.beat, snapBeatTime: snap.beat,
}; };
}, },
[scrollRef, ppsRef, durationRef, trackOrderRef, timelineElementsRef], [scrollRef, ppsRef, durationRef, trackOrderRef, timelineLayersRef, timelineElementsRef],
); );
const stopClipDragAutoScroll = useCallback(() => { const stopClipDragAutoScroll = useCallback(() => {
@@ -505,29 +514,23 @@ export function useTimelineClipDrag({
clearSuppressedClick(); clearSuppressedClick();
const hasStackingReorder = const hasStackingReorder =
drag.previewStackingReorder != null && drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
drag.previewStackingReorder.fromIndex !== drag.previewStackingReorder.toIndex; const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
const hasChanged =
drag.previewStart !== drag.element.start ||
drag.previewTrack !== drag.element.track ||
hasStackingReorder;
if (!hasChanged) return; if (!hasChanged) return;
updateElement(drag.element.key ?? drag.element.id, { updateElement(drag.element.key ?? drag.element.id, {
start: drag.previewStart, start: drag.previewStart,
...(hasStackingReorder ? {} : { track: drag.previewTrack }),
}); });
Promise.resolve( Promise.resolve(
onMoveElementRef.current?.(drag.element, { onMoveElementRef.current?.(drag.element, {
start: drag.previewStart, start: drag.previewStart,
track: drag.previewTrack, track: drag.element.track,
stackingReorder: drag.previewStackingReorder, stackingReorder: drag.previewStackingReorder,
}), }),
).catch((error) => { ).catch((error) => {
updateElement(drag.element.key ?? drag.element.id, { updateElement(drag.element.key ?? drag.element.id, {
start: drag.element.start, start: drag.element.start,
...(hasStackingReorder ? {} : { track: drag.element.track }),
}); });
console.error("[Timeline] Failed to persist clip move", error); console.error("[Timeline] Failed to persist clip move", error);
}); });
@@ -243,7 +243,10 @@ describe("buildStandaloneRootTimelineElement", () => {
iframeSrc: "http://127.0.0.1:4173/api/projects/demo/preview/comp/scenes/hero.html?_t=123", iframeSrc: "http://127.0.0.1:4173/api/projects/demo/preview/comp/scenes/hero.html?_t=123",
selector: '[data-composition-id="hero"]', selector: '[data-composition-id="hero"]',
}), }),
).toEqual({ // toMatchObject (not toEqual): asserts the selector/source metadata this
// test is about, without re-pinning the full element shape (stacking
// metadata like hasExplicitZIndex is covered in timelineDOM.test.ts).
).toMatchObject({
id: "hero", id: "hero",
label: "hero", label: "hero",
key: 'scenes/hero.html:[data-composition-id="hero"]:0', key: 'scenes/hero.html:[data-composition-id="hero"]:0',
@@ -27,6 +27,20 @@ export function getElementZIndex(element: HTMLElement): number {
} }
} }
// fallow-ignore-next-line complexity
export function hasExplicitZIndex(element: HTMLElement): boolean {
try {
const inline = element.style?.zIndex;
if (inline) return inline !== "auto";
const win = element.ownerDocument?.defaultView;
if (!win) return false;
const value = win.getComputedStyle(element).zIndex;
return value !== "auto" && value !== "";
} catch {
return false;
}
}
export function computeReorderZValues( export function computeReorderZValues(
existingValues: readonly number[], existingValues: readonly number[],
fromIndex: number, fromIndex: number,
@@ -4,6 +4,7 @@ import {
createTimelineElementFromManifestClip, createTimelineElementFromManifestClip,
parseTimelineFromDOM, parseTimelineFromDOM,
createImplicitTimelineLayersFromDOM, createImplicitTimelineLayersFromDOM,
buildStandaloneRootTimelineElement,
} from "./timelineDOM"; } from "./timelineDOM";
function makeDoc(html: string): Document { function makeDoc(html: string): Document {
@@ -12,6 +13,29 @@ function makeDoc(html: string): Document {
return d; return d;
} }
function makeLiveDoc(html: string): Document {
document.head.innerHTML = "";
document.body.innerHTML = html;
return document;
}
function mockComputedZIndex(doc: Document, zIndexById: ReadonlyMap<string, string>): void {
const win = doc.defaultView;
if (!win) throw new Error("Expected document window");
const original = win.getComputedStyle.bind(win);
Object.defineProperty(win, "getComputedStyle", {
configurable: true,
value: (element: Element, pseudoElt?: string | null) => {
const style = original(element, pseudoElt);
const zIndex = zIndexById.get(element.id);
if (zIndex != null) {
Object.defineProperty(style, "zIndex", { configurable: true, value: zIndex });
}
return style;
},
});
}
describe("parseTimelineFromDOM — hfId from data-hf-id", () => { describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
it("harvests hfId from a data-start element that has data-hf-id", () => { it("harvests hfId from a data-start element that has data-hf-id", () => {
const doc = makeDoc(` const doc = makeDoc(`
@@ -135,6 +159,24 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
}); });
expect(element.zIndex).toBe(30); expect(element.zIndex).toBe(30);
expect(element.hasExplicitZIndex).toBe(true);
});
it("marks parsed inline, CSS-rule, and auto z-index authorship accurately", () => {
const doc = makeLiveDoc(`
<div data-composition-id="root">
<div id="inline" class="clip" data-start="0" data-duration="2" style="z-index: 3"></div>
<div id="rule" class="clip" data-start="0" data-duration="2"></div>
<div id="auto" class="clip" data-start="0" data-duration="2"></div>
</div>
`);
mockComputedZIndex(doc, new Map([["rule", "12"]]));
const elements = parseTimelineFromDOM(doc, 10);
expect(elements.find((el) => el.id === "inline")?.hasExplicitZIndex).toBe(true);
expect(elements.find((el) => el.id === "rule")?.hasExplicitZIndex).toBe(true);
expect(elements.find((el) => el.id === "auto")?.hasExplicitZIndex).toBe(false);
}); });
}); });
@@ -169,4 +211,31 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
expect(layers).toEqual([]); expect(layers).toEqual([]);
}); });
it("marks implicit layer CSS z-index authorship from computed style", () => {
const doc = makeLiveDoc(`
<div data-composition-id="root">
<div id="layer" class="clip"></div>
</div>
`);
mockComputedZIndex(doc, new Map([["layer", "8"]]));
const layers = createImplicitTimelineLayersFromDOM(doc, 10);
expect(layers[0]?.zIndex).toBe(8);
expect(layers[0]?.hasExplicitZIndex).toBe(true);
});
});
describe("buildStandaloneRootTimelineElement", () => {
it("marks the standalone root as auto z-index", () => {
const root = buildStandaloneRootTimelineElement({
compositionId: "root",
tagName: "div",
rootDuration: 10,
iframeSrc: "/preview/comp/index.html",
});
expect(root?.hasExplicitZIndex).toBe(false);
});
}); });
+18 -3
View File
@@ -10,7 +10,7 @@
import type { TimelineElement } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes"; import type { ClipManifestClip } from "./playbackTypes";
import { getElementZIndex } from "./layerOrdering"; import { getElementZIndex, hasExplicitZIndex } from "./layerOrdering";
import { import {
resolveMediaElement, resolveMediaElement,
applyMediaMetadataFromElement, applyMediaMetadataFromElement,
@@ -95,9 +95,18 @@ function resolveDomCompositionContext(
}; };
} }
function isHTMLElement(element: Element | null): element is HTMLElement {
if (!element) return false;
const HtmlElementCtor = element.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
return typeof HtmlElementCtor !== "undefined" && element instanceof HtmlElementCtor;
}
function getTimelineElementZIndex(element: Element | null): number | undefined { function getTimelineElementZIndex(element: Element | null): number | undefined {
if (!element || !("style" in element)) return undefined; return isHTMLElement(element) ? getElementZIndex(element) : undefined;
return getElementZIndex(element as HTMLElement); }
function getTimelineElementHasExplicitZIndex(element: Element | null): boolean {
return isHTMLElement(element) ? hasExplicitZIndex(element) : false;
} }
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
@@ -160,6 +169,7 @@ export function createTimelineElementFromManifestClip(params: {
// is captured. clip.zIndex from the runtime is inline-only (0 for CSS rules), // 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. // so it can only serve as a fallback when the element isn't live.
zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0, zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0,
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(hostEl),
stackingContextId, stackingContextId,
parentCompositionId, parentCompositionId,
compositionAncestors, compositionAncestors,
@@ -197,6 +207,8 @@ export function createTimelineElementFromManifestClip(params: {
} }
} }
if (hostEl) { if (hostEl) {
entry.zIndex = getTimelineElementZIndex(hostEl) ?? entry.zIndex;
entry.hasExplicitZIndex = getTimelineElementHasExplicitZIndex(hostEl);
entry.domId = hostEl.id || undefined; entry.domId = hostEl.id || undefined;
entry.hfId = hostEl.getAttribute("data-hf-id") || undefined; entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
entry.selector = getTimelineElementSelector(hostEl); entry.selector = getTimelineElementSelector(hostEl);
@@ -270,6 +282,7 @@ export function createImplicitTimelineLayersFromDOM(
sourceFile, sourceFile,
start: 0, start: 0,
zIndex: getTimelineElementZIndex(child), zIndex: getTimelineElementZIndex(child),
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(child),
stackingContextId: compositionContext.stackingContextId, stackingContextId: compositionContext.stackingContextId,
parentCompositionId: compositionContext.parentCompositionId, parentCompositionId: compositionContext.parentCompositionId,
compositionAncestors: compositionContext.compositionAncestors, compositionAncestors: compositionContext.compositionAncestors,
@@ -344,6 +357,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
duration: dur, duration: dur,
track: isNaN(track) ? 0 : track, track: isNaN(track) ? 0 : track,
zIndex: getTimelineElementZIndex(el), zIndex: getTimelineElementZIndex(el),
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(el),
stackingContextId: compositionContext.stackingContextId, stackingContextId: compositionContext.stackingContextId,
parentCompositionId: compositionContext.parentCompositionId, parentCompositionId: compositionContext.parentCompositionId,
compositionAncestors: compositionContext.compositionAncestors, compositionAncestors: compositionContext.compositionAncestors,
@@ -468,6 +482,7 @@ export function buildStandaloneRootTimelineElement(params: {
duration: params.rootDuration, duration: params.rootDuration,
track: 0, track: 0,
zIndex: 0, zIndex: 0,
hasExplicitZIndex: false,
stackingContextId: params.compositionId, stackingContextId: params.compositionId,
parentCompositionId: null, parentCompositionId: null,
compositionAncestors: [params.compositionId], compositionAncestors: [params.compositionId],
@@ -30,6 +30,8 @@ export interface TimelineElement {
track: number; track: number;
/** Resolved z-index for stacking-aware timeline ordering. */ /** Resolved z-index for stacking-aware timeline ordering. */
zIndex?: number; zIndex?: number;
/** True when the effective z-index was authored inline or through CSS, not auto. */
hasExplicitZIndex?: boolean;
/** Stacking context this element belongs to; root clips use the root composition id. */ /** Stacking context this element belongs to; root clips use the root composition id. */
stackingContextId?: string | null; stackingContextId?: string | null;
/** Nearest parent composition context, matching RuntimeTimelineClip. */ /** Nearest parent composition context, matching RuntimeTimelineClip. */
@@ -143,7 +145,10 @@ interface PlayerState {
updateElement: ( updateElement: (
elementId: string, elementId: string,
updates: Partial< updates: Partial<
Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart" | "hidden"> Pick<
TimelineElement,
"start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden"
>
>, >,
) => void; ) => void;
setZoomMode: (mode: ZoomMode) => void; setZoomMode: (mode: ZoomMode) => void;