mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* feat(studio): timeline collision and placement model What: new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite. Why: the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes. How: new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs. Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed). * feat(studio): timeline magnetic snapping What: new pure module timelineSnapping — snap-target collection and pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime, snapMoveToTargets) with tests. Why: the magnet math for clip drags/trims, reviewable standalone. How: new files only; type-only playerStore imports; consumers land with the drag engine. Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): multi-clip drag preview math What: new pure module timelineMultiDragPreview — group-drag passenger offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds, multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests. Why: the group-drag math, standalone and DOM-free. How: new files only; consumed later by TimelineLanes. Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): timeline z-stacking sync model What: new pure module timelineStackingSync — lane order ↔ z-index reconciliation (laneIsAbove, computeStackingPatches) with tests. Why: the single source of truth for how timeline lane order maps to canvas stacking; the ordering rules and tie-breaks live here. How: new files only; consumed later by timelineZones and the stacking-sync hook. Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit; fallow audit clean. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import {
|
||
clampGroupMoveDelta,
|
||
isMultiDragActive,
|
||
isMultiDragPassenger,
|
||
multiDragDeltaSeconds,
|
||
multiDragPassengerOffsetPx,
|
||
type MultiDragPreviewInput,
|
||
} from "./timelineMultiDragPreview";
|
||
|
||
const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
|
||
dragStarted: true,
|
||
draggedKey: "a",
|
||
draggedOriginStart: 2,
|
||
draggedPreviewStart: 5,
|
||
selectedKeys: new Set(["a", "b", "c"]),
|
||
...over,
|
||
});
|
||
|
||
describe("isMultiDragActive", () => {
|
||
it("is active when a started drag's clip is part of a 2+ selection", () => {
|
||
expect(isMultiDragActive(base())).toBe(true);
|
||
});
|
||
|
||
it("is inactive before the drag starts", () => {
|
||
expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
|
||
});
|
||
|
||
it("is inactive for a single-clip selection (single-drag behavior)", () => {
|
||
expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
|
||
});
|
||
|
||
it("is inactive when the dragged clip is not itself selected", () => {
|
||
expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("multiDragDeltaSeconds (the one formation delta)", () => {
|
||
it("is the grabbed clip's preview − origin start when active", () => {
|
||
// The preview start is already group-clamped upstream, so this delta is the
|
||
// clamped delta every member (ghost + passengers) moves by.
|
||
expect(multiDragDeltaSeconds(base())).toBe(3);
|
||
});
|
||
|
||
it("supports a leftward (negative) delta", () => {
|
||
expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
|
||
});
|
||
|
||
it("is zero when no multi-drag is active", () => {
|
||
expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe("isMultiDragPassenger", () => {
|
||
it("marks a selected non-dragged clip as a passenger", () => {
|
||
expect(isMultiDragPassenger("b", base())).toBe(true);
|
||
expect(isMultiDragPassenger("c", base())).toBe(true);
|
||
});
|
||
|
||
it("never marks the dragged clip itself (it is the free ghost)", () => {
|
||
expect(isMultiDragPassenger("a", base())).toBe(false);
|
||
});
|
||
|
||
it("never marks an unselected clip", () => {
|
||
expect(isMultiDragPassenger("d", base())).toBe(false);
|
||
});
|
||
|
||
it("marks nothing when the drag is a single-drag", () => {
|
||
const single = base({ selectedKeys: new Set(["a"]) });
|
||
expect(isMultiDragPassenger("b", single)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
|
||
it("converts the one formation delta to pixels for every passenger", () => {
|
||
// Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
|
||
expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
|
||
expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
|
||
});
|
||
|
||
it("is zero for the dragged clip and for non-passengers", () => {
|
||
expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
|
||
expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
|
||
});
|
||
|
||
it("is zero for a non-finite pps", () => {
|
||
expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
|
||
});
|
||
|
||
it("follows a leftward delta", () => {
|
||
expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
|
||
});
|
||
});
|
||
|
||
describe("clampGroupMoveDelta (rigid group move)", () => {
|
||
it("passes a rightward delta through unchanged (no right wall)", () => {
|
||
expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
|
||
expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
|
||
});
|
||
|
||
it("passes a leftward delta through when no member would cross 0", () => {
|
||
// Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
|
||
expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
|
||
});
|
||
|
||
it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
|
||
// Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
|
||
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
|
||
// formation instead of out-running it.
|
||
expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
|
||
});
|
||
|
||
it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
|
||
// Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
|
||
expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
|
||
});
|
||
|
||
it("already-at-0 member forbids any leftward move", () => {
|
||
expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
|
||
// rightward still allowed
|
||
expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
|
||
});
|
||
|
||
it("returns the raw delta for an empty formation", () => {
|
||
expect(clampGroupMoveDelta(-9, [])).toBe(-9);
|
||
});
|
||
});
|