fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-02 03:38:38 -07:00
parent 6632d99c9c
commit 64076fcda3
22 changed files with 1244 additions and 921 deletions
+75 -164
View File
@@ -14,6 +14,7 @@ import {
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
import { usePlayerStore } from "../player/store/playerStore";
import { liveTween, previewWith } from "./gsapParsedTween.test-helpers";
// Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).
const selection = (): DomEditSelection =>
@@ -111,175 +112,86 @@ function recordingCallbacks(): {
};
}
describe("commitGsapPositionFromDrag — flat tween", () => {
beforeEach(() => {
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
describe("commitGsapPositionFromDrag — at a selected keyframe or past the tween", () => {
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
const puck = () => {
const element = document.createElement("div");
element.id = "puck-a";
return { id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection;
};
const preview = (
sel: DomEditSelection,
anim: GsapAnimation,
ends: Record<string, [number, number]>,
from = false,
) =>
previewWith(sel.element, [
liveTween(
sel.element,
{ start: anim.resolvedStart!, duration: anim.duration!, vars: anim.properties, ends },
{ from },
),
]);
it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
// fallow-ignore-next-line code-duplication
usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
const { types, callbacks } = recordingCallbacks();
await commitGsapPositionFromDrag(
selection(),
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
callbacks,
);
expect(types).toContain("convert-to-keyframes");
expect(types).toContain("replace-with-keyframes"); // the extend
expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
});
it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
// fallow-ignore-next-line code-duplication
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
// fallow-ignore-next-line code-duplication
const { types, callbacks } = recordingCallbacks();
await commitGsapPositionFromDrag(
selection(),
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
callbacks,
);
expect(types).toContain("add-keyframe");
expect(types).not.toContain("add-with-keyframes");
});
it("MODIFIES the selected keyframe (no extend) when one is selected, even past the tween end", async () => {
// User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
// the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
it("changes the selected keyframe without extending, even with the playhead past the end", async () => {
usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
// fallow-ignore-next-line code-duplication
const { types, mutations, callbacks } = recordingCallbacks();
const { mutations, callbacks } = recordingCallbacks();
const sel = puck();
await commitGsapPositionFromDrag(
selection(),
sel,
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
preview(sel, flatTween(), { x: [0, -260] }),
callbacks,
);
expect(types).toContain("add-keyframe");
expect(types).not.toContain("replace-with-keyframes"); // not extended
const addKf = mutations.find((m) => m.type === "add-keyframe");
expect(addKf?.percentage).toBe(100); // modified the selected endpoint
// consumed: cleared so the next free drag doesn't keep modifying
expect(mutations).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
position: 1.2,
duration: 2.2,
easeEach: "power1.out",
keyframes: [{ percentage: 100, properties: { x: -100, y: 0 } }],
}),
]);
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
// parked the playhead on the edited keyframe (1.2 start + 100% * 2.2 dur),
// so the edit is visible instead of rendering the base pose
expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4);
});
});
describe("commitGsapPositionFromDrag — keyframed tween backfill", () => {
beforeEach(() => {
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
const keyframedTween = (): GsapAnimation =>
({
id: "#puck-a-kf",
targetSelector: "#puck-a",
method: "to",
resolvedStart: 1.2,
duration: 2.2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: -260 } },
],
},
}) as unknown as GsapAnimation;
it("passes backfillDefaults so a newly-introduced prop doesn't move the other keyframes", async () => {
// Drag the 0% keyframe DOWN (introduces y on an x-only tween). The add-keyframe
// must carry backfillDefaults at the element's base so 100% gets y:0, not y:780.
usePlayerStore.setState({ currentTime: 1.2, activeKeyframePct: 0 });
const { mutations, callbacks } = recordingCallbacks();
await commitGsapPositionFromDrag(
selection(),
keyframedTween(),
{ x: 0, y: 780 }, // studioOffset: dragged straight down
{ x: 0, y: 0 }, // gsapPos → base falls back to {0,0} (selection has no base attrs)
null,
"#puck-a",
callbacks,
);
const addKf = mutations.find((m) => m.type === "add-keyframe");
expect(addKf).toBeDefined();
expect(addKf?.percentage).toBe(0); // edited the selected 0% keyframe
expect(addKf?.properties).toMatchObject({ y: 780 });
expect(addKf?.backfillDefaults).toEqual({ x: 0, y: 0 }); // base → 100% gets y:0
});
});
describe("commitGsapPositionFromDrag — from() tween dragged outside its range", () => {
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
const fromTween = (): GsapAnimation =>
({
id: "#title-from-400",
it("extends a from() tween to the playhead, keeping its parsed end as the old end", async () => {
usePlayerStore.setState({ currentTime: 2.2 }); // outside [0.4, 1.3]
const fromTween = {
id: "#title-from-400-position",
targetSelector: "#title",
method: "from",
propertyGroup: "position",
resolvedStart: 0.4,
duration: 0.9,
ease: "none",
properties: { y: 70 },
}) as unknown as GsapAnimation;
it("REPLACES the split position from() tween (no parallel tween → no drop jump)", async () => {
usePlayerStore.setState({ currentTime: 2.13 }); // outside [0.4, 1.3]
const types: string[] = [];
const mutations: Array<Record<string, unknown>> = [];
const callbacks: GsapDragCommitCallbacks = {
commitMutation: async (_s, m) => {
types.push(m.type as string);
mutations.push(m);
},
// After split-into-property-groups, the position group is a from() tween (no keyframes).
fetchAnimations: async () => [
{
id: "#title-from-400-position",
targetSelector: "#title",
method: "from",
propertyGroup: "position",
resolvedStart: 0.4,
duration: 0.9,
properties: { y: 70 },
} as unknown as GsapAnimation,
],
};
} as unknown as GsapAnimation;
const { types, mutations, callbacks } = recordingCallbacks();
const sel = puck();
await commitGsapPositionFromDrag(
selection(),
fromTween(),
sel,
fromTween,
{ x: 0, y: -333 },
{ x: 0, y: 0 },
null,
"#title",
preview(sel, fromTween, { y: [70, 0] }, true),
callbacks,
);
expect(types).toContain("split-into-property-groups");
expect(types).toContain("replace-with-keyframes");
expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
const replace = mutations.find((m) => m.type === "replace-with-keyframes");
expect(replace?.animationId).toBe("#title-from-400-position"); // replaces the split from()
expect(types).toEqual(["replace-with-keyframes"]);
expect(mutations[0]).toMatchObject({
animationId: "#title-from-400-position",
position: 0.4,
duration: 1.8,
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { y: 70, x: 0 } },
{ percentage: 50, properties: { y: 0, x: 0 } },
{ percentage: 100, properties: { y: -333, x: 0 } },
],
});
});
});
@@ -581,32 +493,31 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
});
});
describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => {
describe("commitGsapPositionFromDrag — keyframe commits omit instantPatch", () => {
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => {
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe
it("a keyframe drag relies on the soft reload, not the instant patch", async () => {
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
const { commits, callbacks } = optionRecordingCallbacks();
const element = document.createElement("div");
const sel = { id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection;
const live = liveTween(element, {
start: 1.2,
duration: 2.2,
vars: { x: -260 },
ends: { x: [0, -260] },
});
await commitGsapPositionFromDrag(
selection(),
sel,
flatTween(),
{ x: -100, y: 0 },
{ x: 0, y: 0 },
null,
"#puck-a",
previewWith(element, [live]),
callbacks,
);
// The keyframe path is structural here (convert + add-keyframe) and must rely
// on the soft reload — none of its commits opt into the instant patch.
expect(commits.length).toBeGreaterThan(0);
for (const c of commits) {
expect(c.options.instantPatch).toBeUndefined();
}
const types = commits.map((c) => c.mutation.type);
expect(types).toContain("convert-to-keyframes");
expect(types).toContain("add-keyframe");
expect(commits.map((c) => c.mutation.type)).toEqual(["replace-with-keyframes"]);
expect(commits[0]!.options.instantPatch).toBeUndefined();
expect(commits[0]!.options.softReload).toBe(true);
});
});
+2 -105
View File
@@ -4,14 +4,10 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
STUDIO_ORIGINAL_WIDTH_ATTR,
STUDIO_ORIGINAL_HEIGHT_ATTR,
} from "../components/editor/manualEditsTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
@@ -313,104 +309,6 @@ export async function commitStaticGsapSize(
);
}
/** Rounded `n` when it's a positive finite number, else `fallback`. */
function positiveOr(n: number, fallback: number): number {
return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback;
}
/**
* Prior size for a keyframed resize: the existing global set's value, else the
* element's pre-resize size (the draft saved it on the element before mutating
* el.style.width/height). Falls back to the new size when neither is available.
*/
function resolvePriorSize(
sizeSet: GsapAnimation | null,
el: Element | null | undefined,
fallbackW: number,
fallbackH: number,
): { width: number; height: number } {
if (sizeSet) {
return {
width: positiveOr(Number(sizeSet.properties.width), fallbackW),
height: positiveOr(Number(sizeSet.properties.height), fallbackH),
};
}
const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
}
/**
* Resize an *animated* element by keyframing its size at the current playhead,
* instead of a global `gsap.set` hold. Builds a width/height keyframe tween
* aligned to the element's existing animation: every base keyframe keeps the
* prior size, only the keyframe nearest the playhead gets the new size — so
* resizing one keyframe leaves the others unchanged. Replaces any prior global
* size set. Returns false when there's no usable range (caller falls back to the
* static set).
*/
export async function commitKeyframedSizeFromResize(
selection: DomEditSelection,
size: { width: number; height: number },
selector: string,
sizeSet: GsapAnimation | null,
animatedTween: GsapAnimation,
callbacks: GsapDragCommitCallbacks,
): Promise<boolean> {
const ts = resolveTweenStart(animatedTween) ?? 0;
const td = resolveTweenDuration(animatedTween);
if (!(td > 0)) return false;
const newW = roundToLayoutPx(size.width);
const newH = roundToLayoutPx(size.height);
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
const ct = usePlayerStore.getState().currentTime;
const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
// Base keyframe percentages from the animated tween (flat tween → 0 & 100),
// plus the endpoints and the playhead. Each keeps the prior size except the
// keyframe at the playhead, which gets the new size.
const pcts = new Set<number>(
animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
);
pcts.add(0);
pcts.add(100);
pcts.add(pct);
const keyframes = Array.from(pcts)
.sort((a, b) => a - b)
.map((p) => ({
percentage: p,
properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
}));
// Add the size keyframe tween FIRST, then delete the old global hold. The gesture
// transport applies both in one ordered batch; a plain commit fallback keeps the
// same recoverable ordering. Only the transaction's result triggers the reload.
const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
const target = newTweenTarget(selection);
if (!target) return false;
await callbacks.commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector: target,
position: roundTo3(ts),
duration: roundTo3(td),
keyframes,
},
sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
);
if (sizeSet) {
await callbacks.commitMutation(
selection,
{ type: "delete", animationId: sizeSet.id },
{ label: "Resize layer", softReload: true },
);
}
return true;
}
// ── Whole-path offset (plain drag on animated element) ──────────────────
/**
@@ -456,7 +354,6 @@ export async function commitWholePathOffset(
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
let kfs = effectiveAnim.keyframes?.keyframes ?? [];
if (kfs.length === 0) {
@@ -493,7 +390,7 @@ export async function commitWholePathOffset(
position: roundTo3(ts ?? 0),
duration: roundTo3(td || 1),
keyframes: shifted,
ease,
...keyframeEases(effectiveAnim),
},
{ label: "Move animation path", softReload: true, beforeReload: restoreOffset },
);
@@ -1,8 +1,7 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveTweenStart } from "../utils/globalTimeCompiler";
import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
@@ -10,8 +9,9 @@ import {
type GsapDragCommitCallbacks,
computeCurrentPercentage,
parkPlayheadOnKeyframe,
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapEditOutcome } from "./gsapEditOutcome";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
/**
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
@@ -38,234 +38,6 @@ export function buildTemporalArcKeyframes(
].sort((a, b) => a.percentage - b.percentage);
}
async function extendTweenAndAddKeyframe(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
targetTime: number,
tweenStart: number,
tweenDuration: number,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
backfillDefaults?: Record<string, number>,
): Promise<void> {
const tweenEnd = tweenStart + tweenDuration;
const newStart = Math.min(targetTime, tweenStart);
const newEnd = Math.max(targetTime, tweenEnd);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = anim.keyframes?.keyframes ?? [];
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
[];
for (const kf of existingKfs) {
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
const props: Record<string, number | string> = { ...kf.properties };
for (const k of Object.keys(properties)) {
if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k];
}
remappedKfs.push({ percentage: newPct, properties: props });
}
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
remappedKfs.push({ percentage: targetPct, properties });
remappedKfs.sort((a, b) => a.percentage - b.percentage);
await callbacks.commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes: remappedKfs,
},
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
);
}
// fallow-ignore-next-line complexity
async function commitKeyframedPosition(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
backfillDefaults?: Record<string, number>,
): Promise<void> {
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const computedPct = computeCurrentPercentage(selection, anim);
const pct = activeKeyframePct ?? computedPct;
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
...(backfillDefaults ? { backfillDefaults } : {}),
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
if (activeKeyframePct != null) {
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, pct);
}
}
interface DragRuntimeGsap {
getProperty: (target: Element, key: string) => unknown;
set: (target: Element, vars: Record<string, unknown>) => void;
}
interface DragRuntimeTimeline {
seek: (time: number) => void;
}
interface DragRuntime {
gsapLib: DragRuntimeGsap;
el: Element;
mainTl: DragRuntimeTimeline;
}
function resolveDragRuntime(
iframe: HTMLIFrameElement | null | undefined,
selector: string | undefined,
): DragRuntime | null {
if (!iframe || !selector) return null;
const win = iframe.contentWindow as
| (Window & {
gsap?: Partial<DragRuntimeGsap>;
__timelines?: Record<string, Partial<DragRuntimeTimeline>>;
})
| null;
const gsap = win?.gsap;
if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null;
let el: Element | null = null;
try {
el = iframe.contentDocument?.querySelector(selector) ?? null;
} catch {
return null;
}
if (!el) return null;
const timelines = win?.__timelines;
const mainTl = timelines ? Object.values(timelines)[0] : undefined;
if (typeof mainTl?.seek !== "function") return null;
return {
gsapLib: gsap as DragRuntimeGsap,
el,
mainTl: mainTl as DragRuntimeTimeline,
};
}
// fallow-ignore-next-line complexity
async function commitFlatViaKeyframes(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
iframe?: HTMLIFrameElement | null,
selector?: string,
backfillDefaults?: Record<string, number>,
): Promise<void> {
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveEditableTweenDuration(anim, selection);
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const outsideRange =
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const resolvedFromValues: Record<string, number | string> = {};
const runtime = resolveDragRuntime(iframe, selector);
if (runtime && ts !== null) {
const { gsapLib, el, mainTl } = runtime;
const draggedValues: Record<string, number> = {};
const channelsTheClearWipes = new Set([
...Object.keys(properties),
...PROPERTY_GROUPS.position,
]);
for (const key of channelsTheClearWipes) {
const v = Number(gsapLib.getProperty(el, key));
if (Number.isFinite(v)) draggedValues[key] = v;
}
try {
gsapLib.set(el, { clearProps: Object.keys(properties).join(",") });
mainTl.seek(ts);
for (const key of Object.keys(properties)) {
const v = Number(gsapLib.getProperty(el, key));
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
}
mainTl.seek(ct);
} catch {
for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
} finally {
if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
}
}
if (outsideRange && ts !== null) {
const coalesceKey = `gsap:convert-drag:${anim.id}`;
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
},
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
);
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
// By id first: a target with several tweens (two `to`s on the same selector)
// matches the selector lookup on whichever one happens to be first, and the
// extend-and-add below would then rewrite a tween the drag never touched.
const converted =
fresh.find((a) => a.id === anim.id && a.keyframes) ??
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
anim;
const convertedStart = resolveTweenStart(converted) ?? ts;
const convertedDur = resolveTweenDuration(converted) || td;
await extendTweenAndAddKeyframe(
selection,
converted,
properties,
ct,
convertedStart,
convertedDur,
callbacks,
beforeReload,
);
return;
}
const coalesceKey = `gsap:convert-drag:${anim.id}`;
// fallow-ignore-next-line code-duplication
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
},
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
);
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
const editedSelected = activeKeyframePct != null;
if (editedSelected) setActiveKeyframePct(null);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
...(backfillDefaults ? { backfillDefaults } : {}),
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
);
if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
}
// fallow-ignore-next-line code-duplication
// fallow-ignore-next-line complexity
export async function commitGsapPositionFromDrag(
@@ -274,9 +46,8 @@ export async function commitGsapPositionFromDrag(
studioOffset: { x: number; y: number },
gsapPos: { x: number; y: number },
iframe: HTMLIFrameElement | null,
selector: string,
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
): Promise<GsapEditOutcome> {
const el = selection.element;
// fallow-ignore-next-line code-duplication
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
@@ -293,8 +64,6 @@ export async function commitGsapPositionFromDrag(
el.removeAttribute("data-hf-drag-initial-offset-y");
};
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);
@@ -319,14 +88,15 @@ export async function commitGsapPositionFromDrag(
);
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, pct);
return;
return { status: "persisted" };
}
const tweenStart = resolveTweenStart(anim);
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
// default here would collapse a duration-less arc's window on drag.
const tweenDuration = resolveEditableTweenDuration(anim, selection);
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
return { status: "persisted" };
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
await callbacks.commitMutation(
selection,
@@ -341,156 +111,11 @@ export async function commitGsapPositionFromDrag(
},
{ 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;
const dragProps: Record<string, number> = { x: newX, y: newY };
const ts = resolveTweenStart(effectiveAnim);
const td = resolveEditableTweenDuration(effectiveAnim, selection);
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
if (outsideRange && !hasSelectedKeyframe) {
await extendTweenAndAddKeyframe(
selection,
effectiveAnim,
dragProps,
ct,
ts,
td,
callbacks,
restoreOffset,
backfillDefaults,
);
} else {
await commitKeyframedPosition(
selection,
effectiveAnim,
dragProps,
callbacks,
restoreOffset,
backfillDefaults,
);
}
} else if (anim.method === "from" || anim.method === "fromTo") {
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveEditableTweenDuration(anim, selection);
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
const outsideRange =
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
const dragProps: Record<string, number> = { x: newX, y: newY };
if (outsideRange && ts !== null) {
await callbacks.commitMutation(
selection,
{ type: "split-into-property-groups", animationId: anim.id },
{ label: "Split from() for drag", skipReload: true },
);
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
const existingPosAnim = allAnims.find(
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
);
if (existingPosAnim?.keyframes) {
const posTs = resolveTweenStart(existingPosAnim);
const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
if (posTs !== null) {
await extendTweenAndAddKeyframe(
selection,
existingPosAnim,
{ x: newX, y: newY },
ct,
posTs,
posTd,
callbacks,
restoreOffset,
backfillDefaults,
);
return;
}
}
const newStart = Math.min(ct, ts);
const newEnd = Math.max(ct, ts + td);
const newDuration = Math.max(0.01, newEnd - newStart);
const dragBefore = ct < ts;
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
[];
if (dragBefore) {
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
if (origStartPct > 0.5 && origStartPct < 99.5) {
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
}
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
} else {
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
if (origEndPct > 0.5 && origEndPct < 99.5) {
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
}
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
}
keyframes.sort((a, b) => a.percentage - b.percentage);
const baseKf = {
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes,
};
await callbacks.commitMutation(
selection,
existingPosAnim
? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
: { type: "add-with-keyframes", ...baseKf },
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
);
} else {
const coalesceKey = `gsap:convert-drag:${anim.id}`;
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
},
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
);
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties: dragProps,
...(backfillDefaults ? { backfillDefaults } : {}),
},
{
label: `Move layer (keyframe ${pct}%)`,
softReload: true,
beforeReload: restoreOffset,
coalesceKey,
},
);
}
} else {
await commitFlatViaKeyframes(
selection,
anim,
{ x: newX, y: newY },
callbacks,
restoreOffset,
iframe,
selector,
backfillDefaults,
);
return { status: "persisted" };
}
return commitValueAtPlayhead(selection, anim, { x: newX, y: newY }, iframe, callbacks, {
label: "Move layer",
backfill: { x: baseGsapX, y: baseGsapY },
beforeReload: restoreOffset,
});
}
+20 -2
View File
@@ -1,6 +1,10 @@
import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
export type GsapEditBlockReason =
| "no-selector"
| "unroll-required"
| "source-uneditable"
| "keyframes-uneditable";
/**
* Which of the nine situations produced a block. The user-facing `reason` stays
@@ -21,7 +25,19 @@ export type GsapEditBlockDetail =
| "no-position-tween"
| "live-rotation-no-source-tween"
| "live-resize-no-source-tween"
| "zero-duration-tween";
| "zero-duration-tween"
| PlayheadEditRefusal;
/** Why an edit at the playhead could not be written as keyframes of the file's tween. */
export type PlayheadEditRefusal =
| "eased-keyframes"
| "simple-array-keyframes"
| "unknown-ease"
| "implicit-end-unknown"
| "not-a-tween"
| "tween-extras"
| "no-timing"
| "shared-tween";
export type GsapEditOutcome =
| {
@@ -52,6 +68,8 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
"unroll-required":
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
"source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
"keyframes-uneditable":
"Studio can't add this edit to the animation's keyframes. Edit this animation in the Code tab.",
};
export class GsapEditBlockedError extends Error {
@@ -0,0 +1,45 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
/** A property's `[start, end]` as GSAP parsed it from the file when the tween first rendered. */
type Ends = Record<string, [number, number]>;
/** A GSAP 3 tween as the preview runtime holds it, with the property tweens the parse readers walk. */
export function liveTween(
target: Element,
tween: { start: number; duration: number; vars: Record<string, unknown>; ends?: Ends },
{ from = false, parts }: { from?: boolean; parts?: unknown[] } = {},
) {
let head: Record<string, unknown> | undefined;
for (const [p, [s, e]] of Object.entries(tween.ends ?? {}).reverse())
head = from ? { p, s: e, c: s - e, _next: head } : { p, s, c: e - s, _next: head };
return {
targets: () => [target],
startTime: () => tween.start,
duration: () => tween.duration,
vars: tween.vars,
_from: from,
...(head && { _pt: { d: { _pt: head } } }),
...(parts && { timeline: { getChildren: () => parts } }),
};
}
/** A preview iframe whose one timeline holds `tweens`, answering `getProperty` from `live`. */
export function previewWith(
element: Element,
tweens: unknown[],
live: Record<string, number> = {},
): HTMLIFrameElement {
return {
contentWindow: {
__timelines: { main: { getChildren: () => tweens, duration: () => 10 } },
gsap: {
getProperty: (_el: Element, prop: string) => live[prop] ?? 0,
defaults: () => ({ ease: "power1.out" }),
},
},
contentDocument: element.ownerDocument,
} as unknown as HTMLIFrameElement;
}
export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
@@ -0,0 +1,113 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { resolveTweenStart } from "../utils/globalTimeCompiler";
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
interface PropTween {
p?: string;
s?: number;
c?: number;
d?: { _pt?: PropTween };
_next?: PropTween;
}
interface ParsedTween {
_pt?: PropTween;
_from?: boolean;
vars?: Record<string, unknown>;
parent?: { vars?: { defaults?: { ease?: unknown } } };
timeline?: { getChildren?: () => ParsedTween[] };
targets?: () => Element[];
startTime?: () => number;
duration?: () => number;
}
interface GsapWindow {
gsap?: { defaults?: () => { ease?: unknown } };
__timelines?: Record<string, { getChildren?: (nested: boolean) => ParsedTween[] }>;
}
// `scale` parses into the two longhands, which always share a start and end for a `scale` tween.
const PARSED_NAME: Record<string, string> = { scale: "scaleX", rotate: "rotation" };
function findPropTween(pt: PropTween | undefined, prop: string): PropTween | null {
for (let node = pt; node; node = node._next) {
if (node.p === prop && typeof node.s === "number" && typeof node.c === "number") return node;
const nested = findPropTween(node.d?._pt, prop);
if (nested) return nested;
}
return null;
}
/** `[start, end]` of `prop` in one initialised tween; a from() tween runs its pair backwards. */
function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
const pt = findPropTween(tween._pt, PARSED_NAME[prop] ?? prop);
if (!pt) return null;
const pair: [number, number] = [pt.s!, pt.s! + pt.c!];
return tween._from ? [pair[1], pair[0]] : pair;
}
/** The live tween GSAP built from `anim`: same element, start and channels. */
// fallow-ignore-next-line complexity
export function findParsedTween(
iframe: HTMLIFrameElement | null,
element: Element,
anim: GsapAnimation,
): ParsedTween | null {
const win = iframe?.contentWindow as GsapWindow | null;
const start = resolveTweenStart(anim);
if (!win?.__timelines || start == null) return null;
const props = Object.keys(anim.keyframes?.keyframes[0]?.properties ?? anim.properties);
for (const timeline of Object.values(win.__timelines)) {
for (const tween of timeline?.getChildren?.(true) ?? []) {
const targets = tween.targets?.() ?? [];
if (!targets.includes(element) && !targets.some((t) => element.id && t.id === element.id))
continue;
if (Math.abs((tween.startTime?.() ?? Number.NaN) - start) > 1e-3) continue;
const vars = tween.vars ?? {};
const carries = props.some((p) => p in vars) || "keyframes" in vars;
if (carries && (tween.duration?.() ?? 0) > 0) return tween;
}
}
return null;
}
/** Start and end values from GSAP's own parse of the tween, as loaded from the file. Null when
* GSAP has not initialised that part of the tween yet: the caller refuses rather than guess. */
export function parsedImplicitEndValue(tween: ParsedTween | null): ImplicitEndValue {
return (prop, end) => {
if (!tween) return null;
const parts = tween.timeline?.getChildren?.() ?? [];
const ordered = parts.length > 0 ? (end === "start" ? parts : [...parts].reverse()) : [tween];
for (const part of ordered) {
const pair = endsIn(part, prop);
if (pair) return end === "start" ? pair[0] : pair[1];
// The nearest part animating `prop` is not initialised: an earlier one would be a wrong value.
if (prop in (part.vars ?? {})) return null;
}
return null;
};
}
/** The ease GSAP resolved for a flat tween that authors none: its timeline's default, else GSAP's. */
export function parsedTweenEase(iframe: HTMLIFrameElement | null, tween: ParsedTween | null) {
if (!tween) return null;
const win = iframe?.contentWindow as GsapWindow | null;
const ease =
tween.vars?.ease ?? tween.parent?.vars?.defaults?.ease ?? win?.gsap?.defaults?.().ease;
return typeof ease === "string" ? ease : null;
}
/** An array of keyframe steps at the exact percentages GSAP times them; the parse rounds them. */
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
const data = anim.keyframes;
const parts = tween?.timeline?.getChildren?.() ?? [];
const total = tween?.duration?.() ?? 0;
if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
return anim;
const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
if (ends.some((end) => !Number.isFinite(end))) return anim;
const keyframes = data.keyframes.map((kf, i) => ({
...kf,
percentage: Math.round(ends[i]! * 100000) / 1000,
}));
return { ...anim, keyframes: { ...data, keyframes } };
}
@@ -7,7 +7,7 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { getIframeGsap, queryIframeElement } from "./gsapShared";
import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
// fallow-ignore-next-line complexity
@@ -47,33 +47,41 @@ export function findGsapPositionAnimation(
if (animations.length === 0) return null;
const currentTime = usePlayerStore.getState().currentTime;
const scored = animations
.filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
.map((a) => {
let score = 0;
if (animHasPosition(a)) score += 10;
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
else
score -= Math.round(
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
);
return { anim: a, score };
});
const scored = animations.filter(animHasPosition).map((a) => {
let score = 0;
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
else
score -= Math.round(
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
);
return { anim: a, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.anim ?? animations[0];
return scored[0]?.anim ?? null;
}
/** Whether the file states `anim`'s value at `time`; a tween's implicit start is GSAP's current value. */
function statesValueAt(anim: GsapAnimation, time: number): boolean {
const duration = resolveTweenDuration(anim);
if (!(duration > 0)) return false;
const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage));
if (anim.method === "from") return near(0);
return near(100) || (anim.method === "fromTo" && near(0));
}
/**
* From a set of candidate tweens, pick the one whose time range is closest to
* the current playhead. A tween that *contains* the playhead wins outright;
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
* extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
* gesture-recorded tween beats a stub when both are equidistant).
* extends) the 4s tween, not the 1.5s one. Ties: the one whose file states a value at the
* playhead (the later one, which renders, if both do), then most keyframes.
*/
// fallow-ignore-next-line complexity
export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
@@ -88,6 +96,11 @@ export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | n
const bestDist =
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
if (dist < bestDist) return a;
if (dist === 0 && bestDist === 0) {
const [mine, theirs] = [statesValueAt(a, ct), statesValueAt(best, ct)];
if (mine !== theirs) return mine ? a : best;
if (mine && s !== bestS) return s > bestS ? a : best;
}
if (
dist === bestDist &&
(a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
@@ -148,17 +148,18 @@ function positionAnimation(
} as unknown as GsapAnimation;
}
/** Every x/y a run wrote, in commit order. */
/** Every x/y a run wrote AT THE PLAYHEAD (percentage 0 here), in commit order. */
function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> {
const written: Array<{ x?: number; y?: number }> = [];
for (const call of calls) {
const mutation = call[1] as {
properties?: Record<string, number>;
keyframes?: Array<{ properties: Record<string, number> }>;
keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
x?: number;
y?: number;
};
const sources = [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)];
const atPlayhead = (mutation.keyframes ?? []).filter((k) => k.percentage === 0);
const sources = [mutation.properties, ...atPlayhead.map((k) => k.properties)];
if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y });
for (const source of sources) {
if (source && (source.x != null || source.y != null))
@@ -37,6 +37,7 @@ const widthTween = {
position: 0,
resolvedStart: 0,
duration: 4,
ease: "none",
} as unknown as GsapAnimation;
function mount(animations: GsapAnimation[]) {
@@ -67,7 +67,7 @@ function tween(id: string, properties: Props, duration: number): GsapAnimation {
position: 0,
resolvedStart: 0,
duration,
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : { ease: "none" }),
} as unknown as GsapAnimation;
}
@@ -5,10 +5,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { computeCurrentPercentage } from "./gsapDragCommit";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { liveTween } from "./gsapParsedTween.test-helpers";
afterEach(() => {
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
// One #clip per test: leftovers would make every tween on it look shared.
document.body.innerHTML = "";
});
/**
@@ -36,10 +39,18 @@ function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
// The element's OPACITY intro tween lives on the timeline: unfiltered
// capture would pick `opacity` up via the other-tween sweep.
const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
const scaleIntro = liveTween(
el,
{ start: 0.2, duration: 0.8, vars: { scale: 0.9 }, ends: { scaleX: [0.9, 1] } },
{ from: true },
);
return {
contentWindow: {
__timelines: { main: { getChildren: () => [opacityIntro] } },
gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
__timelines: { main: { getChildren: () => [opacityIntro, scaleIntro] } },
gsap: {
getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0,
defaults: () => ({ ease: "power1.out" }),
},
},
contentDocument: document,
} as unknown as HTMLIFrameElement;
@@ -289,24 +300,26 @@ async function runResize(
return committed;
}
it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
it("scale-route resize keyframes scale through the group filter, not width/height", async () => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
// uniform: 800/640 === 450/360
const committed = await runResize(el, iframe, { width: 800, height: 450 });
const convert = committed.find((m) => m.type === "convert-to-keyframes");
expect(convert).toBeDefined();
const fromValues = convert!.resolvedFromValues as Record<string, number>;
// Group filter: the opacity intro tween must NOT leak into the conversion.
expect(fromValues).not.toHaveProperty("opacity");
expect(fromValues).toHaveProperty("scale");
const keyframes = committed.find((m) => m.type === "replace-with-keyframes")?.keyframes as
| Array<{ properties: Record<string, number> }>
| undefined;
// Group filter: the opacity intro tween must NOT leak into the scale keyframes.
expect(keyframes?.some((k) => "opacity" in k.properties)).toBe(false);
expect(keyframes?.some((k) => "scale" in k.properties)).toBe(true);
// Every committed property is scale-group — the resize never writes
// width/height for a scale-driven element (the double-apply bug class).
const allProps = committed.flatMap((m) => [
...Object.keys((m.properties as Record<string, unknown>) ?? {}),
...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
...((m.keyframes as Array<{ properties: object }>) ?? []).flatMap((k) =>
Object.keys(k.properties),
),
]);
expect(allProps).not.toContain("width");
expect(allProps).not.toContain("height");
+36 -161
View File
@@ -14,44 +14,34 @@ import {
} from "../components/editor/manualEditsTypes";
import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import { readGsapProperty } from "./gsapRuntimeReaders";
import {
commitStaticGsapPosition,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
computeCurrentPercentage,
findExistingPositionWrite,
findSizeSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
import { resolveGroupTween } from "./gsapRuntimeBridge";
import { logResize } from "../utils/resizeDebug";
import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
import {
animationWritesAnyProperty,
assertGsapEditPersisted,
type GsapEditOutcome,
} from "./gsapEditOutcome";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
const SIZE_PROPS = new Set(["width", "height"]);
/** Build identity (zero / one) values for each property in `source`. */
function synthesizeIdentityProps(
source: Record<string, number | string>,
): Record<string, number | string> {
const id: Record<string, number | string> = {};
for (const [k, v] of Object.entries(source)) {
if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
else id[k] = v;
}
return id;
}
/**
* The element's box before the resize draft ran, in CSS pixels.
*
@@ -72,6 +62,13 @@ function originalBoxSize(
return Number.isFinite(inline) && inline > 0 ? inline : null;
}
/** The box's width and height before the draft, for a size tween that animates only one. */
function preGestureBoxSize(el: HTMLElement): Record<string, number> {
const width = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width");
const height = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height");
return { ...(width != null && { width }), ...(height != null && { height }) };
}
/**
* Whether this tween already states scale as `scaleX`/`scaleY`.
*
@@ -120,7 +117,7 @@ export async function tryGsapResizeIntercept(
postSplitFetch,
);
let anim =
const anim =
resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
? resolved.anim
: null;
@@ -166,15 +163,14 @@ export async function tryGsapResizeIntercept(
);
if (animatedTween) {
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
const handled = await commitKeyframedSizeFromResize(
return commitValueAtPlayhead(
selection,
size,
sel,
sizeSet,
animatedTween,
{ width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) },
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
{ label: "Resize", backfill: preGestureBoxSize(selection.element) },
);
if (handled) return { status: "persisted" };
}
}
@@ -196,16 +192,10 @@ export async function tryGsapResizeIntercept(
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
const selector = selectorFromSelection(selection);
// Scope every capture to the resize group — same contract as the rotation
// intercept. Unfiltered, an opacity-touching intro tween on the element
// would ride into resize conversions/backfills (the Fix-2 bake class).
const runtimeProps = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: {};
let resizeProps: Record<string, number>;
let resizeBackfill: Record<string, number>;
let scaleDraftEl: HTMLElement | null = null;
let scaleDraftDropPoint: { x: number; y: number } | null = null;
/** The scale this commit is putting on the element, for the finalize step. */
@@ -262,6 +252,7 @@ export async function tryGsapResizeIntercept(
resizeProps = useScaleLonghands
? { scaleX: newScaleX, scaleY: newScaleY }
: { scale: newScaleX };
resizeBackfill = { scaleX: liveScaleX, scaleY: liveScaleY };
logResize("intercept-route", {
route: "scale-tween",
cssW,
@@ -295,6 +286,7 @@ export async function tryGsapResizeIntercept(
width: roundToLayoutPx(size.width),
height: roundToLayoutPx(size.height),
};
resizeBackfill = preGestureBoxSize(selection.element);
}
// Finalize a scale-route commit: tear down the gesture's inline width/height
// draft (leaving it applied compounds with the committed scale — the element
@@ -404,10 +396,12 @@ export async function tryGsapResizeIntercept(
);
if (positionTween) {
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
assertGsapEditPersisted(
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
}),
);
return true;
}
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
@@ -435,134 +429,15 @@ export async function tryGsapResizeIntercept(
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = tweenDuration;
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
// Outside-range uses the extend path which handles everything atomically.
if (!outsideRange) {
// fallow-ignore-next-line code-duplication
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for resize" },
);
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const refreshed = fresh.find(
(a) => a.targetSelector === anim!.targetSelector && a.keyframes,
);
if (refreshed) anim = refreshed;
}
}
}
// A NON-uniform scale must also take the full-rewrite path: it mixes
// scaleX/scaleY into a tween whose existing keyframes may carry the uniform
// `scale` shorthand, and GSAP's percentage keyframes animate each property
// name independently — a shorthand/longhand mix would leave the old `scale`
// sub-tween running against the new scaleX/scaleY. The rewrite below
// normalizes every keyframe to the longhands. For an in-range resize the
// min/max window math below degenerates to the tween's own start/duration,
// so timing is unchanged.
if ((outsideRange || useScaleLonghands) && ts !== null) {
// For flat tweens, synthesize the keyframes from the tween's properties
const kfs =
anim.keyframes?.keyframes ??
(() => {
const fromProps =
anim.method === "from" || anim.method === "fromTo"
? { ...anim.properties }
: synthesizeIdentityProps(anim.properties);
const toProps =
anim.method === "from"
? synthesizeIdentityProps(anim.properties)
: { ...anim.properties };
return [
{ percentage: 0, properties: fromProps },
{ percentage: 100, properties: toProps },
];
})();
const newStart = Math.min(ct, ts);
const newEnd = Math.max(ct, ts + td);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = kfs;
const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
for (const kf of existingKfs) {
const absTime = ts + (kf.percentage / 100) * td;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
const props = { ...kf.properties };
// Normalize the uniform `scale` shorthand to longhands when this commit
// writes scaleX/scaleY, so the tween never mixes the two forms.
if (nonUniformScale && "scale" in props) {
const uniform = props.scale;
if (typeof uniform === "number") {
props.scaleX = uniform;
props.scaleY = uniform;
}
delete props.scale;
}
// Only backfill properties that the animation already had (x, y, scale).
// Don't backfill width/height — they should only appear on the resize keyframe.
for (const k of Object.keys(resizeProps)) {
if (k in props) continue;
if (k === "width" || k === "height") continue;
props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
remapped.push({ percentage: newPct, properties: props });
}
const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
// An in-range rewrite can land on an existing keyframe's percentage —
// merge into it instead of emitting a duplicate step.
const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05);
if (collidingKf) Object.assign(collidingKf.properties, resizeProps);
else remapped.push({ percentage: targetPct, properties: resizeProps });
remapped.sort((a, b) => a.percentage - b.percentage);
await commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(newStart),
duration: roundTo3(newDuration),
keyframes: remapped,
},
{
label: outsideRange
? `Resize (extended to ${ct.toFixed(2)}s)`
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
softReload: true,
},
);
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
const backfillDefaults: Record<string, number> = {};
for (const k of Object.keys(runtimeProps)) {
if (SIZE_PROPS.has(k)) continue;
backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
}
await commitMutation(
const written = await commitValueAtPlayhead(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties: resizeProps,
backfillDefaults,
},
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
anim,
resizeProps,
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
{ label: "Resize", backfill: resizeBackfill },
);
if (written.status !== "persisted") return written;
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
}
@@ -23,7 +23,7 @@ function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement {
return {
contentWindow: {
__timelines: { "index.html": timeline },
gsap: { getProperty: () => 0 },
gsap: { getProperty: () => 0, defaults: () => ({ ease: "power1.out" }) },
},
contentDocument: { querySelector: (sel: string) => (sel === `#${elId}` ? el : null) },
} as unknown as HTMLIFrameElement;
@@ -451,7 +451,8 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
fakeIframe("puck-b", []),
commitMutation,
);
return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
const mutations = commitMutation.mock.calls.map(([, mutation]) => mutation);
return { handled, mutations, types: mutations.map((mutation) => mutation.type) };
}
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
@@ -461,10 +462,40 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
expect(types).not.toContain("add-keyframe");
});
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
const { handled, types } = await runAutoKeyframeDrag(true);
it("shifts a linear flat tween as keyframes that stay linear", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 1 });
const commitMutation = vi.fn();
const linear = { ...stalePositionAnim, ease: "none" } as GsapAnimation;
const live = {
targets: () => [{ id: "puck-b" }],
vars: { x: -180, y: -60, ease: "none" },
duration: () => 2,
startTime: () => 1,
};
await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
[linear],
fakeIframe("puck-b", [live]),
commitMutation,
);
const [mutation] = commitMutation.mock.calls.map(([, m]) => m);
expect(mutation).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" });
expect(mutation).not.toHaveProperty("ease", "none");
});
it("still changes only the keyframe at the playhead when the toggle is on (default)", async () => {
const { handled, mutations } = await runAutoKeyframeDrag(true);
expect(handled).toEqual({ status: "persisted" });
expect(types).not.toContain("replace-with-keyframes");
expect(mutations).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: -50, y: 0 } },
],
}),
]);
});
});
+23 -45
View File
@@ -15,6 +15,7 @@ import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
import {
commitStaticGsapPosition,
commitStaticGsapRotation,
@@ -22,7 +23,6 @@ import {
computeCurrentPercentage,
findExistingPositionWrite,
findRotationSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { isGestureTransactionCommit } from "./gestureTransaction";
@@ -250,7 +250,8 @@ export async function tryGsapDragIntercept(
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const dupes = fresh.filter(isPosWrite);
if (dupes.length > 1) {
// Real tweens one after another are a motion, not a conflict: only holds can fight.
if (dupes.length > 1 && dupes.filter((a) => !isInstantHold(a)).length <= 1) {
const keeper =
dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
await commitMutation(
@@ -329,12 +330,16 @@ export async function tryGsapDragIntercept(
// current ID and avoid a stale-ID remove that creates duplicate tweens.
if (fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const freshMatch = fresh.find(
(a) =>
a.targetSelector === posAnim!.targetSelector &&
a.propertyGroup === posAnim!.propertyGroup &&
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
);
const freshMatch =
fresh.find((a) => a.id === posAnim!.id) ??
pickClosestToPlayhead(
fresh.filter(
(a) =>
a.targetSelector === posAnim!.targetSelector &&
a.propertyGroup === posAnim!.propertyGroup &&
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
),
);
if (freshMatch && freshMatch.id !== posAnim.id) {
posAnim = freshMatch;
}
@@ -349,7 +354,7 @@ export async function tryGsapDragIntercept(
if (options?.altKey || !autoKeyframeEnabled) {
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
} else {
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs);
}
return { status: "persisted" };
}
@@ -428,8 +433,9 @@ export async function tryGsapRotationIntercept(
? resolved.anim
: null;
if (!anim) {
anim =
workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
anim = pickClosestToPlayhead(
workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
);
}
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -469,42 +475,14 @@ export async function tryGsapRotationIntercept(
return { status: "persisted" };
}
// fallow-ignore-next-line code-duplication
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
} else if (!anim.keyframes) {
const resolvedFromValues = selector
? readAllAnimatedProperties(iframe, selector, anim, "rotation")
: undefined;
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
{ label: "Convert to keyframes for rotation", skipReload: true },
);
}
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
const backfillDefaults: Record<string, number> = { ...runtimeProps };
if (!("rotation" in runtimeProps)) {
backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
}
const properties = { ...runtimeProps, rotation: newRotation };
await commitMutation(
return commitValueAtPlayhead(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
backfillDefaults,
},
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
anim,
{ rotation: newRotation },
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
{ label: "Rotate" },
);
return { status: "persisted" };
}
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
+7
View File
@@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
height: 100,
};
/** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
* since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
if (!anim.keyframes) return { easeEach: anim.ease };
return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
}
/**
* A timeline write that applies an instantaneous value and then holds it.
* `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
@@ -0,0 +1,445 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
import { planValueAtPlayhead, type PlayheadEdit } from "./gsapValueAtPlayhead";
let el: HTMLElement;
let selection: DomEditSelection;
beforeEach(() => {
el = document.createElement("div");
el.id = "box";
document.body.append(el);
selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
});
afterEach(() => {
el.remove();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
/** Drags `#box` by `dx` from a pre-gesture GSAP position of `base`, at `time`. */
async function drag(
anims: GsapAnimation[],
live: unknown[],
{
time,
base,
dx,
values = {},
}: { time: number; base: [number, number]; dx: number; values?: Record<string, number> },
) {
usePlayerStore.setState({ currentTime: time });
el.setAttribute("data-hf-drag-gsap-base-x", String(base[0]));
el.setAttribute("data-hf-drag-gsap-base-y", String(base[1]));
const commitMutation = vi.fn();
const outcome = await tryGsapDragIntercept(
selection,
{ x: dx, y: 0 },
anims,
previewWith(el, live, values),
commitMutation,
async () => anims,
);
return { outcome, mutations: commitMutation.mock.calls.map((call) => call[1]) };
}
const fromX = tween({
id: "#box-from-0-position",
method: "from",
properties: { x: -60 },
resolvedStart: 0,
duration: 2,
ease: "none",
});
const fromXLive = (end: number) =>
liveTween(
el,
{ start: 0, duration: 2, vars: { x: -60, ease: "none" }, ends: { x: [-60, end] } },
{ from: true },
);
describe("a move on a GSAP-animated layer, at the playhead", () => {
it("adds a keyframe inside a from() tween and keeps its end where GSAP parsed it", async () => {
const { outcome, mutations } = await drag([fromX], [fromXLive(40)], {
time: 1,
base: [-10, 0],
dx: 30,
});
expect(outcome).toEqual({ status: "persisted" });
expect(mutations).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-from-0-position",
position: 0,
duration: 2,
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { x: -60, y: 0 } },
{ percentage: 50, properties: { x: 20, y: 0 } },
{ percentage: 100, properties: { x: 40, y: 0 } },
],
}),
]);
});
it("writes the end GSAP parsed from the file, not the dragged element's live value", async () => {
const { mutations } = await drag([fromX], [fromXLive(40)], {
time: 1,
base: [-10, 0],
dx: 30,
values: { x: 999 },
});
expect(mutations[0].keyframes.at(-1)).toEqual({ percentage: 100, properties: { x: 40, y: 0 } });
});
it("extends a to() tween back to the playhead, holding the start GSAP folded from CSS translate", async () => {
const toX = tween({
id: "#box-to-2000-position",
method: "to",
properties: { x: 60 },
resolvedStart: 2,
duration: 1,
ease: "none",
});
// `translate: 40px 30px` on the element: GSAP parses x from 40, not 0.
const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [40, 60] } });
const { mutations } = await drag([toX], [live], { time: 1, base: [40, 0], dx: -20 });
expect(mutations).toEqual([
expect.objectContaining({
animationId: "#box-to-2000-position",
position: 1,
duration: 2,
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { x: 20, y: 0 } },
{ percentage: 50, properties: { x: 40, y: 0 } },
{ percentage: 100, properties: { x: 60, y: 0 } },
],
}),
]);
});
it("refuses, with the Code tab message, when GSAP has not parsed the tween's end yet", async () => {
const unparsed = liveTween(el, { start: 0, duration: 2, vars: { x: -60 } }, { from: true });
const { outcome, mutations } = await drag([fromX], [unparsed], {
time: 1,
base: [-10, 0],
dx: 30,
});
expect(outcome).toEqual({
status: "blocked",
reason: "keyframes-uneditable",
detail: "implicit-end-unknown",
});
expect(mutations).toEqual([]);
expect(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"]).toContain("Code tab");
});
it("adds a keyframe between two steps of a keyframes array", async () => {
const keys = tween({
id: "#box-to-0-position",
method: "to",
properties: {},
resolvedStart: 0,
duration: 3,
keyframes: {
format: "object-array",
keyframes: [
// The parse rounds the step to 66.7%; GSAP times it at 2 s of 3.
{ percentage: 66.7, properties: { x: 60 } },
{ percentage: 100, properties: { x: 120 } },
],
},
});
const steps = [
{ startTime: () => 0, duration: () => 2 },
{ startTime: () => 2, duration: () => 1 },
];
const live = liveTween(
el,
{ start: 0, duration: 3, vars: { keyframes: [] } },
{ parts: steps },
);
const { mutations } = await drag([keys], [live], { time: 1, base: [30, 0], dx: 10 });
expect(mutations.map((m) => m.type)).toEqual(["replace-with-keyframes"]);
// An array step eases linearly unless it says otherwise; percentage keyframes would not.
expect(mutations[0].keyframes).toEqual([
{ percentage: 33.333, properties: { x: 40, y: 0 }, ease: "none" },
{ percentage: 66.667, properties: { x: 60, y: 0 }, ease: "none" },
{ percentage: 100, properties: { x: 120, y: 0 }, ease: "none" },
]);
});
it("never writes x and y into a tween that only animates size", async () => {
const size = tween({
id: "#box-to-0-size",
propertyGroup: "size",
method: "to",
properties: {},
resolvedStart: 0,
duration: 2,
keyframes: {
format: "percentage",
keyframes: [{ percentage: 100, properties: { width: 300 } }],
},
});
const live = liveTween(el, {
start: 0,
duration: 2,
vars: { keyframes: { "100%": { width: 300 } } },
});
const { mutations } = await drag([size], [live], { time: 1, base: [0, 0], dx: 10 });
expect(mutations.some((m) => m.animationId === "#box-to-0-size")).toBe(false);
});
});
describe("where two tweens meet at the playhead", () => {
it("edits the one whose keyframe the file states there, not the one listed first", async () => {
const fromTo = tween({
id: "#box-fromTo-2000-position",
method: "fromTo",
fromProperties: { x: 0 },
properties: { x: 60 },
resolvedStart: 2,
duration: 1,
ease: "none",
});
const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [0, 60] } });
const { mutations } = await drag([fromX, fromTo], [fromXLive(40), live], {
time: 2,
base: [0, 0],
dx: 25,
});
expect(mutations).toEqual([
expect.objectContaining({
animationId: "#box-fromTo-2000-position",
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { x: 25, y: 0 } },
{ percentage: 100, properties: { x: 60, y: 0 } },
],
}),
]);
});
});
describe("a resize on a layer whose size GSAP animates, at the playhead", () => {
it("adds a size keyframe inside the tween at the playhead", async () => {
const grow = (id: string, start: number, duration: number, width: number, height: number) =>
tween({
id,
propertyGroup: "size",
method: "to",
properties: { width, height },
resolvedStart: start,
duration,
ease: "none",
});
const first = grow("#box-to-0-size", 0, 2, 300, 200);
const second = grow("#box-to-2000-size", 2, 1, 360, 240);
const live = [first, second].map((a) =>
liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }),
);
usePlayerStore.setState({ currentTime: 1 });
const commitMutation = vi.fn();
await tryGsapResizeIntercept(
selection,
{ width: 330, height: 210 },
[first, second],
previewWith(el, live),
commitMutation,
);
expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-to-0-size",
easeEach: "none",
keyframes: [
{ percentage: 50, properties: { width: 330, height: 210 } },
{ percentage: 100, properties: { width: 300, height: 200 } },
],
}),
]);
});
});
describe("a resize on a layer whose scale GSAP animates, at the playhead", () => {
it("adds a scale keyframe inside the tween at the playhead and leaves the next tween alone", async () => {
const grow = (id: string, start: number, duration: number, scale: number) =>
tween({
id,
propertyGroup: "scale",
method: "to",
properties: { scale },
resolvedStart: start,
duration,
ease: "none",
});
const first = grow("#box-to-0-scale", 0, 2, 1.25);
const second = grow("#box-to-2000-scale", 2, 1, 1.5);
el.setAttribute("data-hf-studio-original-box-width", "240");
el.setAttribute("data-hf-studio-original-box-height", "160");
const live = [first, second].map((a) =>
liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }),
);
usePlayerStore.setState({ currentTime: 1 });
const commitMutation = vi.fn();
// Dropped at 1.5x the box with a live scale of 1.125, the playhead's value of the first tween.
await tryGsapResizeIntercept(
selection,
{ width: 320, height: 213.333 },
[first, second],
previewWith(el, live, { scaleX: 1.125, scaleY: 1.125 }),
commitMutation,
);
const writes = commitMutation.mock.calls.map((call) => call[1]);
expect(writes[0]).toMatchObject({
type: "replace-with-keyframes",
animationId: "#box-to-0-scale",
easeEach: "none",
keyframes: [
{ percentage: 50, properties: { scale: 1.5 } },
{ percentage: 100, properties: { scale: 1.25 } },
],
});
expect(writes.some((m) => m.animationId === "#box-to-2000-scale")).toBe(false);
});
});
describe("a rotate on a GSAP-animated layer, at the playhead", () => {
it("changes the keyframe two flat tweens meet at, on the tween that states it", async () => {
const spin = (id: string, start: number, duration: number, rotation: number) =>
tween({
id,
propertyGroup: "rotation",
method: "to",
properties: { rotation },
resolvedStart: start,
duration,
ease: "none",
});
const first = spin("#box-to-0-rotation", 0, 2, 20);
const second = spin("#box-to-2000-rotation", 2, 1, 40);
usePlayerStore.setState({ currentTime: 2 });
const commitMutation = vi.fn();
const live = [first, second].map((a) =>
liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }),
);
await tryGsapRotationIntercept(
selection,
25,
[first, second],
previewWith(el, live),
commitMutation,
);
expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "#box-to-0-rotation",
position: 0,
duration: 2,
easeEach: "none",
keyframes: [{ percentage: 100, properties: { rotation: 25 } }],
}),
]);
});
});
describe("planValueAtPlayhead", () => {
const plan = (edit: Partial<PlayheadEdit>) =>
planValueAtPlayhead({
anim: tween({ method: "to", properties: {}, resolvedStart: 0, duration: 4 }),
at: { time: 2 },
values: {},
implicitEndValue: () => null,
...edit,
});
const kf = (
keyframes: Array<{ percentage: number; properties: Record<string, number> }>,
easeEach?: string,
) =>
tween({
method: "to",
properties: {},
resolvedStart: 0,
duration: 4,
keyframes: { format: "percentage", keyframes, ...(easeEach && { easeEach }) },
});
it("changes the keyframe under the playhead and no other", () => {
const result = plan({
anim: kf([
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 10 } },
{ percentage: 100, properties: { x: 20 } },
]),
values: { x: 15 },
});
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 15, 20]);
});
it("eases the new segment like the one it splits", () => {
const result = plan({
anim: kf([{ percentage: 100, properties: { x: 20 } }], "power2.in"),
at: { time: 1 },
values: { x: 5 },
});
expect(result.ok && result.mutation).toMatchObject({
easeEach: "power2.in",
keyframes: [
{ percentage: 25, properties: { x: 5 } },
{ percentage: 100, properties: { x: 20 } },
],
});
});
it("extends past the end and keeps the authored end where it was", () => {
const result = plan({
anim: tween({
method: "to",
properties: { width: 300 },
resolvedStart: 0,
duration: 2,
ease: "none",
}),
at: { time: 3 },
values: { width: 400 },
});
expect(result.ok && result.mutation).toMatchObject({
position: 0,
duration: 3,
easeEach: "none",
keyframes: [
{ percentage: 66.667, properties: { width: 300 } },
{ percentage: 100, properties: { width: 400 } },
],
});
});
it("splits a uniform scale into longhands for a per-axis edit", () => {
const result = plan({
anim: tween({
method: "to",
properties: { scale: 1.25 },
resolvedStart: 0,
duration: 4,
ease: "none",
}),
values: { scaleX: 1.5, scaleY: 1.1 },
});
expect(result.ok && result.mutation.keyframes).toEqual([
{ percentage: 50, properties: { scaleX: 1.5, scaleY: 1.1 } },
{ percentage: 100, properties: { scaleX: 1.25, scaleY: 1.25 } },
]);
});
it("refuses keyframes eased as a whole, whose segment curves it cannot keep", () => {
const anim = { ...kf([{ percentage: 100, properties: { x: 20 } }]), ease: "power2.out" };
expect(plan({ anim, values: { x: 5 } })).toEqual({ ok: false, reason: "eased-keyframes" });
});
});
@@ -0,0 +1,266 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import {
materializeIfDynamic,
parkPlayheadOnKeyframe,
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome";
import {
findParsedTween,
parsedImplicitEndValue,
parsedTweenEase,
withExactStepTimes,
} from "./gsapParsedTween";
import { KEYFRAME_PCT_MATCH } from "./gsapShared";
import { tweenReach } from "./gsapTweenReach";
type Props = Record<string, number | string>;
interface Keyframe {
percentage: number;
properties: Props;
ease?: string;
auto?: boolean;
}
/** A tween's un-animated value of `prop` at its start or end, from GSAP's own parse; null when unknown. */
export type ImplicitEndValue = (prop: string, end: "start" | "end") => number | null;
export interface PlayheadEdit {
anim: GsapAnimation;
/** The playhead's composition time, or a keyframe the user selected in the lane. */
at: { time: number } | { percentage: number };
/** Every channel here must already be animated by `anim`. */
values: Record<string, number>;
implicitEndValue: ImplicitEndValue;
/** The flat tween's ease as GSAP resolved it, for a tween that authors none. */
parsedEase?: string | null;
/** A channel of the edit the tween does not animate yet, held at this value on its keyframes. */
backfill?: Record<string, number>;
}
export type PlayheadEditPlan =
| {
ok: true;
mutation: {
type: "replace-with-keyframes";
animationId: string;
targetSelector: string;
position: number;
duration: number;
keyframes: Keyframe[];
easeEach?: string;
};
}
| { ok: false; reason: PlayheadEditRefusal };
// GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
const ARRAY_STEP_EASE = "none";
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear";
interface Normalized {
keyframes: Keyframe[];
easeEach?: string;
}
function refuse(reason: PlayheadEditRefusal): { ok: false; reason: PlayheadEditRefusal } {
return { ok: false, reason };
}
// fallow-ignore-next-line complexity
function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefusal } {
const { anim } = edit;
const data = anim.keyframes;
if (data) {
if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" };
if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
const arrayStep = data.format === "object-array";
return {
keyframes: data.keyframes.map((kf) => ({
...kf,
properties: { ...kf.properties },
...(arrayStep ? { ease: kf.ease ?? ARRAY_STEP_EASE } : {}),
})),
...(data.easeEach && !arrayStep ? { easeEach: data.easeEach } : {}),
};
}
// The flat tween's ease becomes easeEach: a top-level ease would leave each segment power1.inOut.
const easeEach = anim.ease ?? edit.parsedEase;
if (!easeEach) return { reason: "unknown-ease" };
const to = { percentage: 100, properties: { ...anim.properties } };
if (anim.method === "to") return { keyframes: [to], easeEach };
if (anim.method === "fromTo") {
return { keyframes: [{ percentage: 0, properties: { ...anim.fromProperties } }, to], easeEach };
}
if (anim.method === "from") {
const end: Props = {};
for (const prop of Object.keys(anim.properties)) {
const value = edit.implicitEndValue(prop, "end");
if (value == null) return { reason: "implicit-end-unknown" };
end[prop] = value;
}
return {
keyframes: [
{ percentage: 0, properties: { ...anim.properties } },
{ percentage: 100, properties: end },
],
easeEach,
};
}
return { reason: "not-a-tween" };
}
function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
const carrying = keyframes.filter((kf) => kf.properties[prop] != null);
const kf = side === "first" ? carrying[0] : carrying.at(-1);
return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null;
}
function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
const hit = keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
if (hit) {
Object.assign(hit.properties, properties);
return hit;
}
const added = { percentage, properties, ...(ease ? { ease } : {}) };
keyframes.push(added);
return added;
}
/** Every channel the tween animates, held at its value at the tween's authored start or end. */
function heldEnds(
norm: Normalized,
end: "start" | "end",
implicit: ImplicitEndValue,
backfilled: Record<string, number>,
) {
const props = new Set(norm.keyframes.flatMap((kf) => Object.keys(kf.properties)));
const held: Props = {};
for (const prop of props) {
const known = valueAt(norm.keyframes, prop, end === "start" ? "first" : "last");
// A channel holds its last keyframe after it; only a start can be implicit.
const value =
known && (end === "end" || known.percentage <= 0)
? known.value
: (backfilled[prop] ?? implicit(prop, "start"));
if (value == null) return null;
held[prop] = value;
}
return held;
}
/** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there
* and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */
// fallow-ignore-next-line complexity
export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
const { anim, values } = edit;
if (anim.extras && Object.keys(anim.extras).length > 0) return refuse("tween-extras");
const start = resolveTweenStart(anim);
const duration = resolveTweenDuration(anim);
if (start == null || !(duration > 0)) return refuse("no-timing");
const norm = normalize(edit);
if ("reason" in norm) return refuse(norm.reason);
const keyframes = norm.keyframes;
// GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it.
if ("scaleX" in values || "scaleY" in values) {
for (const kf of keyframes) {
const { scale, ...rest } = kf.properties;
if (scale != null) kf.properties = { scaleX: scale, scaleY: scale, ...rest };
}
}
const backfilled: Record<string, number> = {};
for (const [prop, value] of Object.entries(edit.backfill ?? {})) {
if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue;
for (const kf of keyframes) kf.properties[prop] = value;
backfilled[prop] = value;
}
let position = start;
let span = duration;
const pct =
"percentage" in edit.at
? edit.at.percentage
: roundPct(((edit.at.time - start) / duration) * 100);
if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
const at = Math.min(100, Math.max(0, pct));
const next = [...keyframes]
.sort((a, b) => a.percentage - b.percentage)
.find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
// The new keyframe splits next's segment, so it takes next's ease.
upsert(keyframes, at, { ...values }, next?.ease);
} else {
const time = (edit.at as { time: number }).time;
const before = time < start;
const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled);
if (!held) return refuse("implicit-end-unknown");
position = before ? time : start;
span = before ? start + duration - time : time - start;
for (const kf of keyframes) {
kf.percentage = roundPct(
((start + (kf.percentage / 100) * duration - position) / span) * 100,
);
}
const oldEdge = roundPct((((before ? start : start + duration) - position) / span) * 100);
// The span the tween gains is new and linear; the authored segments keep their eases.
const linear = isLinear(norm.easeEach ?? PERCENTAGE_SEGMENT_EASE) ? undefined : "none";
const edge = upsert(keyframes, oldEdge, held, linear);
if (before && linear) edge.ease ??= linear;
upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear);
}
keyframes.sort((a, b) => a.percentage - b.percentage);
return {
ok: true,
mutation: {
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: anim.targetSelector,
position: roundTo3(position),
duration: roundTo3(span),
keyframes,
...(norm.easeEach ? { easeEach: norm.easeEach } : {}),
},
};
}
/** Writes `values` into `anim` at the playhead (or the keyframe selected in the lane). */
export async function commitValueAtPlayhead(
selection: DomEditSelection,
anim: GsapAnimation,
values: Record<string, number>,
iframe: HTMLIFrameElement | null,
callbacks: GsapDragCommitCallbacks,
options: { label: string; backfill?: Record<string, number>; beforeReload?: () => void },
): Promise<GsapEditOutcome> {
await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
// One keyframe of a tween its siblings share would move them all.
if (tweenReach(anim, selection.element) === "shared")
return { status: "blocked", reason: "keyframes-uneditable", detail: "shared-tween" };
const { activeKeyframePct, setActiveKeyframePct, currentTime } = usePlayerStore.getState();
const tween = findParsedTween(iframe, selection.element, anim);
const timed = withExactStepTimes(anim, tween);
const plan = planValueAtPlayhead({
anim: timed.duration == null && tween ? { ...timed, duration: tween.duration?.() } : timed,
at: activeKeyframePct != null ? { percentage: activeKeyframePct } : { time: currentTime },
values,
backfill: options.backfill,
implicitEndValue: parsedImplicitEndValue(tween),
parsedEase: parsedTweenEase(iframe, tween),
});
if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
await callbacks.commitMutation(selection, plan.mutation, {
label: options.label,
softReload: true,
beforeReload: options.beforeReload,
});
if (activeKeyframePct != null) {
setActiveKeyframePct(null);
parkPlayheadOnKeyframe(anim, activeKeyframePct);
}
return { status: "persisted" };
}
@@ -159,4 +159,57 @@ describe("commitWholePropertyOffset", () => {
{ type: "update-properties", animationId: "#box-hold", properties: { width: 300 } },
]);
});
it("keeps a linear flat tween linear: its ease becomes the keyframes' easeEach", async () => {
// A tween-level ease leaves each percentage segment power1.inOut, so the box drifts mid-segment.
const anim = {
id: "#box-rotate",
targetSelector: "#box",
method: "to",
resolvedStart: 0,
duration: 2,
ease: "none",
properties: { rotation: 40 },
} as unknown as GsapAnimation;
const { mutations, callbacks } = recordingCallbacks();
await commitWholePropertyOffset(
selection(),
anim,
{ rotation: 50 },
100,
null,
callbacks,
"Rotate",
);
expect(mutations[0]).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" });
expect(mutations[0]).not.toHaveProperty("ease", "none");
});
it("keeps a keyframed tween's easeEach", async () => {
const anim = {
id: "#box-rotate",
targetSelector: "#box",
method: "to",
resolvedStart: 0,
duration: 2,
keyframes: {
easeEach: "none",
keyframes: [
{ percentage: 0, properties: { rotation: 10 } },
{ percentage: 100, properties: { rotation: 40 } },
],
},
} as unknown as GsapAnimation;
const { mutations, callbacks } = recordingCallbacks();
await commitWholePropertyOffset(
selection(),
anim,
{ rotation: 50 },
100,
null,
callbacks,
"Rotate",
);
expect(mutations[0]).toMatchObject({ easeEach: "none" });
});
});
@@ -6,7 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { PROPERTY_DEFAULTS } from "./gsapShared";
import { keyframeEases, PROPERTY_DEFAULTS } from "./gsapShared";
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
@@ -38,7 +38,6 @@ export async function commitWholePropertyOffset(
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
const keys = Object.keys(newValues);
const at = (props: Record<string, number | string>, key: string) =>
typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0);
@@ -79,7 +78,7 @@ export async function commitWholePropertyOffset(
position: roundTo3(ts ?? 0),
duration: roundTo3(td || 1),
keyframes: shifted,
ease,
...keyframeEases(effectiveAnim),
},
{ label, softReload: true },
);
@@ -20,11 +20,11 @@ import {
commitStaticGsapPosition,
commitStaticGsapRotation,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
commitWholePathOffset,
findExistingPositionWrite,
} from "./gsapDragCommit";
import { promoteSetToKeyframes } from "./useEnableKeyframes";
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
afterEach(() => {
document.body.innerHTML = "";
@@ -172,30 +172,30 @@ describe("gsapDragCommit — new-tween targets", () => {
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-4"]);
});
it("commitKeyframedSizeFromResize authors the new keyframe tween against one element", async () => {
it("a keyframe edit refuses a tween its siblings share instead of moving them all", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
const animatedTween = {
const sharedTween = {
id: "t1",
targetSelector: ".group",
method: "to",
properties: {},
properties: { width: 120 },
resolvedStart: 0,
duration: 2,
keyframes: { keyframes: [{ percentage: 0, properties: { x: 0 } }] },
ease: "none",
} as unknown as GsapAnimation;
const handled = await commitKeyframedSizeFromResize(
const outcome = await commitValueAtPlayhead(
classOnlySelection(groups[3]!),
{ width: 80, height: 40 },
".group",
sharedTween,
{ width: 80 },
null,
animatedTween,
callbacks,
{ label: "Resize" },
);
expect(handled).toBe(true);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-3"]);
expect(outcome).toMatchObject({ status: "blocked", detail: "shared-tween" });
expect(mutations).toEqual([]);
});
it("commitStaticGsapPosition replaces a corrupt keyframed hold against one element", async () => {
@@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection {
} as DomEditSelection;
}
function stubFlatTweenConversion(id: string): {
function stubFlatTweenConversion(
id: string,
easeEach?: string,
): {
flat: GsapAnimation;
converted: GsapAnimation;
} {
@@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): {
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 10 } },
],
...(easeEach && { easeEach }),
},
});
flatTweenResponses(flat, converted);
@@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => {
expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
});
it("keeps the converted tween's easeEach when it extends it to the playhead", async () => {
window.location.hash = "#/project/test-project";
usePlayerStore.setState({ currentTime: 3 });
const { flat } = stubFlatTweenConversion("flat-1", "none");
const commitMutation = vi.fn(async () => undefined);
const enable = renderEnableKeyframes({
domEditSelection: makeElementSelection(),
selectedGsapAnimations: [flat],
previewIframeRef: {
current: {
contentWindow: { gsap: { getProperty: () => 10 } },
} as unknown as HTMLIFrameElement,
},
handleGsapAddAnimation: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(async () => undefined),
handleGsapRemoveKeyframe: vi.fn(),
commitMutation,
});
await act(async () => enable());
expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({
type: "replace-with-keyframes",
easeEach: "none",
});
});
it("passes the convert coalesce key to an inside-range batch edit", async () => {
window.location.hash = "#/project/test-project";
usePlayerStore.setState({ currentTime: 1.5 });
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
import {
existingTweenTargetSelector,
computeElementPercentage,
keyframeEases,
KEYFRAME_PCT_MATCH,
isInstantHold,
resolveEditableTweenDuration,
@@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead(
position: extended.position,
duration: extended.duration,
keyframes: extended.keyframes,
ease: anim.ease,
...keyframeEases(anim),
},
{
label: "Add keyframe",