Merge pull request #2791 from heygen-com/codex/studio-timeline-b-expanded-lanes-v2

feat(studio): wire expanded keyframe timeline lanes
This commit is contained in:
Miguel Ángel
2026-07-28 02:37:23 +02:00
committed by GitHub
125 changed files with 9343 additions and 2026 deletions
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
@@ -90,6 +91,7 @@ export function EditorShell({
handleRazorSplitAll,
setCompIdToSrc,
setCompositionLoading,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
@@ -149,6 +151,7 @@ export function EditorShell({
onDeleteElement={handleTimelineElementDelete}
previewOverlay={
<PreviewOverlays
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
isGestureRecording={isGestureRecording}
@@ -2,8 +2,10 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { makeSelection } from "../hooks/domSelectionTestHarness";
import { TimelineToolbar } from "./TimelineToolbar";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -13,12 +15,14 @@ afterEach(() => {
usePlayerStore.setState({ autoKeyframeEnabled: true });
});
function renderToolbar() {
function renderToolbar(
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<TimelineToolbar />);
root.render(<TimelineToolbar domEditSession={domEditSession} />);
});
return { host, root };
}
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
act(() => root.unmount());
});
});
describe("TimelineToolbar — motion path endpoints", () => {
it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
usePlayerStore.setState({ currentTime: 10 });
const animation: GsapAnimation = {
id: "#el-to-0-position",
targetSelector: "#el",
method: "to",
position: 0,
duration: 10,
properties: {},
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
},
arcPath: {
enabled: true,
autoRotate: false,
segments: [{ curviness: 1 }],
},
};
const element = document.createElement("div");
element.id = "el";
const session = {
domEditSelection: makeSelection("Element", element),
selectedGsapAnimations: [animation],
handleGsapAddAnimation: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
const { host, root } = renderToolbar(session);
const button = host.querySelector<HTMLButtonElement>(
'button[aria-label="Motion path endpoint"]',
);
expect(button?.disabled).toBe(true);
act(() => root.unmount());
});
});
@@ -5,7 +5,7 @@ import {
isPlayheadWithinTween,
type EnableKeyframesSession,
} from "../hooks/useEnableKeyframes";
import { computeElementPercentage } from "../hooks/gsapShared";
import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
import {
getNextTimelineZoomPercent,
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
}
interface KeyframeToggleState {
state: "active" | "inactive" | "none";
isMotionPath: boolean;
pathEndpoint: boolean;
willExtend: boolean;
}
const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
state: "none",
isMotionPath: false,
pathEndpoint: false,
willExtend: false,
};
function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
if (!animation?.keyframes) return false;
const keyframes = animation.keyframes.keyframes;
return (
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
);
}
function resolveKeyframeToggleState(
session: DomEditSessionSlice | undefined,
currentTime: number,
): KeyframeToggleState {
if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
const arcAnimation = session.selectedGsapAnimations.find(
(animation) => animation.arcPath && animation.keyframes,
);
const animation =
arcAnimation ??
session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
const isMotionPath = Boolean(arcAnimation);
if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
}
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
const active = animation.keyframes.keyframes.some(
(keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
);
return {
state: pathEndpoint ? "none" : active ? "active" : "inactive",
isMotionPath,
pathEndpoint,
willExtend: false,
};
}
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
const sessionRef = useRef(session);
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
);
if (!session) return { state: "none" as const, onToggle: undefined };
const toggleState = resolveKeyframeToggleState(session, currentTime);
const sel = session.domEditSelection;
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
let state: "active" | "inactive" | "none" = "none";
// Outside the tween, clicking extends the animation to the playhead rather than
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
let willExtend = false;
if (kfAnim?.keyframes && sel) {
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
state = "inactive";
willExtend = true;
} else {
// Tween-relative percentage (not the clip range) so the button state matches
// where the keyframe would actually land.
const pct = computeElementPercentage(currentTime, sel, kfAnim);
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
}
}
return { state, willExtend, onToggle: sel ? onToggle : undefined };
return {
...toggleState,
onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
};
}
// fallow-ignore-next-line complexity
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const {
state: keyframeState,
isMotionPath: keyframeIsMotionPath,
pathEndpoint: keyframePathEndpoint,
willExtend: keyframeWillExtend,
onToggle: onToggleKeyframe,
} = useKeyframeToggle(domEditSession);
@@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
// toolbar layout never shifts.
<Tooltip
label={
!onToggleKeyframe
? "Select an animated element to add keyframes"
: keyframeState === "active"
? "Remove keyframe at playhead (K)"
: keyframeState === "inactive"
keyframePathEndpoint
? "Motion path endpoints cannot be removed"
: !onToggleKeyframe
? "Select an animated element to add keyframes"
: keyframeIsMotionPath
? keyframeWillExtend
? "Add keyframe at playhead, extends animation (K)"
: "Add keyframe at playhead (K)"
: "Add keyframe (K)"
? "Extend motion path to playhead (K)"
: keyframeState === "active"
? "Remove waypoint from motion path (K)"
: "Add waypoint to motion path (K)"
: keyframeState === "active"
? "Remove keyframe at playhead (K)"
: keyframeState === "inactive"
? keyframeWillExtend
? "Add keyframe at playhead, extends animation (K)"
: "Add keyframe at playhead (K)"
: "Add keyframe (K)"
}
>
<button
@@ -196,9 +241,17 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
disabled={!onToggleKeyframe}
onClick={onToggleKeyframe}
aria-label={
keyframeState === "active"
? "Remove keyframe at playhead"
: "Add keyframe at playhead"
keyframePathEndpoint
? "Motion path endpoint"
: keyframeIsMotionPath
? keyframeState === "active"
? "Remove motion path waypoint"
: keyframeWillExtend
? "Extend motion path to playhead"
: "Add motion path waypoint"
: keyframeState === "active"
? "Remove keyframe at playhead"
: "Add keyframe at playhead"
}
className={
!onToggleKeyframe
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}
function renderExpandedCard({
animation,
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({
animation = baseAnimation(),
defaultExpanded = true,
flat,
onUpdateMeta = vi.fn(),
onUpdateKeyframeEase = vi.fn(),
onDeleteAnimation = noop,
}: {
animation: GsapAnimation;
animation?: GsapAnimation;
defaultExpanded?: boolean;
flat?: boolean;
onUpdateMeta?: ReturnType<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
}) {
onDeleteAnimation?: (id: string) => void;
} = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
@@ -63,11 +68,11 @@ function renderExpandedCard({
root.render(
<AnimationCard
animation={animation}
defaultExpanded
defaultExpanded={defaultExpanded}
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const view = renderCard({ animation, onUpdateKeyframeEase });
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
action: "commit",
ease,
});
act(() => view.root.unmount());
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
const view = renderCard({
animation,
flat: true,
onUpdateMeta,
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
describe("AnimationCard flat branch", () => {
it("renders a mint border-left and panel-token colors when flat", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
const card = host.querySelector('[data-flat-effect-card="true"]');
expect(card).not.toBeNull();
expect(card?.className).toContain("border-panel-accent");
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
});
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false });
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
expect(host.textContent).toContain("power2.out");
act(() => root.unmount());
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
for (const flat of [false, true]) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat={flat || undefined}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
expect(host.textContent).not.toContain("Remove");
const button = host.querySelector("button");
expect(button).not.toBeNull();
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
const onDeleteAnimation = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={true}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
const buttons = Array.from(host.querySelectorAll("button"));
const removeButton = buttons.find((b) => b.textContent === "Remove");
expect(removeButton).not.toBeUndefined();
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
@@ -23,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
defaultExpanded: boolean;
flat?: boolean;
focusedSegment?: { tweenPercentage: number } | null;
onFocusSegmentConsumed?: () => void;
}
// fallow-ignore-next-line complexity
@@ -30,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({
animation,
defaultExpanded,
flat,
focusedSegment,
onFocusSegmentConsumed,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
@@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false);
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const pendingAutoScrollRef = useRef(false);
useEffect(() => {
if (!focusedSegment) return;
setExpanded(true);
pendingAutoScrollRef.current = true;
setExpandedKfPct(focusedSegment.tweenPercentage);
onFocusSegmentConsumed?.();
}, [focusedSegment, onFocusSegmentConsumed]);
useEffect(() => {
if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
const segment = cardRef.current?.querySelector<HTMLElement>(
`[data-ease-segment-pct="${expandedKfPct}"]`,
);
segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
pendingAutoScrollRef.current = false;
}, [expandedKfPct]);
const usedProps = useMemo(
() => new Set(Object.keys(animation.properties)),
@@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
return (
<div
ref={cardRef}
data-flat-effect-card={flat ? "true" : undefined}
className={
flat
@@ -267,7 +291,7 @@ export const AnimationCard = memo(function AnimationCard({
onToggle={setExpandedKfPct}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
trackStudioSegmentEaseEdit({ ease });
trackStudioSegmentEaseEdit({ action: "commit", ease });
}}
onApplyAll={
onSetAllKeyframeEases
@@ -0,0 +1,68 @@
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
const STYLES = {
classic: {
method:
"rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white",
cancel: "px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300",
trigger: "text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200",
},
flat: {
method:
"rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0",
cancel: "px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1",
trigger: "text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1",
},
};
export function GsapAddAnimationControl({
open,
setOpen,
onAddAnimation,
track,
variant,
}: {
open: boolean;
setOpen: (open: boolean) => void;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
track: (control: string, name: string) => void;
variant: keyof typeof STYLES;
}) {
const styles = STYLES[variant];
return (
<div className="relative pt-1">
{open ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setOpen(false);
}}
className={styles.method}
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button type="button" onClick={() => setOpen(false)} className={styles.cancel}>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className={styles.trigger}
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
);
}
@@ -2,13 +2,15 @@ import { memo, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Film } from "../../icons/SystemIcons";
import { Section } from "./propertyPanelPrimitives";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import { AnimationCard } from "./AnimationCard";
import {
trackAnimationMetaUpdate,
type GsapAnimationEditCallbacks,
withTrackedGsapAnimationCallbacks,
clearFocusedEaseSegment,
} from "./gsapAnimationCallbacks";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
animations: GsapAnimation[];
@@ -21,41 +23,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
animations,
multipleTimelines,
unsupportedTimelinePattern,
onUpdateProperty,
onUpdateMeta,
onDeleteAnimation,
onAddProperty,
onRemoveProperty,
onUpdateFromProperty,
onAddFromProperty,
onRemoveFromProperty,
onAddAnimation,
onLivePreview,
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
onUnroll,
...callbacks
}: GsapAnimationSectionProps) {
const track = useTrackDesignInput();
const [addMenuOpen, setAddMenuOpen] = useState(false);
const trackProperty = (property: string) => {
const control =
property === "visibility"
? "toggle"
: property === "filter" || property === "clipPath"
? "text"
: "metric";
track(control, property);
};
const updateMeta = (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => {
trackAnimationMetaUpdate(track, updates);
onUpdateMeta(animationId, updates);
};
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
return (
<Section title="Animation" icon={<Film size={15} />}>
@@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
{...trackedCallbacks}
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
onUpdateProperty={(animationId, property, value) => {
trackProperty(property);
onUpdateProperty(animationId, property, value);
}}
onUpdateMeta={updateMeta}
onDeleteAnimation={(animationId) => {
track("button", "Remove animation");
onDeleteAnimation(animationId);
}}
onAddProperty={(animationId, property) => {
track("select", "Add effect property");
onAddProperty(animationId, property);
}}
onRemoveProperty={(animationId, property) => {
track("button", `Remove ${property}`);
onRemoveProperty(animationId, property);
}}
onUpdateFromProperty={
onUpdateFromProperty
? (animationId, property, value) => {
trackProperty(property);
onUpdateFromProperty(animationId, property, value);
}
: undefined
}
onAddFromProperty={
onAddFromProperty
? (animationId, property) => {
track("select", "Add from property");
onAddFromProperty(animationId, property);
}
: undefined
}
onRemoveFromProperty={
onRemoveFromProperty
? (animationId, property) => {
track("button", `Remove from ${property}`);
onRemoveFromProperty(animationId, property);
}
: undefined
}
onLivePreview={onLivePreview}
onLivePreviewEnd={onLivePreviewEnd}
onSetArcPath={
onSetArcPath
? (animationId, config) => {
track(
"toggle",
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
);
onSetArcPath(animationId, config);
}
: undefined
}
onUpdateArcSegment={
onUpdateArcSegment
? (animationId, segmentIndex, update) => {
if (update.curviness === undefined) {
track("button", `Reset arc segment ${segmentIndex + 1}`);
}
onUpdateArcSegment(animationId, segmentIndex, update);
}
: undefined
}
onUpdateKeyframeEase={
onUpdateKeyframeEase
? (animationId, percentage, ease) => {
track("select", "Keyframe ease");
onUpdateKeyframeEase(animationId, percentage, ease);
}
: undefined
}
onSetAllKeyframeEases={
onSetAllKeyframeEases
? (animationId, ease) => {
track("select", "All keyframe eases");
onSetAllKeyframeEases(animationId, ease);
}
: undefined
}
onUnroll={
onUnroll
? (animationId) => {
track("button", "Unroll animation");
onUnroll(animationId);
}
: undefined
focusedSegment={
focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
}
onFocusSegmentConsumed={clearFocusedEaseSegment}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
<GsapAddAnimationControl
open={addMenuOpen}
setOpen={setAddMenuOpen}
onAddAnimation={onAddAnimation}
track={track}
variant="classic"
/>
</div>
)}
</Section>
@@ -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)}
@@ -11,17 +11,50 @@ interface KeyframeNavigationProps {
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
/** The tween that authored this keyframe, when the cache knows it. */
animationId?: string;
}> | null;
/** Current playhead percentage within the element's lifetime (0-100) */
currentPercentage: number;
onSeek: (percentage: number) => void;
onAddKeyframe: (percentage: number) => void;
onRemoveKeyframe: (percentage: number) => void;
/** `animationId` is the clicked keyframe's OWN tween; see handleDiamondClick. */
onRemoveKeyframe: (percentage: number, animationId?: string) => void;
onConvertToKeyframes: () => void;
}
const TOLERANCE = 0.5;
interface NavigableKeyframe {
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
}
export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
keyframes: readonly Keyframe[],
currentPercentage: number,
property?: string,
) {
const propertyKeyframes = property
? keyframes.filter((keyframe) => property in keyframe.properties)
: keyframes;
return {
propertyKeyframes,
prevKeyframe:
propertyKeyframes
.filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
.at(-1) ?? null,
nextKeyframe:
propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
null,
currentKeyframe:
propertyKeyframes.find(
(keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
) ?? null,
};
}
/**
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
* the TWEEN-relative percentage the GSAP writer/runtime key on. The cliptween
@@ -92,18 +125,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
onRemoveKeyframe,
onConvertToKeyframes,
}: KeyframeNavigationProps) {
// Find keyframes that contain this property
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
const prevKf =
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
const nextKf =
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
const atCurrent =
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
null;
const {
propertyKeyframes,
prevKeyframe: prevKf,
nextKeyframe: nextKf,
currentKeyframe: atCurrent,
} = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);
// Diamond state
let diamondState: DiamondState;
@@ -128,7 +155,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
if (diamondState === "ghost") {
onConvertToKeyframes();
} else if (diamondState === "active" && atCurrent) {
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
// Report the keyframe's OWN tween. A merged gutter row shows the keyframes
// of every tween in the property group, so the caller's "the group's
// animation" guess names the wrong tween whenever the clicked keyframe
// belongs to a sibling — and the writer then finds no keyframe at that
// percentage and silently does nothing.
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage, atCurrent.animationId);
} else {
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
}
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { act } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
import { KeyframeNavigation } from "./KeyframeNavigation";
beforeAll(installReactActEnvironment);
/**
* Regression: a merged gutter row shows the keyframes of EVERY tween in the
* property group. The panel guesses "the group's animation" for the write, so a
* click on a keyframe authored by a sibling tween named the wrong animation and
* the writer silently found nothing to remove. The diamond now reports the
* clicked keyframe's own animationId.
*/
const MERGED_ROW = [
{ percentage: 0, tweenPercentage: 0, properties: { x: 0 }, animationId: "delta-to-500-position" },
{
percentage: 50,
tweenPercentage: 25,
properties: { x: 120 },
animationId: "delta-to-4000-position",
},
];
function clickDiamond(currentPercentage: number, onRemoveKeyframe: (...args: never[]) => void) {
const root = mountReactHarness(
<KeyframeNavigation
property="x"
keyframes={MERGED_ROW}
currentPercentage={currentPercentage}
onSeek={vi.fn()}
onAddKeyframe={vi.fn()}
onRemoveKeyframe={onRemoveKeyframe as never}
onConvertToKeyframes={vi.fn()}
/>,
);
const diamond = document.querySelector<HTMLElement>('[title="Remove x keyframe"]');
expect(diamond).not.toBeNull();
act(() => {
diamond?.click();
});
act(() => root.unmount());
}
describe("KeyframeNavigation diamond", () => {
it("reports the clicked keyframe's own animation on a merged row", () => {
const onRemoveKeyframe = vi.fn();
clickDiamond(50, onRemoveKeyframe);
expect(onRemoveKeyframe).toHaveBeenCalledWith(25, "delta-to-4000-position");
});
it("still reports the first tween's keyframe as its own", () => {
const onRemoveKeyframe = vi.fn();
clickDiamond(0, onRemoveKeyframe);
expect(onRemoveKeyframe).toHaveBeenCalledWith(0, "delta-to-500-position");
});
});
@@ -45,6 +45,19 @@ type DragState = {
ref: MotionNodeRef;
};
/**
* Tween-% for a stop inserted at fraction `t` along the segment between two
* nodes. null when either end is not a keyframe (arc waypoints carry no %).
*/
function interpolatedKeyframePct(
a: MotionNodeRef | undefined,
b: MotionNodeRef | undefined,
t: number,
): number | null {
if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
}
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
// (select the keyframe); at or above it the gesture commits a move. Screen-space
@@ -88,6 +101,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// The keyframe % selected by clicking its node — highlighted, and the next drag
// modifies it rather than adding a keyframe.
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
const timelineElement = usePlayerStore((state) => {
if (!selection) return undefined;
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
return state.elements.find(
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
);
});
// Set-destination mode is armed from the preview toolbar (replaces the old
// double-click-on-canvas UX). See createMode effects below.
const armed = usePlayerStore((s) => s.motionPathArmed);
@@ -395,13 +415,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
} else {
// Linear keyframe path: interpolate the new stop's tween-% from the two
// keyframes bounding the clicked segment (np.t = fraction along it), then
// insert it. Lands ON the current line, so the dot doesn't jump — drag it
// after to bend the path.
const a = abs[np.segIndex]?.ref;
const b = abs[np.segIndex + 1]?.ref;
if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
// keyframes bounding the clicked segment, then insert it. Lands ON the
// current line, so the dot doesn't jump — drag it after to bend the path.
const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
if (pct === null) return;
e.stopPropagation();
void commitAddKeyframe(animId, pct, x, y, commitMutation);
}
@@ -418,12 +435,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// Right-click a keyframe node → the timeline's keyframe context menu (delete
// this keyframe / delete all), so motion-path keyframes are removable in place.
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
if (ref.type !== "keyframe" || !animId || !elementId) return;
if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
e.preventDefault();
e.stopPropagation();
setKfMenu({
x: e.clientX,
y: e.clientY,
element: timelineElement,
elementId,
percentage: ref.pct,
tweenPercentage: ref.pct,
@@ -512,9 +530,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
<KeyframeDiamondContextMenu
state={kfMenu}
onClose={() => setKfMenu(null)}
onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
onDelete={(_elId, keyframe) =>
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
}
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
onMoveToPlayhead={(_element, keyframe) =>
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
}
/>
)}
</>
@@ -408,7 +408,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("x"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
/>
)}
@@ -433,7 +435,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("y"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
/>
)}
@@ -458,7 +462,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("width"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
/>
)}
@@ -483,7 +489,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("height"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
/>
)}
@@ -507,7 +515,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
/>
)}
@@ -19,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview } from "./propertyPanelSections";
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
import { useColorGradingController } from "./useColorGradingController";
import { usePlayerStore } from "../../player";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
@@ -231,7 +232,40 @@ export function PropertyPanelFlat({
: "layout",
);
// Animate only the groups that changed during this toggle cycle.
// Tracks which group(s) are actively transitioning this toggle cycle, so
// their header/body gets the fast entrance animation (hf-flat-group-enter)
// and no one else's does. Deliberately NOT derived from remounting alone:
// FlatGroupHeader instances are keyed by group id and React normally
// preserves them across re-renders, but toggling a non-adjacent group still
// shifts the untouched collapsed siblings between the before/after-open
// slices below, and Chromium restarts a CSS animation on that kind of
// position shift even though nothing about the sibling actually changed.
// Gating on these ids (cleared shortly after the 120ms CSS animation
// finishes) keeps the animation scoped to only the groups that actually
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
// AND whichever group was open immediately before the click and got
// implicitly closed by it — both freshly-mounted headers need to animate.
// When the inline timeline ease button focuses a segment on this element,
// force the Motion group open so its AnimationCard (which only mounts while
// the group is expanded) can consume the focus and reveal the ease editor.
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
// The element THIS panel renders, not the store's selectedElementId: that
// flips synchronously while the panel still renders its predecessor, so a
// stale panel would consume a request meant for its successor whenever the
// two share a class-selector animation id.
const renderedElementId = `${element.sourceFile}#${element.id}`;
// Adjusted during render (not an effect) so the card mounts on the same
// commit the request lands on. Keyed on request identity: a group the user
// closes afterwards stays closed.
const [consumedFocus, setConsumedFocus] = useState(focusedEaseSegment);
if (focusedEaseSegment !== consumedFocus) {
setConsumedFocus(focusedEaseSegment);
const focusesThisPanel =
focusedEaseSegment?.elementId === renderedElementId &&
gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
if (focusesThisPanel) setOpenGroupId("motion");
}
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -493,6 +527,17 @@ export function PropertyPanelFlat({
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
const openGroup = openIndex === -1 ? null : groups[openIndex];
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
const renderClosedGroup = (group: FlatGroupDescriptor) => (
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
<FlatGroupHeader
title={group.title}
isOpen={false}
onToggleOpen={() => toggleOpen(group.id)}
summary={group.summary}
animateEntrance={justToggledIds.includes(group.id)}
/>
</DesignPanelInputProvider>
);
return (
<DesignPanelInputProvider ui="flat">
@@ -520,17 +565,7 @@ export function PropertyPanelFlat({
data-flat-panel-body="true"
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
>
{beforeOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
</DesignPanelInputProvider>
))}
{beforeOpen.map(renderClosedGroup)}
{openGroup && (
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
<div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
@@ -549,17 +584,7 @@ export function PropertyPanelFlat({
</div>
</DesignPanelInputProvider>
)}
{afterOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
</DesignPanelInputProvider>
))}
{afterOpen.map(renderClosedGroup)}
</div>
<DesignPanelInputProvider section="footer">
<PropertyPanelFlatFooter
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
type GsapAnimationEditCallbacks,
withTrackedGsapAnimationCallbacks,
} from "./gsapAnimationCallbacks";
function requiredCallbacks(): GsapAnimationEditCallbacks {
return {
onUpdateProperty: vi.fn(),
onUpdateMeta: vi.fn(),
onDeleteAnimation: vi.fn(),
onAddProperty: vi.fn(),
onRemoveProperty: vi.fn(),
};
}
function requireCallback<T>(callback: T | undefined): T {
if (callback === undefined) throw new Error("expected callback to be present");
return callback;
}
describe("withTrackedGsapAnimationCallbacks", () => {
it("keeps absent optional callbacks absent and passes preview callbacks through unchanged", () => {
const callbacks = requiredCallbacks();
const onLivePreview = vi.fn();
const onLivePreviewEnd = vi.fn();
callbacks.onLivePreview = onLivePreview;
callbacks.onLivePreviewEnd = onLivePreviewEnd;
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
expect(tracked.onUpdateFromProperty).toBeUndefined();
expect(tracked.onAddFromProperty).toBeUndefined();
expect(tracked.onRemoveFromProperty).toBeUndefined();
expect(tracked.onSetArcPath).toBeUndefined();
expect(tracked.onUpdateArcSegment).toBeUndefined();
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
expect(tracked.onUnroll).toBeUndefined();
expect(tracked.onLivePreview).toBe(onLivePreview);
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
});
it("tracks each edit once before invoking its mutation callback", () => {
const events: string[] = [];
const mutation = (name: string) => () => events.push(`mutate:${name}`);
const callbacks: GsapAnimationEditCallbacks = {
onUpdateProperty: mutation("update-property"),
onUpdateMeta: mutation("update-meta"),
onDeleteAnimation: mutation("delete"),
onAddProperty: mutation("add-property"),
onRemoveProperty: mutation("remove-property"),
onUpdateFromProperty: mutation("update-from"),
onAddFromProperty: mutation("add-from"),
onRemoveFromProperty: mutation("remove-from"),
onSetArcPath: mutation("arc-path"),
onUpdateArcSegment: mutation("arc-segment"),
onUpdateKeyframeEase: mutation("keyframe-ease"),
onSetAllKeyframeEases: mutation("all-eases"),
onUnroll: mutation("unroll"),
};
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
events.push(`track:${control}:${name}`);
});
tracked.onUpdateProperty("a1", "visibility", 1);
tracked.onUpdateProperty("a1", "filter", "blur(2px)");
tracked.onUpdateProperty("a1", "opacity", 0.5);
tracked.onUpdateMeta("a1", { duration: 2, ease: "none", position: 1 });
tracked.onDeleteAnimation("a1");
tracked.onAddProperty("a1", "scale");
tracked.onRemoveProperty("a1", "scale");
requireCallback(tracked.onUpdateFromProperty)("a1", "clipPath", "none");
requireCallback(tracked.onAddFromProperty)("a1", "x");
requireCallback(tracked.onRemoveFromProperty)("a1", "x");
requireCallback(tracked.onSetArcPath)("a1", { enabled: true });
requireCallback(tracked.onSetArcPath)("a1", { enabled: true, autoRotate: true });
requireCallback(tracked.onUpdateArcSegment)("a1", 1, {});
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
requireCallback(tracked.onUnroll)("a1");
expect(events).toEqual([
"track:toggle:visibility",
"mutate:update-property",
"track:text:filter",
"mutate:update-property",
"track:metric:opacity",
"mutate:update-property",
"track:metric:Length",
"track:select:Speed",
"track:metric:Starts at",
"mutate:update-meta",
"track:button:Remove animation",
"mutate:delete",
"track:select:Add effect property",
"mutate:add-property",
"track:button:Remove scale",
"mutate:remove-property",
"track:text:clipPath",
"mutate:update-from",
"track:select:Add from property",
"mutate:add-from",
"track:button:Remove from x",
"mutate:remove-from",
"track:toggle:Arc motion",
"mutate:arc-path",
"track:toggle:Auto rotate",
"mutate:arc-path",
"track:button:Reset arc segment 2",
"mutate:arc-segment",
"mutate:arc-segment",
"track:select:Keyframe ease",
"mutate:keyframe-ease",
"track:select:All keyframe eases",
"mutate:all-eases",
"track:button:Unroll animation",
"mutate:unroll",
]);
});
});
@@ -1,4 +1,5 @@
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
import { usePlayerStore } from "../../player";
/**
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -35,6 +36,18 @@ export interface GsapAnimationEditCallbacks {
onUnroll?: (animationId: string) => void;
}
type TrackDesignInput = (control: string, name: string) => void;
function trackAnimationProperty(track: TrackDesignInput, property: string): void {
const control =
property === "visibility"
? "toggle"
: property === "filter" || property === "clipPath"
? "text"
: "metric";
track(control, property);
}
// User-facing control label for each animation-meta field. The ease control is
// labelled "Speed" in the card UI, so ease/easeEach map there.
const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
@@ -51,13 +64,104 @@ const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> =
* added later is attributed honestly by its own key instead of poisoning another
* control's usage count.
*/
export function trackAnimationMetaUpdate(
track: (control: string, name: string) => void,
updates: Record<string, unknown>,
): void {
function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record<string, unknown>): void {
for (const key of Object.keys(updates)) {
const mapped = ANIMATION_META_LABELS[key];
if (mapped) track(mapped.control, mapped.name);
else track("select", key);
}
}
/**
* Add design-input telemetry to the shared animation-edit callback surface.
* Optional callbacks remain absent, pass-through preview callbacks keep their
* original identity, and every tracked event fires once before its mutation.
*/
export function withTrackedGsapAnimationCallbacks(
callbacks: GsapAnimationEditCallbacks,
track: TrackDesignInput,
): GsapAnimationEditCallbacks {
return {
onUpdateProperty: (animationId, property, value) => {
trackAnimationProperty(track, property);
callbacks.onUpdateProperty(animationId, property, value);
},
onUpdateMeta: (animationId, updates) => {
trackAnimationMetaUpdate(track, updates);
callbacks.onUpdateMeta(animationId, updates);
},
onDeleteAnimation: (animationId) => {
track("button", "Remove animation");
callbacks.onDeleteAnimation(animationId);
},
onAddProperty: (animationId, property) => {
track("select", "Add effect property");
callbacks.onAddProperty(animationId, property);
},
onRemoveProperty: (animationId, property) => {
track("button", `Remove ${property}`);
callbacks.onRemoveProperty(animationId, property);
},
onUpdateFromProperty: callbacks.onUpdateFromProperty
? (animationId, property, value) => {
trackAnimationProperty(track, property);
callbacks.onUpdateFromProperty?.(animationId, property, value);
}
: undefined,
onAddFromProperty: callbacks.onAddFromProperty
? (animationId, property) => {
track("select", "Add from property");
callbacks.onAddFromProperty?.(animationId, property);
}
: undefined,
onRemoveFromProperty: callbacks.onRemoveFromProperty
? (animationId, property) => {
track("button", `Remove from ${property}`);
callbacks.onRemoveFromProperty?.(animationId, property);
}
: undefined,
onLivePreview: callbacks.onLivePreview,
onLivePreviewEnd: callbacks.onLivePreviewEnd,
onSetArcPath: callbacks.onSetArcPath
? (animationId, config) => {
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
callbacks.onSetArcPath?.(animationId, config);
}
: undefined,
onUpdateArcSegment: callbacks.onUpdateArcSegment
? (animationId, segmentIndex, update) => {
if (update.curviness === undefined) {
track("button", `Reset arc segment ${segmentIndex + 1}`);
}
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
}
: undefined,
onUpdateKeyframeEase: callbacks.onUpdateKeyframeEase
? (animationId, percentage, ease) => {
track("select", "Keyframe ease");
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
}
: undefined,
onSetAllKeyframeEases: callbacks.onSetAllKeyframeEases
? (animationId, ease) => {
track("select", "All keyframe eases");
callbacks.onSetAllKeyframeEases?.(animationId, ease);
}
: undefined,
onUnroll: callbacks.onUnroll
? (animationId) => {
track("button", "Unroll animation");
callbacks.onUnroll?.(animationId);
}
: undefined,
};
}
/**
* Stable consumer for the store's one-shot ease-focus request. Module-level on
* purpose: an inline arrow in the section components is a dep of AnimationCard's
* focus effect, so a fresh identity each render re-runs that effect every render.
*/
export function clearFocusedEaseSegment(): void {
usePlayerStore.getState().setFocusedEaseSegment(null);
}
@@ -1,3 +1,6 @@
// Boundary cases share an arrange/assert shape on purpose: each case states its
// own window, drag, and expected remap so a failure reads without cross-referencing.
// fallow-ignore-file code-duplication
import { describe, expect, it } from "vitest";
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
@@ -11,6 +14,22 @@ const KEYFRAMES: RetimeKeyframe[] = [
const WINDOW = { tweenStart: 2, tweenDuration: 4 };
const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 };
function expectLeftResize(
keyframes: RetimeKeyframe[],
draggedTweenPct: number,
pctRemap: Array<{ from: number; to: number }>,
): void {
const result = resolveKeyframeRetime({
...LEFT_BOUNDARY_DROP,
keyframes,
draggedTweenPct,
});
expect(result.kind).toBe("resize");
expect(result.position).toBeCloseTo(0.5, 5);
expect(result.duration).toBeCloseTo(5.5, 5);
expect(result.pctRemap).toEqual(pctRemap);
}
describe("resolveKeyframeRetime — move (within the tween window)", () => {
it("re-keys an interior keyframe to the tween-% of the drop", () => {
const r = resolveKeyframeRetime({
@@ -94,29 +113,21 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(2, 5); // start unchanged
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
expect(r.pctRemap).toEqual([
{ from: 0, to: 0 },
{ from: 50, to: 33.3 },
{ from: 50, to: 33.333 },
{ from: 100, to: 100 },
]);
});
it("extends the FIRST keyframe before the start, shifting position earlier", () => {
const r = resolveKeyframeRetime({
...LEFT_BOUNDARY_DROP,
keyframes: KEYFRAMES,
draggedTweenPct: 0,
});
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
expect(r.pctRemap).toEqual([
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
expectLeftResize(KEYFRAMES, 0, [
{ from: 0, to: 0 },
{ from: 50, to: 63.6 },
{ from: 50, to: 63.636 },
{ from: 100, to: 100 },
]);
});
@@ -139,15 +150,7 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () =
});
it("resizes left before the start", () => {
const r = resolveKeyframeRetime({
...LEFT_BOUNDARY_DROP,
keyframes: lone,
draggedTweenPct: 100,
});
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5);
expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]);
expectLeftResize(lone, 100, [{ from: 100, to: 0 }]);
});
});
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
const MIN_TWEEN_DURATION = 0.01;
const round3 = (n: number) => Math.round(n * 1000) / 1000;
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
/** Resolve timing for a flat tween's synthesized start/end diamond. */
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
const absTime =
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
});
return {
@@ -83,10 +83,10 @@ function KeyframeGutter({
track("button", `Add ${property} keyframe`);
void onCommitAnimatedProperty(element, property, displayValue);
}}
onRemoveKeyframe={(pct) => {
onRemoveKeyframe={(pct, animationId) => {
if (!onRemoveKeyframe) return;
track("button", `Remove ${property} keyframe`);
onRemoveKeyframe(animIdForProp(property), pct);
onRemoveKeyframe(animationId ?? animIdForProp(property), pct);
}}
onConvertToKeyframes={() => {
if (!onConvertToKeyframes) return;
@@ -6,12 +6,14 @@ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { parseTimingValue } from "./propertyPanelTimingSection";
import { CommitField } from "./propertyPanelPrimitives";
import { AnimationCard } from "./AnimationCard";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import {
trackAnimationMetaUpdate,
type GsapAnimationEditCallbacks,
withTrackedGsapAnimationCallbacks,
clearFocusedEaseSegment,
} from "./gsapAnimationCallbacks";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
export function FlatTimingRow({
element,
@@ -135,15 +137,17 @@ export function FlatMotionSection({
} & GsapAnimationEditCallbacks) {
const track = useTrackDesignInput();
const [addMenuOpen, setAddMenuOpen] = useState(false);
const trackProperty = (property: string) => {
const control =
property === "visibility"
? "toggle"
: property === "filter" || property === "clipPath"
? "text"
: "metric";
track(control, property);
};
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
// Only consume a focus request aimed at the element THIS panel renders (not
// the store's selectedElementId, which flips synchronously during async
// selection resolution), so a shared class-selector animation id can't open
// the wrong element's editor.
const renderedElementId = `${element.sourceFile}#${element.id}`;
const focusedHere =
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
? focusedEaseSegment
: null;
return (
<div className="space-y-3">
@@ -172,140 +176,22 @@ export function FlatMotionSection({
<div className="space-y-2">
{animations.map((anim, index) => (
<AnimationCard
{...trackedCallbacks}
key={anim.id}
animation={anim}
defaultExpanded={index === 0}
flat
onUpdateProperty={(animationId, property, value) => {
trackProperty(property);
callbacks.onUpdateProperty(animationId, property, value);
}}
onUpdateMeta={(animationId, updates) => {
trackAnimationMetaUpdate(track, updates);
callbacks.onUpdateMeta(animationId, updates);
}}
onDeleteAnimation={(animationId) => {
track("button", "Remove animation");
callbacks.onDeleteAnimation(animationId);
}}
onAddProperty={(animationId, property) => {
track("select", "Add effect property");
callbacks.onAddProperty(animationId, property);
}}
onRemoveProperty={(animationId, property) => {
track("button", `Remove ${property}`);
callbacks.onRemoveProperty(animationId, property);
}}
onUpdateFromProperty={
callbacks.onUpdateFromProperty
? (animationId, property, value) => {
trackProperty(property);
callbacks.onUpdateFromProperty?.(animationId, property, value);
}
: undefined
}
onAddFromProperty={
callbacks.onAddFromProperty
? (animationId, property) => {
track("select", "Add from property");
callbacks.onAddFromProperty?.(animationId, property);
}
: undefined
}
onRemoveFromProperty={
callbacks.onRemoveFromProperty
? (animationId, property) => {
track("button", `Remove from ${property}`);
callbacks.onRemoveFromProperty?.(animationId, property);
}
: undefined
}
onLivePreview={callbacks.onLivePreview}
onLivePreviewEnd={callbacks.onLivePreviewEnd}
onSetArcPath={
callbacks.onSetArcPath
? (animationId, config) => {
track(
"toggle",
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
);
callbacks.onSetArcPath?.(animationId, config);
}
: undefined
}
onUpdateArcSegment={
callbacks.onUpdateArcSegment
? (animationId, segmentIndex, update) => {
if (update.curviness === undefined) {
track("button", `Reset arc segment ${segmentIndex + 1}`);
}
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
}
: undefined
}
onUpdateKeyframeEase={
callbacks.onUpdateKeyframeEase
? (animationId, percentage, ease) => {
track("select", "Keyframe ease");
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
}
: undefined
}
onSetAllKeyframeEases={
callbacks.onSetAllKeyframeEases
? (animationId, ease) => {
track("select", "All keyframe eases");
callbacks.onSetAllKeyframeEases?.(animationId, ease);
}
: undefined
}
onUnroll={
callbacks.onUnroll
? (animationId) => {
track("button", "Unroll animation");
callbacks.onUnroll?.(animationId);
}
: undefined
}
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
onFocusSegmentConsumed={clearFocusedEaseSegment}
/>
))}
<div className="relative pt-1">
{addMenuOpen ? (
<div className="flex gap-1.5">
{ADD_METHODS.map((method) => (
<button
key={method}
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setAddMenuOpen(false);
}}
className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
>
{ADD_METHOD_LABELS[method] ?? method}
</button>
))}
<button
type="button"
onClick={() => setAddMenuOpen(false)}
className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setAddMenuOpen(true)}
className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
title="Add a new animation effect to this element"
>
+ Add effect
</button>
)}
</div>
<GsapAddAnimationControl
open={addMenuOpen}
setOpen={setAddMenuOpen}
onAddAnimation={onAddAnimation}
track={track}
variant="flat"
/>
</div>
)}
</>
@@ -299,22 +299,17 @@ describe("FlatTextFieldEditor controls", () => {
describe("FlatTextSection — multi-field", () => {
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatTextSection
element={makeMultiFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
});
const { host, root } = renderInto(
<FlatTextSection
element={makeMultiFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
expect(host.textContent).toContain("Headline");
expect(host.textContent).toContain("Subhead");
// Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor).
@@ -408,6 +403,27 @@ describe("FlatTextSection — multi-field", () => {
act(() => root.unmount());
});
it("does not steal canvas focus when a multi-field element is selected", () => {
const focusOwner = document.createElement("button");
document.body.append(focusOwner);
focusOwner.focus();
const { root } = renderInto(
<FlatTextSection
element={makeMultiFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
expect(document.activeElement).toBe(focusOwner);
act(() => root.unmount());
});
it("auto-focuses the Content textarea when a new text field is added", async () => {
let addResolved = false;
@@ -257,6 +257,12 @@ export function FlatTextSection({
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
element.textFields[0]?.key ?? null,
);
// Armed by the add handler so the newly added field mounts focused. State, not
// a ref cleared during render: Strict Mode renders twice, so the first pass
// would eat the marker and the second would mount the field unfocused. Nothing
// clears it on read either — `autoFocus` is a mount-only DOM prop and the
// editor is keyed on the field, so it can only fire once per added field.
const [autoFocusFieldKey, setAutoFocusFieldKey] = useState<string | null>(null);
useEffect(() => {
const nextFields = element.textFields;
@@ -271,6 +277,8 @@ export function FlatTextSection({
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
if (!activeField) return null;
const autoFocusActiveField = autoFocusFieldKey === activeField.key;
if (textFields.length > 1) {
return (
<div className="space-y-2.5">
@@ -278,10 +286,15 @@ export function FlatTextSection({
fields={textFields}
activeFieldKey={activeField.key}
styles={styles}
onSelect={setActiveFieldKey}
onSelect={(fieldKey) => {
setAutoFocusFieldKey(null);
setActiveFieldKey(fieldKey);
}}
onAdd={() =>
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (nextKey) setActiveFieldKey(nextKey);
if (!nextKey) return;
setAutoFocusFieldKey(nextKey);
setActiveFieldKey(nextKey);
})
}
onRemove={onRemoveTextField}
@@ -295,7 +308,7 @@ export function FlatTextSection({
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
autoFocus
autoFocus={autoFocusActiveField}
/>
</div>
);
@@ -316,7 +329,11 @@ export function FlatTextSection({
type="button"
onClick={() => {
track("button", "Add text field");
void onAddTextField(activeField.key);
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (!nextKey) return;
setAutoFocusFieldKey(nextKey);
setActiveFieldKey(nextKey);
});
}}
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
@@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl";
import type { ReactNode } from "react";
export interface PreviewOverlaysProps {
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
@@ -132,6 +133,7 @@ export function resolveZIndexEntries(
// fallow-ignore-next-line complexity
export function PreviewOverlays({
shouldShowMotionPath,
shouldShowSelectedDomBounds,
blockPreview,
isGestureRecording,
@@ -274,7 +276,7 @@ export function PreviewOverlays({
{STUDIO_KEYFRAMES_ENABLED && (
<MotionPathOverlay
iframeRef={previewIframeRef}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
selection={shouldShowMotionPath ? domEditSelection : null}
compositionSize={compositionDimensions}
isPlaying={isPlaying}
/>
@@ -40,6 +40,36 @@ describe("resolveTimelineKeyframeTarget", () => {
).toBeNull();
});
it("uses the rendered animation identity to resolve a same-group collision", () => {
expect(
resolveTimelineKeyframeTarget(
50,
[
{
percentage: 50,
tweenPercentage: 25,
propertyGroup: "position",
animationId: "position-b",
},
],
[
{ id: "position-a", propertyGroup: "position" },
{ id: "position-b", propertyGroup: "position" },
],
),
).toEqual({ animId: "position-b", tweenPct: 25 });
});
it("rejects a rendered animation identity absent from the element", () => {
expect(
resolveTimelineKeyframeTarget(
50,
[{ percentage: 50, animationId: "stale-position" }],
[{ id: "position", propertyGroup: "position" }],
),
).toBeNull();
});
it("keeps a keyframed and flat tween in the same property group unresolved", () => {
expect(
resolveTimelineKeyframeTarget(
@@ -0,0 +1,693 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../../player";
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
import { usePlayerStore } from "../../player/store/playerStore";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
installReactActEnvironment();
const mocks = vi.hoisted(() => ({
actions: {
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
handleGsapDeleteAnimation: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
},
selection: { id: "box", selector: "#box", sourceFile: "index.html" },
animations: Array<GsapAnimation>(),
}));
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }),
}));
vi.mock("../../contexts/DomEditContext", () => ({
useDomEditActionsContext: () => mocks.actions,
useDomEditSelectionContext: () => ({
domEditSelection: mocks.selection,
selectedGsapAnimations: mocks.animations,
}),
}));
import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks";
const element: TimelineElement = {
id: "box",
key: "index.html#box",
domId: "box",
tag: "div",
start: 0,
duration: 1,
track: 0,
sourceFile: "index.html",
};
const flatAnimation: GsapAnimation = {
id: "box-to-0-position",
targetSelector: "#box",
method: "to",
position: 0,
resolvedStart: 0,
duration: 1,
properties: { x: 420 },
propertyGroup: "position",
};
const otherFlatAnimation: GsapAnimation = {
...flatAnimation,
id: "circle-to-0-position",
targetSelector: "#circle",
};
const otherKeyframedAnimation: GsapAnimation = {
...otherFlatAnimation,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 420 } },
],
},
};
function authoredInteriorAnimation(): GsapAnimation {
return {
...flatAnimation,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
],
},
};
}
function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } {
let callbacks: TimelineEditCallbacks | null = null;
function Harness() {
callbacks = useTimelineEditCallbacks({
handleTimelineElementMove: vi.fn(),
handleTimelineElementsMove: vi.fn(),
handleTimelineElementResize: vi.fn(),
handleTimelineGroupResize: vi.fn(),
handleToggleTrackHidden: vi.fn(),
handleBlockedTimelineEdit: vi.fn(),
handleTimelineElementSplit: vi.fn(),
handleRazorSplit: vi.fn(),
handleRazorSplitAll: vi.fn(),
});
return null;
}
const root = mountReactHarness(<Harness />);
if (!callbacks) throw new Error("timeline callbacks did not initialize");
return { callbacks, unmount: () => act(() => root.unmount()) };
}
// One selection PER element, so a callback that resolves the selection for the
// wrong element gets a visibly different object. A single mockResolvedValue
// hands every element the same selection, which passes just as happily when the
// write is committed through whatever happens to be selected.
function selectionForElement(el: TimelineElement): {
id: string;
selector: string;
sourceFile: string;
} {
if (el.id === "box") return mocks.selection;
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
}
function arrangeClickedCircle(): {
circle: TimelineElement;
selection: { id: string; selector: string; sourceFile: string };
} {
const elementKey = "scenes/main.html#circle";
const circle: TimelineElement = {
...element,
id: "circle",
key: elementKey,
domId: "circle",
sourceFile: "scenes/main.html",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
});
return { circle, selection: selectionForElement(circle) };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.animations = [flatAnimation];
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
Promise.resolve(selectionForElement(el)),
);
usePlayerStore.setState({
currentTime: 0.5,
elements: [element],
domClipChildren: [],
keyframeCache: new Map(),
gsapAnimations: new Map([["box", [flatAnimation]]]),
});
});
afterEach(() => {
usePlayerStore.setState({
elements: [],
domClipChildren: [],
keyframeCache: new Map(),
gsapAnimations: new Map(),
});
});
describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
it("adds an interior point through the add-keyframe persist boundary", async () => {
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
animationId: flatAnimation.id,
propertyGroup: "position",
tweenPercentage: 50,
properties: { x: 210 },
remove: false,
});
});
expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith(
flatAnimation.id,
50,
{ x: 210 },
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled();
view.unmount();
});
it("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => {
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
},
25,
),
).resolves.toBe(true);
// The start boundary moved to 0.25s; the end stays put, so the window is 0.75s.
expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith(
flatAnimation.id,
{ position: 0.25, duration: 0.75 },
mocks.selection,
);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
// resize-keyframed-tween would convert the flat tween to keyframes form.
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
const view = renderCallbacks();
// The diamond snaps back on `false`. Answering `true` the moment update-meta
// was dispatched left a rejected boundary drag rendered at its drop position.
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
},
25,
),
).resolves.toBe(false);
view.unmount();
});
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: otherFlatAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
// Persisted through the CLICKED element's own selection, not the current one,
// and as a remove-keyframe the writer can refuse — never a whole-tween delete.
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherFlatAnimation.id,
0,
undefined,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("removes a non-selected element authored endpoint through the clicked element's selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
undefined,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
const { circle, selection } = arrangeClickedCircle();
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
selection,
);
view.unmount();
});
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
const opacityAnimation: GsapAnimation = {
...otherKeyframedAnimation,
id: "circle-to-0-visual",
propertyGroup: "visual",
};
const { circle } = arrangeClickedCircle();
usePlayerStore.setState({
gsapAnimations: new Map([
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
otherKeyframedAnimation.id,
opacityAnimation.id,
]);
view.unmount();
});
it("aborts every mutation when the clicked element resolves no selection", async () => {
const { circle } = arrangeClickedCircle();
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteAllKeyframes?.(circle);
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
await Promise.resolve();
});
// No selection for the clicked element means there is nothing safe to write
// to: falling back to the current selection would edit a different file.
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
const { circle, selection } = arrangeClickedCircle();
const view = renderCallbacks();
await act(async () => {
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
});
// The retime target, the selection it commits through, and the animation the
// playhead percentage is computed against all come from the CLICKED element.
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
selection,
otherKeyframedAnimation,
);
view.unmount();
});
it("keeps a selected-element flat boundary on the remove-keyframe path", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", {
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
0,
undefined,
undefined,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("routes the flat lane-header remove toggle through the refusable remove path", async () => {
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
animationId: flatAnimation.id,
propertyGroup: "position",
tweenPercentage: 100,
properties: { x: 420 },
remove: true,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
100,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
// The lane-header toggle fires on whichever element owns the lane, which need
// not be the selected one. It must still commit through that element's own
// selection, and it must never escalate a flat tween to a whole-tween delete.
it("removes a non-selected element's flat tween through that element's own selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, {
animationId: otherFlatAnimation.id,
propertyGroup: "position",
tweenPercentage: 0,
properties: { x: 0 },
remove: true,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherFlatAnimation.id,
0,
undefined,
selectionForElement(circle),
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps authored interior deletion on the per-keyframe path", () => {
mocks.animations = [authoredInteriorAnimation()];
usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) });
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", {
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: flatAnimation.id,
});
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
50,
undefined,
undefined,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps an authored interior drag on the per-keyframe move path", async () => {
const authored = authoredInteriorAnimation();
mocks.animations = [authored];
usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: authored.id,
},
75,
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
50,
75,
mocks.selection,
);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
// A drag starts on whatever diamond the pointer is over, which need not be the
// selected element. Resolving against the selection would retime the selected
// element's tween and commit it through the selected element's file.
it("retimes a non-selected element's keyframe through that element's own selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
sourceFile: "scenes/main.html",
};
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" };
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]),
});
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onMoveKeyframe?.(
"scenes/main.html#circle",
{
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: circleAnimation.id,
},
75,
);
});
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
circleAnimation.id,
50,
75,
circleSelection,
);
view.unmount();
});
// The diamond's rapid-second-retime path reports the PENDING clip-% (where the
// first drag put the keyframe), which the keyframe cache has not caught up to.
// TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves
// the real callback resolves that stale-cache position off the identity fields
// instead of failing the lookup.
it("retimes from a pending position the keyframe cache has not caught up to", async () => {
const authored = authoredInteriorAnimation();
mocks.animations = [authored];
usePlayerStore.setState({
elements: [element],
gsapAnimations: new Map([["index.html#box", [authored]]]),
// Still the pre-drag positions: 75% is not in here.
keyframeCache: new Map([
[
"index.html#box",
{
format: "percentage" as const,
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
],
},
],
]),
});
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"index.html#box",
{
percentage: 75,
propertyGroup: "position",
tweenPercentage: 50,
animationId: authored.id,
},
85,
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
50,
85,
mocks.selection,
);
// Control: the same drag WITHOUT the identity fields falls back to the cache
// lookup, finds nothing at 75%, and cannot retime.
mocks.actions.handleGsapMoveKeyframe.mockClear();
await expect(
view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85),
).resolves.toBe(false);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("uses the clip timing basis when retiming a duration-less tween", async () => {
const durationless = {
...authoredInteriorAnimation(),
position: 3.2,
resolvedStart: 3.2,
duration: undefined,
};
const wideElement = { ...element, start: 10.94, duration: 16.26 };
mocks.animations = [durationless];
usePlayerStore.setState({
elements: [wideElement],
gsapAnimations: new Map([["box", [durationless]]]),
});
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 19.1,
propertyGroup: "position",
tweenPercentage: 50,
animationId: durationless.id,
},
40,
),
).resolves.toBe(true);
// The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
// 17.444s, and the duration-less tween borrows the clip's 16.26s window from
// its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
// zero-length tween, or the clip's own 0-100 %) produces a different number.
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
durationless.id,
50,
expect.closeTo(87.601, 3),
mocks.selection,
);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
});
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../../player";
import { usePlayerStore } from "../../player/store/playerStore";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
@@ -10,8 +11,15 @@ import {
} from "../../contexts/DomEditContext";
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
import type { DomEditSelection } from "../editor/domEditingTypes";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import {
getTimelineElementIdentity,
splitTimelineElementKey,
} from "../../player/lib/timelineElementHelpers";
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
export interface TimelineEditCallbackDeps {
handleTimelineElementMove: (
@@ -46,15 +54,14 @@ interface TimelineCachedKeyframe {
percentage: number;
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
}
/**
* Resolve a rendered timeline diamond back to the animation that authored it.
* Flat tweens use synthesized diamonds, so a mixed flat tween may have neither
* a property group nor real keyframes. The cache currently carries a property
* group, not an animation id, so resolution is safe only when that group has a
* single candidate. Ambiguous candidates remain unresolved rather than
* retiming an arbitrary tween.
* Prefer the animation identity carried by the rendered keyframe. Legacy cache
* entries without one are safe only when their property group has one candidate;
* ambiguous candidates remain unresolved rather than retiming an arbitrary tween.
*/
export function resolveTimelineKeyframeTarget(
pct: number,
@@ -63,6 +70,14 @@ export function resolveTimelineKeyframeTarget(
): { animId: string; tweenPct: number } | null {
const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
if (!kf) return null;
const identifiedAnimation = kf.animationId
? animations.find((animation) => animation.id === kf.animationId)
: undefined;
if (kf.animationId) {
return identifiedAnimation
? { animId: identifiedAnimation.id, tweenPct: kf.tweenPercentage ?? pct }
: null;
}
const group = kf?.propertyGroup;
const candidates = group
? animations.filter((animation) => animation.propertyGroup === group)
@@ -98,22 +113,69 @@ export function useTimelineEditCallbacks({
handleGsapResizeKeyframedTween,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
buildDomSelectionForTimelineElement,
} = useDomEditActionsContext();
const resolveElementAnimations = useCallback(
(elementKey: string): GsapAnimation[] => {
const { gsapAnimations } = usePlayerStore.getState();
const { sourceFile, domId } = splitTimelineElementKey(elementKey);
const scope = sourceFile ?? activeCompPath ?? "index.html";
// elementCacheKeys owns the key-variant list the writers use; reading it
// back by hand here is how the two sides drift.
for (const key of elementCacheKeys(scope, domId)) {
const animations = gsapAnimations.get(key);
if (animations) return animations;
}
return [];
},
[activeCompPath],
);
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
// anim in the keyframe's property group, falling back to the first keyframed one.
const resolveKeyframeTarget = useCallback(
// fallow-ignore-next-line complexity
(pct: number): { animId: string; tweenPct: number } | null => {
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
(
elementKey: string,
target: TimelineKeyframeTarget,
animations: GsapAnimation[] = selectedGsapAnimations,
): { animId: string; tweenPct: number } | null => {
const carriesIdentity =
target.propertyGroup !== undefined ||
target.tweenPercentage !== undefined ||
target.animationId !== undefined;
// The clicked element's own cache: the diamond context menu can open on an
// element that is not the selected one, and reading the selection's cache
// there resolves against the wrong element.
const keyframeCache = usePlayerStore.getState().keyframeCache;
const cached =
keyframeCache.get(elementKey) ??
keyframeCache.get(splitTimelineElementKey(elementKey).domId);
return resolveTimelineKeyframeTarget(
target.percentage,
carriesIdentity ? [target] : (cached?.keyframes ?? []),
animations,
);
},
[domEditSelection?.id, selectedGsapAnimations],
[selectedGsapAnimations],
);
const removeKeyframeTarget = useCallback(
(animationId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
// A flat tween's two diamonds are SYNTHESIZED endpoints, not authored
// keyframes, so "remove keyframe" has nothing to remove. Escalating to a
// whole-animation delete here destroyed the authored tween and its source
// comment on a single click, with no undo beyond the editor's own stack.
// Always post remove-keyframe: the writer refuses it for a flat tween
// (`changed:false`, file untouched), which is the correct no-op.
handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride);
},
[handleGsapRemoveKeyframe],
);
return useMemo(
@@ -127,22 +189,59 @@ export function useTimelineEditCallbacks({
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
onRazorSplitAll: handleRazorSplitAll,
onDeleteAllKeyframes: () => {
onDeleteAllKeyframes: (element) => {
// Hold the element where it is (collapse keyframes to a static set) rather
// than deleting the whole animation — deleting strands a stale GSAP base
// that the next drag adds to, flinging the element off-screen.
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (!anim) return;
handleGsapRemoveAllKeyframes(anim.id);
const elementKey = getTimelineElementIdentity(element);
// Every keyframed tween on the layer, not just the first: a layer with
// position AND opacity keyframes left the second one keyframed, so
// "Delete All Keyframes" visibly did half the job.
const anims = resolveElementAnimations(elementKey).filter(
(animation) => animation.keyframes,
);
if (anims.length === 0) return;
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
if (!selection) return;
// Serial: each removal rewrites the same source file, so dispatching
// them together would have the later writes read a pre-edit document.
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
});
},
onDeleteKeyframe: (_elId: string, pct: number) => {
const target = resolveKeyframeTarget(pct);
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
onDeleteKeyframe: (elId, keyframe) => {
const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(elId, keyframe, animations);
if (!target) return;
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
if (!element) {
removeKeyframeTarget(target.animId, target.tweenPct);
return;
}
// Persist through the CLICKED element's own selection so a deletion on a
// non-selected element (especially one in a different source file) commits
// against the right element instead of the current domEditSelection.
void buildDomSelectionForTimelineElement(element).then((selection) => {
if (selection) removeKeyframeTarget(target.animId, target.tweenPct, selection);
});
},
// Retime the keyframe to the playhead, preserving its value + ease.
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
const target = resolveKeyframeTarget(pct);
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
// Retime the keyframe to the playhead, preserving its value + ease. The
// clicked element owns the whole write: its animations resolve the target,
// its selection commits it, and its animation computes the playhead
// percentage. Mixing frames here retimed against the selected element's
// tween and wrote the result into the clicked element's file.
onMoveKeyframeToPlayhead: (element, keyframe) => {
const elementKey = getTimelineElementIdentity(element);
const animations = resolveElementAnimations(elementKey);
const target = resolveKeyframeTarget(elementKey, keyframe, animations);
const animation = target
? animations.find((candidate) => candidate.id === target.animId)
: undefined;
if (!target || !animation) return;
void buildDomSelectionForTimelineElement(element).then((selection) => {
if (selection) {
handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct, selection, animation);
}
});
},
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
// the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
@@ -152,14 +251,19 @@ export function useTimelineEditCallbacks({
// resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct);
const sel = domEditSelection;
if (!target || !sel) return;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
onMoveKeyframe: async (elId, keyframe, toClipPct) => {
const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(elId, keyframe, animations);
if (!target) return false;
// The dragged diamond's OWN element, not the selected one: a drag on a
// non-selected clip has to read that clip's animations and commit
// through that clip's selection, or it retimes whatever is selected.
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
const sel = element ? await buildDomSelectionForTimelineElement(element) : domEditSelection;
if (!sel) return false;
const anim = animations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
if (!anim || tweenStart === null) return Promise.resolve(false);
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState();
const { elStart, elDuration } = resolveClipTimingBasis(
@@ -168,6 +272,7 @@ export function useTimelineEditCallbacks({
elements,
domClipChildren,
);
const tweenDuration = resolveTweenDuration(anim, elDuration);
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
const decision = resolveKeyframeRetime({
keyframes: anim.keyframes?.keyframes ?? [],
@@ -177,35 +282,37 @@ export function useTimelineEditCallbacks({
dropAbsTime,
});
if (decision.kind === "move" && decision.toTweenPct != null) {
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
} else if (
decision.kind === "resize" &&
decision.pctRemap &&
decision.position != null &&
decision.duration != null
) {
if (anim.keyframes) {
handleGsapResizeKeyframedTween(
// An empty remap means a FLAT tween's synthesized boundary: there is no
// keyframe node to re-key, only the window to move. Sending it through
// the keyframed-resize writer would rewrite the authored flat tween into
// keyframes form as a side effect of a pure position/duration change, so
// dispatch update-meta and leave the tween as the author wrote it.
if (decision.pctRemap.length === 0) {
// Report the write's real settlement, like every other branch here:
// answering `true` while the meta update is still in flight tells the
// diamond the retime landed, so a rejected write never snaps back.
return handleGsapUpdateMeta(
target.animId,
decision.position,
decision.duration,
decision.pctRemap,
{ position: decision.position, duration: decision.duration },
sel,
);
} else {
// resize-keyframed-tween requires an authored `keyframes` AST node
// and intentionally no-ops for a flat tween. Update its real tween
// window through the metadata writer (and SDK cutover path) instead.
handleGsapUpdateMeta(target.animId, {
position: decision.position,
duration: decision.duration,
});
}
return handleGsapResizeKeyframedTween(
target.animId,
decision.position,
decision.duration,
decision.pctRemap,
sel,
);
}
},
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
for (const anim of selectedGsapAnimations) {
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
}
return Promise.resolve(false);
},
// fallow-ignore-next-line complexity
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
@@ -214,18 +321,49 @@ export function useTimelineEditCallbacks({
el.duration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
: 0;
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim?.keyframes) {
const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
if (existing) {
handleGsapRemoveKeyframe(anim.id, existing.percentage);
// Same frame for read and write: the toggled element's animations decide
// add-vs-remove, and its selection is what the mutation commits through.
const animations = resolveElementAnimations(getTimelineElementIdentity(el));
void buildDomSelectionForTimelineElement(el).then((selection) => {
if (!selection) return;
const anim = animations.find((a) => a.keyframes);
if (anim?.keyframes) {
const existing = anim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - pct) <= 1,
);
if (existing) {
handleGsapRemoveKeyframe(anim.id, existing.percentage, undefined, selection);
} else {
handleGsapAddKeyframe(anim.id, pct, "x", 0, selection);
}
} else {
handleGsapAddKeyframe(anim.id, pct, "x", 0);
const flatAnim = animations.find((a) => !a.keyframes);
if (flatAnim) {
void handleGsapConvertToKeyframes(
flatAnim.id,
undefined,
undefined,
undefined,
selection,
);
}
}
} else {
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
});
},
onTogglePropertyGroupKeyframe: async (element, target) => {
const selection = await buildDomSelectionForTimelineElement(element);
if (!selection) return;
if (target.remove) {
removeKeyframeTarget(target.animationId, target.tweenPercentage, selection);
return;
}
await handleGsapAddKeyframeBatch(
target.animationId,
target.tweenPercentage,
target.properties,
undefined,
selection,
);
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -240,14 +378,16 @@ export function useTimelineEditCallbacks({
handleRazorSplit,
handleRazorSplitAll,
handleGsapRemoveAllKeyframes,
resolveElementAnimations,
resolveKeyframeTarget,
removeKeyframeTarget,
selectedGsapAnimations,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapConvertToKeyframes,
buildDomSelectionForTimelineElement,
projectId,