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
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:42 +02:00
parent 4a12eb9d9c
commit 282cc883e5
16 changed files with 284 additions and 103 deletions
@@ -93,7 +93,11 @@ export function KeyframeEaseList({
? "Custom" ? "Custom"
: (EASE_LABELS[segEase] ?? segEase); : (EASE_LABELS[segEase] ?? segEase);
return ( return (
<div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50"> <div
key={`${i}-${kf.percentage}`}
data-ease-segment-pct={kf.percentage}
className="rounded-md bg-neutral-900/50"
>
<button <button
type="button" type="button"
onClick={() => onToggle(isExpanded ? null : kf.percentage)} onClick={() => onToggle(isExpanded ? null : kf.percentage)}
@@ -2,9 +2,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore"; import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveEditableTweenDuration } from "./gsapShared";
import { roundTo3 } from "../utils/rounding"; import { roundTo3 } from "../utils/rounding";
import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { resolveEditableTweenDuration } from "./gsapShared";
import { import {
type GsapDragCommitCallbacks, type GsapDragCommitCallbacks,
computeCurrentPercentage, computeCurrentPercentage,
@@ -159,7 +159,7 @@ async function commitFlatViaKeyframes(
): Promise<void> { ): Promise<void> {
const ct = usePlayerStore.getState().currentTime; const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim); const ts = resolveTweenStart(anim);
const td = resolveTweenDuration(anim); const td = resolveEditableTweenDuration(anim, selection);
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const outsideRange = const outsideRange =
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
@@ -324,7 +324,7 @@ export async function commitGsapPositionFromDrag(
const dragProps: Record<string, number> = { x: newX, y: newY }; const dragProps: Record<string, number> = { x: newX, y: newY };
const ts = resolveTweenStart(effectiveAnim); const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim); const td = resolveEditableTweenDuration(effectiveAnim, selection);
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
if (outsideRange && !hasSelectedKeyframe) { if (outsideRange && !hasSelectedKeyframe) {
@@ -352,7 +352,7 @@ export async function commitGsapPositionFromDrag(
} else if (anim.method === "from" || anim.method === "fromTo") { } else if (anim.method === "from" || anim.method === "fromTo") {
const ct = usePlayerStore.getState().currentTime; const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim); const ts = resolveTweenStart(anim);
const td = resolveTweenDuration(anim); const td = resolveEditableTweenDuration(anim, selection);
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
const outsideRange = const outsideRange =
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
@@ -372,7 +372,7 @@ export async function commitGsapPositionFromDrag(
if (existingPosAnim?.keyframes) { if (existingPosAnim?.keyframes) {
const posTs = resolveTweenStart(existingPosAnim); const posTs = resolveTweenStart(existingPosAnim);
const posTd = resolveTweenDuration(existingPosAnim); const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
if (posTs !== null) { if (posTs !== null) {
await extendTweenAndAddKeyframe( await extendTweenAndAddKeyframe(
selection, selection,
@@ -390,4 +390,36 @@ describe("deleteSelectedKeyframes", () => {
expect.objectContaining({ softReload: true }), expect.objectContaining({ softReload: true }),
); );
}); });
it("drops keyframes that belong to other elements", () => {
// A stale selection from a previously active element must not delete
// anything on the element that is active now.
usePlayerStore.setState({
selectedElementId: "card",
selectedKeyframes: new Set([
timelineKeyframeSelectionKey("card", {
percentage: 30,
tweenPercentage: 20,
propertyGroup: "position",
animationId: "card-position",
}),
timelineKeyframeSelectionKey("other", {
percentage: 70,
tweenPercentage: 80,
propertyGroup: "position",
animationId: "card-position",
}),
]),
});
const handleGsapRemoveKeyframe =
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
deleteSelectedKeyframes({
selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
handleGsapRemoveKeyframe,
});
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(1);
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
});
}); });
@@ -330,9 +330,14 @@ export function useGsapAnimationsForElement(
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
useEffect(() => { useEffect(() => {
if (!elementId) return; if (!elementId) return;
// No property-group filter: ungrouped tweens are recorded here as well. // Same admission rule as the keyframe cache below (hold skip included) and
// no property-group filter: the two stores must agree, or a hold draws an
// expanded property lane with no collapsed diamond behind it and an
// ungrouped tween draws diamonds with no lane source.
const sourceAnimations = animations.filter( const sourceAnimations = animations.filter(
(animation) => animation.keyframes || synthesizeFlatTweenKeyframes(animation), (animation) =>
!isStaticPositionHold(animation) &&
(animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
); );
if (sourceAnimations.length > 0) if (sourceAnimations.length > 0)
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations); writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
@@ -96,6 +96,9 @@ export function useInspectorState(
inspectorPanelActive, inspectorPanelActive,
inspectorButtonActive: inspectorButtonActive:
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
// handles ARE the arc-drag affordance, so gating them on an open Inspector
// would make keyframe path editing reachable only from a side panel.
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording, shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
// Keep the selection box drawn even when the Inspector is collapsed — // Keep the selection box drawn even when the Inspector is collapsed —
// closing the panel shouldn't visually deselect the element. // closing the panel shouldn't visually deselect the element.
@@ -312,25 +312,32 @@ export function useTimelineGroupEditing({
// reload (see the trackOnly doc above). Mixed batches (any start // reload (see the trackOnly doc above). Mixed batches (any start
// change) keep the full fallback below. // change) keep the full fallback below.
if (trackOnly) return; if (trackOnly) return;
await finishGroupTimingGsapFallback({ // The timing persist above already committed to disk, so the cached
projectId, // GSAP read is stale whether or not the position rewrite succeeded —
iframe: previewIframeRef.current, // invalidate on the error path too (matches the single-element path's
reloadPreview, // `.finally`), or a failed rewrite leaves the editor reading old tweens.
label: "Move timeline clips", try {
errorLabel: "Failed to shift GSAP positions", await finishGroupTimingGsapFallback({
coalesceKey, projectId,
recordEdit, iframe: previewIframeRef.current,
activeCompPath, reloadPreview,
changes, label: "Move timeline clips",
resolveChangePath: (element) => targetPathFor(element, activeCompPath), errorLabel: "Failed to shift GSAP positions",
mutateChange: (change, changePath) => { coalesceKey,
const delta = change.start - change.element.start; recordEdit,
const domId = change.element.domId; activeCompPath,
if (delta === 0 || !domId) return null; changes,
return shiftGsapPositions(projectId, changePath, domId, delta); resolveChangePath: (element) => targetPathFor(element, activeCompPath),
}, mutateChange: (change, changePath) => {
}); const delta = change.start - change.element.start;
invalidateGsapCache?.(); const domId = change.element.domId;
if (delta === 0 || !domId) return null;
return shiftGsapPositions(projectId, changePath, domId, delta);
},
});
} finally {
invalidateGsapCache?.();
}
}).catch((error) => { }).catch((error) => {
// Failed persist: revert the optimistic duration readout + live root // Failed persist: revert the optimistic duration readout + live root
// alongside the gesture owner's store rollback. // alongside the gesture owner's store rollback.
@@ -410,34 +417,40 @@ export function useTimelineGroupEditing({
coalesceMs, coalesceMs,
); );
} }
await finishGroupTimingGsapFallback({ // See the move path: the timing persist is already on disk, so the GSAP
projectId, // cache must be invalidated even when the position rewrite throws.
iframe: previewIframeRef.current, try {
reloadPreview, await finishGroupTimingGsapFallback({
label: "Resize timeline clips", projectId,
errorLabel: "Failed to scale GSAP positions", iframe: previewIframeRef.current,
coalesceKey, reloadPreview,
recordEdit, label: "Resize timeline clips",
activeCompPath, errorLabel: "Failed to scale GSAP positions",
changes, coalesceKey,
resolveChangePath: (element) => targetPathFor(element, activeCompPath), recordEdit,
mutateChange: (change, changePath) => { activeCompPath,
const domId = change.element.domId; changes,
const timingChanged = resolveChangePath: (element) => targetPathFor(element, activeCompPath),
change.start !== change.element.start || change.duration !== change.element.duration; mutateChange: (change, changePath) => {
if (!timingChanged || !domId) return null; const domId = change.element.domId;
return scaleGsapPositions( const timingChanged =
projectId, change.start !== change.element.start ||
changePath, change.duration !== change.element.duration;
domId, if (!timingChanged || !domId) return null;
change.element.start, return scaleGsapPositions(
change.element.duration, projectId,
change.start, changePath,
change.duration, domId,
); change.element.start,
}, change.element.duration,
}); change.start,
invalidateGsapCache?.(); change.duration,
);
},
});
} finally {
invalidateGsapCache?.();
}
}).catch((error) => { }).catch((error) => {
// Failed persist: revert the optimistic duration readout + live root // Failed persist: revert the optimistic duration readout + live root
// alongside the gesture owner's store rollback. // alongside the gesture owner's store rollback.
@@ -31,6 +31,7 @@ export function LayerDisclosureRow({
> >
<button <button
type="button" type="button"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`} aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`} title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]" className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
@@ -47,10 +48,9 @@ export function LayerDisclosureRow({
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }} style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
/> />
</button> </button>
<span {/* Decorative: the disclosure button above already names the row's keyframe
aria-label="Layer keyframe indicator" state, and aria-label on a plain span is not exposed reliably anyway. */}
className="shrink-0 text-[13px] leading-none text-white/40" <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
>
</span> </span>
<span className="min-w-0 flex-1 truncate font-medium" title={name}> <span className="min-w-0 flex-1 truncate font-medium" title={name}>
@@ -108,7 +108,7 @@ function renderBasicTimeline() {
} }
describe("Timeline provider boundary", () => { describe("Timeline provider boundary", () => {
it("keeps all-collapsed horizontal positions at the 32px gutter", () => { it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
usePlayerStore.setState({ usePlayerStore.setState({
duration: 11, duration: 11,
timelineReady: true, timelineReady: true,
@@ -121,13 +121,13 @@ describe("Timeline provider boundary", () => {
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
renderTimelineGeometry("clip-1"); renderTimelineGeometry("clip-1");
expect(trackHeader.style.width).toBe("32px"); expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
expect(clip.style.left).toBe("1000px"); expect(clip.style.left).toBe("1000px");
expect(clip.style.height).toBe(""); expect(clip.style.height).toBe("");
expect(clip.style.bottom).toBe(`${CLIP_Y}px`); expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
expect(rulerOrigin.style.width).toBe("32px"); expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
expect(rulerTick.style.left).toBe("999.5px"); expect(rulerTick.style.left).toBe("999.5px");
expect(playhead.style.left).toBe(`${1032 - PLAYHEAD_HEAD_W / 2}px`); expect(playhead.style.left).toBe(`${GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2}px`);
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`); expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
expect( expect(
resolveTimelineAssetDrop( resolveTimelineAssetDrop(
@@ -21,7 +21,7 @@ import { useTimelineEditPinning } from "./useTimelineEditPinning";
import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineStackingSync } from "./useTimelineStackingSync";
import { useTimelineGeometry } from "./useTimelineGeometry"; import { useTimelineGeometry } from "./useTimelineGeometry";
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
import { GUTTER, LABEL_COL_W, generateTicks } from "./timelineLayout"; import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
@@ -127,7 +127,10 @@ export const Timeline = memo(function Timeline({
[gsapAnimations], [gsapAnimations],
); );
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips; const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER; // Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD
// (dropping it would jam clip 0 against the gutter on every non-keyframed
// composition); in label mode the 232px label column already provides it.
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
const contentGutter = labelMode ? GUTTER : 0; const contentGutter = labelMode ? GUTTER : 0;
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const currentTime = usePlayerStore((s) => s.currentTime); const currentTime = usePlayerStore((s) => s.currentTime);
@@ -47,6 +47,11 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
const draggedRowIndex = const draggedRowIndex =
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights); const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
// ghost and drop placeholder must clamp to it or they stretch to the full
// expanded row height and stop matching the clip being dragged.
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
const { const {
onResizeElement, onResizeElement,
onMoveElement, onMoveElement,
@@ -177,7 +182,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y, top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
left: props.contentOrigin + draggedClip.previewStart * props.pps, left: props.contentOrigin + draggedClip.previewStart * props.pps,
width: Math.max(draggedClip.element.duration * props.pps, 4), width: Math.max(draggedClip.element.duration * props.pps, 4),
height: draggedRowHeight - CLIP_Y * 2, height: draggedClipHeight,
border: "1px solid rgba(60,230,172,0.55)", border: "1px solid rgba(60,230,172,0.55)",
background: "rgba(60,230,172,0.12)", background: "rgba(60,230,172,0.12)",
borderRadius: 4, borderRadius: 4,
@@ -230,7 +235,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
top: activeDraggedPosition.top, top: activeDraggedPosition.top,
left: activeDraggedPosition.left, left: activeDraggedPosition.left,
width: Math.max(activeDraggedElement.duration * props.pps, 4), width: Math.max(activeDraggedElement.duration * props.pps, 4),
height: draggedRowHeight - CLIP_Y * 2, height: draggedClipHeight,
zIndex: 40, zIndex: 40,
}} }}
> >
@@ -375,12 +375,23 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
pendingRetimeRef.current.delete(kfKey); pendingRetimeRef.current.delete(kfKey);
} }
}; };
// A rejected drop (the destination time is already occupied) snaps
// the diamond back to its source position, so the pending entry AND
// the selection have to revert with it — parking on the ghost drop
// position strands the playhead + selection on a keyframe that does
// not exist there.
const revertRetime = () => {
clearPending();
onClickKeyframe?.(fromTarget);
};
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => { void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
if (!committed) clearPending(); if (!committed) revertRetime();
}, clearPending); }, revertRetime);
// A retime still targeted this exact diamond — park/select it at its // A retime still targeted this exact diamond — park/select it at its
// new position, same as a plain click, or a drag that actually moved // new position, same as a plain click, or a drag that actually moved
// something looks identical to one that silently did nothing. // something looks identical to one that silently did nothing. Done
// optimistically so the gesture stays responsive; revertRetime puts
// it back if the move is rejected.
onClickKeyframe?.({ onClickKeyframe?.({
...target, ...target,
percentage: res.toClipPct, percentage: res.toClipPct,
@@ -1,4 +1,4 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react"; import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
import { import {
classifyPropertyGroup, classifyPropertyGroup,
type GsapAnimation, type GsapAnimation,
@@ -59,6 +59,14 @@ function sourceGroups(animations: readonly GsapAnimation[]) {
return groups; 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;
}
function groupKeyframes( function groupKeyframes(
animations: readonly GsapAnimation[], animations: readonly GsapAnimation[],
group: PropertyGroupName, group: PropertyGroupName,
@@ -79,6 +87,7 @@ function groupKeyframes(
tweenPercentage: keyframe.percentage, tweenPercentage: keyframe.percentage,
propertyGroup: group, propertyGroup: group,
animationId: animation.id, animationId: animation.id,
ease: keyframeEase(keyframe, animation),
}); });
} }
} }
@@ -116,15 +125,33 @@ export function TimelinePropertyLanes({
onMoveKeyframe, onMoveKeyframe,
suppressClickRef, suppressClickRef,
}: TimelinePropertyLanesProps) { }: TimelinePropertyLanesProps) {
if (clipWidthPx < 20 || clipDuration <= 0) return null; // Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
const lanes = getTimelinePropertyLanes(animations, clipStart, clipDuration); // 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],
);
if (lanes.length === 0) return null; if (laneData.length === 0) return null;
return ( return (
<> <>
{lanes.map(({ group, animations: groupAnimations, keyframes }, laneIndex) => ( {laneData.map(({ group, keyframesData }, laneIndex) => (
<div <div
key={group} key={group}
role="group"
aria-label={`${group} keyframes`}
data-property-group={group} data-property-group={group}
data-timeline-property-lane="" data-timeline-property-lane=""
data-timeline-lane-top={getTimelineLaneTop(laneIndex)} data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
@@ -137,10 +164,7 @@ export function TimelinePropertyLanes({
}} }}
> >
<TimelineDiamondLane <TimelineDiamondLane
keyframesData={{ format: "percentage", keyframes }} keyframesData={keyframesData}
globalEase={
groupAnimations[0]?.keyframes?.easeEach ?? groupAnimations[0]?.ease ?? "none"
}
clipWidthPx={clipWidthPx} clipWidthPx={clipWidthPx}
clipHeightPx={LANE_H} clipHeightPx={LANE_H}
accentColor={accentColor} accentColor={accentColor}
@@ -32,6 +32,7 @@ function animation(
keyframes: Array<{ keyframes: Array<{
percentage: number; percentage: number;
properties: Record<string, number | string>; properties: Record<string, number | string>;
ease?: string;
}>, }>,
): GsapAnimation { ): GsapAnimation {
return { return {
@@ -126,7 +127,7 @@ describe("TimelineTrackHeader", () => {
const onTogglePropertyGroupKeyframe = vi.fn(); const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe }); const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe"); click(view.host, "Add Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith( expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT, ELEMENT,
expect.objectContaining({ expect.objectContaining({
@@ -139,7 +140,7 @@ describe("TimelineTrackHeader", () => {
); );
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe }); view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe"); click(view.host, "Remove Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith( expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT, ELEMENT,
expect.objectContaining({ expect.objectContaining({
@@ -214,13 +215,13 @@ describe("TimelineTrackHeader", () => {
it("fills the toggle diamond exactly at that group's keyframe", () => { it("fills the toggle diamond exactly at that group's keyframe", () => {
const view = renderHeader({ currentTime: 0.5 }); const view = renderHeader({ currentTime: 0.5 });
const positionToggle = view.host.querySelector<HTMLButtonElement>( const positionToggle = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle Position keyframe"]', 'button[aria-label="Add Position keyframe"]',
); );
expect(positionToggle?.textContent).toBe("◇"); expect(positionToggle?.textContent).toBe("◇");
view.rerender({ currentTime: 1 }); view.rerender({ currentTime: 1 });
expect( expect(
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]') view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
?.textContent, ?.textContent,
).toBe("◆"); ).toBe("◆");
act(() => view.root.unmount()); act(() => view.root.unmount());
@@ -241,6 +242,36 @@ describe("TimelineTrackHeader", () => {
act(() => view.root.unmount()); act(() => view.root.unmount());
}); });
it("samples mid-segment values along the segment's ease, not linearly", () => {
// GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
// runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
// the readout is 12.5/6.25 and NOT the linear 50/25.
const eased = animation("eased-position", "position", [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
{ percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
]);
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({
animations: [eased],
currentTime: 0.5,
onTogglePropertyGroupKeyframe,
});
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"12.5, 6.25",
);
// The same sampled value is what an added keyframe gets stamped with, so a
// header insert lands on the existing curve instead of deforming it.
click(view.host, "Add Position keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
properties: { x: 12.5, y: 6.25 },
});
act(() => view.root.unmount());
});
it("disables the previous chevron at or before the group's first keyframe", () => { it("disables the previous chevron at or before the group's first keyframe", () => {
const view = renderHeader({ currentTime: 0 }); const view = renderHeader({ currentTime: 0 });
const prevAt0 = view.host.querySelector<HTMLButtonElement>( const prevAt0 = view.host.querySelector<HTMLButtonElement>(
@@ -129,6 +129,14 @@ function PropertyGroupNavigation({
onSeek?: (time: number) => void; onSeek?: (time: number) => void;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
// target is met with a centered transparent ::before overlay instead of a
// bigger box; focus-visible matches every other control in this header.
const CHEVRON_BUTTON_CLASS =
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
const seekTo = (keyframe: { percentage: number } | null) => { const seekTo = (keyframe: { percentage: number } | null) => {
if (keyframe) { if (keyframe) {
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration); onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
@@ -140,7 +148,7 @@ function PropertyGroupNavigation({
type="button" type="button"
aria-label={`Previous ${label} keyframe`} aria-label={`Previous ${label} keyframe`}
disabled={!navigation.prevKeyframe} disabled={!navigation.prevKeyframe}
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15" className={CHEVRON_BUTTON_CLASS}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
seekTo(navigation.prevKeyframe); seekTo(navigation.prevKeyframe);
@@ -153,7 +161,7 @@ function PropertyGroupNavigation({
type="button" type="button"
aria-label={`Next ${label} keyframe`} aria-label={`Next ${label} keyframe`}
disabled={!navigation.nextKeyframe} disabled={!navigation.nextKeyframe}
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15" className={CHEVRON_BUTTON_CLASS}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
seekTo(navigation.nextKeyframe); seekTo(navigation.nextKeyframe);
@@ -242,7 +250,8 @@ function PropertyGroupHeaderRow({
> >
<button <button
type="button" type="button"
aria-label={`Toggle ${label} keyframe`} aria-pressed={!!navigation.currentKeyframe}
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`} title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
className="flex h-5 w-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]" className="flex h-5 w-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onClick={(event) => { onClick={(event) => {
@@ -77,12 +77,24 @@ function validRowHeight(height: number | undefined): number {
return height; return height;
} }
/**
* Memoized by the rowHeights array identity: a marquee/drag pointer tick calls
* getTimelineRowTop once per clip, and each call would otherwise rebuild the
* whole cumulative array (O(clips x rows) allocations per tick). rowHeights is
* itself memoized upstream (useTimelineTrackLayout), so the identity is stable
* for the life of a gesture. Callers must treat the result as read-only.
*/
const rowOffsetsCache = new WeakMap<readonly number[], number[]>();
/** Cumulative top offsets, including the final bottom boundary. */ /** Cumulative top offsets, including the final bottom boundary. */
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] { export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
const cached = rowOffsetsCache.get(rowHeights);
if (cached) return cached;
const offsets = [0]; const offsets = [0];
for (const height of rowHeights) { for (const height of rowHeights) {
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height)); offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
} }
rowOffsetsCache.set(rowHeights, offsets);
return offsets; return offsets;
} }
@@ -4,6 +4,7 @@
* reads to a human. Kept apart from the header's JSX so a formatting change and * reads to a human. Kept apart from the header's JSX so a formatting change and
* a layout change never touch the same file. * a layout change never touch the same file.
*/ */
import gsap from "gsap";
import { import {
classifyPropertyGroup, classifyPropertyGroup,
type GsapAnimation, type GsapAnimation,
@@ -16,31 +17,59 @@ function roundValue(value: number): string {
return String(Math.round(value * 100) / 100); 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( function propertyValueAt(
animation: GsapAnimation, animation: GsapAnimation,
property: string, property: string,
tweenPercentage: number, tweenPercentage: number,
): number | string | undefined { ): number | string | undefined {
const keyframes = animation.keyframes?.keyframes ?? []; const stops = propertyStops(animation, property);
const values = keyframes const before = stops.filter((stop) => stop.percentage <= tweenPercentage).at(-1);
.filter((keyframe) => property in keyframe.properties) const after = stops.find((stop) => stop.percentage >= tweenPercentage);
.map((keyframe) => ({
percentage: keyframe.percentage,
value: keyframe.properties[property],
}));
const before = values.filter((value) => value.percentage <= tweenPercentage).at(-1);
const after = values.find((value) => value.percentage >= tweenPercentage);
if (!before) return after?.value; if (!before) return after?.value;
if (!after) return before.value; if (!after) return before.value;
if ( const ends = interpolableEnds(before, after);
typeof before.value !== "number" || if (!ends) return before.value;
typeof after.value !== "number" ||
before.percentage === after.percentage
) {
return before.value;
}
const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage); const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
return before.value + (after.value - before.value) * progress; return ends.from + (ends.to - ends.from) * easedProgress(progress, animation, after.ease);
} }
/** Every property of `group` this animation touches, sampled at `tweenPercentage`. */ /** Every property of `group` this animation touches, sampled at `tweenPercentage`. */