mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 19:57:13 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user