feat(studio): bulk-edit easing for merged keyframes

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-29 03:39:51 +02:00
parent 7482c22d82
commit 10d45def05
22 changed files with 687 additions and 65 deletions
@@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist"; import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { useSlideshowTabState } from "../hooks/useSlideshowTabState"; import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider"; import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext"; import { useFileManagerContext } from "../contexts/FileManagerContext";
@@ -156,6 +155,7 @@ export function StudioRightPanel({
handleUpdateArcSegment, handleUpdateArcSegment,
handleUnroll, handleUnroll,
handleUpdateKeyframeEase, handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases, handleSetAllKeyframeEases,
handleGsapAddKeyframe, handleGsapAddKeyframe,
handleGsapRemoveKeyframe, handleGsapRemoveKeyframe,
@@ -406,6 +406,7 @@ export function StudioRightPanel({
onUnroll={handleUnroll} onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase} onUpdateKeyframeEase={handleUpdateKeyframeEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases} onSetAllKeyframeEases={handleSetAllKeyframeEases}
onUpdateSegmentEase={handleUpdateSegmentEase}
recordingState={recordingState} recordingState={recordingState}
recordingDuration={recordingDuration} recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording} onToggleRecording={onToggleRecording}
@@ -2,34 +2,88 @@
import React, { act } from "react"; import React, { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { AnimationCard } from "./AnimationCard"; import { AnimationCard } from "./AnimationCard";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { EASE_PRESETS } from "./easePresetLibrary"; import { EASE_PRESETS } from "./easePresetLibrary";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn()); const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit })); vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const ANIMATION: GsapAnimation = {
id: "position-tween",
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 2,
ease: "power1.out",
properties: { x: 200 },
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
{ percentage: 100, properties: { x: 200 } },
],
},
};
const FLAT_ANIMATION: GsapAnimation = {
...ANIMATION,
id: "flat-position-tween",
keyframes: undefined,
};
afterEach(() => { afterEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear(); trackStudioSegmentEaseEdit.mockClear();
}); });
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation { function renderFocusCard(
return { focusedSegment: {
id: "anim-1", tweenPercentage: number;
method: "to", collidingAnimationTargets?: AnimationKeyframeTarget[];
position: 0.8, } | null,
duration: 1.2, onEaseCommit = vi.fn(),
ease: "power2.out", defaultExpanded = false,
properties: { opacity: 1 }, animation = ANIMATION,
...overrides, onUpdateMeta = vi.fn(),
} as GsapAnimation; onUpdateSegmentEase = vi.fn(),
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (nextFocusedSegment: { tweenPercentage: number } | null) => {
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded={defaultExpanded}
focusedSegment={nextFocusedSegment}
onFocusSegmentConsumed={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
onUpdateKeyframeEase={onEaseCommit}
onUpdateSegmentEase={onUpdateSegmentEase}
/>,
);
});
};
render(focusedSegment);
return { host, root, render };
} }
const noop = () => {}; function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
return Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes(text),
);
}
function selectPreset(host: HTMLElement, presetId: string): string { function selectPreset(host: HTMLElement, presetId: string): string {
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId); const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
@@ -45,6 +99,8 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease; return presetConfig.ease;
} }
const noop = () => {};
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */ /** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({ function renderCard({
animation = baseAnimation(), animation = baseAnimation(),
@@ -82,6 +138,126 @@ function renderCard({
return { host, root }; return { host, root };
} }
function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void {
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
describe("AnimationCard", () => {
it("scrolls a focused segment into view but not a manually toggled segment", () => {
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
const view = renderFocusCard({ tweenPercentage: 50 });
try {
expect(scrollIntoView).toHaveBeenCalledOnce();
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
view.render(null);
const manualToggle = findButton(view.host, "50% → 100%");
expect(manualToggle).toBeDefined();
act(() => manualToggle?.click());
expect(scrollIntoView).toHaveBeenCalledOnce();
} finally {
act(() => view.root.unmount());
restoreScrollIntoView(originalScrollIntoView);
}
});
it("tracks a committed segment ease alongside the existing update", () => {
const onEaseCommit = vi.fn();
const view = renderFocusCard(null, onEaseCommit, true);
const segment = findButton(view.host, "0% → 50%");
expect(segment).toBeDefined();
act(() => segment?.click());
const ease = selectPreset(view.host, "quad-out");
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease });
act(() => view.root.unmount());
});
it("commits a focused multi-id segment ease through the bulk callback", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
const collidingAnimationTargets = [
{ animationId: ANIMATION.id, tweenPercentage: 50 },
{ animationId: "scale-tween", tweenPercentage: 75 },
{ animationId: "opacity-tween", tweenPercentage: 25 },
];
const view = renderFocusCard(
{ tweenPercentage: 50, collidingAnimationTargets },
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, ease);
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("keeps a focused single-id segment ease on the single callback", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
const view = renderFocusCard(
{
tweenPercentage: 50,
collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }],
},
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
expect(onUpdateSegmentEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("commits a focused flat tween segment ease through tween metadata", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const view = renderFocusCard(
{ tweenPercentage: 100 },
onUpdateKeyframeEase,
false,
FLAT_ANIMATION,
onUpdateMeta,
);
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease });
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
});
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "anim-1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
...overrides,
} as GsapAnimation;
}
describe("AnimationCard ease editing", () => { describe("AnimationCard ease editing", () => {
it("commits one preset change to the selected keyframe segment", () => { it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn(); const onUpdateKeyframeEase = vi.fn();
@@ -18,12 +18,16 @@ import {
parseNumericOrString, parseNumericOrString,
BOOLEAN_PROPS, BOOLEAN_PROPS,
} from "./AnimationCardParts"; } from "./AnimationCardParts";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
interface AnimationCardProps extends GsapAnimationEditCallbacks { interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation; animation: GsapAnimation;
defaultExpanded: boolean; defaultExpanded: boolean;
flat?: boolean; flat?: boolean;
focusedSegment?: { tweenPercentage: number } | null; focusedSegment?: {
tweenPercentage: number;
collidingAnimationTargets?: AnimationKeyframeTarget[];
} | null;
onFocusSegmentConsumed?: () => void; onFocusSegmentConsumed?: () => void;
} }
@@ -47,6 +51,7 @@ export const AnimationCard = memo(function AnimationCard({
onSetArcPath, onSetArcPath,
onUpdateArcSegment, onUpdateArcSegment,
onUpdateKeyframeEase, onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases, onSetAllKeyframeEases,
onUnroll, onUnroll,
}: AnimationCardProps) { }: AnimationCardProps) {
@@ -54,6 +59,9 @@ export const AnimationCard = memo(function AnimationCard({
const [addingProp, setAddingProp] = useState(false); const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false); const [addingFromProp, setAddingFromProp] = useState(false);
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null); const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
const [focusedCollidingAnimationTargets, setFocusedCollidingAnimationTargets] = useState<
AnimationKeyframeTarget[] | undefined
>();
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const pendingAutoScrollRef = useRef(false); const pendingAutoScrollRef = useRef(false);
@@ -62,6 +70,7 @@ export const AnimationCard = memo(function AnimationCard({
setExpanded(true); setExpanded(true);
pendingAutoScrollRef.current = true; pendingAutoScrollRef.current = true;
setExpandedKfPct(focusedSegment.tweenPercentage); setExpandedKfPct(focusedSegment.tweenPercentage);
setFocusedCollidingAnimationTargets(focusedSegment.collidingAnimationTargets);
onFocusSegmentConsumed?.(); onFocusSegmentConsumed?.();
}, [focusedSegment, onFocusSegmentConsumed]); }, [focusedSegment, onFocusSegmentConsumed]);
@@ -288,9 +297,21 @@ export const AnimationCard = memo(function AnimationCard({
keyframes={animation.keyframes.keyframes} keyframes={animation.keyframes.keyframes}
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"} globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
expandedPct={expandedKfPct} expandedPct={expandedKfPct}
onToggle={setExpandedKfPct} collidingAnimationTargets={focusedCollidingAnimationTargets}
onToggle={(pct) => {
setExpandedKfPct(pct);
setFocusedCollidingAnimationTargets(undefined);
}}
onEaseCommit={(pct, ease) => { onEaseCommit={(pct, ease) => {
if (
focusedCollidingAnimationTargets &&
focusedCollidingAnimationTargets.length > 1 &&
onUpdateSegmentEase
) {
onUpdateSegmentEase(focusedCollidingAnimationTargets, ease);
} else {
onUpdateKeyframeEase(animation.id, pct, ease); onUpdateKeyframeEase(animation.id, pct, ease);
}
trackStudioSegmentEaseEdit({ action: "commit", ease }); trackStudioSegmentEaseEdit({ action: "commit", ease });
}} }}
onApplyAll={ onApplyAll={
@@ -4,6 +4,7 @@ import React, { act, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection"; import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -11,12 +12,22 @@ afterEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
}); });
function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) { function renderSection(
ease = "none",
onCustomEaseCommit = vi.fn(),
collidingAnimationTargets?: AnimationKeyframeTarget[],
) {
const host = document.createElement("div"); const host = document.createElement("div");
document.body.append(host); document.body.append(host);
const root = createRoot(host); const root = createRoot(host);
act(() => { act(() => {
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />); root.render(
<EaseCurveSection
ease={ease}
onCustomEaseCommit={onCustomEaseCommit}
collidingAnimationTargets={collidingAnimationTargets}
/>,
);
}); });
return { host, root, onCustomEaseCommit }; return { host, root, onCustomEaseCommit };
} }
@@ -82,6 +93,29 @@ function editorLabel(host: HTMLElement): string | null {
} }
describe("EaseCurveSection preset grid", () => { describe("EaseCurveSection preset grid", () => {
it("shows the number of animations for a multi-id segment", () => {
const { host, root } = renderSection("power2.out", vi.fn(), [
{ animationId: "move-x", tweenPercentage: 20 },
{ animationId: "move-y", tweenPercentage: 50 },
{ animationId: "fade", tweenPercentage: 80 },
]);
expect(host.textContent).toContain("Applies to 3 animations");
act(() => root.unmount());
});
it.each([undefined, [{ animationId: "move-x", tweenPercentage: 20 }]])(
"does not show a property count for a non-colliding segment",
(collidingAnimationTargets) => {
const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationTargets);
expect(host.textContent).not.toContain("Applies to");
act(() => root.unmount());
},
);
it.each([ it.each([
["curve", "none", "linear", ["flow-7", "spring-bouncy"]], ["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]], ["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
@@ -10,6 +10,7 @@ import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg";
import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields"; import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants"; import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants";
import { roundToCenti } from "../../utils/rounding"; import { roundToCenti } from "../../utils/rounding";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
export { MiniCurveSvg } from "./easeCurveSvg"; export { MiniCurveSvg } from "./easeCurveSvg";
@@ -323,9 +324,11 @@ function EaseParameterField({
export function EaseCurveSection({ export function EaseCurveSection({
ease, ease,
onCustomEaseCommit, onCustomEaseCommit,
collidingAnimationTargets,
}: { }: {
ease: string; ease: string;
onCustomEaseCommit: (ease: string) => void; onCustomEaseCommit: (ease: string) => void;
collidingAnimationTargets?: AnimationKeyframeTarget[];
}) { }) {
const springBounce = parseSpringBounce(ease); const springBounce = parseSpringBounce(ease);
const isSpring = springBounce !== null; const isSpring = springBounce !== null;
@@ -419,6 +422,11 @@ export function EaseCurveSection({
return ( return (
<div className="rounded-lg bg-neutral-900/50 p-2"> <div className="rounded-lg bg-neutral-900/50 p-2">
<EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} /> <EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
<p className="mb-1 text-[9px] text-neutral-500">
Applies to {collidingAnimationTargets.length} animations
</p>
)}
<EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} /> <EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
<span className="sr-only" aria-live="polite"> <span className="sr-only" aria-live="polite">
{MODE_LABELS[mode]} ease editor selected {MODE_LABELS[mode]} ease editor selected
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { usePlayerStore } from "../../player";
import { GsapAnimationSection } from "./GsapAnimationSection";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("./AnimationCard", () => ({
AnimationCard: ({
animation,
focusedSegment,
onFocusSegmentConsumed,
}: {
animation: GsapAnimation;
focusedSegment: { tweenPercentage: number } | null;
onFocusSegmentConsumed: () => void;
}) => (
<button
type="button"
data-testid={`animation-${animation.id}`}
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
// Mirrors the real card: the consume callback only fires from the effect
// that runs when this card actually received a focusedSegment.
onClick={() => focusedSegment && onFocusSegmentConsumed()}
/>
),
}));
vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.getState().reset();
});
const sharedAnimation: GsapAnimation = {
id: "shared-animation",
targetSelector: ".shared",
method: "to",
position: 0,
properties: { x: 100 },
};
const requiredCallbacks = {
onAddAnimation: vi.fn(),
onUpdateProperty: vi.fn(),
onUpdateMeta: vi.fn(),
onDeleteAnimation: vi.fn(),
onAddProperty: vi.fn(),
onRemoveProperty: vi.fn(),
};
function renderSection(elementId: string) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (nextElementId: string) => {
act(() => {
root.render(
<DesignPanelInputProvider section="test">
<GsapAnimationSection
{...requiredCallbacks}
elementId={nextElementId}
animations={[sharedAnimation]}
/>
</DesignPanelInputProvider>,
);
});
};
render(elementId);
return { host, root, render };
}
describe("GsapAnimationSection", () => {
it("consumes a shared animation id only for the focused element", () => {
usePlayerStore.getState().setFocusedEaseSegment({
elementId: "index.html#second",
animationId: sharedAnimation.id,
tweenPercentage: 50,
});
const view = renderSection("index.html#first");
const card = view.host.querySelector<HTMLButtonElement>(
"[data-testid='animation-shared-animation']",
);
if (!card) throw new Error("expected animation card");
expect(card.dataset.focused).toBe("");
act(() => card.click());
expect(usePlayerStore.getState().focusedEaseSegment?.elementId).toBe("index.html#second");
view.render("index.html#second");
expect(card.dataset.focused).toBe("50");
act(() => card.click());
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
act(() => view.root.unmount());
});
});
@@ -13,6 +13,7 @@ import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl"; import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks { interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
elementId: string;
animations: GsapAnimation[]; animations: GsapAnimation[];
multipleTimelines?: boolean; multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean; unsupportedTimelinePattern?: boolean;
@@ -21,6 +22,7 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
export const GsapAnimationSection = memo(function GsapAnimationSection({ export const GsapAnimationSection = memo(function GsapAnimationSection({
animations, animations,
elementId,
multipleTimelines, multipleTimelines,
unsupportedTimelinePattern, unsupportedTimelinePattern,
onAddAnimation, onAddAnimation,
@@ -55,7 +57,10 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
animation={anim} animation={anim}
defaultExpanded={index === 0} defaultExpanded={index === 0}
focusedSegment={ focusedSegment={
focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null focusedEaseSegment?.elementId === elementId &&
focusedEaseSegment.animationId === anim.id
? focusedEaseSegment
: null
} }
onFocusSegmentConsumed={clearFocusedEaseSegment} onFocusSegmentConsumed={clearFocusedEaseSegment}
/> />
@@ -1,6 +1,7 @@
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import { EASE_LABELS } from "./gsapAnimationConstants"; import { EASE_LABELS } from "./gsapAnimationConstants";
import { EaseCurveSection } from "./EaseCurveSection"; import { EaseCurveSection } from "./EaseCurveSection";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
// The full GSAP easing vocabulary offered by the "Set all…" bulk control — // The full GSAP easing vocabulary offered by the "Set all…" bulk control —
// every standard family in in/out/inOut, so authors aren't limited to a curated // every standard family in in/out/inOut, so authors aren't limited to a curated
@@ -44,6 +45,7 @@ export function KeyframeEaseList({
keyframes, keyframes,
globalEase, globalEase,
expandedPct, expandedPct,
collidingAnimationTargets,
onToggle, onToggle,
onEaseCommit, onEaseCommit,
onApplyAll, onApplyAll,
@@ -51,6 +53,7 @@ export function KeyframeEaseList({
keyframes: GsapPercentageKeyframe[]; keyframes: GsapPercentageKeyframe[];
globalEase: string; globalEase: string;
expandedPct: number | null; expandedPct: number | null;
collidingAnimationTargets?: AnimationKeyframeTarget[];
onToggle: (pct: number | null) => void; onToggle: (pct: number | null) => void;
onEaseCommit: (pct: number, ease: string) => void; onEaseCommit: (pct: number, ease: string) => void;
/** Apply one ease to every segment at once (clears per-segment overrides). */ /** Apply one ease to every segment at once (clears per-segment overrides). */
@@ -119,6 +122,7 @@ export function KeyframeEaseList({
<div className="px-2 pb-2"> <div className="px-2 pb-2">
<EaseCurveSection <EaseCurveSection
ease={segEase} ease={segEase}
collidingAnimationTargets={collidingAnimationTargets}
onCustomEaseCommit={(ease) => onEaseCommit(kf.percentage, ease)} onCustomEaseCommit={(ease) => onEaseCommit(kf.percentage, ease)}
/> />
</div> </div>
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { memo, useEffect, useRef, useState, type RefObject } from "react"; import { memo, useEffect, useRef, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing"; import type { DomEditSelection } from "./domEditing";
import { useDomEditContext } from "../../contexts/DomEditContext"; import { useDomEditContext } from "../../contexts/DomEditContext";
@@ -103,7 +104,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct); const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
const timelineElement = usePlayerStore((state) => { const timelineElement = usePlayerStore((state) => {
if (!selection) return undefined; if (!selection) return undefined;
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`; const sourceScopedId = scopedElementKey(selection);
return state.elements.find( return state.elements.find(
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id, (element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
); );
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { memo, useEffect, useMemo, useRef, useState } from "react"; import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Move } from "../../icons/SystemIcons"; import { Move } from "../../icons/SystemIcons";
import { InspectorHeaderActions } from "./InspectorHeaderActions"; import { InspectorHeaderActions } from "./InspectorHeaderActions";
@@ -101,6 +102,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onUpdateArcSegment, onUpdateArcSegment,
onUnroll, onUnroll,
onUpdateKeyframeEase, onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases, onSetAllKeyframeEases,
onAddKeyframe, onAddKeyframe,
onRemoveKeyframe, onRemoveKeyframe,
@@ -556,6 +558,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onAddGsapProperty && onAddGsapProperty &&
onAddGsapAnimation && ( onAddGsapAnimation && (
<GsapAnimationSection <GsapAnimationSection
elementId={scopedElementKey(element)}
animations={gsapAnimations} animations={gsapAnimations}
multipleTimelines={gsapMultipleTimelines} multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern} unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -573,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onUnroll={onUnroll} onUnroll={onUnroll}
onUpdateKeyframeEase={onUpdateKeyframeEase} onUpdateKeyframeEase={onUpdateKeyframeEase}
onSetAllKeyframeEases={onSetAllKeyframeEases} onSetAllKeyframeEases={onSetAllKeyframeEases}
onUpdateSegmentEase={onUpdateSegmentEase}
/> />
)} )}
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { type ReactNode, useEffect, useRef, useState } from "react"; import { type ReactNode, useEffect, useRef, useState } from "react";
import { resolveEditingSections } from "@hyperframes/core/editing"; import { resolveEditingSections } from "@hyperframes/core/editing";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext"; import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
@@ -135,6 +136,7 @@ export function PropertyPanelFlat({
onUpdateArcSegment, onUpdateArcSegment,
onUnroll, onUnroll,
onUpdateKeyframeEase, onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases, onSetAllKeyframeEases,
}: Pick< }: Pick<
PropertyPanelProps, PropertyPanelProps,
@@ -180,6 +182,7 @@ export function PropertyPanelFlat({
| "onUnroll" | "onUnroll"
| "onUpdateKeyframeEase" | "onUpdateKeyframeEase"
| "onSetAllKeyframeEases" | "onSetAllKeyframeEases"
| "onUpdateSegmentEase"
| "recordingState" | "recordingState"
| "recordingDuration" | "recordingDuration"
| "onToggleRecording" | "onToggleRecording"
@@ -253,7 +256,7 @@ export function PropertyPanelFlat({
// flips synchronously while the panel still renders its predecessor, so a // flips synchronously while the panel still renders its predecessor, so a
// stale panel would consume a request meant for its successor whenever the // stale panel would consume a request meant for its successor whenever the
// two share a class-selector animation id. // two share a class-selector animation id.
const renderedElementId = `${element.sourceFile}#${element.id}`; const renderedElementId = scopedElementKey(element);
// Adjusted during render (not an effect) so the card mounts on the same // 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 // commit the request lands on. Keyed on request identity: a group the user
// closes afterwards stays closed. // closes afterwards stays closed.
@@ -330,6 +333,7 @@ export function PropertyPanelFlat({
onUpdateArcSegment, onUpdateArcSegment,
onUnroll, onUnroll,
onUpdateKeyframeEase, onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases, onSetAllKeyframeEases,
} }
: null; : null;
@@ -26,7 +26,6 @@ describe("withTrackedGsapAnimationCallbacks", () => {
const onLivePreviewEnd = vi.fn(); const onLivePreviewEnd = vi.fn();
callbacks.onLivePreview = onLivePreview; callbacks.onLivePreview = onLivePreview;
callbacks.onLivePreviewEnd = onLivePreviewEnd; callbacks.onLivePreviewEnd = onLivePreviewEnd;
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn()); const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
expect(tracked.onUpdateFromProperty).toBeUndefined(); expect(tracked.onUpdateFromProperty).toBeUndefined();
@@ -37,6 +36,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
expect(tracked.onUpdateKeyframeEase).toBeUndefined(); expect(tracked.onUpdateKeyframeEase).toBeUndefined();
expect(tracked.onSetAllKeyframeEases).toBeUndefined(); expect(tracked.onSetAllKeyframeEases).toBeUndefined();
expect(tracked.onUnroll).toBeUndefined(); expect(tracked.onUnroll).toBeUndefined();
expect(tracked.onUpdateSegmentEase).toBeUndefined();
expect(tracked.onLivePreview).toBe(onLivePreview); expect(tracked.onLivePreview).toBe(onLivePreview);
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd); expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
}); });
@@ -57,6 +57,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
onUpdateArcSegment: mutation("arc-segment"), onUpdateArcSegment: mutation("arc-segment"),
onUpdateKeyframeEase: mutation("keyframe-ease"), onUpdateKeyframeEase: mutation("keyframe-ease"),
onSetAllKeyframeEases: mutation("all-eases"), onSetAllKeyframeEases: mutation("all-eases"),
onUpdateSegmentEase: mutation("segment-ease"),
onUnroll: mutation("unroll"), onUnroll: mutation("unroll"),
}; };
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => { const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
@@ -79,6 +80,10 @@ describe("withTrackedGsapAnimationCallbacks", () => {
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 }); requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out"); requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none"); requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
requireCallback(tracked.onUpdateSegmentEase)(
[{ animationId: "a1", tweenPercentage: 50 }],
"none",
);
requireCallback(tracked.onUnroll)("a1"); requireCallback(tracked.onUnroll)("a1");
expect(events).toEqual([ expect(events).toEqual([
@@ -115,6 +120,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
"mutate:keyframe-ease", "mutate:keyframe-ease",
"track:select:All keyframe eases", "track:select:All keyframe eases",
"mutate:all-eases", "mutate:all-eases",
"track:select:Segment ease",
"mutate:segment-ease",
"track:button:Unroll animation", "track:button:Unroll animation",
"mutate:unroll", "mutate:unroll",
]); ]);
@@ -1,5 +1,6 @@
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser"; import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
import { usePlayerStore } from "../../player"; import { usePlayerStore } from "../../player";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
/** /**
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it * Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -30,6 +31,7 @@ export interface GsapAnimationEditCallbacks {
update: Partial<ArcPathSegment>, update: Partial<ArcPathSegment>,
) => void; ) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void; onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
onUpdateSegmentEase?: (targets: AnimationKeyframeTarget[], ease: string) => void;
/** Apply one ease to every keyframe segment at once (clears per-segment overrides). */ /** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
onSetAllKeyframeEases?: (animationId: string, ease: string) => void; onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */ /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
@@ -122,6 +124,12 @@ export function withTrackedGsapAnimationCallbacks(
: undefined, : undefined,
onLivePreview: callbacks.onLivePreview, onLivePreview: callbacks.onLivePreview,
onLivePreviewEnd: callbacks.onLivePreviewEnd, onLivePreviewEnd: callbacks.onLivePreviewEnd,
onUpdateSegmentEase: callbacks.onUpdateSegmentEase
? (targets, ease) => {
track("select", "Segment ease");
callbacks.onUpdateSegmentEase?.(targets, ease);
}
: undefined,
onSetArcPath: callbacks.onSetArcPath onSetArcPath: callbacks.onSetArcPath
? (animationId, config) => { ? (animationId, config) => {
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion"); track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection"; import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
import type { DomEditSelection } from "./domEditing"; import type { DomEditSelection } from "./domEditing";
import { usePlayerStore } from "../../player";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => { afterEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
// The store is module-global, so a test that parks a focused ease segment
// would otherwise leak it into every test that runs after it.
usePlayerStore.getState().reset();
}); });
function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection { function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
@@ -270,3 +274,68 @@ describe("FlatMotionSection", () => {
act(() => root.unmount()); act(() => root.unmount());
}); });
}); });
it("forwards focused bulk segment easing through the flat animation card", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
usePlayerStore.setState({
focusedEaseSegment: {
elementId: "index.html#hero",
animationId: "a1",
tweenPercentage: 50,
collidingAnimationTargets: [
{ animationId: "a1", tweenPercentage: 50 },
{ animationId: "a2", tweenPercentage: 75 },
],
},
});
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0,
duration: 1,
properties: { x: 100 },
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
],
},
} as never,
]}
showTiming={false}
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
onUpdateKeyframeEase={onUpdateKeyframeEase}
onUpdateSegmentEase={onUpdateSegmentEase}
/>,
);
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull();
act(() => dropdown?.click());
const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
expect(preset).not.toBeNull();
act(() => preset?.click());
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
[
{ animationId: "a1", tweenPercentage: 50 },
{ animationId: "a2", tweenPercentage: 75 },
],
"power2.out",
);
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => root.unmount());
});
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { useState } from "react"; import { useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
@@ -143,7 +144,7 @@ export function FlatMotionSection({
// the store's selectedElementId, which flips synchronously during async // the store's selectedElementId, which flips synchronously during async
// selection resolution), so a shared class-selector animation id can't open // selection resolution), so a shared class-selector animation id can't open
// the wrong element's editor. // the wrong element's editor.
const renderedElementId = `${element.sourceFile}#${element.id}`; const renderedElementId = scopedElementKey(element);
const focusedHere = const focusedHere =
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
? focusedEaseSegment ? focusedEaseSegment
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import type { DomEditSelection } from "./domEditing"; import type { DomEditSelection } from "./domEditing";
import type { ImportedFontAsset } from "./fontAssets"; import type { ImportedFontAsset } from "./fontAssets";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
export interface BackgroundRemovalProgress { export interface BackgroundRemovalProgress {
status: "processing" | "complete" | "failed"; status: "processing" | "complete" | "failed";
@@ -123,6 +124,7 @@ export interface PropertyPanelProps {
onRemoveKeyframe?: (animationId: string, percentage: number) => void; onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void; onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
onSetAllKeyframeEases?: (animationId: string, ease: string) => void; onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
onConvertToKeyframes?: (animationId: string, duration?: number) => void; onConvertToKeyframes?: (animationId: string, duration?: number) => void;
onCommitAnimatedProperty?: ( onCommitAnimatedProperty?: (
selection: DomEditSelection, selection: DomEditSelection,
@@ -71,6 +71,7 @@ export interface DomEditActionsValue extends Pick<
| "commitMutation" | "commitMutation"
| "applyMarqueeSelection" | "applyMarqueeSelection"
| "handleUpdateKeyframeEase" | "handleUpdateKeyframeEase"
| "handleUpdateSegmentEase"
| "handleSetAllKeyframeEases" | "handleSetAllKeyframeEases"
> {} > {}
@@ -200,6 +201,7 @@ export function DomEditProvider({
applyMarqueeSelection, applyMarqueeSelection,
handleUpdateKeyframeEase, handleUpdateKeyframeEase,
handleSetAllKeyframeEases, handleSetAllKeyframeEases,
handleUpdateSegmentEase,
}, },
children, children,
}: { }: {
@@ -281,6 +283,7 @@ export function DomEditProvider({
commitMutation: stableCommitMutation, commitMutation: stableCommitMutation,
applyMarqueeSelection, applyMarqueeSelection,
handleUpdateKeyframeEase, handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases, handleSetAllKeyframeEases,
}), }),
[ [
@@ -349,6 +352,7 @@ export function DomEditProvider({
stableCommitMutation, stableCommitMutation,
applyMarqueeSelection, applyMarqueeSelection,
handleUpdateKeyframeEase, handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases, handleSetAllKeyframeEases,
], ],
); );
@@ -156,6 +156,20 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
} }
} }
/**
* The source-scoped key that names one element across panels and the store
* (focused ease segment, keyframe cache reads). Four call sites built this
* string by hand and two of them omitted the index.html fallback, so an element
* with no sourceFile was addressed as `#box` by one panel and `index.html#box`
* by another and the two never matched. One builder keeps them on one key.
*/
export function scopedElementKey(element: {
sourceFile?: string | null;
id?: string | null;
}): string {
return `${element.sourceFile || "index.html"}#${element.id}`;
}
/** Every cache key a write for this element sets, in read-preference order. */ /** Every cache key a write for this element sets, in read-preference order. */
export function elementCacheKeys(sourceFile: string, elementId: string): string[] { export function elementCacheKeys(sourceFile: string, elementId: string): string[] {
return sourceFile === "index.html" return sourceFile === "index.html"
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { shouldUseSdkCutover } from "../utils/sdkCutover"; import { shouldUseSdkCutover } from "../utils/sdkCutover";
import type { PatchOperation } from "../utils/sourcePatcher"; import type { PatchOperation } from "../utils/sourcePatcher";
import type { Composition } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { UseDomEditSessionParams } from "./useDomEditSession"; import type { UseDomEditSessionParams } from "./useDomEditSession";
const styleOp = (property: string, value: string): PatchOperation => ({ const styleOp = (property: string, value: string): PatchOperation => ({
@@ -57,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
fn: undefined, fn: undefined,
}; };
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
vi.mock("../utils/sdkResolverShadow", () => ({ vi.mock("../utils/sdkResolverShadow", () => ({
runResolverShadow: vi.fn(), runResolverShadow: vi.fn(),
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args), recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
@@ -83,11 +86,11 @@ vi.mock("./useDomEditCommits", () => ({
})); }));
vi.mock("./useDomSelection", () => ({ vi.mock("./useDomSelection", () => ({
useDomSelection: () => ({ useDomSelection: () => ({
domEditSelection: null, domEditSelection: domEditSelectionRef.current,
domEditGroupSelections: [], domEditGroupSelections: [],
domEditHoverSelection: null, domEditHoverSelection: null,
activeGroupElement: null, activeGroupElement: null,
domEditSelectionRef: { current: null }, domEditSelectionRef,
domEditGroupSelectionsRef: { current: [] }, domEditGroupSelectionsRef: { current: [] },
setActiveGroupElement: vi.fn(), setActiveGroupElement: vi.fn(),
applyDomSelection: vi.fn(), applyDomSelection: vi.fn(),
@@ -123,7 +126,7 @@ vi.mock("./useGsapTweenCache", () => ({
})); }));
vi.mock("./useGsapScriptCommits", () => ({ vi.mock("./useGsapScriptCommits", () => ({
useGsapScriptCommits: () => ({ useGsapScriptCommits: () => ({
commitMutation: vi.fn(), commitMutation: gsapCommitMutation,
updateGsapProperty: vi.fn(), updateGsapProperty: vi.fn(),
updateGsapMeta: vi.fn(), updateGsapMeta: vi.fn(),
deleteGsapAnimation: vi.fn(), deleteGsapAnimation: vi.fn(),
@@ -277,3 +280,149 @@ describe("onReorderShadow source filter", () => {
} }
}); });
}); });
describe("bulk segment ease commits", () => {
it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
const { useDomEditSession } = await import("./useDomEditSession");
const selection: DomEditSelection = {
id: "hero",
element: document.createElement("div"),
label: "Hero",
tagName: "DIV",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
textContent: null,
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
domEditSelectionRef.current = selection;
gsapCommitMutation.mockClear();
gsapCommitMutation.batch.mockClear();
let updateSegmentEase:
| ((targets: Array<{ animationId: string; tweenPercentage: number }>, ease: string) => void)
| undefined;
function Probe() {
const params: UseDomEditSessionParams = {
projectId: "proj-1",
activeCompPath: "index.html",
isMasterView: false,
compIdToSrc: new Map(),
captionEditMode: false,
compositionLoading: false,
previewIframeRef: { current: null },
timelineElements: [],
setSelectedTimelineElementId: vi.fn(),
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
showToast: vi.fn(),
refreshPreviewDocumentVersion: vi.fn(),
queueDomEditSave: async <T,>(save: () => Promise<T>) => save(),
readProjectFile: async () => "",
writeProjectFile: async () => {},
updateEditingFileContent: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: async () => {} },
fileTree: [],
importedFontAssetsRef: { current: [] },
projectDir: null,
projectIdRef: { current: "proj-1" },
previewIframe: null,
refreshKey: 0,
previewDocumentVersion: 0,
rightPanelTab: "design",
applyStudioManualEditsToPreviewRef: { current: async () => {} },
syncPreviewHistoryHotkey: vi.fn(),
reloadPreview: vi.fn(),
setRefreshKey: vi.fn(),
};
updateSegmentEase = useDomEditSession(params).handleUpdateSegmentEase;
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
act(() => root.render(<Probe />));
try {
expect(updateSegmentEase).toBeTypeOf("function");
if (!updateSegmentEase) return;
const options = { label: "Update segment ease", softReload: true };
const targets = [
{ animationId: "move-x", tweenPercentage: 20 },
{ animationId: "move-y", tweenPercentage: 50 },
{ animationId: "fade", tweenPercentage: 80 },
];
updateSegmentEase(targets, "power2.inOut");
expect(gsapCommitMutation).not.toHaveBeenCalled();
expect(gsapCommitMutation.batch).toHaveBeenCalledTimes(1);
expect(gsapCommitMutation.batch).toHaveBeenCalledWith(
targets.map(({ animationId, tweenPercentage }) => ({
selection,
mutation: {
type: "update-keyframe",
animationId,
percentage: tweenPercentage,
properties: {},
ease: "power2.inOut",
},
options,
})),
options,
);
gsapCommitMutation.mockClear();
gsapCommitMutation.batch.mockClear();
updateSegmentEase([{ animationId: "fade", tweenPercentage: 25 }], "none");
expect(gsapCommitMutation).toHaveBeenCalledTimes(1);
expect(gsapCommitMutation).toHaveBeenCalledWith(
selection,
{
type: "update-keyframe",
animationId: "fade",
percentage: 25,
properties: {},
ease: "none",
},
{ label: "Update keyframe ease", softReload: true },
);
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
gsapCommitMutation.mockClear();
updateSegmentEase([], "linear");
expect(gsapCommitMutation).not.toHaveBeenCalled();
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
// A commit with no batch transport (a wrapped one, e.g. inside a gesture
// transaction) must still write every tween. Optional-chaining the batch
// call would drop the whole edit here and report nothing.
gsapCommitMutation.mockClear();
const restoreBatch = gsapCommitMutation.batch;
Reflect.deleteProperty(gsapCommitMutation, "batch");
try {
updateSegmentEase(targets, "power1.in");
expect(gsapCommitMutation).toHaveBeenCalledTimes(targets.length);
} finally {
gsapCommitMutation.batch = restoreBatch;
}
} finally {
domEditSelectionRef.current = null;
act(() => root.unmount());
}
});
});
+37 -29
View File
@@ -19,8 +19,7 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring"; import { useDomEditWiring } from "./useDomEditWiring";
import { useGsapAwareEditing } from "./useGsapAwareEditing"; import { useGsapAwareEditing } from "./useGsapAwareEditing";
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher"; import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
import type { AnimationKeyframeTarget } from "./gsapTweenSynth";
// ── Types ──
interface RecordEditInput { interface RecordEditInput {
label: string; label: string;
@@ -71,8 +70,6 @@ export interface UseDomEditSessionParams {
forceReloadSdkSession?: () => void; forceReloadSdkSession?: () => void;
} }
// ── Hook ──
export function useDomEditSession({ export function useDomEditSession({
projectId, projectId,
activeCompPath, activeCompPath,
@@ -113,8 +110,6 @@ export function useDomEditSession({
forceReloadSdkSession, forceReloadSdkSession,
}: UseDomEditSessionParams) { }: UseDomEditSessionParams) {
void _setRefreshKey; void _setRefreshKey;
// ── Selection ──
const { const {
domEditSelection, domEditSelection,
domEditGroupSelections, domEditGroupSelections,
@@ -149,8 +144,6 @@ export function useDomEditSession({
rightPanelTab, rightPanelTab,
}); });
// ── Agent modal ──
const { const {
agentModalOpen, agentModalOpen,
agentModalAnchorPoint, agentModalAnchorPoint,
@@ -423,9 +416,6 @@ export function useDomEditSession({
removeAllKeyframes, removeAllKeyframes,
handleDomManualEditsReset, handleDomManualEditsReset,
}); });
// ── Preview interaction ──
const { const {
handlePreviewCanvasMouseDown, handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove, handlePreviewCanvasPointerMove,
@@ -444,9 +434,6 @@ export function useDomEditSession({
setActiveGroupElement, setActiveGroupElement,
onClickToSource, onClickToSource,
}); });
// ── GSAP-aware geometry intercepts + animated property commit ──
const { const {
handleGsapAwarePathOffsetCommit, handleGsapAwarePathOffsetCommit,
handleGsapAwareGroupPathOffsetCommit, handleGsapAwareGroupPathOffsetCommit,
@@ -473,28 +460,49 @@ export function useDomEditSession({
setArcPath, setArcPath,
updateArcSegment, updateArcSegment,
}); });
const handleUpdateSegmentEase = useCallback(
const handleUpdateKeyframeEase = useCallback( (targets: AnimationKeyframeTarget[], ease: string) => {
(animationId: string, percentage: number, ease: string) => { const selection = domEditSelectionRef.current;
const sel = domEditSelectionRef.current; if (!selection || targets.length === 0) return;
if (!sel) return; const options = {
gsapCommitMutation( label: targets.length === 1 ? "Update keyframe ease" : "Update segment ease",
sel, softReload: true,
{ };
const calls = targets.map(({ animationId, tweenPercentage }) => ({
selection,
mutation: {
type: "update-keyframe", type: "update-keyframe",
animationId, animationId,
percentage, percentage: tweenPercentage,
properties: {}, properties: {},
ease, ease,
}, },
{ label: "Update keyframe ease", softReload: true }, options,
); }));
if (calls.length === 1) {
const call = calls[0];
if (call) void gsapCommitMutation(call.selection, call.mutation, call.options);
return;
}
const batch = gsapCommitMutation.batch;
if (batch) {
void batch(calls, options);
return;
}
// A wrapped commit (a gesture transaction, say) carries no batch
// transport. Serial keeps the edit correct at the cost of one round trip
// per tween and one undo entry per tween; an optional-chained call here
// would silently drop the whole edit instead.
for (const call of calls)
void gsapCommitMutation(call.selection, call.mutation, call.options);
}, },
[gsapCommitMutation, domEditSelectionRef], [gsapCommitMutation, domEditSelectionRef],
); );
const handleUpdateKeyframeEase = useCallback(
// Apply one ease to every segment at once (AE select-all + F9): set easeEach (animationId: string, percentage: number, ease: string) =>
// and strip per-keyframe overrides in a single mutation. handleUpdateSegmentEase([{ animationId, tweenPercentage: percentage }], ease),
[handleUpdateSegmentEase],
);
const handleSetAllKeyframeEases = useCallback( const handleSetAllKeyframeEases = useCallback(
(animationId: string, ease: string) => { (animationId: string, ease: string) => {
const sel = domEditSelectionRef.current; const sel = domEditSelectionRef.current;
@@ -511,7 +519,6 @@ export function useDomEditSession({
}, },
[gsapCommitMutation, domEditSelectionRef], [gsapCommitMutation, domEditSelectionRef],
); );
return { return {
// State // State
domEditSelection, domEditSelection,
@@ -591,6 +598,7 @@ export function useDomEditSession({
commitAnimatedProperty, commitAnimatedProperty,
commitAnimatedProperties, commitAnimatedProperties,
handleSetArcPath, handleSetArcPath,
handleUpdateSegmentEase,
handleUpdateArcSegment, handleUpdateArcSegment,
handleUnroll, handleUnroll,
invalidateGsapCache: bumpGsapCache, invalidateGsapCache: bumpGsapCache,
@@ -686,12 +686,12 @@ describe("TimelineClipDiamonds", () => {
return { host, root }; return { host, root };
}; };
it("hides the inline ease button on a colliding merged segment", () => { it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
// The 50->100 segment ends on a keyframe shared by two animations, so one // Both segments (0->50, 50->100) render their ease button; the 50->100
// button cannot honestly stand for the several curves that meet there. Only // segment ends on a keyframe shared by two animations and the button now
// the unambiguous 0->50 segment keeps its button. // bulk-edits both rather than being hidden.
const { host, root } = renderSegmentLane(true); const { host, root } = renderSegmentLane(true);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -90,13 +90,13 @@ export function TimelineDiamondConnectors({
/** /**
* The ease control targets one segment, so it needs the keyframe's own * The ease control targets one segment, so it needs the keyframe's own
* animationId/tweenPercentage. On a merged inline row it is hidden where two * animationId. A merged keyframe now shows one too: the editor edits every
* source animations collide at this percentage (one button cannot honestly * colliding tween together, so one button standing for several curves is
* stand for several curves) or the keyframe has no source animation id * honest. Only a keyframe with no source animation id (runtime-scanned) is left
* (runtime-scanned) so there is no tween to target. * out, because there is no tween to target.
*/ */
function showsEaseControl(kf: TimelineDiamondKeyframe): boolean { function showsEaseControl(kf: TimelineDiamondKeyframe): boolean {
return (kf.collidingAnimationTargets?.length ?? 0) <= 1 && kf.animationId !== undefined; return kf.animationId !== undefined;
} }
/** /**