mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -93,7 +93,11 @@ export function KeyframeEaseList({
|
||||
? "Custom"
|
||||
: (EASE_LABELS[segEase] ?? segEase);
|
||||
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
|
||||
type="button"
|
||||
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 { usePlayerStore } from "../player/store/playerStore";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { resolveEditableTweenDuration } from "./gsapShared";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||
import { resolveEditableTweenDuration } from "./gsapShared";
|
||||
import {
|
||||
type GsapDragCommitCallbacks,
|
||||
computeCurrentPercentage,
|
||||
@@ -159,7 +159,7 @@ async function commitFlatViaKeyframes(
|
||||
): Promise<void> {
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const td = resolveEditableTweenDuration(anim, selection);
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const outsideRange =
|
||||
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 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 hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
||||
if (outsideRange && !hasSelectedKeyframe) {
|
||||
@@ -352,7 +352,7 @@ export async function commitGsapPositionFromDrag(
|
||||
} else if (anim.method === "from" || anim.method === "fromTo") {
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const td = resolveEditableTweenDuration(anim, selection);
|
||||
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
||||
const outsideRange =
|
||||
!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) {
|
||||
const posTs = resolveTweenStart(existingPosAnim);
|
||||
const posTd = resolveTweenDuration(existingPosAnim);
|
||||
const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
|
||||
if (posTs !== null) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
|
||||
@@ -390,4 +390,36 @@ describe("deleteSelectedKeyframes", () => {
|
||||
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
|
||||
useEffect(() => {
|
||||
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(
|
||||
(animation) => animation.keyframes || synthesizeFlatTweenKeyframes(animation),
|
||||
(animation) =>
|
||||
!isStaticPositionHold(animation) &&
|
||||
(animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
|
||||
);
|
||||
if (sourceAnimations.length > 0)
|
||||
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
||||
|
||||
@@ -96,6 +96,9 @@ export function useInspectorState(
|
||||
inspectorPanelActive,
|
||||
inspectorButtonActive:
|
||||
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,
|
||||
// Keep the selection box drawn even when the Inspector is collapsed —
|
||||
// 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
|
||||
// change) keep the full fallback below.
|
||||
if (trackOnly) return;
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Move timeline clips",
|
||||
errorLabel: "Failed to shift GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) return null;
|
||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||
},
|
||||
});
|
||||
invalidateGsapCache?.();
|
||||
// The timing persist above already committed to disk, so the cached
|
||||
// GSAP read is stale whether or not the position rewrite succeeded —
|
||||
// invalidate on the error path too (matches the single-element path's
|
||||
// `.finally`), or a failed rewrite leaves the editor reading old tweens.
|
||||
try {
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Move timeline clips",
|
||||
errorLabel: "Failed to shift GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) return null;
|
||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
invalidateGsapCache?.();
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
@@ -410,34 +417,40 @@ export function useTimelineGroupEditing({
|
||||
coalesceMs,
|
||||
);
|
||||
}
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Resize timeline clips",
|
||||
errorLabel: "Failed to scale GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start || change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) return null;
|
||||
return scaleGsapPositions(
|
||||
projectId,
|
||||
changePath,
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
},
|
||||
});
|
||||
invalidateGsapCache?.();
|
||||
// See the move path: the timing persist is already on disk, so the GSAP
|
||||
// cache must be invalidated even when the position rewrite throws.
|
||||
try {
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Resize timeline clips",
|
||||
errorLabel: "Failed to scale GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start ||
|
||||
change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) return null;
|
||||
return scaleGsapPositions(
|
||||
projectId,
|
||||
changePath,
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
invalidateGsapCache?.();
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
|
||||
@@ -31,6 +31,7 @@ export function LayerDisclosureRow({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
||||
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]"
|
||||
@@ -47,10 +48,9 @@ export function LayerDisclosureRow({
|
||||
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
aria-label="Layer keyframe indicator"
|
||||
className="shrink-0 text-[13px] leading-none text-white/40"
|
||||
>
|
||||
{/* Decorative: the disclosure button above already names the row's keyframe
|
||||
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
||||
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
||||
◇
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
|
||||
|
||||
@@ -108,7 +108,7 @@ function renderBasicTimeline() {
|
||||
}
|
||||
|
||||
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({
|
||||
duration: 11,
|
||||
timelineReady: true,
|
||||
@@ -121,13 +121,13 @@ describe("Timeline provider boundary", () => {
|
||||
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
||||
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.height).toBe("");
|
||||
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(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(
|
||||
resolveTimelineAssetDrop(
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
||||
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
||||
import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||
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 { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
@@ -127,7 +127,10 @@ export const Timeline = memo(function Timeline({
|
||||
[gsapAnimations],
|
||||
);
|
||||
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 setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
@@ -47,6 +47,11 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
const draggedRowIndex =
|
||||
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
||||
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 {
|
||||
onResizeElement,
|
||||
onMoveElement,
|
||||
@@ -177,7 +182,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
|
||||
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
||||
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)",
|
||||
background: "rgba(60,230,172,0.12)",
|
||||
borderRadius: 4,
|
||||
@@ -230,7 +235,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
top: activeDraggedPosition.top,
|
||||
left: activeDraggedPosition.left,
|
||||
width: Math.max(activeDraggedElement.duration * props.pps, 4),
|
||||
height: draggedRowHeight - CLIP_Y * 2,
|
||||
height: draggedClipHeight,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -375,12 +375,23 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
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) => {
|
||||
if (!committed) clearPending();
|
||||
}, clearPending);
|
||||
if (!committed) revertRetime();
|
||||
}, revertRetime);
|
||||
// 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
|
||||
// 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?.({
|
||||
...target,
|
||||
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 {
|
||||
classifyPropertyGroup,
|
||||
type GsapAnimation,
|
||||
@@ -59,6 +59,14 @@ function sourceGroups(animations: readonly GsapAnimation[]) {
|
||||
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(
|
||||
animations: readonly GsapAnimation[],
|
||||
group: PropertyGroupName,
|
||||
@@ -79,6 +87,7 @@ function groupKeyframes(
|
||||
tweenPercentage: keyframe.percentage,
|
||||
propertyGroup: group,
|
||||
animationId: animation.id,
|
||||
ease: keyframeEase(keyframe, animation),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -116,15 +125,33 @@ export function TimelinePropertyLanes({
|
||||
onMoveKeyframe,
|
||||
suppressClickRef,
|
||||
}: TimelinePropertyLanesProps) {
|
||||
if (clipWidthPx < 20 || clipDuration <= 0) return null;
|
||||
const lanes = getTimelinePropertyLanes(animations, clipStart, clipDuration);
|
||||
// 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],
|
||||
);
|
||||
|
||||
if (lanes.length === 0) return null;
|
||||
if (laneData.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{lanes.map(({ group, animations: groupAnimations, keyframes }, laneIndex) => (
|
||||
{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)}
|
||||
@@ -137,10 +164,7 @@ export function TimelinePropertyLanes({
|
||||
}}
|
||||
>
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{ format: "percentage", keyframes }}
|
||||
globalEase={
|
||||
groupAnimations[0]?.keyframes?.easeEach ?? groupAnimations[0]?.ease ?? "none"
|
||||
}
|
||||
keyframesData={keyframesData}
|
||||
clipWidthPx={clipWidthPx}
|
||||
clipHeightPx={LANE_H}
|
||||
accentColor={accentColor}
|
||||
|
||||
@@ -32,6 +32,7 @@ function animation(
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
): GsapAnimation {
|
||||
return {
|
||||
@@ -126,7 +127,7 @@ describe("TimelineTrackHeader", () => {
|
||||
const onTogglePropertyGroupKeyframe = vi.fn();
|
||||
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
|
||||
|
||||
click(view.host, "Toggle Opacity keyframe");
|
||||
click(view.host, "Add Opacity keyframe");
|
||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
||||
ELEMENT,
|
||||
expect.objectContaining({
|
||||
@@ -139,7 +140,7 @@ describe("TimelineTrackHeader", () => {
|
||||
);
|
||||
|
||||
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
|
||||
click(view.host, "Toggle Opacity keyframe");
|
||||
click(view.host, "Remove Opacity keyframe");
|
||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
||||
ELEMENT,
|
||||
expect.objectContaining({
|
||||
@@ -214,13 +215,13 @@ describe("TimelineTrackHeader", () => {
|
||||
it("fills the toggle diamond exactly at that group's keyframe", () => {
|
||||
const view = renderHeader({ currentTime: 0.5 });
|
||||
const positionToggle = view.host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Toggle Position keyframe"]',
|
||||
'button[aria-label="Add Position keyframe"]',
|
||||
);
|
||||
expect(positionToggle?.textContent).toBe("◇");
|
||||
|
||||
view.rerender({ currentTime: 1 });
|
||||
expect(
|
||||
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
|
||||
view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
|
||||
?.textContent,
|
||||
).toBe("◆");
|
||||
act(() => view.root.unmount());
|
||||
@@ -241,6 +242,36 @@ describe("TimelineTrackHeader", () => {
|
||||
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", () => {
|
||||
const view = renderHeader({ currentTime: 0 });
|
||||
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
|
||||
|
||||
@@ -129,6 +129,14 @@ function PropertyGroupNavigation({
|
||||
onSeek?: (time: number) => void;
|
||||
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) => {
|
||||
if (keyframe) {
|
||||
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
||||
@@ -140,7 +148,7 @@ function PropertyGroupNavigation({
|
||||
type="button"
|
||||
aria-label={`Previous ${label} keyframe`}
|
||||
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) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.prevKeyframe);
|
||||
@@ -153,7 +161,7 @@ function PropertyGroupNavigation({
|
||||
type="button"
|
||||
aria-label={`Next ${label} keyframe`}
|
||||
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) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.nextKeyframe);
|
||||
@@ -242,7 +250,8 @@ function PropertyGroupHeaderRow({
|
||||
>
|
||||
<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`}
|
||||
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) => {
|
||||
|
||||
@@ -77,12 +77,24 @@ function validRowHeight(height: number | undefined): number {
|
||||
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. */
|
||||
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
||||
const cached = rowOffsetsCache.get(rowHeights);
|
||||
if (cached) return cached;
|
||||
const offsets = [0];
|
||||
for (const height of rowHeights) {
|
||||
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
||||
}
|
||||
rowOffsetsCache.set(rowHeights, offsets);
|
||||
return offsets;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 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,
|
||||
@@ -16,31 +17,59 @@ 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 keyframes = animation.keyframes?.keyframes ?? [];
|
||||
const values = keyframes
|
||||
.filter((keyframe) => property in keyframe.properties)
|
||||
.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);
|
||||
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;
|
||||
if (
|
||||
typeof before.value !== "number" ||
|
||||
typeof after.value !== "number" ||
|
||||
before.percentage === after.percentage
|
||||
) {
|
||||
return before.value;
|
||||
}
|
||||
const ends = interpolableEnds(before, after);
|
||||
if (!ends) return before.value;
|
||||
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`. */
|
||||
|
||||
Reference in New Issue
Block a user