Files
hyperframes/packages/studio/src/player/components/useTimelineClipDrag.test.tsx
T
Miguel Angel Simon Sierra d7e7fca8ec feat(studio): magnetic snapping to clip edges, playhead, and bounds
Timeline moves and resizes previously snapped only to the beat grid; they
now also snap the dragged edge to other clips' start/end, the playhead,
and the composition bounds, whichever is nearest within the existing
threshold. Beats fold in as one target kind, so beat snapping is
unchanged. Snap logic lives in a new pure timelineSnapTargets module with
unit tests; a snapped edge shows a guide line (beat highlight, else a thin
accent line) for both drag and resize.
2026-07-08 23:46:27 -04:00

230 lines
7.0 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, ResizingClipState } 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;
start?: number;
duration?: number;
sourceDuration?: number;
}): TimelineElement {
return {
id: input.id,
domId: input.id,
tag: "div",
start: input.start ?? 0,
duration: input.duration ?? 2,
sourceDuration: input.sourceDuration,
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();
const onResizeElement = vi.fn();
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
function Harness() {
const hook = useTimelineClipDrag({
scrollRef: { current: scroll },
ppsRef: { current: 100 },
trackOrderRef: { current: layers.map((layer) => layer.id) },
timelineLayersRef: { current: layers },
timelineElementsRef: { current: elements },
onMoveElement,
onResizeElement,
onBlockedEditAttempt: vi.fn(),
setShowPopover: vi.fn(),
setRangeSelectionRef: { current: vi.fn() },
});
setDraggedClip = hook.setDraggedClip;
setResizingClip = hook.setResizingClip;
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");
if (!setResizingClip) throw new Error("Expected resize setter");
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
const applyResizingClip: (state: ResizingClipState | null) => void = setResizingClip;
return {
layers,
onMoveElement,
onResizeElement,
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,
snapGuideTime: null,
snapGuideKind: null,
started: false,
});
});
},
startResize(element: TimelineElement, edge: "start" | "end") {
act(() => {
applyResizingClip({
element,
edge,
originClientX: 0,
previewStart: element.start,
previewDuration: element.duration,
previewPlaybackStart: element.playbackStart,
snapGuideTime: null,
snapGuideKind: 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("allows moving a clip past the current composition duration", async () => {
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1 });
const harness = renderDragHarness([clip]);
harness.startDrag(clip, 0);
harness.movePointer(1100, 0);
await harness.dropPointer();
expect(harness.onMoveElement).toHaveBeenCalledWith(
clip,
expect.objectContaining({ start: 11 }),
);
harness.unmount();
});
it("allows right-edge resize past the current composition duration", async () => {
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 });
const harness = renderDragHarness([clip]);
harness.startResize(clip, "end");
harness.movePointer(400, 0);
await harness.dropPointer();
expect(harness.onResizeElement).toHaveBeenCalledWith(
clip,
expect.objectContaining({ start: 6, duration: 6 }),
);
harness.unmount();
});
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();
});
});