mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): lane-model timeline (non-overlapping clips share a row)
Rework the timeline row model from one-row-per-stacking-layer to NLE "lanes": a row holds a time-sequence of non-overlapping clips, ordered top = front. Stacking only matters between clips that overlap in time. - Lane packing: per stacking context, sort clips by effective z-index desc (DOM order tiebreak) and greedily pack each onto the first lane whose members don't overlap it in time; else open a new lane. Lane's representative z is the max member z. Audio stays one clip per lane. - Lane-aware drag: dropping onto a lane joins it (clip takes the lane's z) when there's no time-conflict; a conflicting drop is rejected and the preview snaps to the nearest valid lane or a new-lane insertion. Between/above/below still create a new lane at that stacking level. - Overlap checks use the drag-preview start/duration, so conflict is judged by where the clip lands, not where it started. Only explicit drags write z-index; authored z is preserved and data-track-index is never rewritten.
This commit is contained in:
@@ -158,7 +158,7 @@ describe("resolveTimelineMove", () => {
|
||||
).toEqual({ start: 2, track: 2 });
|
||||
});
|
||||
|
||||
it("resolves vertical stacking movement as a layer join without changing data-track-index", () => {
|
||||
it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
|
||||
const stackingElements = [
|
||||
{
|
||||
id: "root-front",
|
||||
@@ -209,12 +209,12 @@ describe("resolveTimelineMove", () => {
|
||||
expect(result).toEqual({
|
||||
start: 0,
|
||||
track: 1,
|
||||
previewLayerId: layers[0]!.id,
|
||||
previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
|
||||
previewLayerIndex: 0,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "onto", layerId: layers[0]!.id },
|
||||
zIndexChanges: [{ key: "root-back", zIndex: 2 }],
|
||||
placement: { type: "above", layerId: layers[0]!.id },
|
||||
zIndexChanges: [{ key: "root-back", zIndex: 3 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,10 +110,15 @@ export function resolveTimelineMove(
|
||||
// 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 stackingElement = {
|
||||
...input.stackingElement,
|
||||
start: nextStart,
|
||||
duration: input.duration,
|
||||
};
|
||||
const layerMove =
|
||||
input.timelineLayers && input.layerOrder
|
||||
? resolveTimelineLayerStackingMove({
|
||||
element: input.stackingElement,
|
||||
element: stackingElement,
|
||||
layers: input.timelineLayers,
|
||||
layerOrder: input.layerOrder,
|
||||
trackDeltaRaw,
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { StackingTimelineLayer } from "./timelineTrackOrder";
|
||||
import { resolveTimelineLayerZIndexChanges } from "./timelineLayerDrag";
|
||||
import {
|
||||
resolveTimelineLayerStackingMove,
|
||||
resolveTimelineLayerZIndexChanges,
|
||||
} from "./timelineLayerDrag";
|
||||
|
||||
function element(input: { id: string; zIndex: number; tag?: string }): TimelineElement {
|
||||
function element(input: {
|
||||
id: string;
|
||||
zIndex: number;
|
||||
tag?: string;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
tag: input.tag ?? "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 1,
|
||||
track: 0,
|
||||
zIndex: input.zIndex,
|
||||
hasExplicitZIndex: true,
|
||||
@@ -18,30 +27,48 @@ function element(input: { id: string; zIndex: number; tag?: string }): TimelineE
|
||||
};
|
||||
}
|
||||
|
||||
function layer(id: string, zIndex: number): StackingTimelineLayer {
|
||||
function layer(
|
||||
id: string,
|
||||
zIndex: number,
|
||||
elements: TimelineElement[] = [element({ id, zIndex })],
|
||||
): StackingTimelineLayer {
|
||||
return {
|
||||
id,
|
||||
kind: "visual",
|
||||
contextKey: "root",
|
||||
zIndex,
|
||||
placementTrack: 0,
|
||||
elements: [element({ id, zIndex })],
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveTimelineLayerZIndexChanges", () => {
|
||||
it("joins an existing layer by assigning the dragged clip that layer's z-index", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1 });
|
||||
it("joins an existing lane by assigning the dragged clip that lane's z-index", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers: [layer("front", 10), layer("back", 1)],
|
||||
layers: [layer("front", 10, [front]), layer("back", 1)],
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
})?.zIndexChanges,
|
||||
).toEqual([{ key: "dragged", zIndex: 10 }]);
|
||||
});
|
||||
|
||||
it("rejects an onto-lane join when the dragged clip would overlap that lane", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerZIndexChanges({
|
||||
element: dragged,
|
||||
layers: [layer("front", 10, [front]), layer("back", 1)],
|
||||
placement: { type: "onto", layerId: "front" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("interpolates a new integer z-index strictly between neighboring layers", () => {
|
||||
const dragged = element({ id: "dragged", zIndex: 1 });
|
||||
|
||||
@@ -100,3 +127,28 @@ describe("resolveTimelineLayerZIndexChanges", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineLayerStackingMove", () => {
|
||||
it("snaps a blocked onto-lane drop to the nearest new lane instead of committing the conflict", () => {
|
||||
const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 });
|
||||
const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 });
|
||||
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
|
||||
|
||||
expect(
|
||||
resolveTimelineLayerStackingMove({
|
||||
element: dragged,
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
trackDeltaRaw: -1,
|
||||
}),
|
||||
).toEqual({
|
||||
previewLayerId: "preview:dragged:above:front",
|
||||
previewLayerIndex: 0,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "above", layerId: "front" },
|
||||
zIndexChanges: [{ key: "dragged", zIndex: 11 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { resolveStackingContextKey } from "../lib/layerOrdering";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
import {
|
||||
timelineElementsOverlap,
|
||||
type StackingTimelineLayer,
|
||||
type TimelineLayerId,
|
||||
} from "./timelineTrackOrder";
|
||||
import {
|
||||
toStackingOrderItem,
|
||||
type TimelineLayerDropPlacement,
|
||||
@@ -25,6 +29,18 @@ function layerContainsElement(layer: StackingTimelineLayer, key: string): boolea
|
||||
return layer.elements.some((element) => getTimelineElementIdentity(element) === key);
|
||||
}
|
||||
|
||||
function layerConflictsWithElement(
|
||||
layer: StackingTimelineLayer,
|
||||
element: TimelineStackingElement,
|
||||
draggedKey: string,
|
||||
): boolean {
|
||||
return layer.elements.some(
|
||||
(candidate) =>
|
||||
getTimelineElementIdentity(candidate) !== draggedKey &&
|
||||
timelineElementsOverlap(candidate, element),
|
||||
);
|
||||
}
|
||||
|
||||
function addElementChange(
|
||||
changes: TimelineStackingZIndexChange[],
|
||||
element: TimelineStackingElement,
|
||||
@@ -161,6 +177,77 @@ function resolveBetweenChanges(input: {
|
||||
return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes;
|
||||
}
|
||||
|
||||
function resolveOntoChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
layerId: string;
|
||||
}): TimelineStackingZIndexChange[] | null {
|
||||
const target = findLayer(input.layers, input.layerId);
|
||||
if (!target) return null;
|
||||
if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) {
|
||||
return null;
|
||||
}
|
||||
return resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: target.zIndex,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEdgeChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
layerId: string;
|
||||
offset: number;
|
||||
}): TimelineStackingZIndexChange[] | null {
|
||||
const target = findLayer(input.layers, input.layerId);
|
||||
if (!target) return null;
|
||||
return resolvePlacementZIndexChanges({
|
||||
element: input.element,
|
||||
targetZIndex: target.zIndex + input.offset,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlacementChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
placement: TimelineLayerDropPlacement;
|
||||
}): TimelineStackingZIndexChange[] | null {
|
||||
switch (input.placement.type) {
|
||||
case "onto":
|
||||
return resolveOntoChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
layerId: input.placement.layerId,
|
||||
});
|
||||
case "above":
|
||||
return resolveEdgeChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
layerId: input.placement.layerId,
|
||||
offset: 1,
|
||||
});
|
||||
case "below":
|
||||
return resolveEdgeChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
layerId: input.placement.layerId,
|
||||
offset: -1,
|
||||
});
|
||||
case "between": {
|
||||
const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId);
|
||||
const afterLayer = findLayer(input.layers, input.placement.afterLayerId);
|
||||
return beforeLayer && afterLayer
|
||||
? resolveBetweenChanges({
|
||||
element: input.element,
|
||||
layers: input.layers,
|
||||
beforeLayer,
|
||||
afterLayer,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimelineLayerZIndexChanges(input: {
|
||||
element: TimelineStackingElement;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
@@ -169,37 +256,13 @@ export function resolveTimelineLayerZIndexChanges(input: {
|
||||
if (isAudioElement(input.element)) return null;
|
||||
const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element));
|
||||
const layers = getContextLayers(input.layers, contextKey);
|
||||
let changes: TimelineStackingZIndexChange[] = [];
|
||||
const changes = resolvePlacementChanges({
|
||||
element: input.element,
|
||||
layers,
|
||||
placement: input.placement,
|
||||
});
|
||||
|
||||
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
|
||||
return changes && changes.length > 0
|
||||
? { contextKey, placement: input.placement, zIndexChanges: changes }
|
||||
: null;
|
||||
}
|
||||
@@ -233,6 +296,111 @@ function resolveDragPlacement(
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildInsertionPlacement(
|
||||
layers: readonly StackingTimelineLayer[],
|
||||
insertionIndex: number,
|
||||
): TimelineLayerDropPlacement | null {
|
||||
const first = layers[0];
|
||||
const last = layers[layers.length - 1];
|
||||
if (!first || !last) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
interface ValidPlacementCandidate {
|
||||
placement: TimelineLayerDropPlacement;
|
||||
position: number;
|
||||
kind: "onto" | "insert";
|
||||
}
|
||||
|
||||
function comparePlacementCandidates(input: {
|
||||
targetPosition: number;
|
||||
currentIndex: number;
|
||||
a: ValidPlacementCandidate;
|
||||
b: ValidPlacementCandidate;
|
||||
}): number {
|
||||
const distanceA = Math.abs(input.a.position - input.targetPosition);
|
||||
const distanceB = Math.abs(input.b.position - input.targetPosition);
|
||||
if (distanceA !== distanceB) return distanceA - distanceB;
|
||||
|
||||
const direction = Math.sign(input.targetPosition - input.currentIndex);
|
||||
if (direction !== 0) {
|
||||
const biasA =
|
||||
direction < 0
|
||||
? input.a.position <= input.targetPosition
|
||||
: input.a.position >= input.targetPosition;
|
||||
const biasB =
|
||||
direction < 0
|
||||
? input.b.position <= input.targetPosition
|
||||
: input.b.position >= input.targetPosition;
|
||||
if (biasA !== biasB) return biasA ? -1 : 1;
|
||||
}
|
||||
|
||||
if (input.a.kind !== input.b.kind) return input.a.kind === "onto" ? -1 : 1;
|
||||
return input.a.position - input.b.position;
|
||||
}
|
||||
|
||||
function resolveNearestValidPlacement(input: {
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
element: TimelineStackingElement;
|
||||
draggedKey: string;
|
||||
targetPosition: number;
|
||||
currentIndex: number;
|
||||
}): TimelineLayerDropPlacement | null {
|
||||
const candidates: ValidPlacementCandidate[] = [];
|
||||
|
||||
input.layers.forEach((layer, index) => {
|
||||
if (!layerConflictsWithElement(layer, input.element, input.draggedKey)) {
|
||||
candidates.push({
|
||||
placement: { type: "onto", layerId: layer.id },
|
||||
position: index,
|
||||
kind: "onto",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (let insertionIndex = 0; insertionIndex <= input.layers.length; insertionIndex += 1) {
|
||||
const placement = buildInsertionPlacement(input.layers, insertionIndex);
|
||||
if (!placement) continue;
|
||||
candidates.push({
|
||||
placement,
|
||||
position: insertionIndex - 0.5,
|
||||
kind: "insert",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.sort((a, b) =>
|
||||
comparePlacementCandidates({
|
||||
targetPosition: input.targetPosition,
|
||||
currentIndex: input.currentIndex,
|
||||
a,
|
||||
b,
|
||||
}),
|
||||
)[0]?.placement ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLaneAwareDragPlacement(input: {
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
element: TimelineStackingElement;
|
||||
draggedKey: string;
|
||||
placement: TimelineLayerDropPlacement;
|
||||
targetPosition: number;
|
||||
currentIndex: number;
|
||||
}): TimelineLayerDropPlacement | null {
|
||||
if (input.placement.type !== "onto") return input.placement;
|
||||
const target = findLayer(input.layers, input.placement.layerId);
|
||||
if (!target) return null;
|
||||
if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement;
|
||||
return resolveNearestValidPlacement(input);
|
||||
}
|
||||
|
||||
function getPreviewLayerId(
|
||||
draggedKey: string,
|
||||
placement: TimelineLayerDropPlacement,
|
||||
@@ -275,7 +443,17 @@ export function resolveTimelineLayerStackingMove(input: {
|
||||
const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey));
|
||||
if (currentIndex < 0) return null;
|
||||
|
||||
const placement = resolveDragPlacement(contextLayers, currentIndex + input.trackDeltaRaw);
|
||||
const targetPosition = currentIndex + input.trackDeltaRaw;
|
||||
const rawPlacement = resolveDragPlacement(contextLayers, targetPosition);
|
||||
if (!rawPlacement) return null;
|
||||
const placement = resolveLaneAwareDragPlacement({
|
||||
layers: contextLayers,
|
||||
element: input.element,
|
||||
draggedKey,
|
||||
placement: rawPlacement,
|
||||
targetPosition,
|
||||
currentIndex,
|
||||
});
|
||||
if (!placement) return null;
|
||||
return {
|
||||
previewLayerId: getPreviewLayerId(draggedKey, placement),
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface TimelineStackingElement {
|
||||
id: string;
|
||||
key?: string;
|
||||
tag?: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex?: number;
|
||||
stackingContextId?: string | null;
|
||||
|
||||
@@ -34,33 +34,43 @@ function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): stri
|
||||
}
|
||||
|
||||
describe("buildStackingTimelineLayers", () => {
|
||||
it("merges explicit same-z clips in one context when they do not overlap in time", () => {
|
||||
it("packs non-overlapping clips into the same lane even when their z-index differs", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "a", zIndex: 5, start: 0, duration: 1 }),
|
||||
rowElement({ id: "b", zIndex: 5, start: 1, duration: 1 }),
|
||||
rowElement({ id: "back", zIndex: 1, start: 0, duration: 1 }),
|
||||
rowElement({ id: "front", zIndex: 10, start: 1, duration: 1 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["back", "front"]]);
|
||||
expect(result.visualLayers[0]?.zIndex).toBe(10);
|
||||
});
|
||||
|
||||
it("splits clips into separate lanes when they overlap in time", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "front", zIndex: 10, start: 0, duration: 2 }),
|
||||
rowElement({ id: "back", zIndex: 1, start: 1, duration: 2 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
|
||||
});
|
||||
|
||||
it("uses DOM order to break stacking ties before lane packing", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "first", track: 2, zIndex: 5, start: 0, duration: 2 }),
|
||||
rowElement({ id: "second", track: 0, zIndex: 5, start: 1, duration: 2 }),
|
||||
]);
|
||||
|
||||
expect(rowIds(result.visualLayers)).toEqual([["first"], ["second"]]);
|
||||
});
|
||||
|
||||
it("packs auto-z clips by time instead of forcing one row per clip", () => {
|
||||
const result = buildStackingTimelineLayers([
|
||||
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false, start: 0, duration: 1 }),
|
||||
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false, 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 }),
|
||||
|
||||
@@ -24,13 +24,10 @@ type TimelineLayerOrderItem = TimelineStackingOrderItem & {
|
||||
start: number;
|
||||
duration: number;
|
||||
index: number;
|
||||
hasExplicitZIndex: boolean;
|
||||
element: TimelineElement;
|
||||
};
|
||||
|
||||
type BuildLayer = StackingTimelineLayer & {
|
||||
hasExplicitZIndex: boolean;
|
||||
};
|
||||
type BuildLayer = Omit<StackingTimelineLayer, "id">;
|
||||
|
||||
function toTimelineLayerOrderItem(element: TimelineElement, index: number): TimelineLayerOrderItem {
|
||||
return {
|
||||
@@ -38,12 +35,11 @@ function toTimelineLayerOrderItem(element: TimelineElement, index: number): Time
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
index,
|
||||
hasExplicitZIndex: element.hasExplicitZIndex === true,
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
function timelineElementsOverlap(
|
||||
export function timelineElementsOverlap(
|
||||
a: Pick<TimelineElement, "start" | "duration">,
|
||||
b: Pick<TimelineElement, "start" | "duration">,
|
||||
): boolean {
|
||||
@@ -52,13 +48,10 @@ function timelineElementsOverlap(
|
||||
|
||||
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 a.index - b.index;
|
||||
}
|
||||
|
||||
function buildLayerId(
|
||||
function buildElementLayerId(
|
||||
prefix: string,
|
||||
contextKey: string,
|
||||
element: TimelineElement,
|
||||
@@ -66,6 +59,15 @@ function buildLayerId(
|
||||
return `${prefix}:${contextKey}:${getTimelineElementIdentity(element)}`;
|
||||
}
|
||||
|
||||
function buildLaneId(
|
||||
prefix: string,
|
||||
contextKey: string,
|
||||
elements: TimelineElement[],
|
||||
): TimelineLayerId {
|
||||
const memberKey = elements.map(getTimelineElementIdentity).sort().join("|");
|
||||
return `${prefix}:${contextKey}:${memberKey}`;
|
||||
}
|
||||
|
||||
function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const item of resolveContextOrder(items)) {
|
||||
@@ -77,14 +79,16 @@ function getOrderedContextKeys(items: readonly TimelineLayerOrderItem[]): string
|
||||
|
||||
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 compareElementsByStart(a: TimelineElement, b: TimelineElement): number {
|
||||
if (a.start !== b.start) return a.start - b.start;
|
||||
return getTimelineElementIdentity(a).localeCompare(getTimelineElementIdentity(b));
|
||||
}
|
||||
|
||||
function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
|
||||
const byContext = new Map<string, TimelineLayerOrderItem[]>();
|
||||
for (const item of items) {
|
||||
@@ -99,42 +103,37 @@ function buildVisualLayerRows(items: readonly TimelineLayerOrderItem[]): Stackin
|
||||
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);
|
||||
existing.zIndex = Math.max(existing.zIndex, item.zIndex);
|
||||
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);
|
||||
rows.push(
|
||||
...contextRows.map((row) => {
|
||||
const elements = [...row.elements].sort(compareElementsByStart);
|
||||
return {
|
||||
...row,
|
||||
id: buildLaneId("lane", contextKey, elements),
|
||||
elements,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildAudioLayerRows(items: readonly TimelineLayerOrderItem[]): StackingTimelineLayer[] {
|
||||
return items.map((item) => ({
|
||||
id: buildLayerId("audio", resolveStackingContextKey(item), item.element),
|
||||
id: buildElementLayerId("audio", resolveStackingContextKey(item), item.element),
|
||||
kind: "audio",
|
||||
contextKey: resolveStackingContextKey(item),
|
||||
zIndex: item.zIndex,
|
||||
|
||||
@@ -34,89 +34,134 @@ afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function renderDragHarness(elements: TimelineElement[]) {
|
||||
const layers = buildStackingTimelineLayers(elements).rows;
|
||||
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: layers.map((layer) => layer.id) },
|
||||
timelineLayersRef: { current: layers },
|
||||
timelineElementsRef: { current: elements },
|
||||
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;
|
||||
|
||||
return {
|
||||
layers,
|
||||
onMoveElement,
|
||||
startDrag(element: TimelineElement, layerIndex: number) {
|
||||
act(() => {
|
||||
applyDraggedClip({
|
||||
element,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: element.start,
|
||||
previewTrack: element.track,
|
||||
previewLayerId: layers[layerIndex]!.id,
|
||||
previewLayerIndex: layerIndex,
|
||||
previewStackingReorder: null,
|
||||
snapBeatTime: null,
|
||||
started: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
movePointer(clientX: number, clientY: number) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
clientX,
|
||||
clientY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
async dropPointer() {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
||||
});
|
||||
},
|
||||
unmount() {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTimelineClipDrag", () => {
|
||||
it("passes sibling-scoped stacking intent on vertical drag commit", async () => {
|
||||
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", 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 layers = buildStackingTimelineLayers([front, middle, back]).rows;
|
||||
const scroll = document.createElement("div");
|
||||
document.body.append(scroll);
|
||||
const onMoveElement = vi.fn();
|
||||
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
||||
const harness = renderDragHarness([front, middle, back]);
|
||||
|
||||
function Harness() {
|
||||
const hook = useTimelineClipDrag({
|
||||
scrollRef: { current: scroll },
|
||||
ppsRef: { current: 100 },
|
||||
durationRef: { current: 10 },
|
||||
trackOrderRef: { current: layers.map((layer) => layer.id) },
|
||||
timelineLayersRef: { current: layers },
|
||||
timelineElementsRef: { current: [front, middle, back] },
|
||||
onMoveElement,
|
||||
onResizeElement: vi.fn(),
|
||||
onBlockedEditAttempt: vi.fn(),
|
||||
setShowPopover: vi.fn(),
|
||||
setRangeSelectionRef: { current: vi.fn() },
|
||||
});
|
||||
setDraggedClip = hook.setDraggedClip;
|
||||
return null;
|
||||
}
|
||||
harness.startDrag(back, 2);
|
||||
harness.movePointer(0, -2 * TRACK_H);
|
||||
await harness.dropPointer();
|
||||
|
||||
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,
|
||||
previewLayerId: layers[2]!.id,
|
||||
previewLayerIndex: 2,
|
||||
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({
|
||||
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
|
||||
start: 0,
|
||||
track: 2,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "onto", layerId: layers[0]!.id },
|
||||
placement: { type: "above", layerId: harness.layers[0]!.id },
|
||||
zIndexChanges: [{ key: "back", zIndex: 4 }],
|
||||
},
|
||||
});
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("uses the preview start when resolving whether a dragged clip can join a lane", async () => {
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
||||
const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
|
||||
back.start = 0;
|
||||
front.start = 0;
|
||||
const harness = renderDragHarness([front, back]);
|
||||
|
||||
harness.startDrag(back, 1);
|
||||
harness.movePointer(200, -TRACK_H);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
|
||||
start: 2,
|
||||
track: 1,
|
||||
stackingReorder: {
|
||||
contextKey: "root",
|
||||
placement: { type: "onto", layerId: harness.layers[0]!.id },
|
||||
zIndexChanges: [{ key: "back", zIndex: 3 }],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user