feat(studio): unify timeline vertical reorder with z-index stacking

Timeline rows now order by scoped stacking (z-index per stacking context)
instead of data-track-index, and dragging a clip up/down commits a targeted
z-index change through the same shared path the layers panel uses. Both panels
stay consistent and moving a clip actually changes front/back. data-track-index
is demoted to time-overlap layout only; no bulk z-index injection (#958 intact).

Also restores beat-snapping on keyframe retiming (re-wires snapKeyframePctToBeat,
orphaned when keyframe dragging was removed) which surfaced while unifying the
model. Extracts pure track-ordering logic to timelineTrackOrder.ts, the stacking
reorder commit + deleteSelectedKeyframes to timelineEditingHelpers.ts, to keep
StudioApp / the timeline hook / Timeline under the studio 600-LOC cap.

U3: scoped stacking row order in the timeline
U4: vertical drag commits z-index via the shared reorder commit
U8: restore keyframe beat-snap on retime
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:24 -04:00
parent 070ee91694
commit dd980697a2
16 changed files with 1110 additions and 54 deletions
@@ -18,9 +18,11 @@ import {
shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline,
} from "./Timeline";
import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder";
import { RULER_H, TRACK_H } from "./timelineLayout";
import { formatTime } from "../lib/time";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -201,6 +203,99 @@ describe("Timeline provider boundary", () => {
});
});
function rowElement(input: {
id: string;
track: number;
zIndex?: number;
start?: number;
duration?: number;
stackingContextId?: string | null;
parentCompositionId?: string | null;
compositionAncestors?: string[];
}): TimelineElement {
return {
id: input.id,
tag: "div",
start: input.start ?? 0,
duration: input.duration ?? 1,
track: input.track,
zIndex: input.zIndex ?? 0,
stackingContextId: input.stackingContextId ?? "root",
parentCompositionId: input.parentCompositionId ?? null,
compositionAncestors: input.compositionAncestors ?? ["root"],
};
}
describe("buildStackingTimelineTracks", () => {
it("keeps no-track-index clips in DOM order when stacking ties", () => {
const tracks = buildStackingTimelineTracks([
rowElement({ id: "a", track: 0 }),
rowElement({ id: "b", track: 1 }),
rowElement({ id: "c", track: 2 }),
]);
expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]);
});
it("orders authored track-index rows by stacking order instead of numeric track order", () => {
const tracks = buildStackingTimelineTracks([
rowElement({ id: "dom-first", track: 2 }),
rowElement({ id: "dom-second", track: 0 }),
]);
expect(tracks.map(([track]) => track)).toEqual([2, 0]);
});
it("renders explicit z-index rows top-to-front by descending z-index", () => {
const tracks = buildStackingTimelineTracks([
rowElement({ id: "back", track: 0, zIndex: 1 }),
rowElement({ id: "front", track: 1, zIndex: 10 }),
rowElement({ id: "middle", track: 2, zIndex: 5 }),
]);
expect(tracks.map(([track]) => track)).toEqual([1, 2, 0]);
});
it("keeps nested sub-composition clips scoped below parent-level clips", () => {
const tracks = buildStackingTimelineTracks([
rowElement({ id: "root-low", track: 1, zIndex: 1 }),
rowElement({
id: "nested-high",
track: 0,
zIndex: 100,
stackingContextId: "scene",
parentCompositionId: "scene",
compositionAncestors: ["root", "scene"],
}),
rowElement({ id: "root-front", track: 2, zIndex: 2 }),
]);
expect(tracks.map(([track]) => track)).toEqual([2, 1, 0]);
});
it("keeps time-overlapping equal-rank clips on separate literal-track rows", () => {
const tracks = buildStackingTimelineTracks([
rowElement({ id: "first", track: 0, start: 0, duration: 2 }),
rowElement({ id: "second", track: 1, start: 1, duration: 2 }),
]);
expect(tracks).toHaveLength(2);
expect(
tracks.map(([track, elements]) => [track, elements.map((element) => element.id)]),
).toEqual([
[0, ["first"]],
[1, ["second"]],
]);
});
});
describe("insertPreviewTrackOrder", () => {
it("preserves top and bottom drag-preview row insertion without numeric resorting", () => {
expect(insertPreviewTrackOrder([5, 2, 0], -1)).toEqual([-1, 5, 2, 0]);
expect(insertPreviewTrackOrder([5, 2, 0], 6)).toEqual([5, 2, 0, 6]);
});
});
describe("generateTicks", () => {
it("returns empty arrays for duration <= 0", () => {
expect(generateTicks(0)).toEqual({ major: [], minor: [] });
@@ -23,6 +23,7 @@ import {
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint";
import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder";
import {
GUTTER,
generateTicks,
@@ -186,15 +187,7 @@ export const Timeline = memo(function Timeline({
return Number.isFinite(result) ? result : safeDur;
}, [rawElements, duration]);
const tracks = useMemo(() => {
const map = new Map<number, typeof expandedElements>();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
list.push(el);
map.set(el.track, list);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const tracks = useMemo(() => buildStackingTimelineTracks(expandedElements), [expandedElements]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
@@ -207,6 +200,8 @@ export const Timeline = memo(function Timeline({
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const expandedElementsRef = useRef(expandedElements);
expandedElementsRef.current = expandedElements;
const ppsRef = useRef(100);
const durationRef = useRef(effectiveDuration);
@@ -228,6 +223,7 @@ export const Timeline = memo(function Timeline({
ppsRef,
durationRef,
trackOrderRef,
timelineElementsRef: expandedElementsRef,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
@@ -242,7 +238,7 @@ export const Timeline = memo(function Timeline({
trackOrder.includes(draggedClip.previewTrack)
)
return trackOrder;
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
return insertPreviewTrackOrder(trackOrder, draggedClip.previewTrack);
}, [draggedClip, trackOrder]);
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
@@ -401,6 +401,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
});
@@ -450,6 +451,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={TRACK_H - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
beatTimes={beatAnalysis?.beatTimes}
clipStart={previewElement.start}
clipDurationSeconds={previewElement.duration}
pixelsPerSecond={pps}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
@@ -1,10 +1,12 @@
import { memo, useRef, useState } from "react";
import { BEAT_BAND_H } from "./BeatStrip";
import {
clampToNeighbors,
KEYFRAME_DRAG_THRESHOLD_PX,
previewClipPct,
resolveKeyframeDrag,
} from "../../components/editor/keyframeDrag";
import { snapKeyframePctToBeat } from "./timelineEditing";
interface KeyframeEntry {
percentage: number;
@@ -28,6 +30,14 @@ interface TimelineClipDiamondsProps {
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
* the bottom half so they clear the strip at the top. */
beatsActive?: boolean;
/** Composition-time beat positions (same source the beat strip renders from).
* When present and `beatsActive`, a dragged keyframe snaps to the nearest beat. */
beatTimes?: number[];
/** Clip start / duration (seconds) + pixels-per-second, needed to map a
* dragged keyframe's clip-% to composition time for beat snapping. */
clipStart?: number;
clipDurationSeconds?: number;
pixelsPerSecond?: number;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
@@ -71,6 +81,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
clipWidthPx,
clipHeightPx,
beatsActive,
beatTimes,
clipStart = 0,
clipDurationSeconds = 0,
pixelsPerSecond = 1,
accentColor,
isSelected,
currentPercentage,
@@ -121,6 +135,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const baseOpacity = isSelected ? 0.4 : 0.25;
const canDrag = isSelected && !!onMoveKeyframe;
// Snap a dragged keyframe's clip-% to the nearest beat (within ~8px), then
// re-clamp to neighbours so the snap can't cross a sibling keyframe. No-op
// when the beat strip isn't active for this track or no beats are loaded.
const snapClipPctToBeat = (clipPct: number, draggedIndex: number): number => {
if (!beatsActive || !beatTimes || beatTimes.length === 0) return clipPct;
const snapped = snapKeyframePctToBeat(
{ start: clipStart, duration: clipDurationSeconds },
clipPct,
beatTimes,
pixelsPerSecond,
);
return clampToNeighbors(snapped, sortedClipPcts, draggedIndex);
};
return (
<div
className="absolute inset-0"
@@ -195,14 +223,17 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
if (d.moved) {
setPreview({
kfKey,
clipPct: previewClipPct({
pointerDownX: d.startX,
pointerMoveX: e.clientX,
clipWidthPx,
draggedClipPct: d.fromClipPct,
draggedIndex: i,
sortedClipPcts,
}),
clipPct: snapClipPctToBeat(
previewClipPct({
pointerDownX: d.startX,
pointerMoveX: e.clientX,
clipWidthPx,
draggedClipPct: d.fromClipPct,
draggedIndex: i,
sortedClipPcts,
}),
i,
),
});
}
};
@@ -238,7 +269,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
else onClickKeyframe?.(kf.percentage);
} else if (res.kind === "move" && res.toClipPct != null) {
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
onMoveKeyframe?.(elementId, d.fromClipPct, snapClipPctToBeat(res.toClipPct, i));
// A retime still targeted this exact diamond — park/select it at its
// new position, same as a plain click, or a drag that actually moved
// something looks identical to one that silently did nothing.
@@ -1,7 +1,7 @@
// fallow-ignore-file code-duplication
// fallow-ignore-file dead-code
import type { TimelineElement } from "../store/playerStore";
import type { BlockedTimelineEditIntent } from "./timelineEditing";
import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
/**
* Shared callback signatures for timeline editing operations.
@@ -26,7 +26,9 @@ export interface TimelineDropCallbacks {
export interface TimelineEditCallbacks {
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
updates: Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
},
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
@@ -10,6 +10,7 @@ import {
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
snapKeyframePctToBeat,
type TimelinePromptElement,
} from "./timelineEditing";
@@ -155,6 +156,69 @@ describe("resolveTimelineMove", () => {
),
).toEqual({ start: 2, track: 2 });
});
it("resolves vertical stacking movement within the dragged clip's context siblings", () => {
const result = resolveTimelineMove(
{
start: 0,
track: 1,
duration: 2,
originClientX: 0,
originClientY: 0,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 99, 1],
stackingElement: {
id: "root-back",
track: 1,
zIndex: 1,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
stackingElements: [
{
id: "root-front",
track: 0,
zIndex: 2,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
{
id: "nested-row",
track: 99,
zIndex: 100,
stackingContextId: "scene",
parentCompositionId: "scene",
compositionAncestors: ["root", "scene"],
},
{
id: "root-back",
track: 1,
zIndex: 1,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
],
},
0,
-72,
);
expect(result).toEqual({
start: 0,
track: 0,
stackingReorder: {
contextKey: "root",
fromIndex: 1,
toIndex: 0,
siblingKeys: ["root-front", "root-back"],
},
});
});
});
describe("hasPatchableTimelineTarget", () => {
@@ -613,3 +677,34 @@ describe("buildPromptCopyText", () => {
);
});
});
describe("snapKeyframePctToBeat", () => {
// el spans 010s, so clip-% maps to composition time as pct * 0.1s.
// At pps=100 the snap window is 8 / 100 = 0.08s.
const el = { start: 0, duration: 10 };
const beats = [2, 5, 8];
it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
// pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
});
it("leaves a keyframe unchanged when no beat is within the window", () => {
// pct 55 → 5.5s, 0.5s from the nearest beat → free.
expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
});
it("is a no-op when there are no beats", () => {
expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
});
it("is a no-op for a zero-duration clip", () => {
expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
});
it("widens the snap window as zoom (pps) decreases", () => {
// pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
});
});
@@ -1,5 +1,6 @@
import { formatTime } from "../lib/time";
import { roundToCenti } from "../../utils/rounding";
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
const roundToCentiseconds = roundToCenti;
@@ -7,6 +8,88 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* A timeline clip described for stacking-order math: its track (timeline row),
* resolved z-index, and stacking-context identity. Structurally satisfied by the
* app's TimelineElement.
*/
export interface TimelineStackingElement {
id: string;
key?: string;
track: number;
zIndex?: number;
stackingContextId?: string | null;
parentCompositionId?: string | null;
compositionAncestors?: string[];
}
/** A resolved vertical reorder: move the dragged clip from `fromIndex` to
* `toIndex` within its stacking context's ordered siblings (top = front). */
export interface TimelineStackingReorderIntent {
contextKey: string;
fromIndex: number;
toIndex: number;
siblingKeys: string[];
}
interface TimelineStackingOrderItem {
key: string;
track: number;
zIndex: number;
stackingContextId: string | null;
parentCompositionId: string | null;
compositionAncestors: readonly string[];
}
function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem {
return {
key: element.key ?? element.id,
track: element.track,
zIndex: element.zIndex ?? 0,
stackingContextId: element.stackingContextId ?? null,
parentCompositionId: element.parentCompositionId ?? null,
compositionAncestors: element.compositionAncestors ?? [],
};
}
/** Ordered siblings of `element` within its own stacking context (z-index desc,
* DOM order tiebreak) — the unit a vertical reorder operates on. */
function resolveContextSiblings(
element: TimelineStackingElement,
elements: readonly TimelineStackingElement[],
): TimelineStackingOrderItem[] {
const contextKey = resolveStackingContextKey(toStackingOrderItem(element));
const items = elements
.map(toStackingOrderItem)
.filter((item) => resolveStackingContextKey(item) === contextKey);
return resolveContextOrder(items);
}
/**
* Resolve the reorder implied by dropping `element` onto `targetTrack` (the track
* of the sibling whose slot it lands in). Returns null when the element has no
* reorderable siblings or the target track matches no sibling.
*/
export function resolveTimelineStackingReorderByTargetTrack(args: {
element: TimelineStackingElement;
elements: readonly TimelineStackingElement[];
targetTrack: number;
}): TimelineStackingReorderIntent | null {
const orderedSiblings = resolveContextSiblings(args.element, args.elements);
if (orderedSiblings.length <= 1) return null;
const draggedKey = args.element.key ?? args.element.id;
const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey);
if (fromIndex < 0) return null;
const toIndex = orderedSiblings.findIndex((sibling) => sibling.track === args.targetTrack);
if (toIndex < 0) return null;
return {
contextKey: resolveStackingContextKey(toStackingOrderItem(args.element)),
fromIndex,
toIndex,
siblingKeys: orderedSiblings.map((sibling) => sibling.key),
};
}
const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
const AUTO_SCROLL_EDGE_ZONE = 40;
const AUTO_SCROLL_MAX_SPEED = 12;
@@ -25,6 +108,10 @@ export interface TimelineMoveInput {
trackHeight: number;
maxStart: number;
trackOrder: number[];
/** When provided, vertical movement is resolved as a z-index stacking reorder
* within `stackingElement`'s context instead of a raw track change. */
stackingElement?: TimelineStackingElement;
stackingElements?: TimelineStackingElement[];
}
export interface TimelineResizeInput {
@@ -73,7 +160,7 @@ export function resolveTimelineMove(
input: TimelineMoveInput,
clientX: number,
clientY: number,
): { start: number; track: number } {
): { start: number; track: number; stackingReorder?: TimelineStackingReorderIntent } {
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
const deltaTime =
@@ -81,6 +168,33 @@ export function resolveTimelineMove(
const trackDeltaRaw =
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
const deltaTrack = Math.round(trackDeltaRaw);
const nextStart = clamp(
roundToCentiseconds(input.start + deltaTime),
0,
Math.max(0, input.maxStart),
);
// Stacking mode: vertical movement reorders z-index within the dragged clip's
// stacking context (top = front), rather than changing the raw track number.
if (input.stackingElement && input.stackingElements) {
const orderedSiblings = resolveContextSiblings(input.stackingElement, input.stackingElements);
const draggedKey = input.stackingElement.key ?? input.stackingElement.id;
const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey);
if (fromIndex >= 0 && orderedSiblings.length > 1) {
const toIndex = clamp(fromIndex + deltaTrack, 0, orderedSiblings.length - 1);
return {
start: nextStart,
track: orderedSiblings[toIndex]!.track,
stackingReorder: {
contextKey: resolveStackingContextKey(toStackingOrderItem(input.stackingElement)),
fromIndex,
toIndex,
siblingKeys: orderedSiblings.map((sibling) => sibling.key),
},
};
}
}
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
const desiredTrackIndex = currentTrackIndex + deltaTrack;
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
@@ -106,7 +220,7 @@ export function resolveTimelineMove(
}
return {
start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
start: nextStart,
track: nextTrack,
};
}
@@ -0,0 +1,116 @@
import { type TimelineElement } from "../store/playerStore";
import {
resolveContextOrder,
resolveStackingContextKey,
type ContextOrderItem,
} from "../lib/layerOrdering";
/**
* Pure timeline track-ordering logic. Timeline rows are ordered by scoped
* stacking (z-index per stacking context, top = front), with data-track-index
* used only to split time-overlapping clips of equal rank onto separate rows.
* Extracted from Timeline.tsx to keep the component under the studio 600-LOC cap.
*/
interface TimelineTrackOrderItem extends ContextOrderItem {
key: string;
track: number;
start: number;
duration: number;
}
function getTimelineElementKey(element: TimelineElement): string {
return element.key ?? element.id;
}
function toTimelineTrackOrderItem(element: TimelineElement): TimelineTrackOrderItem {
return {
key: getTimelineElementKey(element),
track: element.track,
start: element.start,
duration: element.duration,
zIndex: element.zIndex ?? 0,
stackingContextId: element.stackingContextId ?? null,
parentCompositionId: element.parentCompositionId ?? null,
compositionAncestors: element.compositionAncestors ?? [],
};
}
function timelineElementsOverlap(
a: Pick<TimelineElement, "start" | "duration">,
b: Pick<TimelineElement, "start" | "duration">,
): boolean {
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(getTimelineElementKey(element)) ?? Number.POSITIVE_INFINITY,
);
}
return orderIndex;
}
function hasOverlappingEqualRankElements(
aElements: readonly TimelineElement[],
bElements: readonly TimelineElement[],
): boolean {
for (const a of aElements) {
const aOrderItem = toTimelineTrackOrderItem(a);
const aContextKey = resolveStackingContextKey(aOrderItem);
for (const b of bElements) {
const bOrderItem = toTimelineTrackOrderItem(b);
if (aContextKey !== resolveStackingContextKey(bOrderItem)) continue;
if (aOrderItem.zIndex !== bOrderItem.zIndex) continue;
if (timelineElementsOverlap(a, b)) return true;
}
}
return false;
}
export function buildStackingTimelineTracks(
elements: readonly TimelineElement[],
): Array<[number, TimelineElement[]]> {
const tracks = new Map<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;
});
}
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];
}
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
import type { DraggedClipState } from "./useTimelineClipDrag";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement {
return {
id: input.id,
domId: input.id,
tag: "div",
start: 0,
duration: 2,
track: input.track,
zIndex: input.zIndex,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
sourceFile: "index.html",
timingSource: "authored",
};
}
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.getState().reset();
});
describe("useTimelineClipDrag", () => {
it("passes sibling-scoped stacking intent on vertical drag commit", async () => {
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
const scroll = document.createElement("div");
document.body.append(scroll);
const onMoveElement = vi.fn();
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
function Harness() {
const hook = useTimelineClipDrag({
scrollRef: { current: scroll },
ppsRef: { current: 100 },
durationRef: { current: 10 },
trackOrderRef: { current: [0, 1, 2] },
timelineElementsRef: { current: [front, middle, back] },
onMoveElement,
onResizeElement: vi.fn(),
onBlockedEditAttempt: vi.fn(),
setShowPopover: vi.fn(),
setRangeSelectionRef: { current: vi.fn() },
});
setDraggedClip = hook.setDraggedClip;
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
if (!setDraggedClip) throw new Error("Expected drag setter");
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
act(() => {
applyDraggedClip({
element: back,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: back.start,
previewTrack: back.track,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
});
});
act(() => {
window.dispatchEvent(
new MouseEvent("pointermove", {
bubbles: true,
clientX: 0,
clientY: -2 * TRACK_H,
}),
);
});
await act(async () => {
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
});
expect(onMoveElement).toHaveBeenCalledTimes(1);
expect(onMoveElement.mock.calls[0]![1]).toMatchObject({
start: 0,
track: 0,
stackingReorder: {
fromIndex: 2,
toIndex: 0,
siblingKeys: ["front", "middle", "back"],
},
});
act(() => root.unmount());
});
});
@@ -5,6 +5,7 @@ import {
resolveTimelineResize,
resolveTimelineAutoScroll,
type BlockedTimelineEditIntent,
type TimelineStackingReorderIntent,
} from "./timelineEditing";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
@@ -83,6 +84,8 @@ export interface DraggedClipState {
previewTrack: number;
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
snapBeatTime: number | null;
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
previewStackingReorder: TimelineStackingReorderIntent | null;
started: boolean;
}
@@ -110,9 +113,12 @@ interface UseTimelineClipDragInput {
ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>;
trackOrderRef: React.RefObject<number[]>;
timelineElementsRef: React.RefObject<TimelineElement[]>;
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
updates: Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
},
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
@@ -129,6 +135,7 @@ export function useTimelineClipDrag({
ppsRef,
durationRef,
trackOrderRef,
timelineElementsRef,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
@@ -204,6 +211,8 @@ export function useTimelineClipDrag({
trackHeight: TRACK_H,
maxStart: Math.max(0, durationRef.current - drag.element.duration),
trackOrder: trackOrderRef.current,
stackingElement: drag.element,
stackingElements: timelineElementsRef.current,
},
clientX,
clientY,
@@ -225,10 +234,11 @@ export function useTimelineClipDrag({
pointerClientY: clientY,
previewStart: snap.start,
previewTrack: nextMove.track,
previewStackingReorder: nextMove.stackingReorder ?? null,
snapBeatTime: snap.beat,
};
},
[scrollRef, ppsRef, durationRef, trackOrderRef],
[scrollRef, ppsRef, durationRef, trackOrderRef, timelineElementsRef],
);
const stopClipDragAutoScroll = useCallback(() => {
@@ -299,6 +309,7 @@ export function useTimelineClipDrag({
});
};
// fallow-ignore-next-line complexity
const handleWindowPointerMove = (e: PointerEvent) => {
const drag = draggedClipRef.current;
const resize = resizingClipRef.current;
@@ -434,6 +445,7 @@ export function useTimelineClipDrag({
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
};
// fallow-ignore-next-line complexity
const handleWindowPointerUp = () => {
stopClipDragAutoScrollRef.current();
@@ -492,24 +504,30 @@ export function useTimelineClipDrag({
suppressClickRef.current = true;
clearSuppressedClick();
const hasStackingReorder =
drag.previewStackingReorder != null &&
drag.previewStackingReorder.fromIndex !== drag.previewStackingReorder.toIndex;
const hasChanged =
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
drag.previewStart !== drag.element.start ||
drag.previewTrack !== drag.element.track ||
hasStackingReorder;
if (!hasChanged) return;
updateElement(drag.element.key ?? drag.element.id, {
start: drag.previewStart,
track: drag.previewTrack,
...(hasStackingReorder ? {} : { track: drag.previewTrack }),
});
Promise.resolve(
onMoveElementRef.current?.(drag.element, {
start: drag.previewStart,
track: drag.previewTrack,
stackingReorder: drag.previewStackingReorder,
}),
).catch((error) => {
updateElement(drag.element.key ?? drag.element.id, {
start: drag.element.start,
track: drag.element.track,
...(hasStackingReorder ? {} : { track: drag.element.track }),
});
console.error("[Timeline] Failed to persist clip move", error);
});
@@ -41,8 +41,7 @@ export function computeReorderZValues(
return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
}
// Exported in a later unit when the timeline consumes it; internal-only for now.
function resolveStackingContextKey(item: StackingContextDescriptor): string {
export function resolveStackingContextKey(item: StackingContextDescriptor): string {
return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? "";
}