Files
hyperframes/packages/studio/src/player/components/timelineKeyboardNavigation.ts
T
Vance Ingalls 8b422260af fix(studio): gate the label column, match keyboard expandability, stop row collisions
Four confirmed tail findings from the review.

**The label column had no canary gate** while the group ROWS it exists for do.
Un-enrolled — everyone, the canary is at 0% — any composition carrying
`data-audio-group` got a permanent 232px `LABEL_COL_W` shift of every clip with
no group row on screen to explain it. Now gated identically; the two must agree.

**Keyboard expandability disagreed with the header.** `expandable: lanes.length
> 0` against the header's `lanes.length > 0 || automationRows.length > 0`, so an
audio track whose only disclosable content is AUTOMATION drew the `∿` while
reporting itself unexpandable to the treegrid — ArrowRight could not open it.
Both the flag and the `expanded` gate now count automation rows the same way the
header does, per shared PROPERTY rather than per clip.

**Sub-composition child rows could land exactly on a group anchor.** A group row
anchors at `firstMemberTrack - 0.5`, and the child scheme `k / (n + 2)` hits 0.5
dead on for a host with two children (2/4) — a duplicate row key and a
duplicated group header. Children are now confined to the LOWER half of the gap
(`0.5 * k / (n + 1)`, maximum strictly under 0.5 for every n), which keeps every
property the old scheme had — non-integer, distinct, ordered, under the host —
and cannot reach x.5. Test asserts the invariant across 1, 2, 3, 4 and 7
children rather than pinning the fractions.

**The timeline FX popover emitted a `preset_applied` per audition.** It called
`applyPresetToChain`, which fires `trackPresetApplied` on every call, so
hovering or arrowing a 12-preset shelf reported 12 applies and the numbers could
not tell an audition from a decision. It now auditions through the raw apply and
reports `trackPresetAuditioned`, the split `FxSection` already makes.

**Also: the group panel's signal path went stale on a membership change.** Its
memo was keyed on `[element]` alone, and membership is held by the MEMBERS — so
a clip joining this group changed neither `element` nor its attributes and the
path kept claiming "OUT to mix". Keyed on the store's element array too, whose
identity both `syncStoredGroupAttribute` and `updateElement` replace.

**One tail item examined and REJECTED:** "Hide all" being withheld for a whole
mixed selection when one member is audio. Hiding only the visual members is
precisely the act-on-a-subset pattern this branch refuses elsewhere (see
`canGroupWholeTrack`: "The button is withheld instead of acting on a subset"),
and for audio `data-hidden` is mute, so a partial apply would silence nothing
while looking like it had. Current behaviour is consistent; left alone.

studio: 207 files, 2632 tests. fallow clean.
2026-08-20 16:41:31 -07:00

472 lines
16 KiB
TypeScript

import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
import {
timelineClipFocusId,
timelineEaseFocusId,
timelineGroupRowId,
timelineKeyframeFocusId,
timelinePropertyRowId,
timelineTrackRowId,
} from "./timelineNavigationIdentity";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
export type TimelineNavigationKey =
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "ArrowDown"
| "Home"
| "End"
| "PageUp"
| "PageDown";
const NAVIGATION_KEYS: ReadonlySet<string> = new Set<TimelineNavigationKey>([
"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 | 3;
parentId: string | null;
elementId: string | null;
/** Set only on a group's own row (level 1, no clips of its own). */
groupId?: string;
expandable: boolean;
expanded: boolean;
propertyGroup?: PropertyGroupName;
items: readonly TimelineLogicalItem[];
}
export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
export interface BuildTimelineLogicalRowsInput {
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
displayTrackOrder: readonly number[];
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
/** Groups the caret has COLLAPSED — absent means expanded, the default. */
collapsedGroupIds: ReadonlySet<string>;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[];
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
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 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,
};
});
}
/** A track's active clip (if any), its element id, and its automation lanes. */
function resolveActiveTrackClip(
elements: readonly TimelineElement[],
laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
): {
activeClip: TimelineElement | null;
activeId: string | null;
lanes: ReturnType<typeof getTimelinePropertyLanes>;
} {
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,
)
: [];
return { activeClip, activeId, lanes };
}
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;
}
/** A clip's lanes are visible when either the caret or the `∿` button opened it. */
function isRowOpen(
activeId: string | null,
expandedClipIds: ReadonlySet<string>,
expandedLaneOwnerIds: ReadonlySet<string>,
): boolean {
if (activeId === null) return false;
return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
}
/** A single automation-lane row, one level deeper than the track/group row that owns it. */
function buildLaneRow(
track: number,
logicalIndex: number,
activeId: string,
activeClip: TimelineElement,
lane: ReturnType<typeof getTimelinePropertyLanes>[number],
level: 2 | 3,
parentId: string,
): TimelineLogicalRow {
const laneRowId = timelinePropertyRowId(activeId, lane.group);
return {
id: laneRowId,
kind: "row",
physicalTrackKey: track,
logicalIndex,
level,
parentId,
elementId: activeId,
expandable: false,
expanded: false,
propertyGroup: lane.group,
items: propertyItems(laneRowId, activeClip, lane.keyframes),
};
}
/** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
export function buildTimelineLogicalRows({
tracks,
displayTrackOrder,
laneCounts,
selectedElementId,
selectedElementIds,
expandedClipIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
gsapAnimations,
}: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
const trackMap = new Map(tracks);
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
const rows: TimelineLogicalRow[] = [];
// A real track's own row (level 1 ungrouped, level 2 under a group) plus,
// when its clip's lanes are open, the lane rows one level deeper.
function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
const elements = trackMap.get(track) ?? [];
const trackId = timelineTrackRowId(track);
const { activeClip, activeId, lanes } = resolveActiveTrackClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
gsapAnimations,
);
const disclosable = isTrackDisclosable(elements, lanes.length);
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable;
rows.push({
id: trackId,
kind: "row",
physicalTrackKey: track,
logicalIndex: rows.length,
level,
parentId,
elementId: activeId,
expandable: disclosable,
expanded,
items: clipItems(trackId, elements),
});
if (!expanded || !activeClip || !activeId) return;
for (const lane of lanes) {
rows.push(
buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
);
}
}
// A group's own row (level 1) plus, when its `∿` is open, its own
// automation-lane rows (level 2) — structural content deferred to whatever
// step wires group automation editing; this reserves the rows and their
// count.
function emitGroup(group: TimelineTrackGroupInfo): void {
const groupRowId = timelineGroupRowId(group.id);
const groupExpanded = !collapsedGroupIds.has(group.id);
rows.push({
id: groupRowId,
kind: "row",
physicalTrackKey: group.anchorKey,
logicalIndex: rows.length,
level: 1,
parentId: null,
elementId: null,
groupId: group.id,
expandable: group.memberTracks.length > 0,
expanded: groupExpanded,
items: [],
});
if (expandedLaneOwnerIds.has(group.id)) {
// The group's own member list, not `trackMap`: a COLLAPSED group can have
// its lane shelf open, and its members are absent from the display list —
// so looking them up there emitted zero lane rows for exactly that case.
for (const laneGroup of groupAutomationLanes(group.memberElements)) {
rows.push({
id: `${groupRowId}::${laneGroup.key}`,
kind: "row",
physicalTrackKey: group.anchorKey,
logicalIndex: rows.length,
level: 2,
parentId: groupRowId,
elementId: null,
expandable: false,
expanded: false,
items: [],
});
}
}
if (!groupExpanded) return;
for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
}
for (const key of displayTrackOrder) {
const group = groupByAnchor.get(key);
if (group) {
emitGroup(group);
continue;
}
if (trackGroupOf.has(key)) continue; // emitted above, under its group
emitTrack(key, 1, null);
}
return rows;
}
export function locateTimelineLogicalTarget(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 = locateTimelineLogicalTarget(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;
if (options.timelineBoundary) return boundaryRow;
return key === "Home" ? boundaryRow : (boundaryRow.items.at(-1) ?? boundaryRow);
}
if (key === "ArrowLeft") {
if (itemIndex < 0) {
if (!row.parentId) return row;
return locateTimelineLogicalTarget(rows, row.parentId)?.target ?? 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 = locateTimelineLogicalTarget(nextRows, currentId);
if (unchanged) return unchanged.target;
const previous = locateTimelineLogicalTarget(previousRows, currentId);
if (!previous) return null;
if (previous.itemIndex >= 0) {
for (let index = previous.itemIndex - 1; index >= 0; index -= 1) {
const candidate = locateTimelineLogicalTarget(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 = locateTimelineLogicalTarget(nextRows, previous.row.items[index]!.id);
if (candidate) return candidate.target;
}
}
const survivingRow = locateTimelineLogicalTarget(nextRows, previous.row.id);
if (survivingRow) return survivingRow.target;
if (previous.row.parentId) {
const parent = locateTimelineLogicalTarget(nextRows, previous.row.parentId);
if (parent) return parent.target;
}
return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
}
/**
* Does a track have anything to open — the header's own `disclosable`.
*
* `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and
* keyed on tweens alone here an audio track whose only disclosable content is
* AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid,
* so ArrowRight could not open it. Automation rows are counted per shared
* PROPERTY across the track's clips, the way the header counts them, not per
* clip.
*/
function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean {
return laneCount > 0 || groupAutomationLanes(elements).length > 0;
}