diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts
index f2db23a3d..ffd9f61a8 100644
--- a/packages/studio-server/src/routes/files.test.ts
+++ b/packages/studio-server/src/routes/files.test.ts
@@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
expect(result.after).not.toContain("data-hf-studio-rotation");
});
+ it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => {
+ const projectDir = createProjectDir();
+ const PATH_COMP = `
+
+
+`;
+ writeHtml(projectDir, "path.html", PATH_COMP);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const anim = await getFirstAnimation(app, "path.html");
+ const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "replace-with-keyframes",
+ animationId: anim.id,
+ targetSelector: "#box",
+ position: 12.17,
+ duration: 16.055,
+ keyframes: [
+ { percentage: 0, properties: { x: 0, y: 0 } },
+ { percentage: 23.2, properties: { x: 25, y: 30 } },
+ { percentage: 100, properties: { x: 100, y: 100 } },
+ ],
+ ease: "none",
+ }),
+ });
+ const result = (await res.json()) as { ok: boolean; after: string };
+
+ expect(res.status).toBe(200);
+ expect(result.ok).toBe(true);
+ expect(result.after).toContain('"23.2%"');
+ expect(result.after).toContain('easeEach: "power1.inOut"');
+ expect(result.after).toContain('ease: "none"');
+ expect(result.after).not.toContain("motionPath");
+ });
+
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts
index 01e7c3f84..3ec64bbff 100644
--- a/packages/studio-server/src/routes/files.ts
+++ b/packages/studio-server/src/routes/files.ts
@@ -997,6 +997,7 @@ export type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
+ easeEach?: string;
}
| {
type: "split-animations";
@@ -1052,6 +1053,18 @@ export type GsapMutationRequest =
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
+function resolveReplacementEaseEach(
+ scriptText: string,
+ request: { animationId: string; easeEach?: string },
+): string | undefined {
+ if (request.easeEach !== undefined) return request.easeEach;
+ const original = parseGsapScriptAcorn(scriptText).animations.find(
+ (animation) => animation.id === request.animationId,
+ );
+ if (!original?.arcPath?.enabled) return undefined;
+ return original?.keyframes?.easeEach ?? original?.ease;
+}
+
// Mutations that can change a position tween's first keyframe (value/existence/timing)
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
@@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
+ resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
@@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
+ resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx
index 0c21e5e04..0050dcfc9 100644
--- a/packages/studio/src/components/TimelineToolbar.test.tsx
+++ b/packages/studio/src/components/TimelineToolbar.test.tsx
@@ -2,8 +2,10 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
+import { makeSelection } from "../hooks/domSelectionTestHarness";
import { TimelineToolbar } from "./TimelineToolbar";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -13,12 +15,14 @@ afterEach(() => {
usePlayerStore.setState({ autoKeyframeEnabled: true });
});
-function renderToolbar() {
+function renderToolbar(
+ domEditSession?: React.ComponentProps["domEditSession"],
+) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
- root.render();
+ root.render();
});
return { host, root };
}
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
act(() => root.unmount());
});
});
+describe("TimelineToolbar — motion path endpoints", () => {
+ it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
+ usePlayerStore.setState({ currentTime: 10 });
+ const animation: GsapAnimation = {
+ id: "#el-to-0-position",
+ targetSelector: "#el",
+ method: "to",
+ position: 0,
+ duration: 10,
+ properties: {},
+ keyframes: {
+ format: "object-array",
+ keyframes: [
+ { percentage: 0, properties: { x: 0, y: 0 } },
+ { percentage: 100, properties: { x: 100, y: 0 } },
+ ],
+ },
+ arcPath: {
+ enabled: true,
+ autoRotate: false,
+ segments: [{ curviness: 1 }],
+ },
+ };
+ const element = document.createElement("div");
+ element.id = "el";
+ const session = {
+ domEditSelection: makeSelection("Element", element),
+ selectedGsapAnimations: [animation],
+ handleGsapAddAnimation: vi.fn(),
+ handleGsapConvertToKeyframes: vi.fn(),
+ handleGsapRemoveKeyframe: vi.fn(),
+ } satisfies NonNullable["domEditSession"]>;
+
+ const { host, root } = renderToolbar(session);
+ const button = host.querySelector(
+ 'button[aria-label="Motion path endpoint"]',
+ );
+ expect(button?.disabled).toBe(true);
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx
index 1e314ed4e..43416414d 100644
--- a/packages/studio/src/components/TimelineToolbar.tsx
+++ b/packages/studio/src/components/TimelineToolbar.tsx
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
}
+interface KeyframeToggleState {
+ state: "active" | "inactive" | "none";
+ isMotionPath: boolean;
+ pathEndpoint: boolean;
+ willExtend: boolean;
+}
+
+const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
+ state: "none",
+ isMotionPath: false,
+ pathEndpoint: false,
+ willExtend: false,
+};
+
+function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
+ if (!animation?.keyframes) return false;
+ const keyframes = animation.keyframes.keyframes;
+ return (
+ Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= 1 ||
+ Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= 1
+ );
+}
+
+function resolveKeyframeToggleState(
+ session: DomEditSessionSlice | undefined,
+ currentTime: number,
+): KeyframeToggleState {
+ if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
+ const arcAnimation = session.selectedGsapAnimations.find(
+ (animation) => animation.arcPath && animation.keyframes,
+ );
+ const animation =
+ arcAnimation ??
+ session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
+ if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
+
+ const isMotionPath = Boolean(arcAnimation);
+ if (!isPlayheadWithinTween(animation, currentTime)) {
+ return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
+ }
+
+ const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
+ const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
+ const active = animation.keyframes.keyframes.some(
+ (keyframe) => Math.abs(keyframe.percentage - percentage) <= 1,
+ );
+ return {
+ state: pathEndpoint ? "none" : active ? "active" : "inactive",
+ isMotionPath,
+ pathEndpoint,
+ willExtend: false,
+ };
+}
+
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
const sessionRef = useRef(session);
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
sessionRef as React.RefObject,
);
- if (!session) return { state: "none" as const, onToggle: undefined };
+ const toggleState = resolveKeyframeToggleState(session, currentTime);
- const sel = session.domEditSelection;
- const anims = session.selectedGsapAnimations;
- const kfAnim = anims.find((a) => a.keyframes);
-
- let state: "active" | "inactive" | "none" = "none";
- // Outside the tween, clicking extends the animation to the playhead rather than
- // toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
- let willExtend = false;
- if (kfAnim?.keyframes && sel) {
- if (!isPlayheadWithinTween(kfAnim, currentTime)) {
- state = "inactive";
- willExtend = true;
- } else {
- // Tween-relative percentage (not the clip range) so the button state matches
- // where the keyframe would actually land.
- const pct = computeElementPercentage(currentTime, sel, kfAnim);
- state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
- ? "active"
- : "inactive";
- }
- }
-
- return { state, willExtend, onToggle: sel ? onToggle : undefined };
+ return {
+ ...toggleState,
+ onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
+ };
}
// fallow-ignore-next-line complexity
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const {
state: keyframeState,
+ isMotionPath: keyframeIsMotionPath,
+ pathEndpoint: keyframePathEndpoint,
willExtend: keyframeWillExtend,
onToggle: onToggleKeyframe,
} = useKeyframeToggle(domEditSession);
@@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
// toolbar layout never shifts.