Files
hyperframes/packages/studio/src/player/components/timelineLayerDrag.test.ts
T
Miguel Angel Simon Sierra 6b4870246c fix(studio): dropping an overlapping clip on a lane restacks it, not a no-op
A vertical drag onto a lane whose clips overlap the dragged clip in time
used to resolve to the "nearest valid lane", which included the clip's own
lane — so a small drag snapped straight back and felt like the editor
refused the move. Overlapping clips can't share a row (a row is 1-D in
time), but the drag should still restack, never reject.

Now a conflicting onto-drop converts to an edge insertion adjacent to the
target by drag direction (up -> above, down -> below): the clip lands on
its own lane just in front of / behind the target, with its time
unchanged. Non-overlapping drops still join the lane. Deletes the
nearest-valid-placement search (~90 lines) this replaces.
2026-07-08 23:46:27 -04:00

201 lines
6.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { StackingTimelineLayer } from "./timelineTrackOrder";
import {
resolveTimelineLayerStackingMove,
resolveTimelineLayerZIndexChanges,
} from "./timelineLayerDrag";
function element(input: {
id: string;
zIndex: number;
tag?: string;
start?: number;
duration?: number;
}): TimelineElement {
return {
id: input.id,
tag: input.tag ?? "div",
start: input.start ?? 0,
duration: input.duration ?? 1,
track: 0,
zIndex: input.zIndex,
hasExplicitZIndex: true,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
};
}
function layer(
id: string,
zIndex: number,
elements: TimelineElement[] = [element({ id, zIndex })],
): StackingTimelineLayer {
return {
id,
kind: "visual",
contextKey: "root",
zIndex,
placementTrack: 0,
elements,
};
}
describe("resolveTimelineLayerZIndexChanges", () => {
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, [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 });
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();
});
});
describe("resolveTimelineLayerStackingMove", () => {
it("places an upward onto-lane drop above the overlapping target lane", () => {
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: -0.8,
}),
).toEqual({
previewLayerId: "preview:dragged:above:front",
previewLayerIndex: 0,
stackingReorder: {
contextKey: "root",
placement: { type: "above", layerId: "front" },
zIndexChanges: [{ key: "dragged", zIndex: 11 }],
},
});
});
it("places a downward onto-lane drop below the overlapping target lane", () => {
const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 });
const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 });
const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])];
expect(
resolveTimelineLayerStackingMove({
element: dragged,
layers,
layerOrder: layers.map((item) => item.id),
trackDeltaRaw: 0.8,
}),
).toEqual({
previewLayerId: "preview:dragged:below:back",
previewLayerIndex: 2,
stackingReorder: {
contextKey: "root",
placement: { type: "below", layerId: "back" },
zIndexChanges: [{ key: "dragged", zIndex: 0 }],
},
});
});
it("keeps a non-overlapping onto-lane drop joined to the target lane", () => {
const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 });
const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 });
const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])];
expect(
resolveTimelineLayerStackingMove({
element: dragged,
layers,
layerOrder: layers.map((item) => item.id),
trackDeltaRaw: -0.8,
}),
).toEqual({
previewLayerId: "front",
previewLayerIndex: 0,
stackingReorder: {
contextKey: "root",
placement: { type: "onto", layerId: "front" },
zIndexChanges: [{ key: "dragged", zIndex: 10 }],
},
});
});
});