Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/parsers/src/gsapConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* without pulling in gsapParser (which depends on recast / @babel/parser).
*/

import type { GsapAnimation } from "./gsapSerialize.js";

export const SUPPORTED_PROPS = [
// 2D Transforms
"x",
Expand Down Expand Up @@ -69,6 +71,22 @@ for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [
for (const p of props) PROP_TO_GROUP.set(p, group);
}

type PositionWrite = Pick<
GsapAnimation,
"propertyGroup" | "properties" | "fromProperties" | "keyframes"
>;

/** A position write that sets x or y. An xPercent/yPercent centring set never duplicates one. */
export function isXYPositionWrite(a: PositionWrite): boolean {
const xy = (props?: Record<string, unknown>) => !!props && ("x" in props || "y" in props);
return (
a.propertyGroup === "position" &&
(xy(a.properties) ||
xy(a.fromProperties) ||
!!a.keyframes?.keyframes.some((k) => xy(k.properties)))
);
}

export function classifyPropertyGroup(prop: string): PropertyGroupName {
return PROP_TO_GROUP.get(prop) ?? "other";
}
Expand Down
12 changes: 8 additions & 4 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export {
classifyPropertyGroup,
classifyTweenPropertyGroup,
} from "./gsapConstants";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants";
import {
classifyPropertyGroup,
classifyTweenPropertyGroup,
isXYPositionWrite,
} from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
import { clipTweenMatcher, hasExplicitTime } from "./clipTweens";
import {
Expand Down Expand Up @@ -1790,8 +1794,8 @@ function removeCallFromAst(call: TweenCallInfo): void {
/**
* Recast twin of {@link dedupePositionWritesInScript} (acorn). Enforce "exactly
* one position write per element": keep `keepId` (or the LAST position write in
* source order if stale), remove every OTHER pure-position write
* (`propertyGroup === "position"` — tl.to/from/fromTo flat-or-keyframed, tl.set,
* source order if stale), remove every OTHER x/y position write
* (`isXYPositionWrite` — tl.to/from/fromTo flat-or-keyframed, tl.set,
* standalone gsap.set, incl. degenerate duration:0 tweens). Non-position writes
* for the selector are left untouched.
*/
Expand All @@ -1807,7 +1811,7 @@ export function dedupePositionWritesInScript(
return script;
}
const posWrites = parsed.located.filter(
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
(l) => l.animation.targetSelector === selector && isXYPositionWrite(l.animation),
);
if (posWrites.length <= 1) return script;
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
Expand Down
11 changes: 11 additions & 0 deletions packages/parsers/src/gsapWriter.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ describe("parity: dedupePositionWritesInScript (recast vs acorn)", () => {
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
});

it("keeps an xPercent/yPercent centring set beside an x/y tween in both writers", () => {
const centred = `
const tl = gsap.timeline({ paused: true });
gsap.set("#box", { xPercent: -50, yPercent: -50 });
tl.to("#box", { x: 120, y: 60, duration: 4, ease: "none" }, 0);
`;
const keepId = parseGsapScriptAcorn(centred).animations.find((a) => a.method === "to")!.id;
expect(dedupePosAcorn(centred, "#box", keepId)).toBe(centred);
expect(dedupePosRecast(centred, "#box", keepId)).toBe(centred);
});

it("no-op when 0 or 1 position writes — both writers", () => {
const single = `
const tl = gsap.timeline({ paused: true });
Expand Down
6 changes: 3 additions & 3 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
type ParsedGsapAcornForWrite,
type TweenCallInfo,
} from "./gsapParserAcorn.js";
import { classifyPropertyGroup } from "./gsapConstants.js";
import { classifyPropertyGroup, isXYPositionWrite } from "./gsapConstants.js";
import type { PropertyGroupName } from "./gsapConstants.js";
import {
findObjectArrayKeyframeIndex,
Expand Down Expand Up @@ -686,7 +686,7 @@ export function removeAnimationFromScript(script: string, animationId: string):
*
* Keeps `keepId` (the write the commit just edited); falls back to the LAST
* position write in source order (the runtime-effective one) if `keepId` is stale.
* Removes every OTHER pure-position write (`propertyGroup === "position"`, which
* Removes every OTHER x/y position write (`isXYPositionWrite`, which
* covers tl.to/from/fromTo flat-or-keyframed, tl.set, and standalone gsap.set,
* including degenerate duration:0 tweens). Non-position writes for the same
* selector (rotation / opacity / size / mixed) are left untouched.
Expand All @@ -699,7 +699,7 @@ export function dedupePositionWritesInScript(
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const posWrites = parsed.located.filter(
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
(l) => l.animation.targetSelector === selector && isXYPositionWrite(l.animation),
);
if (posWrites.length <= 1) return script;
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
Expand Down
7 changes: 6 additions & 1 deletion packages/studio/src/hooks/gsapDragPositionCommit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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";
Expand Down Expand Up @@ -179,7 +180,11 @@ async function commitFlatViaKeyframes(
if (runtime && ts !== null) {
const { gsapLib, el, mainTl } = runtime;
const draggedValues: Record<string, number> = {};
for (const key of Object.keys(properties)) {
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;
}
Expand Down
55 changes: 55 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,3 +567,58 @@ describe("tryGsapDragIntercept — motion paths", () => {
);
});
});

describe("tryGsapDragIntercept — position self-heal", () => {
const xyTween = {
...stalePositionAnim,
id: "#puck-b-to-0-position",
position: 0,
resolvedStart: 0,
};
const centringSet = {
id: "#puck-b-set-0-position",
targetSelector: "#puck-b",
propertyGroup: "position",
method: "set",
properties: { xPercent: -50, yPercent: -50 },
position: 0,
duration: 0,
} as unknown as GsapAnimation;
const xySet = { ...centringSet, properties: { x: 10, y: 20 } } as GsapAnimation;
const liveTween = {
targets: () => [{ id: "puck-b" }],
vars: { x: -180, y: -60, duration: 2 },
duration: () => 2,
startTime: () => 0,
};

async function drag(second: GsapAnimation) {
usePlayerStore.setState({ currentTime: 1 });
const animations = [second, xyTween];
const commitMutation = vi.fn();
await tryGsapDragIntercept(
selection,
{ x: -50, y: 30 },
animations,
fakeIframe("puck-b", [liveTween]),
commitMutation,
async () => animations,
);
return commitMutation.mock.calls.map(([, mutation]) => mutation);
}

it("keeps an xPercent/yPercent centring set and moves the x/y tween", async () => {
const mutations = await drag(centringSet);
expect(mutations.map((m) => m.type)).not.toContain("consolidate-position-writes");
expect(mutations.length).toBeGreaterThan(0);
expect(mutations.every((m) => m.animationId === xyTween.id)).toBe(true);
});

it("consolidates a second x/y write", async () => {
const mutations = await drag(xySet);
expect(mutations[0]).toMatchObject({
type: "consolidate-position-writes",
keepAnimationId: xyTween.id,
});
});
});
8 changes: 5 additions & 3 deletions packages/studio/src/hooks/gsapRuntimeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* easing, or seek position.
*/
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";

Expand Down Expand Up @@ -245,8 +246,7 @@ export async function tryGsapDragIntercept(
// the live keyframed/real tween if present (else any), strip the rest, so the
// commit below updates ONE write instead of fighting duplicates.
let workingAnimations = animations;
const isPosWrite = (a: GsapAnimation) =>
a.targetSelector === selector && a.propertyGroup === "position";
const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && isXYPositionWrite(a);
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
const dupes = fresh.filter(isPosWrite);
Expand Down Expand Up @@ -331,7 +331,9 @@ export async function tryGsapDragIntercept(
const fresh = await fetchFallbackAnimations();
const freshMatch = fresh.find(
(a) =>
a.targetSelector === posAnim!.targetSelector && a.propertyGroup === posAnim!.propertyGroup,
a.targetSelector === posAnim!.targetSelector &&
a.propertyGroup === posAnim!.propertyGroup &&
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
);
if (freshMatch && freshMatch.id !== posAnim.id) {
posAnim = freshMatch;
Expand Down
Loading