mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user