mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
MARQUEE_DRAG_THRESHOLD_PX,
|
|
isMarqueeDrag,
|
|
isTimelineRulerPress,
|
|
getMarqueeRect,
|
|
getTimelineClipRect,
|
|
getMarqueeClipCandidates,
|
|
computeMarqueeSelection,
|
|
} from "./timelineMarquee";
|
|
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
import type { TimelineElement } from "../store/playerStore";
|
|
import {
|
|
GUTTER,
|
|
LANE_H,
|
|
TRACK_H,
|
|
RULER_H,
|
|
CLIP_Y,
|
|
TRACKS_LEFT_PAD,
|
|
createTimelineRowGeometry,
|
|
getTimelineRowTop,
|
|
} from "./timelineLayout";
|
|
|
|
// Canvas-space time origin used by the breathing-pad (default) test cases: right
|
|
// edge of the sticky gutter + the left pad. Other cases pass GUTTER or LABEL_COL_W
|
|
// directly as contentOrigin to test the plain/keyframe-label-column origins.
|
|
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
|
|
|
|
describe("isTimelineRulerPress", () => {
|
|
const rectTop = 500; // scroll container's viewport top
|
|
|
|
it("treats a press inside the ruler band as a ruler press (unscrolled)", () => {
|
|
expect(isTimelineRulerPress(rectTop, rectTop)).toBe(true);
|
|
expect(isTimelineRulerPress(rectTop + RULER_H - 1, rectTop)).toBe(true);
|
|
});
|
|
|
|
it("treats a press below the ruler band as a body press", () => {
|
|
expect(isTimelineRulerPress(rectTop + RULER_H, rectTop)).toBe(false);
|
|
expect(isTimelineRulerPress(rectTop + RULER_H + 100, rectTop)).toBe(false);
|
|
});
|
|
|
|
it("stays a ruler press when the body is scrolled down (sticky ruler)", () => {
|
|
// The ruler is position:sticky — scrolled down, its VISUAL band is still
|
|
// the top RULER_H px of the container. Content-space math
|
|
// (clientY - rectTop + scrollTop) would report y = 10 + 300 = 310 ≥ RULER_H
|
|
// and misclassify this as a body/marquee press; viewport-space math must
|
|
// still classify it as a ruler press regardless of scrollTop.
|
|
const scrollTop = 300;
|
|
const clientY = rectTop + 10; // visually on the stuck ruler
|
|
const contentSpaceY = clientY - rectTop + scrollTop;
|
|
expect(contentSpaceY).toBeGreaterThanOrEqual(RULER_H); // the old, broken signal
|
|
expect(isTimelineRulerPress(clientY, rectTop)).toBe(true);
|
|
});
|
|
|
|
it("honors a custom ruler height", () => {
|
|
expect(isTimelineRulerPress(rectTop + 30, rectTop, 32)).toBe(true);
|
|
expect(isTimelineRulerPress(rectTop + 33, rectTop, 32)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isMarqueeDrag", () => {
|
|
it("treats sub-threshold movement as a click, not a drag", () => {
|
|
expect(isMarqueeDrag(100, 50, 100, 50)).toBe(false);
|
|
expect(
|
|
isMarqueeDrag(
|
|
100,
|
|
50,
|
|
100 + MARQUEE_DRAG_THRESHOLD_PX - 1,
|
|
50 + MARQUEE_DRAG_THRESHOLD_PX - 1,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("becomes a drag once either axis passes the threshold", () => {
|
|
expect(isMarqueeDrag(100, 50, 100 + MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
|
|
expect(isMarqueeDrag(100, 50, 100, 50 + MARQUEE_DRAG_THRESHOLD_PX)).toBe(true);
|
|
expect(isMarqueeDrag(100, 50, 100 - MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("getMarqueeRect", () => {
|
|
it("builds a rect from origin to current for a down-right drag", () => {
|
|
expect(getMarqueeRect(10, 20, 110, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
});
|
|
|
|
it("normalizes negative drags (up-left) into a positive rect", () => {
|
|
expect(getMarqueeRect(110, 70, 10, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
});
|
|
|
|
it("normalizes mixed-direction drags (down-left / up-right)", () => {
|
|
expect(getMarqueeRect(110, 20, 10, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
expect(getMarqueeRect(10, 70, 110, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 });
|
|
});
|
|
|
|
it("yields a zero-size rect when the pointer has not moved", () => {
|
|
expect(getMarqueeRect(42, 42, 42, 42)).toEqual({ left: 42, top: 42, width: 0, height: 0 });
|
|
});
|
|
});
|
|
|
|
describe("getTimelineClipRect", () => {
|
|
const trackOrder = [0, 2, 5];
|
|
const geometry = createTimelineRowGeometry(
|
|
trackOrder,
|
|
trackOrder.map(() => TRACK_H),
|
|
);
|
|
|
|
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
|
|
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, geometry, 100, GUTTER);
|
|
expect(rect).toEqual({
|
|
left: GUTTER + 200,
|
|
top: getTimelineRowTop(1) + CLIP_Y,
|
|
width: 300,
|
|
height: TRACK_H - CLIP_Y * 2,
|
|
});
|
|
});
|
|
|
|
it("places the first visible track below the ruler + top breathing pad", () => {
|
|
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 50, GUTTER);
|
|
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
|
expect(rect?.left).toBe(GUTTER);
|
|
});
|
|
|
|
it("uses the row index in trackOrder, not the raw track number", () => {
|
|
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, geometry, 50, GUTTER);
|
|
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
|
|
});
|
|
|
|
it("uses cumulative tops and the resolved height for an expanded row", () => {
|
|
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
|
const expandedGeometry = createTimelineRowGeometry(trackOrder, rowHeights);
|
|
const rect = getTimelineClipRect(
|
|
{ start: 0, duration: 1, track: 0 },
|
|
expandedGeometry,
|
|
50,
|
|
GUTTER,
|
|
);
|
|
expect(rect).toMatchObject({
|
|
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
|
|
height: TRACK_H - CLIP_Y * 2,
|
|
});
|
|
expect(
|
|
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, expandedGeometry, 50, GUTTER)?.top,
|
|
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
|
|
});
|
|
|
|
it("enforces the 4px minimum rendered width", () => {
|
|
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, geometry, 10, GUTTER);
|
|
expect(rect?.width).toBe(4);
|
|
});
|
|
|
|
it("returns null for a track that is not displayed or an invalid pps", () => {
|
|
expect(
|
|
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, geometry, 100, GUTTER),
|
|
).toBeNull();
|
|
expect(
|
|
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, 0, GUTTER),
|
|
).toBeNull();
|
|
expect(
|
|
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, geometry, NaN, GUTTER),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("computeMarqueeSelection", () => {
|
|
// Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100.
|
|
const trackOrder = [0, 1];
|
|
const rowGeometry = createTimelineRowGeometry(
|
|
trackOrder,
|
|
trackOrder.map(() => TRACK_H),
|
|
);
|
|
const pps = 100;
|
|
const clips = [
|
|
{ id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0
|
|
{ id: "b", start: 2, duration: 1, track: 0 }, // x [232,332], row 0
|
|
{ id: "c", start: 0.5, duration: 1, track: 1 }, // x [82,182], row 1
|
|
];
|
|
const row0Top = getTimelineRowTop(0) + CLIP_Y;
|
|
const row1Top = getTimelineRowTop(1) + CLIP_Y;
|
|
|
|
it("selects only the clips the marquee rect intersects", () => {
|
|
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
|
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
clips,
|
|
rowGeometry,
|
|
pps,
|
|
contentOrigin: ORIGIN,
|
|
marquee,
|
|
});
|
|
expect(ids).toEqual(new Set(["a"]));
|
|
expect(primaryId).toBe("a");
|
|
});
|
|
|
|
it("selects across tracks when the rect spans multiple rows", () => {
|
|
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
|
const { ids } = computeMarqueeSelection({
|
|
clips,
|
|
rowGeometry,
|
|
pps,
|
|
contentOrigin: ORIGIN,
|
|
marquee,
|
|
});
|
|
expect(ids).toEqual(new Set(["a", "c"]));
|
|
});
|
|
|
|
it("excludes clips outside the rect horizontally", () => {
|
|
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
|
const { ids } = computeMarqueeSelection({
|
|
clips,
|
|
rowGeometry,
|
|
pps,
|
|
contentOrigin: ORIGIN,
|
|
marquee,
|
|
});
|
|
expect(ids).toEqual(new Set());
|
|
});
|
|
|
|
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
|
|
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
clips,
|
|
rowGeometry,
|
|
pps,
|
|
contentOrigin: GUTTER,
|
|
marquee,
|
|
baseSelection: ["b"],
|
|
});
|
|
expect(ids).toEqual(new Set(["b"]));
|
|
expect(primaryId).toBeNull();
|
|
});
|
|
|
|
it("unions additive base selection with new hits; primary comes from the marquee", () => {
|
|
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
|
|
const { ids, primaryId } = computeMarqueeSelection({
|
|
clips,
|
|
rowGeometry,
|
|
pps,
|
|
contentOrigin: GUTTER,
|
|
marquee,
|
|
baseSelection: ["b"],
|
|
});
|
|
expect(ids).toEqual(new Set(["b", "c"]));
|
|
expect(primaryId).toBe("c");
|
|
});
|
|
|
|
it("shrinking the rect live drops clips it no longer covers", () => {
|
|
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
|
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
|
expect(
|
|
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: wide })
|
|
.ids,
|
|
).toEqual(new Set(["a", "b"]));
|
|
expect(
|
|
computeMarqueeSelection({ clips, rowGeometry, pps, contentOrigin: ORIGIN, marquee: narrow })
|
|
.ids,
|
|
).toEqual(new Set(["a"]));
|
|
});
|
|
|
|
it("ignores clips on hidden/undisplayed tracks", () => {
|
|
const marquee = { left: 0, top: 0, width: 10000, height: 10000 };
|
|
const { ids } = computeMarqueeSelection({
|
|
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
|
rowGeometry,
|
|
pps,
|
|
contentOrigin: GUTTER,
|
|
marquee,
|
|
});
|
|
expect(ids).toEqual(new Set());
|
|
});
|
|
});
|
|
|
|
describe("getMarqueeClipCandidates", () => {
|
|
it("queries only the intersecting rows and time span", () => {
|
|
const rowGeometry = createTimelineRowGeometry([0, 1, 2], [TRACK_H, TRACK_H, TRACK_H]);
|
|
const near: TimelineElement = { id: "near", tag: "div", start: 1, duration: 1, track: 1 };
|
|
const wrongTime: TimelineElement = {
|
|
id: "wrong-time",
|
|
tag: "div",
|
|
start: 20,
|
|
duration: 1,
|
|
track: 1,
|
|
};
|
|
const wrongRow: TimelineElement = {
|
|
id: "wrong-row",
|
|
tag: "div",
|
|
start: 1,
|
|
duration: 1,
|
|
track: 2,
|
|
};
|
|
const clipIndex = createTimelineClipIndex([
|
|
[0, []],
|
|
[1, [near, wrongTime]],
|
|
[2, [wrongRow]],
|
|
]);
|
|
|
|
expect(
|
|
getMarqueeClipCandidates({
|
|
clipIndex,
|
|
rowGeometry,
|
|
marquee: {
|
|
left: ORIGIN + 100,
|
|
top: getTimelineRowTop(1),
|
|
width: 100,
|
|
height: TRACK_H - 1,
|
|
},
|
|
pps: 100,
|
|
contentOrigin: ORIGIN,
|
|
}),
|
|
).toEqual([near]);
|
|
});
|
|
});
|