feat(studio): model logical timeline navigation (#2712)

This commit is contained in:
Miguel Ángel
2026-08-04 16:48:39 -07:00
committed by GitHub
parent 19b8a1f3c5
commit 0f1333c80d
2 changed files with 606 additions and 0 deletions
@@ -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();
});
});
@@ -0,0 +1,330 @@
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
export type TimelineNavigationKey =
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "ArrowDown"
| "Home"
| "End"
| "PageUp"
| "PageDown";
export interface TimelineLogicalItem {
id: string;
kind: "clip" | "keyframe" | "ease";
rowId: string;
elementId: string;
/** The item's time anchor. Clips use their midpoint; ease controls use the segment midpoint. */
time: number;
keyframeTarget?: TimelineKeyframeTarget;
}
export interface TimelineLogicalRow {
id: string;
kind: "row";
physicalTrackKey: number;
logicalIndex: number;
level: 1 | 2;
parentId: string | null;
expanded: boolean;
propertyGroup?: PropertyGroupName;
items: readonly TimelineLogicalItem[];
}
export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
interface BuildTimelineLogicalRowsInput {
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
displayTrackOrder: readonly number[];
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
}
export interface TimelineNavigationOptions {
/** Supplied by the viewport actor; the model never guesses a fixed page size. */
pageSize?: number;
/** Ctrl/Meta + Home/End moves to the first/last logical row. */
timelineBoundary?: boolean;
}
function stableId(kind: string, ...parts: Array<string | number>): string {
// Attribute-safe by construction; callers embedding it in CSS selectors must use CSS.escape.
return JSON.stringify(["timeline", kind, ...parts]);
}
export function timelineTrackRowId(track: number): string {
return stableId("track", track);
}
function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
return stableId("property", elementId, group);
}
export function timelineClipFocusId(elementId: string): string {
return stableId("clip", elementId);
}
function timelineKeyframeFocusId(elementId: string, target: TimelineKeyframeTarget): string {
return stableId("keyframe", timelineKeyframeSelectionKey(elementId, target));
}
function timelineEaseFocusId(elementId: string, target: TimelineKeyframeTarget): string {
return stableId("ease", timelineKeyframeSelectionKey(elementId, target));
}
function elementId(element: TimelineElement): string {
return element.key ?? element.id;
}
function clipItems(rowId: string, elements: readonly TimelineElement[]): TimelineLogicalItem[] {
return [...elements]
.sort(
(left, right) =>
left.start - right.start ||
left.start + left.duration - (right.start + right.duration) ||
elementId(left).localeCompare(elementId(right)),
)
.map((element) => {
const id = elementId(element);
return {
id: timelineClipFocusId(id),
kind: "clip",
rowId,
elementId: id,
time: element.start + element.duration / 2,
};
});
}
function keyframeTarget(
keyframe: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"][number],
): TimelineKeyframeTarget {
return {
percentage: keyframe.percentage,
tweenPercentage: keyframe.tweenPercentage,
propertyGroup: keyframe.propertyGroup,
animationId: keyframe.animationId,
collidingAnimationTargets: keyframe.collidingAnimationTargets,
};
}
function propertyItems(
rowId: string,
clip: TimelineElement,
keyframes: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"],
): TimelineLogicalItem[] {
const id = elementId(clip);
const unique = new Map<string, { target: TimelineKeyframeTarget; time: number }>();
for (const keyframe of keyframes) {
const target = keyframeTarget(keyframe);
const key = timelineKeyframeSelectionKey(id, target);
if (!unique.has(key)) {
unique.set(key, {
target,
time: clip.start + (keyframe.percentage / 100) * clip.duration,
});
}
}
const ordered = [...unique.entries()].sort(
([leftKey, left], [rightKey, right]) =>
left.time - right.time || leftKey.localeCompare(rightKey),
);
const items: TimelineLogicalItem[] = [];
// ponytail: The composite property lane owns adjacency, so the incoming keyframe
// owns an ease segment even when its previous neighbor came from another animation.
for (let index = 0; index < ordered.length; index += 1) {
const [, current] = ordered[index]!;
const previous = ordered[index - 1]?.[1];
if (previous && current.time > previous.time && current.target.animationId !== undefined) {
items.push({
id: timelineEaseFocusId(id, current.target),
kind: "ease",
rowId,
elementId: id,
time: previous.time + (current.time - previous.time) / 2,
keyframeTarget: current.target,
});
}
items.push({
id: timelineKeyframeFocusId(id, current.target),
kind: "keyframe",
rowId,
elementId: id,
time: current.time,
keyframeTarget: current.target,
});
}
return items;
}
/** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
export function buildTimelineLogicalRows({
tracks,
displayTrackOrder,
laneCounts,
selectedElementId,
selectedElementIds,
expandedClipIds,
gsapAnimations,
}: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
const trackMap = new Map(tracks);
const rows: TimelineLogicalRow[] = [];
for (const track of displayTrackOrder) {
const elements = trackMap.get(track) ?? [];
const trackId = timelineTrackRowId(track);
const activeClip = resolveTrackKeyframeClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
);
const activeId = activeClip ? elementId(activeClip) : null;
const lanes = activeClip
? getTimelinePropertyLanes(
gsapAnimations.get(elementId(activeClip)) ?? [],
activeClip.start,
activeClip.duration,
)
: [];
const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
rows.push({
id: trackId,
kind: "row",
physicalTrackKey: track,
logicalIndex: rows.length,
level: 1,
parentId: null,
expanded,
items: clipItems(trackId, elements),
});
if (!expanded || !activeClip) continue;
for (const lane of lanes) {
const rowId = timelinePropertyRowId(activeId, lane.group);
rows.push({
id: rowId,
kind: "row",
physicalTrackKey: track,
logicalIndex: rows.length,
level: 2,
parentId: trackId,
expanded: false,
propertyGroup: lane.group,
items: propertyItems(rowId, activeClip, lane.keyframes),
});
}
}
return rows;
}
function locateTarget(rows: readonly TimelineLogicalRow[], id: string) {
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex]!;
if (row.id === id) return { row, rowIndex, itemIndex: -1, target: row };
const itemIndex = row.items.findIndex((item) => item.id === id);
if (itemIndex >= 0) return { row, rowIndex, itemIndex, target: row.items[itemIndex]! };
}
return null;
}
function nearestTarget(row: TimelineLogicalRow, time: number): TimelineLogicalTarget {
return (
[...row.items].sort(
(left, right) =>
Math.abs(left.time - time) - Math.abs(right.time - time) ||
left.time - right.time ||
left.id.localeCompare(right.id),
)[0] ?? row
);
}
/**
* Resolve the next logical target without touching the DOM. The downstream
* `useTimelineKeyboardActor` hook consumes this model when keyboard controls
* are wired to the rendered timeline.
*/
// The branching is the keyboard contract: four key classes intentionally share one actor.
// fallow-ignore-next-line complexity
export function resolveTimelineNavigationTarget(
rows: readonly TimelineLogicalRow[],
currentId: string,
key: TimelineNavigationKey,
options: TimelineNavigationOptions = {},
): TimelineLogicalTarget | null {
const current = locateTarget(rows, currentId);
if (!current) return null;
const { row, rowIndex, itemIndex, target } = current;
if (key === "Home" || key === "End") {
const boundaryRow = options.timelineBoundary ? (key === "Home" ? rows[0] : rows.at(-1)) : row;
if (!boundaryRow) return target;
return key === "Home" ? boundaryRow : (boundaryRow.items.at(-1) ?? boundaryRow);
}
if (key === "ArrowLeft") {
if (itemIndex < 0) return row;
return itemIndex === 0 ? row : row.items[itemIndex - 1]!;
}
if (key === "ArrowRight") {
if (itemIndex < 0) return row.items[0] ?? row;
return row.items[itemIndex + 1] ?? target;
}
const direction = key === "ArrowUp" || key === "PageUp" ? -1 : 1;
const pageKey = key === "PageUp" || key === "PageDown";
const pageSize = options.pageSize;
if (pageKey && (pageSize === undefined || !Number.isFinite(pageSize) || pageSize < 1)) {
return target;
}
const distance = pageKey ? Math.floor(pageSize!) : 1;
const destinationIndex = Math.max(0, Math.min(rows.length - 1, rowIndex + direction * distance));
const destination = rows[destinationIndex];
if (!destination || destinationIndex === rowIndex) return target;
return target.kind === "row" ? destination : nearestTarget(destination, target.time);
}
/**
* Preserve focus when possible, then choose previous, next, parent, or the
* nearest surviving row. The downstream `useTimelineFocusCoordinator` hook
* consumes this fallback when virtualization unmounts a logical target.
*/
// The ordered fallback chain is the invariant; splitting it would duplicate traversal state.
// fallow-ignore-next-line complexity
export function resolveTimelineFocusFallback(
previousRows: readonly TimelineLogicalRow[],
nextRows: readonly TimelineLogicalRow[],
currentId: string,
): TimelineLogicalTarget | null {
const unchanged = locateTarget(nextRows, currentId);
if (unchanged) return unchanged.target;
const previous = locateTarget(previousRows, currentId);
if (!previous) return null;
if (previous.itemIndex >= 0) {
for (let index = previous.itemIndex - 1; index >= 0; index -= 1) {
const candidate = locateTarget(nextRows, previous.row.items[index]!.id);
if (candidate) return candidate.target;
}
for (let index = previous.itemIndex + 1; index < previous.row.items.length; index += 1) {
const candidate = locateTarget(nextRows, previous.row.items[index]!.id);
if (candidate) return candidate.target;
}
}
const survivingRow = locateTarget(nextRows, previous.row.id);
if (survivingRow) return survivingRow.target;
if (previous.row.parentId) {
const parent = locateTarget(nextRows, previous.row.parentId);
if (parent) return parent.target;
}
return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
}