Files
hyperframes/packages/studio/src/player/components/trackHeaderLaneValues.ts
T
Miguel Angel Simon Sierra 282cc883e5 fix(studio): address family B timeline review findings
- revert diamond selection when a rejected retime leaves the source in place
- clear project-local ease focus and expansion on player store reset
- share one static-position-hold predicate across the tween cache
- invalidate the GSAP cache even when a group timing rewrite throws
- stamp each lane keyframe's ease from its own source tween
- memoize property lanes and row offsets so memo'd diamond lanes hold
- use the editable tween duration for drag position commits
- restore the pre-t=0 pad in the all-collapsed content origin
- clamp the drag ghost and drop placeholder to the collapsed clip height
- aria-expanded on the layer disclosure, aria-pressed plus state-specific
  labels on the keyframe toggle, 24px chevron targets, focus-visible parity
2026-07-28 00:40:42 +02:00

140 lines
5.2 KiB
TypeScript

/**
* Sampling and formatting for the property-lane readouts in the track header:
* what value a property holds at a given tween percentage, and how that value
* reads to a human. Kept apart from the header's JSX so a formatting change and
* a layout change never touch the same file.
*/
import gsap from "gsap";
import {
classifyPropertyGroup,
type GsapAnimation,
type PropertyGroupName,
} from "@hyperframes/core/gsap-parser";
export type LaneValues = Record<string, number | string>;
function roundValue(value: number): string {
return String(Math.round(value * 100) / 100);
}
/** GSAP applies a keyframe's `ease` to the segment ARRIVING at it, so the curve
* between two keyframes is named by the later one (with the tween-level
* `easeEach`/`ease` as the fallback). Sampling linearly would report a value the
* element never has at that time, and stamp that wrong value onto a keyframe
* added from the track header. Unknown ease names parse to undefined -> linear. */
function easedProgress(progress: number, animation: GsapAnimation, ease?: string): number {
const resolved = ease ?? animation.keyframes?.easeEach ?? animation.ease;
if (!resolved || resolved === "none") return progress;
return gsap.parseEase(resolved)?.(progress) ?? progress;
}
interface PropertyStop {
percentage: number;
value: number | string;
ease?: string;
}
function propertyStops(animation: GsapAnimation, property: string): PropertyStop[] {
return (animation.keyframes?.keyframes ?? [])
.filter((keyframe) => property in keyframe.properties)
.map((keyframe) => ({
percentage: keyframe.percentage,
value: keyframe.properties[property],
ease: keyframe.ease,
}));
}
/** A pair only interpolates when both ends are numeric and actually span time;
* string values (colors, keywords) and zero-width pairs hold the earlier value.
* Returns the numeric ends so the caller needs no cast to use them. */
function interpolableEnds(
before: PropertyStop,
after: PropertyStop,
): { from: number; to: number } | null {
if (typeof before.value !== "number" || typeof after.value !== "number") return null;
if (before.percentage === after.percentage) return null;
return { from: before.value, to: after.value };
}
function propertyValueAt(
animation: GsapAnimation,
property: string,
tweenPercentage: number,
): number | string | undefined {
const stops = propertyStops(animation, property);
const before = stops.filter((stop) => stop.percentage <= tweenPercentage).at(-1);
const after = stops.find((stop) => stop.percentage >= tweenPercentage);
if (!before) return after?.value;
if (!after) return before.value;
const ends = interpolableEnds(before, after);
if (!ends) return before.value;
const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
return ends.from + (ends.to - ends.from) * easedProgress(progress, animation, after.ease);
}
/** Every property of `group` this animation touches, sampled at `tweenPercentage`. */
export function valuesAt(
animation: GsapAnimation,
group: PropertyGroupName,
tweenPercentage: number,
): LaneValues {
const propertyNames = new Set<string>();
for (const keyframe of animation.keyframes?.keyframes ?? []) {
for (const property of Object.keys(keyframe.properties)) {
if (classifyPropertyGroup(property) === group) propertyNames.add(property);
}
}
const values: LaneValues = {};
for (const property of propertyNames) {
const value = propertyValueAt(animation, property, tweenPercentage);
if (value !== undefined) values[property] = value;
}
return values;
}
export function groupLabel(group: PropertyGroupName, properties: LaneValues): string {
if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
return "Opacity";
}
if (group !== "other") return `${group[0]?.toUpperCase() ?? ""}${group.slice(1)}`;
const property = Object.keys(properties)[0];
return property ? `${property[0]?.toUpperCase() ?? ""}${property.slice(1)}` : "Other";
}
function defaultValueReadout(values: LaneValues): string {
return Object.values(values)
.map((value) => (typeof value === "number" ? roundValue(value) : value))
.join(", ");
}
function positionValueReadout(values: LaneValues): string | null {
const x = values.x;
const y = values.y;
return typeof x === "number" && typeof y === "number"
? `${roundValue(x)}, ${roundValue(y)}`
: null;
}
function rotationValueReadout(values: LaneValues): string | null {
return typeof values.rotation === "number" ? `${roundValue(values.rotation)}°` : null;
}
function visualValueReadout(values: LaneValues): string | null {
const opacity = values.opacity ?? values.autoAlpha;
return typeof opacity === "number"
? `${roundValue(Math.abs(opacity) <= 1 ? opacity * 100 : opacity)}%`
: null;
}
const GROUP_VALUE_READOUTS: Partial<
Record<PropertyGroupName, (values: LaneValues) => string | null>
> = {
position: positionValueReadout,
rotation: rotationValueReadout,
visual: visualValueReadout,
};
export function valueReadout(group: PropertyGroupName, values: LaneValues): string {
return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values);
}