mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(studio): model logical timeline navigation (#2712)
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
buildTimelineLogicalRows,
|
||||
resolveTimelineFocusFallback,
|
||||
resolveTimelineNavigationTarget,
|
||||
timelineClipFocusId,
|
||||
timelineTrackRowId,
|
||||
} from "./timelineKeyboardNavigation";
|
||||
|
||||
function clip(id: string, track: number, start: number, duration = 2): TimelineElement {
|
||||
return { id, track, start, duration, tag: "div" };
|
||||
}
|
||||
|
||||
function fallbackTracks(firstTrack: TimelineElement[]): [number, TimelineElement[]][] {
|
||||
return [
|
||||
[1, firstTrack],
|
||||
[2, []],
|
||||
[3, [clip("right", 3, 18), clip("left", 3, 2)]],
|
||||
];
|
||||
}
|
||||
|
||||
function animation(
|
||||
id: string,
|
||||
group: PropertyGroupName,
|
||||
percentages: readonly number[],
|
||||
resolvedStart = 10,
|
||||
): GsapAnimation {
|
||||
return {
|
||||
id,
|
||||
targetSelector: "#active",
|
||||
method: "to",
|
||||
position: 0,
|
||||
resolvedStart,
|
||||
duration: 10,
|
||||
properties: {},
|
||||
propertyGroup: group,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: percentages.map((percentage) => ({
|
||||
percentage,
|
||||
properties: group === "position" ? { x: percentage } : { opacity: percentage / 100 },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]> = {}) {
|
||||
const active = clip("active", 1, 10, 10);
|
||||
return buildTimelineLogicalRows({
|
||||
tracks: [
|
||||
[1, [clip("late", 1, 20), active, clip("early", 1, 0)]],
|
||||
[2, []],
|
||||
[3, [clip("right", 3, 18), clip("left", 3, 2)]],
|
||||
],
|
||||
displayTrackOrder: [1, 2, 3],
|
||||
laneCounts: new Map([["active", 2]]),
|
||||
selectedElementId: "active",
|
||||
selectedElementIds: new Set(),
|
||||
expandedClipIds: new Set(["active"]),
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"active",
|
||||
[animation("position", "position", [0, 50, 100]), animation("visual", "visual", [25, 75])],
|
||||
],
|
||||
]),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildTimelineLogicalRows", () => {
|
||||
it("projects tracks, empty tracks, and expanded property rows with continuous indices", () => {
|
||||
const rows = model();
|
||||
|
||||
expect(
|
||||
rows.map(({ physicalTrackKey, logicalIndex, level, parentId }) => ({
|
||||
physicalTrackKey,
|
||||
logicalIndex,
|
||||
level,
|
||||
parentId,
|
||||
})),
|
||||
).toEqual([
|
||||
{ physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null },
|
||||
{ physicalTrackKey: 1, logicalIndex: 1, level: 2, parentId: timelineTrackRowId(1) },
|
||||
{ physicalTrackKey: 1, logicalIndex: 2, level: 2, parentId: timelineTrackRowId(1) },
|
||||
{ physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null },
|
||||
{ physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null },
|
||||
]);
|
||||
expect(rows[0]?.expanded).toBe(true);
|
||||
expect(rows[3]?.items).toEqual([]);
|
||||
expect(rows[0]?.items.map((item) => item.elementId)).toEqual(["early", "active", "late"]);
|
||||
});
|
||||
|
||||
it("orders keyframes and their segment ease controls deterministically", () => {
|
||||
const rows = model({
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"active",
|
||||
[
|
||||
animation("z-animation", "position", [100, 0, 50]),
|
||||
animation("a-animation", "position", [50]),
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
const position = rows.find((row) => row.propertyGroup === "position")!;
|
||||
|
||||
expect(
|
||||
position.items.map((item) => [item.kind, item.time, item.keyframeTarget?.animationId]),
|
||||
).toEqual([
|
||||
["keyframe", 10, "z-animation"],
|
||||
["ease", 12.5, "a-animation"],
|
||||
["keyframe", 15, "a-animation"],
|
||||
["keyframe", 15, "z-animation"],
|
||||
["ease", 17.5, "z-animation"],
|
||||
["keyframe", 20, "z-animation"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the selected keyframed clip as the sole expanded-lane owner", () => {
|
||||
const other = clip("other", 1, 0, 4);
|
||||
const rows = model({
|
||||
tracks: [[1, [other, clip("active", 1, 10, 10)]]],
|
||||
displayTrackOrder: [1],
|
||||
laneCounts: new Map([
|
||||
["active", 1],
|
||||
["other", 1],
|
||||
]),
|
||||
selectedElementId: "other",
|
||||
expandedClipIds: new Set(["other", "active"]),
|
||||
gsapAnimations: new Map([
|
||||
["active", [animation("active-position", "position", [0, 100])]],
|
||||
["other", [animation("other-visual", "visual", [0, 100], 0)]],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.propertyGroup).filter(Boolean)).toEqual(["visual"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineNavigationTarget", () => {
|
||||
it("navigates horizontal items plus row Home and End", () => {
|
||||
const rows = model();
|
||||
const activeId = timelineClipFocusId("active");
|
||||
|
||||
expect(resolveTimelineNavigationTarget(rows, activeId, "ArrowLeft")?.id).toBe(
|
||||
timelineClipFocusId("early"),
|
||||
);
|
||||
expect(resolveTimelineNavigationTarget(rows, activeId, "ArrowRight")?.id).toBe(
|
||||
timelineClipFocusId("late"),
|
||||
);
|
||||
expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(1), "ArrowLeft")?.id).toBe(
|
||||
timelineTrackRowId(1),
|
||||
);
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, timelineClipFocusId("late"), "ArrowRight")?.id,
|
||||
).toBe(timelineClipFocusId("late"));
|
||||
expect(resolveTimelineNavigationTarget(rows, activeId, "Home")?.id).toBe(timelineTrackRowId(1));
|
||||
expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(1), "End")?.id).toBe(
|
||||
timelineClipFocusId("late"),
|
||||
);
|
||||
});
|
||||
|
||||
it("navigates every logical row including properties and empty tracks", () => {
|
||||
const rows = model();
|
||||
const activeId = timelineClipFocusId("active");
|
||||
const propertyTarget = resolveTimelineNavigationTarget(rows, activeId, "ArrowDown")!;
|
||||
|
||||
expect(propertyTarget.kind).toBe("keyframe");
|
||||
expect(propertyTarget.time).toBe(15);
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, propertyTarget.id, "PageDown", { pageSize: 2 })?.id,
|
||||
).toBe(timelineTrackRowId(2));
|
||||
expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(2), "ArrowDown")?.id).toBe(
|
||||
timelineTrackRowId(3),
|
||||
);
|
||||
expect(resolveTimelineNavigationTarget(rows, propertyTarget.id, "ArrowUp")?.id).toBe(activeId);
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, propertyTarget.id, "PageUp", { pageSize: 2 })?.id,
|
||||
).toBe(activeId);
|
||||
});
|
||||
|
||||
it("uses a caller-supplied page size and ignores invalid page commands", () => {
|
||||
const rows = model();
|
||||
const current = timelineTrackRowId(1);
|
||||
|
||||
expect(resolveTimelineNavigationTarget(rows, current, "PageDown")?.id).toBe(current);
|
||||
expect(resolveTimelineNavigationTarget(rows, current, "PageDown", { pageSize: 3 })?.id).toBe(
|
||||
timelineTrackRowId(2),
|
||||
);
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, timelineTrackRowId(3), "PageDown", { pageSize: 1 })?.id,
|
||||
).toBe(timelineTrackRowId(3));
|
||||
});
|
||||
|
||||
it("supports modified Home and End across the whole logical model", () => {
|
||||
const rows = model();
|
||||
const current = timelineTrackRowId(2);
|
||||
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, current, "Home", { timelineBoundary: true })?.id,
|
||||
).toBe(timelineTrackRowId(1));
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, current, "End", { timelineBoundary: true })?.id,
|
||||
).toBe(timelineClipFocusId("right"));
|
||||
});
|
||||
|
||||
it("breaks equal-distance vertical ties by time then stable identity", () => {
|
||||
const rows = buildTimelineLogicalRows({
|
||||
tracks: [
|
||||
[1, [clip("current", 1, 9, 2)]],
|
||||
[2, [clip("later", 2, 14, 2), clip("earlier-z", 2, 4, 2), clip("earlier-a", 2, 4, 2)]],
|
||||
],
|
||||
displayTrackOrder: [1, 2],
|
||||
laneCounts: new Map(),
|
||||
selectedElementId: null,
|
||||
selectedElementIds: new Set(),
|
||||
expandedClipIds: new Set(),
|
||||
gsapAnimations: new Map(),
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTimelineNavigationTarget(rows, timelineClipFocusId("current"), "ArrowDown")?.id,
|
||||
).toBe(timelineClipFocusId("earlier-a"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineFocusFallback", () => {
|
||||
it("chooses previous, then next, then the containing row after deletion", () => {
|
||||
const before = model({ expandedClipIds: new Set() });
|
||||
const withoutActive = model({
|
||||
expandedClipIds: new Set(),
|
||||
tracks: fallbackTracks([clip("early", 1, 0), clip("late", 1, 20)]),
|
||||
});
|
||||
expect(
|
||||
resolveTimelineFocusFallback(before, withoutActive, timelineClipFocusId("active"))?.id,
|
||||
).toBe(timelineClipFocusId("early"));
|
||||
|
||||
const onlyNext = model({
|
||||
expandedClipIds: new Set(),
|
||||
tracks: fallbackTracks([clip("late", 1, 20)]),
|
||||
});
|
||||
expect(resolveTimelineFocusFallback(before, onlyNext, timelineClipFocusId("active"))?.id).toBe(
|
||||
timelineClipFocusId("late"),
|
||||
);
|
||||
|
||||
const onlyActive = model({
|
||||
expandedClipIds: new Set(),
|
||||
tracks: [[1, [clip("active", 1, 10, 10)]]],
|
||||
displayTrackOrder: [1],
|
||||
});
|
||||
const empty = model({
|
||||
expandedClipIds: new Set(),
|
||||
tracks: [[1, []]],
|
||||
displayTrackOrder: [1],
|
||||
});
|
||||
expect(resolveTimelineFocusFallback(onlyActive, empty, timelineClipFocusId("active"))?.id).toBe(
|
||||
timelineTrackRowId(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from a collapsed property row to its parent track", () => {
|
||||
const before = model();
|
||||
const property = before.find((row) => row.propertyGroup === "position")!;
|
||||
const after = model({ expandedClipIds: new Set() });
|
||||
|
||||
expect(resolveTimelineFocusFallback(before, after, property.items[0]!.id)?.id).toBe(
|
||||
timelineTrackRowId(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an identity absent from the previous model", () => {
|
||||
expect(resolveTimelineFocusFallback(model(), model(), "missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user