mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(studio): bulk-edit easing for merged keyframes
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user