Skip to content
Merged
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
23 changes: 23 additions & 0 deletions packages/parsers/src/gsapConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ export const SUPPORTED_PROPS = [
"innerText",
];

/** Keys stored on dedicated GsapAnimation fields (not in properties/extras). */
export const BUILTIN_VAR_KEYS: ReadonlySet<string> = new Set(["duration", "ease", "delay"]);
export const DROPPED_VAR_KEYS: ReadonlySet<string> = new Set([
"onComplete",
"onStart",
"onUpdate",
"onRepeat",
]);
/** Keys that go in `extras`: non-editable GSAP config that must survive round-trips. */
export const EXTRAS_KEYS: ReadonlySet<string> = new Set([
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);

export function isTweenConfigKey(key: string): boolean {
return BUILTIN_VAR_KEYS.has(key) || DROPPED_VAR_KEYS.has(key) || EXTRAS_KEYS.has(key);
}

// ── Property Groups ─────────────────────────────────────────────────────────
// Each group maps to an independent GSAP tween so editing one property
// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation).
Expand Down
20 changes: 2 additions & 18 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
positionHoldForAnimation,
} from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
import { BUILTIN_VAR_KEYS, DROPPED_VAR_KEYS, EXTRAS_KEYS, isTweenConfigKey } from "./gsapConstants";
import { clipTweenMatcher, hasExplicitTime } from "./clipTweens";
import {
findObjectArrayKeyframeIndex,
Expand Down Expand Up @@ -707,23 +708,6 @@ function findAllTweenCalls(
return results;
}

/** Keys that are stored on dedicated GsapAnimation fields (not in properties/extras). */
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);

/** Keys that are never preserved (callbacks / advanced patterns). */
const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);

/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */
const EXTRAS_KEYS = new Set([
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);

/**
* Extract raw source text for a property in an ObjectExpression AST node.
* Returns the printed source of the value node, suitable for verbatim re-emission.
Expand Down Expand Up @@ -1387,7 +1371,7 @@ function isObjectProperty(prop: AstNode): boolean {

/** A key the inspector treats as an editable transform/style property. */
function isEditablePropertyKey(key: string): boolean {
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
return !isTweenConfigKey(key);
}

function makeObjectProperty(key: string, value: number | string | boolean): AstNode {
Expand Down
12 changes: 12 additions & 0 deletions packages/parsers/src/gsapParserAcorn.full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,18 @@ describe("native GSAP keyframes parsing", () => {
expectKeyframe(kfs[2], 100, { x: 200 });
});

it("leaves array keyframes it can read only in part to the runtime", () => {
for (const step of ["mid", "{ ...mid, y: 20 }", "{ x: 120, runBackwards: true }"]) {
const anim = parseSingleAnimation(`
const mid = { x: 120 };
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { keyframes: [{ x: 60 }, ${step}, { x: 180 }], duration: 3 }, 0);
`);
expect(anim.keyframes).toBeUndefined();
expect(anim.hasUnresolvedKeyframes).toBe(true);
}
});

it("parses simple array keyframes format", () => {
const script = `
const tl = gsap.timeline({ paused: true });
Expand Down
30 changes: 13 additions & 17 deletions packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import type {
GsapPercentageKeyframe,
ParsedGsap,
} from "./gsapSerialize.js";
import { classifyTweenPropertyGroup } from "./gsapConstants.js";
import {
BUILTIN_VAR_KEYS,
classifyTweenPropertyGroup,
DROPPED_VAR_KEYS,
EXTRAS_KEYS,
} from "./gsapConstants.js";
import { buildArcPath } from "./gsapSerialize.js";
import { inlineComputedTimelines, readProvenance } from "./gsapInline.js";
import { getObjectArrayKeyframeTiming } from "./gsapObjectArrayTiming.js";
Expand Down Expand Up @@ -859,21 +864,6 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {

// ── Tween call collection ─────────────────────────────────────────────────────

/** Keys stored on dedicated GsapAnimation fields (not in properties/extras). */
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
/** Keys never preserved (callbacks / advanced patterns). */
const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);
/** Keys that go in `extras` — non-editable GSAP config that must survive round-trips. */
const EXTRAS_KEYS = new Set([
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);

export interface TweenCallInfo {
node: any;
/** acorn-walk ancestor array at the call site (root→call, call is last). */
Expand Down Expand Up @@ -1082,6 +1072,9 @@ function computeKeyframesTotalDuration(
return getObjectArrayKeyframeTiming(durations)?.totalDuration;
}

const isFullyReadableStep = (el: any) =>
el?.type === "ObjectExpression" && !el.properties.some((p: any) => p.type === "SpreadElement");

// fallow-ignore-next-line complexity
function parseObjectArrayKeyframes(
node: any,
Expand All @@ -1096,7 +1089,7 @@ function parseObjectArrayKeyframes(
}> = [];

for (const el of elements) {
if (!el || el.type !== "ObjectExpression") continue;
if (!isFullyReadableStep(el)) return undefined;
const record = objectExpressionToRecord(el, scope, source);
const properties: Record<string, number | string> = {};
let duration: unknown;
Expand All @@ -1108,6 +1101,9 @@ function parseObjectArrayKeyframes(
ease = v;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
} else {
// A step flag (runBackwards: true) is no channel and a rewrite would drop it.
return undefined;
}
}
raw.push({ properties, duration, ease });
Expand Down
34 changes: 3 additions & 31 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "./gsapParserAcorn.js";
import {
classifyPropertyGroup,
isTweenConfigKey,
isXYPositionWrite,
positionHoldForAnimation,
} from "./gsapConstants.js";
Expand Down Expand Up @@ -199,38 +200,9 @@ function upsertProp(ms: MagicString, objNode: Node, key: string, value: unknown)
}
}

/**
* Vars keys that are NOT editable transform/style props: builtins
* (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…).
* The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS,
* so both writers classify vars keys identically. (Distinct from the keyframe-
* conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease`
* because that path re-emits ease separately.)
*/
const NON_EDITABLE_PROP_KEYS = new Set([
"duration",
"ease",
"delay",
"onComplete",
"onStart",
"onUpdate",
"onRepeat",
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);

/**
* Editable transform/style key test: anything NOT a builtin, dropped callback, or
* extras key. Mirrors recast's isEditablePropertyKey so both writers classify
* vars keys identically.
*/
/** Editable transform/style key test, the same split both parsers make. */
function isEditableVarKey(key: string): boolean {
return !NON_EDITABLE_PROP_KEYS.has(key);
return !isTweenConfigKey(key);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
import { usePlayerStore } from "../../player/store/playerStore";
import { MotionPathOverlay } from "./MotionPathOverlay";
import { commitNodeDrop } from "./motionPathCommit";
import type { DomEditSelection } from "./domEditing";

Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
Expand All @@ -14,6 +16,14 @@ const { commitMutation } = vi.hoisted(() => ({
vi.mock("../../contexts/DomEditContext", () => ({
useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation }),
}));
const showToast = vi.hoisted(() => vi.fn());
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContextOptional: () => ({ showToast }),
}));
vi.mock("./motionPathCommit", async (importOriginal) => ({
...(await importOriginal<typeof import("./motionPathCommit")>()),
commitNodeDrop: vi.fn(),
}));
vi.mock("./motionPathSelection", () => ({
selectorFor: () => "#box",
editableAnimationId: () => "a1",
Expand Down Expand Up @@ -189,3 +199,35 @@ it("draws where GSAP started the tween before the first keyframe, as a mark that
const drawn = host.querySelector("polyline:not(.pointer-events-auto)")!;
expect(drawn.getAttribute("points")).toBe("50,30 60,30 140,30");
});

it("a node drop the writer refuses says why", async () => {
const refusal = new GsapEditBlockedError("keyframes-uneditable");
vi.mocked(commitNodeDrop).mockRejectedValue(refusal);
vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
const host = document.body.appendChild(document.createElement("div"));
const root = createRoot(host);
const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
try {
act(() =>
root.render(
<MotionPathOverlay
iframeRef={{ current: null }}
selection={selection}
compositionSize={{ width: 1920, height: 1080 }}
isPlaying={false}
/>,
),
);
const node = host.querySelector('circle.pointer-events-auto[cx="140"]')!;
document.elementsFromPoint = () => [node];
const at = (clientX: number) => ({ bubbles: true, button: 0, clientX, clientY: 30 });
act(() => void node.dispatchEvent(new PointerEvent("pointerdown", at(140))));
act(() => void node.dispatchEvent(new PointerEvent("pointermove", at(180))));
await act(async () => void node.dispatchEvent(new PointerEvent("pointerup", at(180))));
expect(commitNodeDrop).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith(refusal.message, "error");
} finally {
act(() => root.unmount());
host.remove();
}
});
46 changes: 20 additions & 26 deletions packages/studio/src/components/editor/MotionPathOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { memo, useEffect, useRef, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry";
import { usePlayerStore } from "../../player/store/playerStore";
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
import { editableAnimationId, selectorFor } from "./motionPathSelection";
import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
Expand All @@ -23,8 +22,9 @@ import {
commitAddKeyframe,
commitAddWaypoint,
commitCreatePath,
commitNode,
commitNodeDrop,
commitRemoveWaypoint,
nodeDropLabel,
} from "./motionPathCommit";
import { elementHome } from "./motionPathHome";
import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
Expand Down Expand Up @@ -66,6 +66,7 @@ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
// (select the keyframe); at or above it the gesture commits a move. Screen-space
// (not composition px) so it behaves identically at any zoom.
const DRAG_THRESHOLD_PX = 3;
const noToast = () => {};

/**
* Draws the selected element's GSAP motion path over the canvas — a dashed
Expand All @@ -91,6 +92,11 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
handleGsapRemoveAllKeyframes,
handleGsapMoveKeyframeToPlayhead,
} = useDomEditContext();
const shell = useStudioShellContextOptional();
const reportFailure = useGsapInteractionFailureTelemetry(
shell?.activeCompPath ?? null,
shell?.showToast ?? noToast,
);
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
iframeRef,
selectorFor(selection),
Expand Down Expand Up @@ -373,30 +379,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// high zoom) would commit an identical value — a no-op undo entry. Skip the
// commit, but don't treat it as a click either (the user did drag).
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
// Auto-keyframe off (#1808): dragging a keyframe's node shifts the whole path.
const anim =
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
if (
d.ref.type === "keyframe" &&
anim &&
selection &&
!usePlayerStore.getState().autoKeyframeEnabled
) {
const writes = observeGsapGesture((_sel, mutation, options) =>
commitMutation(mutation, options),
);
void commitWholePropertyOffset(
selection,
anim,
{ x, y },
d.ref.pct,
iframeRef.current,
{ commitMutation: writes.commit! },
"Move animation path",
).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
} else {
void commitNode(d.ref, x, y, animId, commitMutation);
}
const label = nodeDropLabel(d.ref);
commitNodeDrop({
ref: d.ref,
at: { x, y },
animId,
anim,
selection,
iframe: iframeRef.current,
commitMutation,
}).catch((error: unknown) => reportFailure(error, selection, "drag", label));
// Park on the edited keyframe, or a playhead before the tween hides the edit.
if (d.ref.type === "keyframe" && anim) {
parkPlayheadOnKeyframe(anim, d.ref.pct);
Expand Down
Loading
Loading