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}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -59,6 +59,7 @@ export interface StudioPreviewAreaProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => 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;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | 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,
@@ -33,6 +33,7 @@ export function TimelineEditProvider({
value.onMoveElement,
value.onResizeElement,
value.onToggleTrackHidden,
value.onToggleElementHidden,
value.onBlockedEditAttempt,
value.onSplitElement,
value.onRazorSplit,
@@ -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 });
}
}
/**
@@ -208,22 +208,28 @@ async function flushAsyncWork(): Promise<void> {
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();
});
@@ -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: [] });
@@ -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<number, TrackVisualStyle>();
for (const [trackNum, els] of tracks) {
map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
const map = new Map<string, TrackVisualStyle>();
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,
@@ -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<number, TrackVisualStyle>;
displayTrackOrder: TimelineLayerId[];
trackOrder: TimelineLayerId[];
tracks: StackingTimelineLayer[];
trackStyles: Map<TimelineLayerId, TrackVisualStyle>;
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 (
<div
key={trackNum}
key={layerId}
className="relative flex"
style={{
height: TRACK_H,
@@ -234,8 +242,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
>
<button
type="button"
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
aria-label={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
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] ${
isTrackHidden
? "text-[#3CE6AC] hover:text-white"
@@ -246,7 +254,13 @@ export const TimelineCanvas = memo(function TimelineCanvas({
}}
onClick={(e) => {
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 ? (
@@ -401,6 +415,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
previewLayerId: layerId,
previewLayerIndex: rowIndex,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
@@ -35,6 +35,7 @@ export interface TimelineEditCallbacks {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => 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;
onSplitElement?: (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 { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
scrollRef: RefObject<HTMLDivElement | null>;
ppsRef: RefObject<number>;
durationRef: RefObject<number>;
trackOrderRef: RefObject<number[]>;
trackOrderRef: RefObject<TimelineLayerId[]>;
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
}
export function useTimelineAssetDrop({
@@ -15,6 +17,7 @@ export function useTimelineAssetDrop({
ppsRef,
durationRef,
trackOrderRef,
timelineLayersRef,
onFileDrop,
onAssetDrop,
onBlockDrop,
@@ -39,6 +42,10 @@ export function useTimelineAssetDrop({
setIsDragOver(false);
const scroll = scrollRef.current;
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 = {
rectLeft: rect?.left ?? 0,
rectTop: rect?.top ?? 0,
@@ -47,7 +54,7 @@ export function useTimelineAssetDrop({
pixelsPerSecond: ppsRef.current,
duration: durationRef.current,
trackHeight: TRACK_H,
trackOrder: trackOrderRef.current,
trackOrder,
};
if (onFileDrop && e.dataTransfer.files.length > 0) {
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 };
@@ -13,6 +13,7 @@ import {
snapKeyframePctToBeat,
type TimelinePromptElement,
} from "./timelineEditing";
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
describe("resolveTimelineMove", () => {
it("moves timing based on horizontal drag and snaps to centiseconds", () => {
@@ -157,7 +158,34 @@ describe("resolveTimelineMove", () => {
).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(
{
start: 0,
@@ -168,41 +196,11 @@ describe("resolveTimelineMove", () => {
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 99, 1],
stackingElement: {
id: "root-back",
track: 1,
zIndex: 1,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
stackingElements: [
{
id: "root-front",
track: 0,
zIndex: 2,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
{
id: "nested-row",
track: 99,
zIndex: 100,
stackingContextId: "scene",
parentCompositionId: "scene",
compositionAncestors: ["root", "scene"],
},
{
id: "root-back",
track: 1,
zIndex: 1,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
],
trackOrder: [0, 1],
layerOrder: layers.map((layer) => layer.id),
timelineLayers: layers,
stackingElement: stackingElements[1],
stackingElements,
},
0,
-72,
@@ -210,12 +208,13 @@ describe("resolveTimelineMove", () => {
expect(result).toEqual({
start: 0,
track: 0,
track: 1,
previewLayerId: layers[0]!.id,
previewLayerIndex: 0,
stackingReorder: {
contextKey: "root",
fromIndex: 1,
toIndex: 0,
siblingKeys: ["root-front", "root-back"],
placement: { type: "onto", layerId: layers[0]!.id },
zIndexChanges: [{ key: "root-back", zIndex: 2 }],
},
});
});
@@ -1,7 +1,13 @@
import { formatTime } from "../lib/time";
import { roundToCenti } from "../../utils/rounding";
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
export {
type TimelineStackingElement,
type TimelineStackingReorderIntent,
} from "./timelineStacking";
const roundToCentiseconds = roundToCenti;
@@ -9,89 +15,6 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* A timeline clip described for stacking-order math: its track (timeline row),
* resolved z-index, and stacking-context identity. Structurally satisfied by the
* app's TimelineElement.
*/
export interface TimelineStackingElement {
id: string;
key?: string;
track: number;
zIndex?: number;
stackingContextId?: string | null;
parentCompositionId?: string | null;
compositionAncestors?: string[];
}
/** A resolved vertical reorder: move the dragged clip from `fromIndex` to
* `toIndex` within its stacking context's ordered siblings (top = front). */
export interface TimelineStackingReorderIntent {
contextKey: string;
fromIndex: number;
toIndex: number;
siblingKeys: string[];
}
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 AUTO_SCROLL_EDGE_ZONE = 40;
const AUTO_SCROLL_MAX_SPEED = 12;
@@ -110,6 +33,8 @@ export interface TimelineMoveInput {
trackHeight: number;
maxStart: number;
trackOrder: number[];
layerOrder?: TimelineLayerId[];
timelineLayers?: StackingTimelineLayer[];
/** When provided, vertical movement is resolved as a z-index stacking reorder
* within `stackingElement`'s context instead of a raw track change. */
stackingElement?: TimelineStackingElement;
@@ -162,7 +87,13 @@ export function resolveTimelineMove(
input: TimelineMoveInput,
clientX: 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 scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
const deltaTime =
@@ -176,25 +107,25 @@ export function resolveTimelineMove(
Math.max(0, input.maxStart),
);
// Stacking mode: vertical movement reorders z-index within the dragged clip's
// stacking context (top = front), rather than changing the raw track number.
if (input.stackingElement && input.stackingElements) {
const orderedSiblings = resolveContextSiblings(input.stackingElement, input.stackingElements);
const draggedKey = getTimelineElementIdentity(input.stackingElement);
const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey);
if (fromIndex >= 0 && orderedSiblings.length > 1) {
const toIndex = clamp(fromIndex + deltaTrack, 0, orderedSiblings.length - 1);
return {
start: nextStart,
track: orderedSiblings[toIndex]!.track,
stackingReorder: {
contextKey: resolveStackingContextKey(toStackingOrderItem(input.stackingElement)),
fromIndex,
toIndex,
siblingKeys: orderedSiblings.map((sibling) => sibling.key),
},
};
}
// Stacking mode: vertical movement writes z-index only. The authored
// data-track-index is preserved even when the pointer crosses rows.
if (input.stackingElement) {
const layerMove =
input.timelineLayers && input.layerOrder
? resolveTimelineLayerStackingMove({
element: input.stackingElement,
layers: input.timelineLayers,
layerOrder: input.layerOrder,
trackDeltaRaw,
})
: null;
return {
start: nextStart,
track: input.track,
previewLayerId: layerMove?.previewLayerId,
previewLayerIndex: layerMove?.previewLayerIndex,
stackingReorder: layerMove?.stackingReorder ?? null,
};
}
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 { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineEditing";
import { toStackingOrderItem, type TimelineStackingOrderItem } from "./timelineStacking";
/**
* 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.
*/
export type TimelineLayerId = string;
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 {
return { ...toStackingOrderItem(element), start: element.start, duration: element.duration };
export interface StackingTimelineLayerGroups {
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(
@@ -27,74 +50,120 @@ function timelineElementsOverlap(
return a.start < b.start + b.duration && b.start < a.start + a.duration;
}
function trackFrontOrderIndex(
elements: readonly TimelineElement[],
orderIndexByKey: ReadonlyMap<string, number>,
): number {
let orderIndex = Number.POSITIVE_INFINITY;
for (const element of elements) {
orderIndex = Math.min(
orderIndex,
orderIndexByKey.get(getTimelineElementIdentity(element)) ?? Number.POSITIVE_INFINITY,
);
function compareLayerItems(a: TimelineLayerOrderItem, b: TimelineLayerOrderItem): number {
if (a.zIndex !== b.zIndex) return b.zIndex - a.zIndex;
if (a.hasExplicitZIndex && b.hasExplicitZIndex && a.track !== b.track) {
return a.track - b.track;
}
return orderIndex;
return a.index - b.index;
}
function hasOverlappingEqualRankElements(
aElements: readonly TimelineElement[],
bElements: readonly TimelineElement[],
): boolean {
for (const a of aElements) {
const aOrderItem = toTimelineTrackOrderItem(a);
const aContextKey = resolveStackingContextKey(aOrderItem);
for (const b of bElements) {
const bOrderItem = toTimelineTrackOrderItem(b);
if (aContextKey !== resolveStackingContextKey(bOrderItem)) continue;
if (aOrderItem.zIndex !== bOrderItem.zIndex) continue;
if (timelineElementsOverlap(a, b)) return true;
function buildLayerId(
prefix: string,
contextKey: string,
element: TimelineElement,
): TimelineLayerId {
return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`;
}
function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] {
const keys: string[] = [];
for (const item of resolveContextOrder(items)) {
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[],
): Array<[number, TimelineElement[]]> {
const tracks = new Map<number, TimelineElement[]>();
for (const element of elements) {
const list = tracks.get(element.track) ?? [];
list.push(element);
tracks.set(element.track, list);
}
const orderedElements = resolveContextOrder(elements.map(toTimelineTrackOrderItem));
const orderIndexByKey = new Map<string, number>();
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;
});
): StackingTimelineLayerGroups {
const items = elements.map(toTimelineLayerOrderItem);
const visualItems = items.filter((item) => item.element.tag !== "audio");
const audioItems = items.filter((item) => item.element.tag === "audio");
const visualLayers = buildVisualLayerRows(visualItems);
const audioLayers = buildAudioLayerRows(audioItems);
return {
visualLayers,
audioLayers,
rows: [...visualLayers, ...audioLayers],
};
}
export function insertPreviewTrackOrder(
trackOrder: readonly number[],
previewTrack: number,
): number[] {
if (trackOrder.includes(previewTrack)) return [...trackOrder];
if (trackOrder.length === 0) return [previewTrack];
const minTrack = Math.min(...trackOrder);
const maxTrack = Math.max(...trackOrder);
if (previewTrack < minTrack) return [previewTrack, ...trackOrder];
if (previewTrack > maxTrack) return [...trackOrder, previewTrack];
return [...trackOrder, previewTrack];
layerOrder: readonly TimelineLayerId[],
previewLayerId: TimelineLayerId,
previewIndex: number,
): TimelineLayerId[] {
if (layerOrder.includes(previewLayerId)) return [...layerOrder];
const index = Math.max(0, Math.min(layerOrder.length, Math.round(previewIndex)));
return [...layerOrder.slice(0, index), previewLayerId, ...layerOrder.slice(index)];
}
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
import type { DraggedClipState } from "./useTimelineClipDrag";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
@@ -38,6 +39,7 @@ describe("useTimelineClipDrag", () => {
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
const layers = buildStackingTimelineLayers([front, middle, back]).rows;
const scroll = document.createElement("div");
document.body.append(scroll);
const onMoveElement = vi.fn();
@@ -48,7 +50,8 @@ describe("useTimelineClipDrag", () => {
scrollRef: { current: scroll },
ppsRef: { current: 100 },
durationRef: { current: 10 },
trackOrderRef: { current: [0, 1, 2] },
trackOrderRef: { current: layers.map((layer) => layer.id) },
timelineLayersRef: { current: layers },
timelineElementsRef: { current: [front, middle, back] },
onMoveElement,
onResizeElement: vi.fn(),
@@ -82,6 +85,8 @@ describe("useTimelineClipDrag", () => {
pointerOffsetY: 0,
previewStart: back.start,
previewTrack: back.track,
previewLayerId: layers[2]!.id,
previewLayerIndex: 2,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
@@ -104,11 +109,11 @@ describe("useTimelineClipDrag", () => {
expect(onMoveElement).toHaveBeenCalledTimes(1);
expect(onMoveElement.mock.calls[0]![1]).toMatchObject({
start: 0,
track: 0,
track: 2,
stackingReorder: {
fromIndex: 2,
toIndex: 0,
siblingKeys: ["front", "middle", "back"],
contextKey: "root",
placement: { type: "onto", layerId: layers[0]!.id },
zIndexChanges: [{ key: "back", zIndex: 3 }],
},
});
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
import { isMusicTrack } from "../../utils/timelineInspector";
import { mergeUserBeats } from "../../utils/beatEditing";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
const BEAT_SNAP_PX = 8;
const EMPTY_BEAT_TIMES: number[] = [];
@@ -82,6 +83,8 @@ export interface DraggedClipState {
pointerOffsetY: number;
previewStart: number;
previewTrack: number;
previewLayerId: TimelineLayerId;
previewLayerIndex: number;
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
snapBeatTime: number | null;
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
@@ -112,7 +115,8 @@ interface UseTimelineClipDragInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>;
trackOrderRef: React.RefObject<number[]>;
trackOrderRef: React.RefObject<TimelineLayerId[]>;
timelineLayersRef: React.RefObject<StackingTimelineLayer[]>;
timelineElementsRef: React.RefObject<TimelineElement[]>;
onMoveElement?: (
element: TimelineElement,
@@ -135,6 +139,7 @@ export function useTimelineClipDrag({
ppsRef,
durationRef,
trackOrderRef,
timelineLayersRef,
timelineElementsRef,
onMoveElement,
onResizeElement,
@@ -210,7 +215,9 @@ export function useTimelineClipDrag({
pixelsPerSecond: ppsRef.current,
trackHeight: TRACK_H,
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,
stackingElements: timelineElementsRef.current,
},
@@ -234,11 +241,13 @@ export function useTimelineClipDrag({
pointerClientY: clientY,
previewStart: snap.start,
previewTrack: nextMove.track,
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
previewStackingReorder: nextMove.stackingReorder ?? null,
snapBeatTime: snap.beat,
};
},
[scrollRef, ppsRef, durationRef, trackOrderRef, timelineElementsRef],
[scrollRef, ppsRef, durationRef, trackOrderRef, timelineLayersRef, timelineElementsRef],
);
const stopClipDragAutoScroll = useCallback(() => {
@@ -505,29 +514,23 @@ export function useTimelineClipDrag({
clearSuppressedClick();
const hasStackingReorder =
drag.previewStackingReorder != null &&
drag.previewStackingReorder.fromIndex !== drag.previewStackingReorder.toIndex;
const hasChanged =
drag.previewStart !== drag.element.start ||
drag.previewTrack !== drag.element.track ||
hasStackingReorder;
drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
if (!hasChanged) return;
updateElement(drag.element.key ?? drag.element.id, {
start: drag.previewStart,
...(hasStackingReorder ? {} : { track: drag.previewTrack }),
});
Promise.resolve(
onMoveElementRef.current?.(drag.element, {
start: drag.previewStart,
track: drag.previewTrack,
track: drag.element.track,
stackingReorder: drag.previewStackingReorder,
}),
).catch((error) => {
updateElement(drag.element.key ?? drag.element.id, {
start: drag.element.start,
...(hasStackingReorder ? {} : { track: drag.element.track }),
});
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",
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",
label: "hero",
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(
existingValues: readonly number[],
fromIndex: number,
@@ -4,6 +4,7 @@ import {
createTimelineElementFromManifestClip,
parseTimelineFromDOM,
createImplicitTimelineLayersFromDOM,
buildStandaloneRootTimelineElement,
} from "./timelineDOM";
function makeDoc(html: string): Document {
@@ -12,6 +13,29 @@ function makeDoc(html: string): Document {
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", () => {
it("harvests hfId from a data-start element that has data-hf-id", () => {
const doc = makeDoc(`
@@ -135,6 +159,24 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
});
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([]);
});
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 { ClipManifestClip } from "./playbackTypes";
import { getElementZIndex } from "./layerOrdering";
import { getElementZIndex, hasExplicitZIndex } from "./layerOrdering";
import {
resolveMediaElement,
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 {
if (!element || !("style" in element)) return undefined;
return getElementZIndex(element as HTMLElement);
return isHTMLElement(element) ? getElementZIndex(element) : undefined;
}
function getTimelineElementHasExplicitZIndex(element: Element | null): boolean {
return isHTMLElement(element) ? hasExplicitZIndex(element) : false;
}
// 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),
// so it can only serve as a fallback when the element isn't live.
zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0,
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(hostEl),
stackingContextId,
parentCompositionId,
compositionAncestors,
@@ -197,6 +207,8 @@ export function createTimelineElementFromManifestClip(params: {
}
}
if (hostEl) {
entry.zIndex = getTimelineElementZIndex(hostEl) ?? entry.zIndex;
entry.hasExplicitZIndex = getTimelineElementHasExplicitZIndex(hostEl);
entry.domId = hostEl.id || undefined;
entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
entry.selector = getTimelineElementSelector(hostEl);
@@ -270,6 +282,7 @@ export function createImplicitTimelineLayersFromDOM(
sourceFile,
start: 0,
zIndex: getTimelineElementZIndex(child),
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(child),
stackingContextId: compositionContext.stackingContextId,
parentCompositionId: compositionContext.parentCompositionId,
compositionAncestors: compositionContext.compositionAncestors,
@@ -344,6 +357,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
duration: dur,
track: isNaN(track) ? 0 : track,
zIndex: getTimelineElementZIndex(el),
hasExplicitZIndex: getTimelineElementHasExplicitZIndex(el),
stackingContextId: compositionContext.stackingContextId,
parentCompositionId: compositionContext.parentCompositionId,
compositionAncestors: compositionContext.compositionAncestors,
@@ -468,6 +482,7 @@ export function buildStandaloneRootTimelineElement(params: {
duration: params.rootDuration,
track: 0,
zIndex: 0,
hasExplicitZIndex: false,
stackingContextId: params.compositionId,
parentCompositionId: null,
compositionAncestors: [params.compositionId],
@@ -30,6 +30,8 @@ export interface TimelineElement {
track: number;
/** Resolved z-index for stacking-aware timeline ordering. */
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. */
stackingContextId?: string | null;
/** Nearest parent composition context, matching RuntimeTimelineClip. */
@@ -143,7 +145,10 @@ interface PlayerState {
updateElement: (
elementId: string,
updates: Partial<
Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart" | "hidden">
Pick<
TimelineElement,
"start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden"
>
>,
) => void;
setZoomMode: (mode: ZoomMode) => void;