From f191fe95ca60e26fbf3d13d57e12ef59da2a6e0e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 21:34:43 +0200 Subject: [PATCH] feat(studio): model logical timeline navigation --- .../timelineKeyboardNavigation.test.ts | 263 ++++++++++++++ .../components/timelineKeyboardNavigation.ts | 329 ++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 packages/studio/src/player/components/timelineKeyboardNavigation.test.ts create mode 100644 packages/studio/src/player/components/timelineKeyboardNavigation.ts diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts new file mode 100644 index 0000000000..099735544a --- /dev/null +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts @@ -0,0 +1,263 @@ +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 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[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, 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), + ); + }); + + 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), + ); + }); + + 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: [ + [1, [clip("early", 1, 0), clip("late", 1, 20)]], + [2, []], + [3, [clip("right", 3, 18), clip("left", 3, 2)]], + ], + }); + expect( + resolveTimelineFocusFallback(before, withoutActive, timelineClipFocusId("active"))?.id, + ).toBe(timelineClipFocusId("early")); + + const onlyNext = model({ + expandedClipIds: new Set(), + tracks: [ + [1, [clip("late", 1, 20)]], + [2, []], + [3, [clip("right", 3, 18), clip("left", 3, 2)]], + ], + }); + 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(); + }); +}); diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts new file mode 100644 index 0000000000..60588eabd5 --- /dev/null +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -0,0 +1,329 @@ +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"; + +const NAVIGATION_KEYS: ReadonlySet = new Set([ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", +]); + +export function isTimelineNavigationKey(key: string): key is TimelineNavigationKey { + return NAVIGATION_KEYS.has(key); +} + +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; + selectedElementId: string | null; + selectedElementIds: ReadonlySet; + expandedClipIds: ReadonlySet; + gsapAnimations: ReadonlyMap; +} + +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 { + return JSON.stringify(["timeline", kind, ...parts]); +} + +export function timelineTrackRowId(track: number): string { + return stableId("track", track); +} + +export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string { + return stableId("property", elementId, group); +} + +export function timelineClipFocusId(elementId: string): string { + return stableId("clip", elementId); +} + +export function timelineKeyframeFocusId(elementId: string, target: TimelineKeyframeTarget): string { + return stableId("keyframe", timelineKeyframeSelectionKey(elementId, target)); +} + +export 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[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[number]["keyframes"], +): TimelineLogicalItem[] { + const id = elementId(clip); + const unique = new Map(); + 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[] = []; + 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 + ); +} + +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 nearest surviving row. */ +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; +}