mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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.
230 lines
7.0 KiB
TypeScript
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();
|
|
});
|
|
});
|