fix(studio): harden keyframe editing semantics

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:13:46 +02:00
parent 9fc0011703
commit d57039882f
41 changed files with 1294 additions and 435 deletions
@@ -1,5 +1,5 @@
import { usePlayerStore } from "../player/store/playerStore";
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
import { timelineKeyframeTargetFromSelectionKey } from "../player/components/timelineKeyframeIdentity";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
let deleteKeyframesCommitCounter = 0;
@@ -18,18 +18,31 @@ export function deleteSelectedKeyframes(session: {
) => void;
}): void {
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
if (!animation) return;
// Only the active element's keyframes; a stale cross-element selection must not delete here.
const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId);
if (!selectedElementId) return;
const keyframedAnimations = session.selectedGsapAnimations.filter((anim) => anim.keyframes);
const fallbackAnimation = keyframedAnimations[0];
const animationsById = new Map(keyframedAnimations.map((animation) => [animation.id, animation]));
const removals = new Map<string, { animationId: string; percentage: number }>();
for (const key of selectedKeyframes) {
const target = timelineKeyframeTargetFromSelectionKey(selectedElementId, key);
if (!target) continue;
const animation = target.animationId
? animationsById.get(target.animationId)
: fallbackAnimation;
if (!animation) continue;
const percentage = target.tweenPercentage ?? target.percentage;
removals.set(`${animation.id}\0${percentage}`, { animationId: animation.id, percentage });
}
const targets = [...removals.values()];
if (targets.length === 0) return;
const coalesceOptions = {
coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`,
coalesceMs: Number.POSITIVE_INFINITY,
};
for (const [index, pct] of percentages.entries()) {
session.handleGsapRemoveKeyframe(animation.id, pct, {
for (const [index, target] of targets.entries()) {
session.handleGsapRemoveKeyframe(target.animationId, target.percentage, {
...coalesceOptions,
...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }),
...(index === targets.length - 1 ? { softReload: true } : { skipReload: true }),
});
}
}
@@ -11,6 +11,21 @@ import {
materializeIfDynamic,
} from "./gsapDragCommit";
export function buildTemporalArcKeyframes(
anim: GsapAnimation,
percentage: number,
properties: Record<string, number>,
) {
return [
...(anim.keyframes?.keyframes ?? []).map((keyframe) => ({
percentage: keyframe.percentage,
properties: { ...keyframe.properties },
...(keyframe.ease ? { ease: keyframe.ease } : {}),
})),
{ percentage, properties },
].sort((a, b) => a.percentage - b.percentage);
}
async function extendTweenAndAddKeyframe(
selection: DomEditSelection,
anim: GsapAnimation,
@@ -259,6 +274,47 @@ export async function commitGsapPositionFromDrag(
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
const ct = usePlayerStore.getState().currentTime;
if (anim.arcPath?.enabled) {
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
const keyframes = anim.keyframes?.keyframes ?? [];
const pointIndex = keyframes.findIndex((kf) => Math.abs(kf.percentage - pct) < 0.05);
if (pointIndex >= 0) {
await callbacks.commitMutation(
selection,
{
type: "update-motion-path-point",
animationId: anim.id,
pointIndex,
x: newX,
y: newY,
},
{ label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
);
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, pct);
return;
}
const tweenStart = resolveTweenStart(anim);
const tweenDuration = resolveTweenDuration(anim);
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
await callbacks.commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(tweenStart),
duration: roundTo3(tweenDuration),
keyframes: temporalKeyframes,
ease: "none",
},
{ label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
);
return;
}
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
expect(types).not.toContain("replace-with-keyframes");
});
});
describe("tryGsapDragIntercept — motion paths", () => {
const motionPathAnim = {
id: "#puck-b-to-12170-position",
targetSelector: "#puck-b",
propertyGroup: "position",
method: "to",
position: 12.17,
resolvedStart: 12.17,
duration: 16.055,
ease: "power1.inOut",
properties: {},
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: -184, y: 326 } },
{ percentage: 50, properties: { x: 416, y: 804 } },
{ percentage: 100, properties: { x: 796, y: 237 } },
],
},
arcPath: {
enabled: true,
autoRotate: false,
segments: [{ curviness: 1 }, { curviness: 1 }],
},
} as unknown as GsapAnimation;
const liveTween = {
targets: () => [{ id: "puck-b" }],
vars: { motionPath: { path: [] }, duration: 16.055 },
duration: () => 16.055,
startTime: () => 12.17,
};
async function dragMotionPath(activeKeyframePct: number | null) {
usePlayerStore.setState({
autoKeyframeEnabled: true,
activeKeyframePct,
currentTime: 15.9,
});
const commitMutation = vi.fn();
const handled = await tryGsapDragIntercept(
selection,
{ x: -50, y: 30 },
[motionPathAnim],
fakeIframe("puck-b", [liveTween]),
commitMutation,
);
return { commitMutation, handled };
}
afterEach(() => {
usePlayerStore.setState({ activeKeyframePct: null });
});
it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
const { commitMutation, handled } = await dragMotionPath(null);
expect(handled).toBe(true);
expect(commitMutation).toHaveBeenCalledWith(
selection,
{
type: "replace-with-keyframes",
animationId: motionPathAnim.id,
targetSelector: "#puck-b",
position: 12.17,
duration: 16.055,
keyframes: [
{ percentage: 0, properties: { x: -184, y: 326 } },
{ percentage: 23.233, properties: { x: -50, y: 30 } },
{ percentage: 50, properties: { x: 416, y: 804 } },
{ percentage: 100, properties: { x: 796, y: 237 } },
],
ease: "none",
},
expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
);
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
"add-motion-path-point",
);
});
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
const { commitMutation, handled } = await dragMotionPath(50);
expect(handled).toBe(true);
expect(commitMutation).toHaveBeenCalledWith(
selection,
{
type: "update-motion-path-point",
animationId: motionPathAnim.id,
pointIndex: 1,
x: -50,
y: 30,
},
expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
);
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
"replace-with-keyframes",
);
});
});
@@ -16,6 +16,8 @@ export interface MutationResult {
export interface CommitMutationOptions {
label: string;
/** Observe the durable writer result without duplicating the request path. */
onResult?: (result: MutationResult) => void;
coalesceKey?: string;
coalesceMs?: number;
softReload?: boolean;
@@ -1,14 +1,30 @@
import { describe, it, expect } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
idFromSelector,
idSelector,
isInstantHold,
parsePercentageKeyframes,
resolveEditableTweenDuration,
toClipKeyframes,
toClipPercentage,
} from "./gsapShared";
describe("resolveEditableTweenDuration", () => {
const selection = { dataAttributes: { duration: "16.26" } } as DomEditSelection;
it("uses the owning clip duration when the tween omits an outer duration", () => {
expect(resolveEditableTweenDuration({ duration: undefined } as GsapAnimation, selection)).toBe(
16.26,
);
});
it("keeps an explicitly-authored tween duration", () => {
expect(resolveEditableTweenDuration({ duration: 4 } as GsapAnimation, selection)).toBe(4);
});
});
describe("isInstantHold", () => {
const animation = (method: GsapAnimation["method"], duration?: number) =>
({ method, duration }) as unknown as GsapAnimation;
+18 -4
View File
@@ -109,6 +109,22 @@ export function selectorFromSelection(selection: DomEditSelection): string | nul
// ── Percentage computation ────────────────────────────────────────────────────
/**
* Resolve the timing basis used by editor keyframes. The timeline renders a
* duration-less tween across its owning clip, so mutations must use that same
* duration instead of silently falling back to GSAP's 0.5s default.
*/
export function resolveEditableTweenDuration(
animation: GsapAnimation,
selection: DomEditSelection,
): number {
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
return resolveTweenDuration(
animation,
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
);
}
/**
* Compute the current playback percentage within an element's animation range.
* Uses the animation's resolved timing if available, otherwise falls back to
@@ -121,7 +137,7 @@ export function computeElementPercentage(
): number {
if (animation) {
const start = resolveTweenStart(animation);
const duration = resolveTweenDuration(animation);
const duration = resolveEditableTweenDuration(animation, selection);
if (duration <= 0) return 0;
if (start !== null) {
return absoluteToPercentage(currentTime, start, duration);
@@ -129,9 +145,7 @@ export function computeElementPercentage(
}
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
return absoluteToPercentage(currentTime, elStart, elDuration);
}
// ── Iframe accessors ──────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import {
import type { TimelineElement } from "../player/store/playerStore";
import { usePlayerStore } from "../player/store/playerStore";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
afterEach(() => {
usePlayerStore.getState().reset();
@@ -342,4 +343,51 @@ describe("deleteSelectedKeyframes", () => {
expect(options[1]).not.toHaveProperty("softReload");
expect(options[2]).not.toHaveProperty("skipReload");
});
it("deletes two expanded lanes through their own animation and tween percentages", () => {
usePlayerStore.setState({
selectedElementId: "card",
selectedKeyframes: new Set([
timelineKeyframeSelectionKey("card", {
percentage: 30,
tweenPercentage: 20,
propertyGroup: "position",
animationId: "card-position",
}),
timelineKeyframeSelectionKey("card", {
percentage: 70,
tweenPercentage: 80,
propertyGroup: "visual",
animationId: "card-visual",
}),
]),
});
const handleGsapRemoveKeyframe =
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
deleteSelectedKeyframes({
selectedGsapAnimations: [
{ id: "card-position", keyframes: {} },
{ id: "card-visual", keyframes: {} },
],
handleGsapRemoveKeyframe,
});
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
expect(
handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
animationId,
percentage,
]),
).toEqual([
["card-position", 20],
["card-visual", 80],
]);
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ skipReload: true }),
);
expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ softReload: true }),
);
});
});
@@ -92,14 +92,14 @@ export interface UseDomEditWiringParams {
animId: string,
fromPercentage: number,
toPercentage: number,
) => void;
) => Promise<boolean>;
resizeKeyframedTween: (
sel: DomEditSelection,
animId: string,
position: number,
duration: number,
pctRemap: Array<{ from: number; to: number }>,
) => void;
) => Promise<boolean>;
convertToKeyframes: (
sel: DomEditSelection,
animId: string,
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
applyArcKeyframeAtPlayhead,
animatedProps,
buildExtendedKeyframes,
isPlayheadWithinTween,
@@ -219,6 +220,114 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
});
});
describe("applyArcKeyframeAtPlayhead", () => {
const arcAnim = anim({
id: "#el-to-0-position",
position: 0,
duration: 10,
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 50, y: 50 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
},
arcPath: {
enabled: true,
autoRotate: false,
segments: [{ curviness: 1 }, { curviness: 1 }],
},
});
function arcFixture(x: number, y: number) {
const commitMutation = vi.fn(async () => undefined);
const session = { commitMutation } as unknown as EnableKeyframesSession;
const sel = {
id: "el",
selector: "#el",
element: { isConnected: true } as HTMLElement,
dataAttributes: { duration: "10" },
} as DomEditSelection;
const iframe = {
contentWindow: {
gsap: { getProperty: (_element: Element, property: string) => (property === "x" ? x : y) },
},
} as unknown as HTMLIFrameElement;
return { commitMutation, iframe, sel, session };
}
it("removes an existing interior stop without redistributing the remaining times", async () => {
const fixture = arcFixture(50, 50);
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 5, fixture.iframe);
expect(fixture.commitMutation).toHaveBeenCalledWith(
{
type: "replace-with-keyframes",
animationId: arcAnim.id,
targetSelector: "#el",
position: 0,
duration: 10,
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
ease: "none",
},
{ label: "Remove keyframe", softReload: true },
);
});
it("preserves the path endpoints", async () => {
const fixture = arcFixture(0, 0);
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 0, fixture.iframe);
expect(fixture.commitMutation).not.toHaveBeenCalled();
});
it("adds a temporal keyframe at the exact playhead while preserving authored times", async () => {
const fixture = arcFixture(25, 25);
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 2.5, fixture.iframe);
expect(fixture.commitMutation).toHaveBeenCalledWith(
{
type: "replace-with-keyframes",
animationId: arcAnim.id,
targetSelector: "#el",
position: 0,
duration: 10,
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 25, properties: { x: 25, y: 25 } },
{ percentage: 50, properties: { x: 50, y: 50 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
ease: "none",
},
{ label: "Add keyframe", softReload: true },
);
});
it("uses the owning clip duration when an arc omits its outer duration", async () => {
const fixture = arcFixture(25, 25);
const durationlessArc = { ...arcAnim, duration: undefined };
await applyArcKeyframeAtPlayhead(
fixture.session,
fixture.sel,
durationlessArc,
2.5,
fixture.iframe,
);
expect(fixture.commitMutation).toHaveBeenCalledWith(
expect.objectContaining({
type: "replace-with-keyframes",
duration: 10,
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
}),
{ label: "Add keyframe", softReload: true },
);
});
});
function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
let enable: (() => Promise<void>) | null = null;
function Probe() {
+99 -53
View File
@@ -12,16 +12,22 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
import {
selectorFromSelection,
computeElementPercentage,
isInstantHold,
resolveEditableTweenDuration,
} from "./gsapShared";
import {
absoluteToPercentage,
resolveTweenStart,
resolveTweenDuration,
isTimeWithinTween,
} from "../utils/globalTimeCompiler";
import { POSITION_PROPS } from "./gsapRuntimeReaders";
import { roundTo3 } from "../utils/rounding";
import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit";
let enableKeyframesTransactionCounter = 0;
@@ -89,9 +95,10 @@ export function buildExtendedKeyframes(
anim: GsapAnimation,
currentTime: number,
position: Record<string, number>,
sourceDuration = resolveTweenDuration(anim),
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
const oldStart = resolveTweenStart(anim) ?? 0;
const oldDuration = resolveTweenDuration(anim);
const oldDuration = sourceDuration;
const newStart = Math.min(oldStart, currentTime);
const newEnd = Math.max(oldStart + oldDuration, currentTime);
const newDuration = roundTo3(newEnd - newStart);
@@ -222,6 +229,37 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAni
return (await tryFetchAnimationsForElement(sel)) ?? [];
}
async function extendKeyframedTweenToPlayhead(
session: EnableKeyframesSession,
sel: DomEditSelection,
anim: GsapAnimation,
currentTime: number,
duration: number,
iframe: HTMLIFrameElement | null,
commitOverrides?: Partial<CommitMutationOptions>,
): Promise<void> {
const selector = selectorFromSelection(sel);
const position = readElementPosition(iframe, sel, anim);
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
await session.commitMutation(
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: selector,
position: extended.position,
duration: extended.duration,
keyframes: extended.keyframes,
ease: anim.ease,
},
{
label: "Add keyframe",
softReload: true,
...commitOverrides,
},
);
}
/**
* Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
* toggle off an existing stop, add one at the playhead's tween-relative %, or —
@@ -237,31 +275,22 @@ async function applyKeyframeAtPlayhead(
iframe: HTMLIFrameElement | null,
commitOverrides?: Partial<CommitMutationOptions>,
): Promise<void> {
if (!isPlayheadWithinTween(kfAnim, t)) {
const position = readElementPosition(iframe, sel, kfAnim);
const selector = selectorFromSelection(sel);
if (selector && Object.keys(position).length > 0 && session.commitMutation) {
const extended = buildExtendedKeyframes(kfAnim, t, position);
await session.commitMutation(
{
type: "replace-with-keyframes",
animationId: kfAnim.id,
targetSelector: selector,
position: extended.position,
duration: extended.duration,
keyframes: extended.keyframes,
ease: kfAnim.ease,
},
{
label: "Add keyframe",
softReload: true,
...commitOverrides,
},
);
}
const duration = resolveEditableTweenDuration(kfAnim, sel);
const start = resolveTweenStart(kfAnim);
if (start !== null && !isTimeWithinTween(t, start, duration)) {
await extendKeyframedTweenToPlayhead(
session,
sel,
kfAnim,
t,
duration,
iframe,
commitOverrides,
);
return;
}
const pct = computeElementPercentage(t, sel, kfAnim);
const pct =
start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
if (existing) {
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
@@ -332,14 +361,13 @@ export async function promoteSetToKeyframes(
}
/**
* An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so
* it must be edited as waypoints (not x/y keyframes, which would break the curve).
* "Add keyframe at playhead" drops a waypoint where the element currently sits on
* the path, inserted at the matching segment so the curve is preserved. Outside the
* range, extend the duration so the motion reaches the playhead.
* Convert an arc (motionPath) tween to temporal x/y keyframes before toggling the
* playhead stop. A toolbar command named "Add keyframe at playhead" must preserve
* every authored stop's time; inserting a spatial waypoint instead redistributes
* the path and can silently compress the animation.
*/
// fallow-ignore-next-line complexity
async function applyArcWaypointAtPlayhead(
export async function applyArcKeyframeAtPlayhead(
session: EnableKeyframesSession,
sel: DomEditSelection,
arcAnim: GsapAnimation,
@@ -347,8 +375,11 @@ async function applyArcWaypointAtPlayhead(
iframe: HTMLIFrameElement | null,
): Promise<void> {
if (!session.commitMutation) return;
if (!isPlayheadWithinTween(arcAnim, t)) {
const start = resolveTweenStart(arcAnim) ?? 0;
const targetSelector = selectorFromSelection(sel);
if (!targetSelector) return;
const start = resolveTweenStart(arcAnim) ?? 0;
const duration = resolveEditableTweenDuration(arcAnim, sel);
if (!isTimeWithinTween(t, start, duration)) {
if (t > start) {
await session.commitMutation(
{
@@ -361,30 +392,45 @@ async function applyArcWaypointAtPlayhead(
}
return;
}
const nodes = arcAnim.keyframes?.keyframes ?? [];
const playheadPercentage = absoluteToPercentage(t, start, duration);
const timedNodeIndex = nodes.findIndex(
(node) => Math.abs(node.percentage - playheadPercentage) <= 1,
);
if (timedNodeIndex !== -1) {
if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) {
await session.commitMutation(
{
type: "replace-with-keyframes",
animationId: arcAnim.id,
targetSelector,
position: roundTo3(start),
duration: roundTo3(duration),
keyframes: nodes.filter((_, index) => index !== timedNodeIndex),
ease: "none",
},
{ label: "Remove keyframe", softReload: true },
);
}
return;
}
const live = readElementPosition(iframe, sel, arcAnim);
if (typeof live.x !== "number" || typeof live.y !== "number") return;
const liveX = live.x;
const liveY = live.y;
const nodes = (arcAnim.keyframes?.keyframes ?? [])
.map((k) => ({ x: k.properties.x, y: k.properties.y }))
.filter(
(p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
);
// Don't duplicate a waypoint that already sits where the element is (e.g. at the
// path endpoints).
const WAYPOINT_MERGE_PX = 6;
if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
const proj = nearestPointOnPath(liveX, liveY, nodes);
if (!proj) return;
await session.commitMutation(
{
type: "add-motion-path-point",
type: "replace-with-keyframes",
animationId: arcAnim.id,
index: proj.segIndex + 1,
x: liveX,
y: liveY,
targetSelector,
position: roundTo3(start),
duration: roundTo3(duration),
keyframes: buildTemporalArcKeyframes(arcAnim, playheadPercentage, {
x: live.x,
y: live.y,
}),
ease: "none",
},
{ label: "Add waypoint", softReload: true },
{ label: "Add keyframe", softReload: true },
);
}
@@ -420,7 +466,7 @@ export function useEnableKeyframes(
const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
if (arcAnim) {
await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
await applyArcKeyframeAtPlayhead(session, sel, arcAnim, t, iframe);
} else if (kfAnim) {
await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
} else if (setAnim) {
@@ -20,7 +20,12 @@ afterEach(() => {
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
function successfulCommitMutation() {
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({ ok: true }));
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
const options = args[2] as {
onResult?: (result: { ok: boolean; changed: boolean }) => void;
};
options.onResult?.({ ok: true, changed: true });
});
}
function renderKeyframeOps(over: {
@@ -54,6 +59,18 @@ function renderKeyframeOps(over: {
return captured.api;
}
async function moveKeyframeWith(
commitMutation: (...args: unknown[]) => Promise<unknown>,
): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType<typeof vi.fn> }> {
const trackGsapSaveFailure = vi.fn();
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
let committed = true;
await act(async () => {
committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75);
});
return { committed, trackGsapSaveFailure };
}
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
const commitMutation = successfulCommitMutation();
@@ -64,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
{ from: 0, to: 0 },
{ from: 100, to: 100 },
];
let committed = false;
await act(async () => {
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
});
expect(commitMutation).toHaveBeenCalledTimes(1);
@@ -79,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
pctRemap,
});
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
expect(committed).toBe(true);
});
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
@@ -89,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
let committed = true;
await act(async () => {
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]);
// let the rejected commit promise settle inside act
await Promise.resolve();
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
{ from: 100, to: 100 },
]);
});
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
@@ -101,6 +121,46 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
expect(selArg).toBe(selection);
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
expect(labelArg).toBe("Retime keyframe (resize tween)");
expect(committed).toBe(false);
});
});
describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
it("returns false when the commit settles without a durable writer result", async () => {
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {}));
expect(committed).toBe(false);
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
});
it("returns false when the writer accepts but does not change the keyframe", async () => {
const commitMutation = vi.fn(async (...args: unknown[]) => {
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
options.onResult?.({ ok: true, changed: false });
});
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
expect(committed).toBe(false);
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
});
it("returns false and tracks a rejected move", async () => {
const error = new Error("write failed");
const commitMutation = vi.fn().mockRejectedValue(error);
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
expect(committed).toBe(false);
expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith(
error,
selection,
{
type: "move-keyframe",
animationId: "box-to-0-position",
fromPercentage: 50,
toPercentage: 75,
},
"Move keyframe to 75%",
);
});
});
+28 -12
View File
@@ -236,7 +236,7 @@ export function useGsapKeyframeOps({
);
const moveKeyframe = useCallback(
(
async (
selection: DomEditSelection,
animationId: string,
fromPercentage: number,
@@ -247,18 +247,26 @@ export function useGsapKeyframeOps({
// updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
// optimistic cache write is needed (mapping the tween-% to clip-% here would
// duplicate that math). softReload mirrors remove-keyframe.
void commitMutation(selection, mutation, {
label: `Move keyframe to ${toPercentage}%`,
softReload: true,
}).catch((error) => {
try {
let changed = false;
await commitMutation(selection, mutation, {
label: `Move keyframe to ${toPercentage}%`,
softReload: true,
onResult: (result) => {
changed = result.changed !== false;
},
});
return changed;
} catch (error) {
trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
});
return false;
}
},
[commitMutation, trackGsapSaveFailure],
);
const resizeKeyframedTween = useCallback(
(
async (
selection: DomEditSelection,
animationId: string,
position: number,
@@ -275,12 +283,20 @@ export function useGsapKeyframeOps({
// Boundary drag-to-retime: the server re-keys keyframes in place + grows the
// tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
// softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
void commitMutation(selection, mutation, {
label: "Retime keyframe (resize tween)",
softReload: true,
}).catch((error) => {
try {
let changed = false;
await commitMutation(selection, mutation, {
label: "Retime keyframe (resize tween)",
softReload: true,
onResult: (result) => {
changed = result.changed !== false;
},
});
return changed;
} catch (error) {
trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
});
return false;
}
},
[commitMutation, trackGsapSaveFailure],
);
@@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => {
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
});
it("publishes the server mutation outcome to callers", async () => {
mockFetchResult({ changed: false });
const deps = renderCommitHook();
let commitResult: MutationResult | undefined;
await act(async () => {
await deps.api.commitMutation(
selection,
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 75 },
{
label: "Move keyframe",
onResult: (result) => {
commitResult = result;
},
},
);
});
expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false }));
});
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
patchRuntimeTweenInPlace.mockReturnValue(true);
mockFetchResult({ changed: false });
@@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
mutateGsapScript(pid, targetPath, mutation),
);
if (!result) return;
options.onResult?.(result);
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
}, [showToast, finalizeSuccessfulMutation]);
@@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
mutateGsapScriptBatch(pid, targetPath, mutations),
);
if (!result) return;
options.onResult?.(result);
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
}, [showToast, finalizeSuccessfulMutation]);
@@ -38,8 +38,8 @@ function makeParams(overrides: Partial<Params> = {}): Params {
addKeyframe: vi.fn(),
addKeyframeBatch: resolved(),
removeKeyframe: vi.fn(),
moveKeyframe: vi.fn(),
resizeKeyframedTween: vi.fn(),
moveKeyframe: vi.fn().mockResolvedValue(true),
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
convertToKeyframes: resolved(),
removeAllKeyframes: resolved(),
handleDomManualEditsReset: vi.fn(),
@@ -144,3 +144,22 @@ describe("useGsapSelectionHandlers selection override", () => {
rendered.unmount();
});
});
describe("useGsapSelectionHandlers retime settlement", () => {
it("returns false without a selection and forwards the mutation result with one", async () => {
const moveKeyframe = vi.fn().mockResolvedValue(true);
const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
await expect(
withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
).resolves.toBe(false);
expect(moveKeyframe).not.toHaveBeenCalled();
withoutSelection.unmount();
const withSelection = renderHandlers(makeParams({ moveKeyframe }));
await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
true,
);
expect(moveKeyframe).toHaveBeenCalledOnce();
withSelection.unmount();
});
});
@@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({
animId: string,
fromPercentage: number,
toPercentage: number,
) => void;
) => Promise<boolean>;
resizeKeyframedTween: (
sel: DomEditSelection,
animId: string,
position: number,
duration: number,
pctRemap: Array<{ from: number; to: number }>,
) => void;
) => Promise<boolean>;
convertToKeyframes: (
sel: DomEditSelection,
animId: string,
@@ -355,7 +355,7 @@ export function useGsapSelectionHandlers({
const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId);
const toPercentage = computeCurrentPercentage(sel, anim);
trackStudioEvent("keyframe", { action: "move_to_playhead" });
moveKeyframe(sel, animId, fromPercentage, toPercentage);
void moveKeyframe(sel, animId, fromPercentage, toPercentage);
},
[resolveWriteSelection, selectedGsapAnimations, moveKeyframe],
);
@@ -368,13 +368,13 @@ export function useGsapSelectionHandlers({
selectionOverride?: DomEditSelection | null,
) => {
const sel = resolveWriteSelection(selectionOverride);
if (!sel) return;
if (!sel) return Promise.resolve(false);
// Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
// percentages are tween-relative (the drag handler converts the drop
// position before calling). No optimistic runtime hold — the soft-reload
// re-keys the diamond from source.
trackStudioEvent("keyframe", { action: "retime" });
moveKeyframe(sel, animId, fromPercentage, toPercentage);
return moveKeyframe(sel, animId, fromPercentage, toPercentage);
},
[resolveWriteSelection, moveKeyframe],
);
@@ -388,11 +388,11 @@ export function useGsapSelectionHandlers({
selectionOverride?: DomEditSelection | null,
) => {
const sel = resolveWriteSelection(selectionOverride);
if (!sel) return;
if (!sel) return Promise.resolve(false);
// Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
// in place. Distinct telemetry action so resize is separable from in-window move.
trackStudioEvent("keyframe", { action: "retime_resize" });
resizeKeyframedTween(sel, animId, position, duration, pctRemap);
return resizeKeyframedTween(sel, animId, position, duration, pctRemap);
},
[resolveWriteSelection, resizeKeyframedTween],
);
@@ -417,11 +417,12 @@ export function useGsapSelectionHandlers({
);
const handleGsapRemoveAllKeyframes = useCallback(
(animId: string) => {
if (!domEditSelection) return;
(animId: string, selectionOverride?: DomEditSelection | null) => {
const selection = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
if (!selection) return;
observeGsapMutation(
removeAllKeyframes(domEditSelection, animId),
domEditSelection,
removeAllKeyframes(selection, animId),
selection,
"remove-all-keyframes",
"Remove all keyframes",
);
@@ -10,6 +10,16 @@ interface StudioTestHookDeps {
) => void;
}
interface StudioTestApi {
selectByDomId: (id: string) => Promise<boolean>;
}
declare global {
interface Window {
__studioTest?: StudioTestApi;
}
}
/**
* Dev-only headless-QA shortcut. Selecting an element normally requires a
* pixel-precise click inside the preview iframe, which automated verification
@@ -33,7 +43,7 @@ export function useStudioTestHooks({
isDev = false;
}
if (!isDev || typeof window === "undefined") return;
const api = {
const api: StudioTestApi = {
selectByDomId: async (id: string): Promise<boolean> => {
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
if (!element) return false;
@@ -43,11 +53,11 @@ export function useStudioTestHooks({
return true;
},
};
(window as unknown as { __studioTest?: typeof api }).__studioTest = api;
window.__studioTest = api;
return () => {
// delete, not `= undefined`: an own key holding undefined keeps
// `"__studioTest" in window` true, which defeats feature detection.
delete (window as unknown as { __studioTest?: typeof api }).__studioTest;
delete window.__studioTest;
};
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
}