feat(studio): consolidate timeline editor callbacks

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:06 +02:00
parent 555d7f7a5c
commit 2d1b905a56
14 changed files with 959 additions and 342 deletions
+1
View File
@@ -401,6 +401,7 @@ export function StudioApp() {
panelLayout.rightInspectorPanes,
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
gestureState === "recording",
);
useStudioUrlState({
@@ -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
@@ -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,14 @@ 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,
} from "./gsapAnimationCallbacks";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
animations: GsapAnimation[];
@@ -21,41 +22,14 @@ 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);
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
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={() => setFocusedEaseSegment(null)}
/>
))}
<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>
@@ -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,36 @@ 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);
// Identity of the element THIS panel actually renders (not the store's
// selectedElementId, which flips synchronously on selection while the panel
// still renders the previous element during async DOM-selection resolution):
// a stale panel would otherwise consume a focus request meant for its
// successor when both share a class-selector animation id.
const renderedElementId = `${element.sourceFile}#${element.id}`;
useEffect(() => {
if (!focusedEaseSegment || focusedEaseSegment.elementId !== renderedElementId) return;
if (gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId)) {
setOpenGroupId("motion");
}
}, [focusedEaseSegment, gsapAnimations, renderedElementId]);
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -492,6 +522,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">
@@ -519,17 +560,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">
@@ -548,17 +579,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",
]);
});
});
@@ -35,6 +35,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 +63,95 @@ 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,
};
}
@@ -6,12 +6,13 @@ 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,
} from "./gsapAnimationCallbacks";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
export function FlatTimingRow({
element,
@@ -135,15 +136,18 @@ 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);
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
// 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={() => setFocusedEaseSegment(null)}
/>
))}
<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>
)}
</>
@@ -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,306 @@
// @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(),
handleGsapResizeKeyframedTween: vi.fn(),
handleGsapUpdateMeta: vi.fn(),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn(),
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()) };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.animations = [flatAnimation];
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection);
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("safely no-ops a boundary drag while the tween is still flat", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onMoveKeyframe?.("box", 0, 25, "position", 0, flatAnimation.id);
});
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
it("deletes a non-selected element flat boundary 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([["scenes/main.html#circle", [otherFlatAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.(
"scenes/main.html#circle",
0,
"position",
0,
otherFlatAnimation.id,
);
await Promise.resolve();
await Promise.resolve();
});
// Persisted through the CLICKED element's own selection, not the current one.
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
otherFlatAnimation.id,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).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",
100,
"position",
100,
otherKeyframedAnimation.id,
);
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps selected-element flat boundary deletion on the animation delete path", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", 0, "position", 0, flatAnimation.id);
});
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("routes the flat lane-header remove toggle through the guarded delete 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.handleGsapDeleteAnimation).toHaveBeenCalledWith(
flatAnimation.id,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).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", 50, "position", 50, flatAnimation.id);
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps an authored interior drag on the per-keyframe move path", () => {
mocks.animations = [authoredInteriorAnimation()];
const view = renderCallbacks();
act(() => {
view.callbacks.onMoveKeyframe?.("box", 50, 75, "position", 50, flatAnimation.id);
});
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50, 75);
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";
@@ -11,6 +12,7 @@ import {
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
import type { DomEditSelection } from "../editor/domEditingTypes";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
export interface TimelineEditCallbackDeps {
@@ -46,15 +48,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 +64,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,24 +107,76 @@ export function useTimelineEditCallbacks({
handleGsapResizeKeyframedTween,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleGsapDeleteAnimation,
buildDomSelectionForTimelineElement,
} = useDomEditActionsContext();
const resolveElementAnimations = useCallback(
(elementKey: string): GsapAnimation[] => {
const { gsapAnimations } = usePlayerStore.getState();
const hashIndex = elementKey.lastIndexOf("#");
const elementId = hashIndex === -1 ? elementKey : elementKey.slice(hashIndex + 1);
const sourceFile =
hashIndex === -1 ? (activeCompPath ?? "index.html") : elementKey.slice(0, hashIndex);
return (
gsapAnimations.get(`${sourceFile}#${elementId}`) ??
gsapAnimations.get(`index.html#${elementId}`) ??
gsapAnimations.get(elementId) ??
[]
);
},
[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 => {
(
pct: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
animations: GsapAnimation[] = selectedGsapAnimations,
): { animId: string; tweenPct: number } | null => {
const explicitTarget =
propertyGroup !== undefined || tweenPercentage !== undefined || animationId !== undefined
? [{ percentage: pct, propertyGroup, tweenPercentage, animationId }]
: undefined;
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
return resolveTimelineKeyframeTarget(
pct,
explicitTarget ?? cached?.keyframes ?? [],
animations,
);
},
[domEditSelection?.id, selectedGsapAnimations],
);
const removeKeyframeTarget = useCallback(
(
animationId: string,
percentage: number,
animations: GsapAnimation[],
selectionOverride?: DomEditSelection | null,
) => {
const animation = animations.find((candidate) => candidate.id === animationId);
if (animation && !animation.keyframes) {
if (selectionOverride === undefined) handleGsapDeleteAnimation(animationId);
else handleGsapDeleteAnimation(animationId, selectionOverride);
return;
}
if (selectionOverride === undefined) handleGsapRemoveKeyframe(animationId, percentage);
else handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride);
},
[handleGsapDeleteAnimation, handleGsapRemoveKeyframe],
);
return useMemo(
() => ({
onMoveElement: handleTimelineElementMove,
@@ -135,13 +196,25 @@ export function useTimelineEditCallbacks({
if (!anim) return;
handleGsapRemoveAllKeyframes(anim.id);
},
onDeleteKeyframe: (_elId: string, pct: number) => {
const target = resolveKeyframeTarget(pct);
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
onDeleteKeyframe: (elId, pct, group, tweenPct, animationId) => {
const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations);
if (!target) return;
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
if (!element) {
removeKeyframeTarget(target.animId, target.tweenPct, animations);
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) => {
removeKeyframeTarget(target.animId, target.tweenPct, animations, selection);
});
},
// Retime the keyframe to the playhead, preserving its value + ease.
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
const target = resolveKeyframeTarget(pct);
onMoveKeyframeToPlayhead: (_elId, pct, group, tweenPct, animationId) => {
const target = resolveKeyframeTarget(pct, group, tweenPct, animationId);
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
},
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
@@ -152,13 +225,17 @@ 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: async (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct);
onMoveKeyframe: async (_elId, fromClipPct, toClipPct, group, tweenPct, animationId) => {
const target = resolveKeyframeTarget(fromClipPct, group, tweenPct, animationId);
const sel = domEditSelection;
if (!target || !sel) return false;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return false;
// Synthesized flat endpoints are clip boundaries, not authored keyframes.
// Boundary-to-clip resize wiring is intentionally deferred; ignore the
// drag rather than dispatching a free keyframe move that cannot be written.
if (!anim.keyframes) return false;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState();
@@ -230,6 +307,26 @@ export function useTimelineEditCallbacks({
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,
selectedGsapAnimations,
selection,
);
return;
}
await handleGsapAddKeyframeBatch(
target.animationId,
target.tweenPercentage,
target.properties,
undefined,
selection,
);
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -243,14 +340,16 @@ export function useTimelineEditCallbacks({
handleRazorSplit,
handleRazorSplitAll,
handleGsapRemoveAllKeyframes,
resolveElementAnimations,
resolveKeyframeTarget,
removeKeyframeTarget,
selectedGsapAnimations,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapConvertToKeyframes,
buildDomSelectionForTimelineElement,
projectId,
@@ -44,6 +44,7 @@ export function TimelineEditProvider({
value.onMoveKeyframeToPlayhead,
value.onMoveKeyframe,
value.onToggleKeyframeAtPlayhead,
value.onTogglePropertyGroupKeyframe,
],
);
return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { RightInspectorPanes } from "../utils/studioHelpers";
import { makeSelection } from "./domSelectionTestHarness";
import { useInspectorState, type InspectorState } from "./useStudioContextValue";
interface HarnessProps {
rightPanelTab: string;
rightInspectorPanes: RightInspectorPanes;
rightCollapsed: boolean;
isPlaying: boolean;
isGestureRecording: boolean;
domEditSelection: DomEditSelection | null;
}
function renderInspectorState(props: HarnessProps): InspectorState {
let state: InspectorState | null = null;
function Harness() {
state = useInspectorState(
props.rightPanelTab,
props.rightInspectorPanes,
props.rightCollapsed,
props.isPlaying,
props.domEditSelection,
props.isGestureRecording,
);
return null;
}
renderToStaticMarkup(React.createElement(Harness));
if (!state) throw new Error("Expected inspector state");
return state;
}
function selectedProps(
overrides: Partial<HarnessProps> = {},
): HarnessProps & { domEditSelection: DomEditSelection } {
const element = document.createElement("div");
return {
rightPanelTab: "renders",
rightInspectorPanes: { layers: false, design: false },
rightCollapsed: true,
isPlaying: false,
isGestureRecording: false,
domEditSelection: makeSelection("Selected", element),
...overrides,
};
}
describe("useInspectorState", () => {
it("shows the motion path for pure selection with the inspector collapsed", () => {
expect(renderInspectorState(selectedProps()).shouldShowMotionPath).toBe(true);
});
it("hides the motion path without a selection", () => {
expect(
renderInspectorState({ ...selectedProps(), domEditSelection: null }).shouldShowMotionPath,
).toBe(false);
});
it("hides the motion path during playback", () => {
expect(renderInspectorState(selectedProps({ isPlaying: true })).shouldShowMotionPath).toBe(
false,
);
});
it("hides the motion path during gesture recording", () => {
expect(
renderInspectorState(selectedProps({ isGestureRecording: true })).shouldShowMotionPath,
).toBe(false);
});
it("keeps selected DOM bounds coupled to the inspector or variables panel", () => {
expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false);
expect(
renderInspectorState(
selectedProps({
rightPanelTab: "design",
rightInspectorPanes: { layers: false, design: true },
}),
).shouldShowSelectedDomBounds,
).toBe(true);
expect(
renderInspectorState(selectedProps({ rightPanelTab: "variables" }))
.shouldShowSelectedDomBounds,
).toBe(true);
});
});
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { StudioContextValue } from "../contexts/StudioContext";
import type { RightInspectorPanes } from "../utils/studioHelpers";
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
@@ -69,6 +70,7 @@ export interface InspectorState {
designPanelActive: boolean;
inspectorPanelActive: boolean;
inspectorButtonActive: boolean;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
}
@@ -77,6 +79,7 @@ export function useInspectorState(
rightInspectorPanes: RightInspectorPanes,
rightCollapsed: boolean,
isPlaying: boolean,
domEditSelection: DomEditSelection | null,
isGestureRecording?: boolean,
): InspectorState {
// fallow-ignore-next-line complexity
@@ -93,8 +96,9 @@ export function useInspectorState(
inspectorPanelActive,
inspectorButtonActive:
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
// Keep the selection box + motion path drawn even when the Inspector is
// collapsed — closing the panel shouldn't visually deselect the element.
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
// Keep the selection box drawn even when the Inspector is collapsed —
// closing the panel shouldn't visually deselect the element.
// The Variables tab also works against the canvas selection (bind card),
// so the selection outline stays visible there too.
shouldShowSelectedDomBounds:
@@ -102,7 +106,14 @@ export function useInspectorState(
!isPlaying &&
!isGestureRecording,
};
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
}, [
rightPanelTab,
rightInspectorPanes,
rightCollapsed,
isPlaying,
isGestureRecording,
domEditSelection,
]);
}
// fallow-ignore-next-line complexity