mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
The timeline's track key is a fractional z-order sort value, and the header built its visibility label straight from it, so screen readers announced "Hide track 0.16666666666666666". A track's 1-based display row is now passed alongside the key: the row number goes in every label, the key keeps routing every callback (visibility toggle, lane context menu). The same fix covers the `Track N` fallback used when a track holds no labelled element. The layer disclosure caret's aria-controls named a div in the sticky label column. That subtree is not empty, it holds the per-lane keyframe controls, but its children are all absolutely positioned so the div computes to 0x0, and the diamonds the caret visibly reveals live on the canvas instead. The caret expands two disjoint subtrees and was naming the less useful one. TimelinePropertyLanes now renders one static wrapper (static, not relative, so it establishes no containing block and the absolutely-positioned lanes keep resolving against the track-content div with identical geometry) and takes the id. TimelineLanes mints that id, since it is the only place that sees both ends of the disclosure, and mounts the wrapper for the track's keyframe clip in both disclosure states so the reference still resolves while collapsed. TimelineLaneBaseProps moves to its own module: it is the contract shared by TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx well under the 600-line cap instead of pushing past it.
261 lines
9.8 KiB
TypeScript
261 lines
9.8 KiB
TypeScript
import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
|
import {
|
|
classifyPropertyGroup,
|
|
type GsapAnimation,
|
|
type PropertyGroupName,
|
|
} from "@hyperframes/core/gsap-parser";
|
|
import { toClipKeyframes } from "../../hooks/gsapShared";
|
|
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
|
|
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
|
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
|
|
export interface TimelinePropertyLanesProps {
|
|
/**
|
|
* Id of the wrapper below, so the layer's disclosure caret can point
|
|
* `aria-controls` at the lanes a sighted user sees it reveal. Minted by
|
|
* TimelineLanes, which owns both this subtree and the caret's.
|
|
*/
|
|
id?: string;
|
|
animations: readonly GsapAnimation[];
|
|
clipStart: number;
|
|
clipDuration: number;
|
|
clipLeftPx: number;
|
|
clipWidthPx: number;
|
|
accentColor: string;
|
|
isSelected: boolean;
|
|
currentPercentage: number;
|
|
elementId: string;
|
|
selectedKeyframes: ReadonlySet<string>;
|
|
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
|
|
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
|
suppressClickRef?: RefObject<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Keys that ride along in a tween's property bag without being animated: a
|
|
* transform modifier, Studio's internal endpoint marker, and GSAP's reserved
|
|
* `data`. Same exclusion list the parser's classifyTweenPropertyGroup applies —
|
|
* without it `{ x, transformOrigin }` would draw a spurious "Other" lane.
|
|
*/
|
|
const NON_ANIMATED_PROPERTIES = new Set(["transformOrigin", "_auto", "data"]);
|
|
|
|
function isAnimatedProperty(property: string): boolean {
|
|
return !NON_ANIMATED_PROPERTIES.has(property);
|
|
}
|
|
|
|
function hasGroupProperty(
|
|
properties: Record<string, number | string>,
|
|
group: PropertyGroupName,
|
|
): boolean {
|
|
return Object.keys(properties).some(
|
|
(property) => isAnimatedProperty(property) && classifyPropertyGroup(property) === group,
|
|
);
|
|
}
|
|
|
|
/** The tween's editable keyframes: its real keyframes, or the start→end pair
|
|
* synthesized for a flat tween. Empty for a tween that animates nothing. */
|
|
function animationKeyframes(animation: GsapAnimation) {
|
|
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
|
|
}
|
|
|
|
/**
|
|
* Every property group a tween draws a lane for, classified PER PROPERTY.
|
|
* `animation.propertyGroup` is the parser's whole-tween verdict and is
|
|
* `undefined` for anything spanning more than one group — but `{ x, opacity }`
|
|
* is the canonical HyperFrames entrance tween, and reading that verdict gave it
|
|
* no caret, no reserved row and no diamonds. classifyPropertyGroup is total, so
|
|
* an unrecognised property still lands in "other" rather than vanishing.
|
|
*
|
|
* Single owner: the rendered lanes (sourceGroups) and the reserved row heights
|
|
* (computeLaneCounts) both count groups through here, or they drift.
|
|
*/
|
|
export function animationLaneGroups(animation: GsapAnimation): PropertyGroupName[] {
|
|
const groups = new Set<PropertyGroupName>();
|
|
for (const keyframe of animationKeyframes(animation)) {
|
|
for (const property of Object.keys(keyframe.properties)) {
|
|
if (isAnimatedProperty(property)) groups.add(classifyPropertyGroup(property));
|
|
}
|
|
}
|
|
return Array.from(groups);
|
|
}
|
|
|
|
/**
|
|
* Which tween a panel edit to `prop` belongs to.
|
|
*
|
|
* Matches on the groups the tween's KEYFRAMES animate, not on the parser's
|
|
* whole-tween `propertyGroup` verdict: that field is undefined for a legacy
|
|
* mixed tween such as `{ x, opacity }`, so matching it dropped every such
|
|
* tween and sent the edit to the selection's default animation instead, which
|
|
* is a different tween than the lane the user is looking at.
|
|
* {@link animationLaneGroups} is the single owner the rendered lanes count
|
|
* groups through, so resolving here through the same helper keeps the panel
|
|
* and the lanes on one answer.
|
|
*/
|
|
export function resolveAnimIdForProperty(
|
|
prop: string,
|
|
animations: readonly GsapAnimation[] | undefined,
|
|
fallbackAnimId: string | undefined,
|
|
): string {
|
|
const group = classifyPropertyGroup(prop);
|
|
const groupAnim = animations?.find((a) => animationLaneGroups(a).includes(group));
|
|
return groupAnim?.id ?? fallbackAnimId ?? "";
|
|
}
|
|
|
|
/** A tween contributes a property lane when it animates at least one property
|
|
* on at least one editable keyframe (real or synthesized). */
|
|
export function animationContributesLane(animation: GsapAnimation): boolean {
|
|
return animationLaneGroups(animation).length > 0;
|
|
}
|
|
|
|
function sourceGroups(animations: readonly GsapAnimation[]) {
|
|
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
|
|
for (const animation of animations) {
|
|
for (const group of animationLaneGroups(animation)) {
|
|
const groupAnimations = groups.get(group) ?? [];
|
|
groupAnimations.push(animation);
|
|
groups.set(group, groupAnimations);
|
|
}
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/** Resolve the ease from THIS keyframe's own source tween. A lane can merge
|
|
* several tweens, so a shared lane-level fallback would label a segment with a
|
|
* different animation's ease than the one the ease editor targets (it routes
|
|
* by animationId). */
|
|
function keyframeEase(keyframe: { ease?: string }, animation: GsapAnimation): string | undefined {
|
|
return keyframe.ease ?? animation.keyframes?.easeEach ?? animation.ease;
|
|
}
|
|
|
|
/**
|
|
* One lane row per keyframe of `group`. The clip-% re-basing goes through the
|
|
* shared toClipKeyframes so lane rows land on the exact same percentage the
|
|
* keyframe cache writes: this file used to derive it inline and skipped that
|
|
* helper's rounding, which is the one precision every keyframe-cache writer has
|
|
* to agree on (selection keys embed the number).
|
|
*/
|
|
function groupKeyframes(
|
|
animations: readonly GsapAnimation[],
|
|
group: PropertyGroupName,
|
|
clipStart: number,
|
|
clipDuration: number,
|
|
): TimelineDiamondKeyframe[] {
|
|
const keyframes: TimelineDiamondKeyframe[] = [];
|
|
for (const animation of animations) {
|
|
const inGroup = animationKeyframes(animation).filter((keyframe) =>
|
|
hasGroupProperty(keyframe.properties, group),
|
|
);
|
|
for (const keyframe of toClipKeyframes(inGroup, animation, clipStart, clipDuration)) {
|
|
keyframes.push({
|
|
...keyframe,
|
|
// The LANE's group, not the tween's own classification: a mixed-property
|
|
// tween classifies to undefined yet still feeds every group it touches.
|
|
propertyGroup: group,
|
|
ease: keyframeEase(keyframe, animation),
|
|
});
|
|
}
|
|
}
|
|
return keyframes;
|
|
}
|
|
|
|
export function getTimelinePropertyLanes(
|
|
animations: readonly GsapAnimation[],
|
|
clipStart: number,
|
|
clipDuration: number,
|
|
) {
|
|
if (clipDuration <= 0) return [];
|
|
return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
|
|
group,
|
|
animations: groupAnimations,
|
|
keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
|
|
})).filter((lane) => lane.keyframes.length > 0);
|
|
}
|
|
|
|
export function TimelinePropertyLanes({
|
|
id,
|
|
animations,
|
|
clipStart,
|
|
clipDuration,
|
|
clipLeftPx,
|
|
clipWidthPx,
|
|
accentColor,
|
|
isSelected,
|
|
currentPercentage,
|
|
elementId,
|
|
selectedKeyframes,
|
|
onSelectSegment,
|
|
onClickKeyframe,
|
|
onShiftClickKeyframe,
|
|
onContextMenuKeyframe,
|
|
onMoveKeyframe,
|
|
suppressClickRef,
|
|
}: TimelinePropertyLanesProps) {
|
|
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
|
|
// a fresh keyframesData literal per lane) on every render would re-render every
|
|
// diamond in every expanded clip on each playhead tick.
|
|
const lanes = useMemo(
|
|
() =>
|
|
clipWidthPx < 20 || clipDuration <= 0
|
|
? []
|
|
: getTimelinePropertyLanes(animations, clipStart, clipDuration),
|
|
[animations, clipStart, clipDuration, clipWidthPx],
|
|
);
|
|
const laneData = useMemo(
|
|
() =>
|
|
lanes.map((lane) => ({
|
|
...lane,
|
|
keyframesData: { format: "percentage" as const, keyframes: lane.keyframes },
|
|
})),
|
|
[lanes],
|
|
);
|
|
|
|
// One STATIC wrapper, never `relative`: a static box establishes no containing
|
|
// block, so every absolutely-positioned lane below still resolves against the
|
|
// track-content div and the rendered geometry is byte-identical to the bare
|
|
// fragment this replaced. It is also rendered when there are no lanes at all
|
|
// (collapsed layer), so `id` stays resolvable in both disclosure states.
|
|
return (
|
|
<div id={id}>
|
|
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
|
<div
|
|
key={group}
|
|
role="group"
|
|
aria-label={`${group} keyframes`}
|
|
data-property-group={group}
|
|
data-timeline-property-lane=""
|
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
className="absolute"
|
|
style={{
|
|
left: clipLeftPx,
|
|
top: getTimelineLaneTop(laneIndex),
|
|
width: clipWidthPx,
|
|
height: LANE_H,
|
|
}}
|
|
>
|
|
<TimelineDiamondLane
|
|
keyframesData={keyframesData}
|
|
clipWidthPx={clipWidthPx}
|
|
clipHeightPx={LANE_H}
|
|
accentColor={accentColor}
|
|
isSelected={isSelected}
|
|
currentPercentage={currentPercentage}
|
|
elementId={elementId}
|
|
selectedKeyframes={selectedKeyframes}
|
|
onSelectSegment={onSelectSegment}
|
|
onClickKeyframe={onClickKeyframe}
|
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
suppressClickRef={suppressClickRef}
|
|
groupAware
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|