mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user