Files
hyperframes/packages/studio/src/player/components/useTimelineClipDrag.test.tsx
T
Miguel Angel Simon Sierra ed8bf475d3 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.
2026-07-08 23:46:26 -04:00

168 lines
5.1 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
import type { DraggedClipState } from "./useTimelineClipDrag";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement {
return {
id: input.id,
domId: input.id,
tag: "div",
start: 0,
duration: 2,
track: input.track,
zIndex: input.zIndex,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
sourceFile: "index.html",
timingSource: "authored",
};
}
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.getState().reset();
});
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 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 harness = renderDragHarness([front, middle, back]);
harness.startDrag(back, 2);
harness.movePointer(0, -2 * TRACK_H);
await harness.dropPointer();
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
start: 0,
track: 2,
stackingReorder: {
contextKey: "root",
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 }],
},
});
harness.unmount();
});
});