From df251faa2f867e0c43047767ab1ea38b25241657 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 15:35:46 -0700 Subject: [PATCH] fix(studio): dragging a keyframe's path node keeps the other keyframes where they were --- packages/parsers/src/gsapConstants.ts | 23 ++ packages/parsers/src/gsapParser.ts | 20 +- .../parsers/src/gsapParserAcorn.full.test.ts | 12 + packages/parsers/src/gsapParserAcorn.ts | 30 ++- packages/parsers/src/gsapWriterAcorn.ts | 34 +-- .../MotionPathOverlay.layerPress.test.tsx | 42 ++++ .../components/editor/MotionPathOverlay.tsx | 46 ++-- .../editor/motionPathCommit.nodeDrop.test.ts | 212 ++++++++++++++++++ .../src/components/editor/motionPathCommit.ts | 58 +++++ .../editor/motionPathGeometry.test.ts | 13 ++ .../components/editor/motionPathGeometry.ts | 6 +- packages/studio/src/hooks/gsapDragCommit.ts | 34 ++- packages/studio/src/hooks/gsapEditOutcome.ts | 29 ++- .../src/hooks/gsapParsedTween.test-helpers.ts | 29 ++- packages/studio/src/hooks/gsapParsedTween.ts | 25 ++- .../studio/src/hooks/gsapRuntimeBridge.ts | 8 +- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 2 +- packages/studio/src/hooks/gsapShared.test.ts | 1 + packages/studio/src/hooks/gsapShared.ts | 8 +- .../gsapValueAtPlayhead.realGsap.test.ts | 112 ++++++++- .../src/hooks/gsapValueAtPlayhead.test.ts | 156 ++++++++++--- .../studio/src/hooks/gsapValueAtPlayhead.ts | 52 ++++- .../gsapWholePropertyOffsetCommit.test.ts | 73 +++++- .../hooks/gsapWholePropertyOffsetCommit.ts | 7 +- .../hooks/useCommitPreflightCapabilities.ts | 4 +- .../useGsapAwareEditing.groupPlan.test.tsx | 25 +++ 26 files changed, 915 insertions(+), 146 deletions(-) create mode 100644 packages/studio/src/components/editor/motionPathCommit.nodeDrop.test.ts diff --git a/packages/parsers/src/gsapConstants.ts b/packages/parsers/src/gsapConstants.ts index 52fa3e35ad..b5a7948a3e 100644 --- a/packages/parsers/src/gsapConstants.ts +++ b/packages/parsers/src/gsapConstants.ts @@ -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 = new Set(["duration", "ease", "delay"]); +export const DROPPED_VAR_KEYS: ReadonlySet = 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 = 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). diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index ec664b7f9e..671140639c 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -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, @@ -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. @@ -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 { diff --git a/packages/parsers/src/gsapParserAcorn.full.test.ts b/packages/parsers/src/gsapParserAcorn.full.test.ts index 99acded541..ccdca7a08f 100644 --- a/packages/parsers/src/gsapParserAcorn.full.test.ts +++ b/packages/parsers/src/gsapParserAcorn.full.test.ts @@ -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 }); diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index b46410234b..bcdd931546 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -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"; @@ -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). */ @@ -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, @@ -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 = {}; let duration: unknown; @@ -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 }); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 47d822379f..33370b9f4f 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -26,6 +26,7 @@ import { } from "./gsapParserAcorn.js"; import { classifyPropertyGroup, + isTweenConfigKey, isXYPositionWrite, positionHoldForAnimation, } from "./gsapConstants.js"; @@ -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); } /** diff --git a/packages/studio/src/components/editor/MotionPathOverlay.layerPress.test.tsx b/packages/studio/src/components/editor/MotionPathOverlay.layerPress.test.tsx index c841405cad..87628a3e4c 100644 --- a/packages/studio/src/components/editor/MotionPathOverlay.layerPress.test.tsx +++ b/packages/studio/src/components/editor/MotionPathOverlay.layerPress.test.tsx @@ -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); @@ -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()), + commitNodeDrop: vi.fn(), +})); vi.mock("./motionPathSelection", () => ({ selectorFor: () => "#box", editableAnimationId: () => "a1", @@ -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( + , + ), + ); + 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(); + } +}); diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx index 46caab6a47..80515aa80e 100644 --- a/packages/studio/src/components/editor/MotionPathOverlay.tsx +++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx @@ -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"; @@ -23,8 +22,9 @@ import { commitAddKeyframe, commitAddWaypoint, commitCreatePath, - commitNode, + commitNodeDrop, commitRemoveWaypoint, + nodeDropLabel, } from "./motionPathCommit"; import { elementHome } from "./motionPathHome"; import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData"; @@ -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 @@ -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), @@ -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); diff --git a/packages/studio/src/components/editor/motionPathCommit.nodeDrop.test.ts b/packages/studio/src/components/editor/motionPathCommit.nodeDrop.test.ts new file mode 100644 index 0000000000..14a221e28d --- /dev/null +++ b/packages/studio/src/components/editor/motionPathCommit.nodeDrop.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, onTestFinished, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditing"; +import { boxSteps, previewWith, tween } from "../../hooks/gsapParsedTween.test-helpers"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome"; +import { commitNodeDrop, nodeDropLabel, type CommitFn } from "./motionPathCommit"; + +it("names a node drop for its undo entry by what it moved", () => { + const { autoKeyframeEnabled } = usePlayerStore.getState(); + onTestFinished(() => usePlayerStore.setState({ autoKeyframeEnabled })); + usePlayerStore.setState({ autoKeyframeEnabled: true }); + expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move keyframe"); + usePlayerStore.setState({ autoKeyframeEnabled: false }); + expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move animation path"); + expect(nodeDropLabel({ type: "waypoint", index: 0 })).toBe("Move waypoint"); +}); + +afterEach(() => { + usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null, requestedSeekTime: null }); + document.body.innerHTML = ""; +}); + +function box(id = "box") { + const element = document.body.appendChild(document.createElement("div")); + element.id = id; + return { element, selection: { id, selector: `#${id}`, element } as DomEditSelection }; +} + +type Drop = Parameters[0]; + +function drop(fields: Omit & { anim: GsapAnimation }) { + const commitMutation = vi.fn(async () => {}); + const done = commitNodeDrop({ ...fields, animId: fields.anim.id, commitMutation }); + const written = () => + (commitMutation.mock.calls[0]![0] as { keyframes: { properties: object }[] }).keyframes.map( + (kf) => kf.properties, + ); + return { done, commitMutation, written }; +} + +it("a keyframe node's drop changes that keyframe and keeps a newly animated channel on the others", async () => { + const { element, selection } = box(); + // Over a CSS translate GSAP reads as y 30. + const { keys, live } = boxSteps([ + [2, { x: 60 }], + [1, { x: 120 }], + ]); + usePlayerStore.setState({ currentTime: 2, autoKeyframeEnabled: true }); + + const { done, commitMutation, written } = drop({ + ref: { type: "keyframe", pct: 100 }, + at: { x: 150, y: 90 }, + anim: keys, + selection, + iframe: previewWith(element, [live(element)], { x: 60, y: 30 }), + }); + await done; + + expect(commitMutation.mock.calls[0]![0].type).toBe("replace-with-keyframes"); + expect(written()).toEqual([ + { x: 60, y: 30 }, + { x: 150, y: 90 }, + ]); +}); + +it("a drop on an array step's node edits that step, though the path spaces nodes evenly", async () => { + const { element, selection } = box(); + // Step ends at 2 s and 3 s; the path draws the first node at 0%, the parser and lane at 66.7%. + const { keys, live } = boxSteps([ + [2, { x: 60, y: 30 }], + [1, { x: 120, y: 30 }], + ]); + usePlayerStore.setState({ currentTime: 1, autoKeyframeEnabled: true, activeKeyframePct: null }); + + const { done, written } = drop({ + ref: { type: "keyframe", pct: 0, step: 0 }, + at: { x: 40, y: 90 }, + anim: keys, + selection, + iframe: previewWith(element, [live(element)], { x: 60, y: 30 }), + }); + await done; + + expect(written()).toEqual([ + { x: 40, y: 90 }, + { x: 120, y: 30 }, + ]); + expect(usePlayerStore.getState().requestedSeekTime).toBe(2); +}); + +it("a drop the writer refuses rejects with the reason, writes nothing and selects nothing", async () => { + const [a, b] = [box("a"), box("b")]; + for (const { element } of [a, b]) element.className = "card"; + const shared = tween({ + targetSelector: ".card", + method: "to", + properties: {}, + resolvedStart: 0, + duration: 1, + keyframes: { format: "percentage", keyframes: [{ percentage: 100, properties: { x: 20 } }] }, + }); + usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null }); + + const { done, commitMutation } = drop({ + ref: { type: "keyframe", pct: 100 }, + at: { x: 5, y: 0 }, + anim: shared, + selection: a.selection, + iframe: previewWith(b.element, [], { x: 0, y: 0 }), + }); + + await expect(done).rejects.toBeInstanceOf(GsapEditBlockedError); + expect(commitMutation).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().activeKeyframePct).toBeNull(); +}); + +it.each([true, false])( + "a drop on a step list with a step delay says the delay blocks it and writes nothing (auto-keyframe %s)", + async (autoKeyframeEnabled) => { + const { element, selection } = box(); + const { keys } = boxSteps([ + [1.5, { x: 100, delay: 0.5 }], + [1.5, { x: 200, delay: 0.5 }], + ]); + usePlayerStore.setState({ autoKeyframeEnabled, activeKeyframePct: null }); + + const { done, commitMutation } = drop({ + ref: { type: "keyframe", pct: 100 }, + at: { x: 150, y: 0 }, + anim: keys, + selection, + iframe: previewWith(element, [], { x: 0, y: 0 }), + }); + + await expect(done).rejects.toThrow(/its own delay.*Code tab/); + expect(commitMutation).not.toHaveBeenCalled(); + }, +); + +it("with auto-keyframe off, a step's node shifts the path by its own keyframe's move", async () => { + const { selection } = box(); + // Steps of 4 s, 1 s and 1 s: drawn at 0/50/100%, parsed at their step ends. + const { keys } = boxSteps([ + [4, { x: 60, y: 0 }], + [1, { x: 120, y: 0 }], + [1, { x: 180, y: 0 }], + ]); + usePlayerStore.setState({ autoKeyframeEnabled: false, activeKeyframePct: null }); + + const { done, written } = drop({ + ref: { type: "keyframe", pct: 50, step: 1 }, + at: { x: 157, y: 0 }, + anim: keys, + selection, + iframe: null, + }); + await done; + + expect(written().map((props) => (props as { x: number }).x)).toEqual([97, 157, 217]); +}); + +it("a drop on an inner keyframe of a tween eased as a whole changes that keyframe", async () => { + const { element, selection } = box(); + const keys = tween({ + id: "#box-to-0-position", + method: "to", + properties: {}, + resolvedStart: 0, + duration: 2, + ease: "power1.inOut", + keyframes: { + format: "percentage", + keyframes: [0, 100, 200].map((x, i) => ({ percentage: i * 50, properties: { x, y: 0 } })), + }, + }); + usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null }); + + const { done, written } = drop({ + ref: { type: "keyframe", pct: 50 }, + at: { x: 150, y: 0 }, + anim: keys, + selection, + iframe: previewWith(element, [], { x: 0, y: 0 }), + }); + await done; + + expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 200]); +}); + +it("a drop on a short step under 1% from the one before edits the dragged step", async () => { + const { element, selection } = box(); + // Parsed at 50, 50.5 and 100%: a 0.05 s pop step in a 10 s list. + const { keys, live } = boxSteps([ + [5, { x: 0, y: 0 }], + [0.05, { x: 100, y: 0 }], + [4.95, { x: 100, y: 0 }], + ]); + usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null }); + + const { done, written } = drop({ + ref: { type: "keyframe", pct: 50, step: 1 }, + at: { x: 150, y: 0 }, + anim: keys, + selection, + iframe: previewWith(element, [live(element)], { x: 0, y: 0 }), + }); + await done; + + expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 100]); +}); diff --git a/packages/studio/src/components/editor/motionPathCommit.ts b/packages/studio/src/components/editor/motionPathCommit.ts index 3b1b6a7eab..bd67ce8da1 100644 --- a/packages/studio/src/components/editor/motionPathCommit.ts +++ b/packages/studio/src/components/editor/motionPathCommit.ts @@ -5,7 +5,17 @@ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/preview * GSAP source mutation routed through the (selection-bound) commit facade, which * handles the soft reload, undo snapshot, and save-failure feedback. */ +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; +import { assertGsapEditPersisted } from "../../hooks/gsapEditOutcome"; +import { observeGsapGesture } from "../../hooks/gsapGestureOutcome"; +import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection"; +import { commitValueAtPlayhead } from "../../hooks/gsapValueAtPlayhead"; +import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { trackPreviewEditResult } from "../../utils/previewFeatureUsage"; +import type { DomEditSelection } from "./domEditing"; import type { MotionNodeRef } from "./motionPathGeometry"; +import { selectorFor } from "./motionPathSelection"; export type CommitFn = ( mutation: Record, @@ -41,6 +51,54 @@ export function commitNode( ); } +type NodeDrop = { + ref: MotionNodeRef; + at: { x: number; y: number }; + animId: string; + anim: GsapAnimation | undefined; + selection: DomEditSelection | null; + iframe: HTMLIFrameElement | null; + commitMutation: CommitFn; +}; + +/** What a node drop does, for its undo entry and its failure report. */ +export function nodeDropLabel(ref: MotionNodeRef): string { + if (ref.type !== "keyframe") return "Move waypoint"; + return usePlayerStore.getState().autoKeyframeEnabled ? "Move keyframe" : "Move animation path"; +} + +/** A dropped keyframe goes through the layer drag's writer, GSAP's live values backfilling others + * (auto-keyframe off, #1808: the whole path shifts); a waypoint moves in place. */ +export function commitNodeDrop(drop: NodeDrop): Promise { + const { ref, at, anim, selection, iframe, commitMutation } = drop; + if (ref.type !== "keyframe" || !anim || !selection) + return commitNode(ref, at.x, at.y, drop.animId, commitMutation); + const writes = observeGsapGesture((_sel, mutation, options) => commitMutation(mutation, options)); + const callbacks = { commitMutation: writes.commit! }; + let done: Promise; + const store = usePlayerStore.getState(); + const step = ref.step == null ? undefined : anim.keyframes?.keyframes[ref.step]; + const pct = step?.percentage ?? ref.pct; + if (store.autoKeyframeEnabled) { + const selected = store.activeKeyframePct; + store.setActiveKeyframePct(pct); + const live = readGsapPositionFromIframe(iframe, selectorFor(selection) ?? ""); + done = commitValueAtPlayhead(selection, anim, at, iframe, callbacks, { + label: nodeDropLabel(ref), + backfill: live ?? undefined, + }) + .then(assertGsapEditPersisted) + .catch((error: unknown) => { + usePlayerStore.getState().setActiveKeyframePct(selected); + throw error; + }); + } else { + const label = nodeDropLabel(ref); + done = commitWholePropertyOffset(selection, anim, at, pct, iframe, callbacks, label); + } + return done.then(() => trackPreviewEditResult("motion_path", "drag", writes.finish())); +} + export function commitAddWaypoint( animationId: string, index: number, diff --git a/packages/studio/src/components/editor/motionPathGeometry.test.ts b/packages/studio/src/components/editor/motionPathGeometry.test.ts index da65dab330..6ccd07b471 100644 --- a/packages/studio/src/components/editor/motionPathGeometry.test.ts +++ b/packages/studio/src/components/editor/motionPathGeometry.test.ts @@ -182,3 +182,16 @@ describe("start", () => { ); }); }); + +it("an array step's node carries its step, so a drop finds that keyframe whatever its time", () => { + const read = { + keyframes: [ + { percentage: 0, properties: { x: 60 }, step: 0 }, + { percentage: 100, properties: { x: 120 }, step: 1 }, + ], + }; + expect(buildMotionPathGeometry(read)!.nodes.map((n) => n.ref)).toEqual([ + { type: "keyframe", pct: 0, step: 0 }, + { type: "keyframe", pct: 100, step: 1 }, + ]); +}); diff --git a/packages/studio/src/components/editor/motionPathGeometry.ts b/packages/studio/src/components/editor/motionPathGeometry.ts index 6676ca33eb..453dd12341 100644 --- a/packages/studio/src/components/editor/motionPathGeometry.ts +++ b/packages/studio/src/components/editor/motionPathGeometry.ts @@ -8,7 +8,7 @@ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes"; /** Which source edit a dragged node maps to. */ export type MotionNodeRef = - | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative % + | { type: "keyframe"; pct: number; step?: number } // x/y keyframe at this tween-relative %, array slot | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index /** An offset on the path and the layout width/height set there, when one is. */ @@ -141,7 +141,9 @@ export function buildMotionPathGeometry( if (!at) return; nodes.push({ ...at, - ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage }, + ref: isArc + ? { type: "waypoint", index: i } + : { type: "keyframe", pct: kf.percentage, ...(kf.step == null ? {} : { step: kf.step }) }, }); }); diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index a542dbafb1..edc7f4873e 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -12,7 +12,8 @@ import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import type { CommitMutation } from "./gsapScriptCommitTypes"; import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction"; import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers"; -import { GsapEditBlockedError } from "./gsapEditOutcome"; +import { GsapEditBlockedError, type PlayheadEditRefusal } from "./gsapEditOutcome"; +import { isTweenConfigKey } from "@hyperframes/parsers/gsap-constants"; export { findExistingPositionWrite, findRotationSetAnimation, @@ -117,6 +118,36 @@ export async function materializeIfDynamic( throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source"); } +/** Why a percentage keyframe can't stand in for this array step entry, or null when it can. */ +function stepBlock([key, value]: [string, number | string]): PlayheadEditRefusal | null { + if (key === "delay") return "array-step-delay"; + if (/^on[A-Z]/.test(key)) return "array-step-callback"; + if (isTweenConfigKey(key)) return "array-step-config"; + if (typeof value === "number") return null; + return STEP_VALUE_BLOCKS.find(([pattern]) => pattern.test(value))?.[1] ?? null; +} + +/** First match wins: code is computed, even when it calls random(). */ +const STEP_VALUE_BLOCKS: Array<[RegExp, PlayheadEditRefusal]> = [ + [/^__raw:/, "array-step-computed"], + [/random\(/, "array-step-random"], + [/[-+*/]=/, "array-step-relative"], +]; + +/** Why a step list can't be rewritten as percentage keyframes, or null. */ +export function stepListBlock(anim: GsapAnimation): PlayheadEditRefusal | null { + const data = anim.keyframes; + if (data?.format !== "object-array") return null; + const entries = data.keyframes.flatMap((kf) => Object.entries(kf.properties)); + return entries.map(stepBlock).find(Boolean) ?? null; +} + +/** Whole-offset writers rewrite every step as a keyframe; refuse a list that holds more than values. */ +export function refuseStepListRewrite(anim: GsapAnimation): void { + const step = stepListBlock(anim); + if (step) throw new GsapEditBlockedError("keyframes-uneditable", step); +} + // ── Drag → GSAP position math ────────────────────────────────────────────── /** @@ -330,6 +361,7 @@ export async function commitWholePathOffset( // fallow-ignore-next-line code-duplication let effectiveAnim = anim; if (anim.keyframes) { + refuseStepListRewrite(anim); const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); if (newId) effectiveAnim = { ...anim, id: newId }; } diff --git a/packages/studio/src/hooks/gsapEditOutcome.ts b/packages/studio/src/hooks/gsapEditOutcome.ts index eb6544b888..2f329d2a46 100644 --- a/packages/studio/src/hooks/gsapEditOutcome.ts +++ b/packages/studio/src/hooks/gsapEditOutcome.ts @@ -15,7 +15,7 @@ export type GsapEditBlockReason = * true for `provenance-runtime-dynamic`; the others are parser or source-match * limits, where the animation may well be plain authored source. * - * Telemetry only. Nothing branches on it. + * Telemetry, plus the sharper message a few details get (GSAP_EDIT_DETAIL_COPY). */ export type GsapEditBlockDetail = | "provenance-runtime-dynamic" @@ -33,6 +33,12 @@ export type GsapEditBlockDetail = export type PlayheadEditRefusal = | "eased-keyframes" | "simple-array-keyframes" + | "array-step-delay" + | "array-step-callback" + | "array-step-config" + | "array-step-computed" + | "array-step-relative" + | "array-step-random" | "unknown-ease" | "implicit-end-unknown" | "not-a-tween" @@ -74,12 +80,31 @@ export const GSAP_EDIT_BLOCK_COPY: Record = { "These layers are animated in different files. Move each file's layers separately.", }; +const STEP_LIST = "This animation's keyframes have a step"; +const IN_CODE = "Edit them in the Code tab."; +/** A refusal a person can act on more precisely than its reason's message: what blocks it, then what to do. */ +const GSAP_EDIT_DETAIL_COPY: Partial> = { + "array-step-delay": `${STEP_LIST} with its own delay, which Studio can't keep while it edits them. ${IN_CODE}`, + "array-step-callback": `${STEP_LIST} that runs code as it plays (like onComplete), which an edit would run again. ${IN_CODE}`, + "array-step-config": `${STEP_LIST} with its own tween setting (like repeat or stagger), which Studio can't keep while it edits them. ${IN_CODE}`, + "array-step-computed": `${STEP_LIST} whose value comes from code, which Studio can't keep while it edits them. ${IN_CODE}`, + "array-step-relative": `${STEP_LIST} with a relative value (like "+=40"), which an edit would apply again. ${IN_CODE}`, + "array-step-random": `${STEP_LIST} with a random() value, which an edit would roll again. ${IN_CODE}`, +}; + +export function gsapEditBlockMessage( + reason: GsapEditBlockReason, + detail?: GsapEditBlockDetail, +): string { + return (detail && GSAP_EDIT_DETAIL_COPY[detail]) ?? GSAP_EDIT_BLOCK_COPY[reason]; +} + export class GsapEditBlockedError extends Error { constructor( readonly reason: GsapEditBlockReason, readonly detail?: GsapEditBlockDetail, ) { - super(GSAP_EDIT_BLOCK_COPY[reason]); + super(gsapEditBlockMessage(reason, detail)); this.name = "GsapEditBlockedError"; } } diff --git a/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts b/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts index cc0e6a4621..c1a6e2a660 100644 --- a/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts +++ b/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts @@ -22,7 +22,7 @@ export function liveTween( vars: tween.vars, _from: from, ...(head && { _pt: { d: { _pt: head } } }), - ...(parts && { timeline: { getChildren: () => parts } }), + ...(parts && { timeline: { getChildren: () => parts, duration: () => tween.duration } }), }; } @@ -47,6 +47,33 @@ export function previewWith( export const tween = (fields: Partial): GsapAnimation => ({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation; +/** `#box`'s position as a keyframe step list, parsed and run by GSAP (step `[duration, props]`). */ +export function boxSteps(steps: Array<[number, Record]>) { + const duration = steps.reduce((sum, [d]) => sum + d, 0); + let end = 0; + const keyframes = steps.map(([d, properties]) => { + end += d; + return { percentage: Math.round((end / duration) * 1000) / 10, properties }; + }); + const keys = tween({ + id: "#box-to-0-position", + method: "to", + properties: {}, + resolvedStart: 0, + duration, + keyframes: { format: "object-array", keyframes }, + }); + let start = 0; + const parts = steps.map(([d]) => { + const at = start; + start += d; + return { startTime: () => at, duration: () => d }; + }); + const live = (el: Element) => + liveTween(el, { start: 0, duration, vars: { keyframes: [] } }, { parts }); + return { keys, live }; +} + /** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */ export function elTween( id: string, diff --git a/packages/studio/src/hooks/gsapParsedTween.ts b/packages/studio/src/hooks/gsapParsedTween.ts index 168f26a8bb..71848cdad7 100644 --- a/packages/studio/src/hooks/gsapParsedTween.ts +++ b/packages/studio/src/hooks/gsapParsedTween.ts @@ -1,6 +1,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { elementTargets } from "../utils/elementGsap"; import { resolveTweenStart } from "../utils/globalTimeCompiler"; +import { KEYFRAME_PCT_MATCH } from "./gsapShared"; import type { ImplicitEndValue } from "./gsapValueAtPlayhead"; import type { ReadTween } from "./gsapRuntimeKeyframes"; @@ -23,7 +24,7 @@ interface ParsedTween { seek?: (time: number, suppressEvents?: boolean) => unknown; getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[]; }; - timeline?: { getChildren?: () => ParsedTween[] }; + timeline?: { getChildren?: () => ParsedTween[]; duration?: () => number }; targets?: () => unknown[]; startTime?: () => number; duration?: () => number; @@ -246,7 +247,7 @@ const BUILT_IN_EASES = [ export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation { const data = anim.keyframes; const parts = tween?.timeline?.getChildren?.() ?? []; - const total = tween?.duration?.() ?? 0; + const total = tween?.timeline?.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); @@ -257,3 +258,23 @@ export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | nul })); return { ...anim, keyframes: { ...data, keyframes } }; } + +/** The keyframe nearest `pct` within {@link KEYFRAME_PCT_MATCH}, or -1: two steps can sit under 1% apart. */ +export function nearestKeyframeIndex(keyframes: { percentage: number }[], pct: number): number { + let best = -1; + keyframes.forEach((kf, i) => { + const off = Math.abs(kf.percentage - pct); + if ( + off <= KEYFRAME_PCT_MATCH && + (best < 0 || off < Math.abs(keyframes[best]!.percentage - pct)) + ) + best = i; + }); + return best; +} + +export function exactKeyframePct(anim: GsapAnimation, tween: ParsedTween | null, pct: number) { + const authored = anim.keyframes?.keyframes ?? []; + const i = nearestKeyframeIndex(authored, pct); + return withExactStepTimes(anim, tween).keyframes?.keyframes[i]?.percentage ?? pct; +} diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index b4d4c85da1..b413c89721 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -23,6 +23,7 @@ import { computeCurrentPercentage, findExistingPositionWrite, findRotationSetAnimation, + stepListBlock, } from "./gsapDragCommit"; import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; import { isGestureTransactionCommit } from "./gestureTransaction"; @@ -218,7 +219,12 @@ async function planDrag( if (!posAnim) { return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" }; } - if (route === "whole-path") return { status: "persisted" }; + if (route === "whole-path") { + const step = stepListBlock(posAnim); + return step + ? { status: "blocked", reason: "keyframes-uneditable", detail: step } + : { status: "persisted" }; + } const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 }; return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe); } diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 691025489c..a52bed69a0 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -43,7 +43,7 @@ export interface RuntimeTimeline { to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween; } -type Pct = { percentage: number; properties: Record }; +type Pct = { percentage: number; properties: Record; step?: number }; export type ReadTween = { keyframes: Pct[]; easeEach?: string; diff --git a/packages/studio/src/hooks/gsapShared.test.ts b/packages/studio/src/hooks/gsapShared.test.ts index b2180889fc..a339c1ae3f 100644 --- a/packages/studio/src/hooks/gsapShared.test.ts +++ b/packages/studio/src/hooks/gsapShared.test.ts @@ -64,6 +64,7 @@ describe("parsePercentageKeyframes", () => { { x: 1480, y: 160 }, ] as unknown as Record); expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]); + expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]); expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 }); }); diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index 92a7569880..17e4277af8 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -399,7 +399,11 @@ export function queryIframeElement( // ── Keyframe parsing ────────────────────────────────────────────────────────── export interface ParsedPercentageKeyframes { - keyframes: Array<{ percentage: number; properties: Record }>; + keyframes: Array<{ + percentage: number; + properties: Record; + step?: number; + }>; easeEach?: string; } @@ -442,7 +446,7 @@ export function parsePercentageKeyframes( if (!entry || typeof entry !== "object") return; const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0; const properties = collectAnimatableKeyframeProperties(entry); - if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties }); + if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties, step: i }); }); return keyframes.length > 0 ? { keyframes } : null; } diff --git a/packages/studio/src/hooks/gsapValueAtPlayhead.realGsap.test.ts b/packages/studio/src/hooks/gsapValueAtPlayhead.realGsap.test.ts index 420d7e845b..7a1b324026 100644 --- a/packages/studio/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +++ b/packages/studio/src/hooks/gsapValueAtPlayhead.realGsap.test.ts @@ -2,11 +2,13 @@ import { gsap } from "gsap"; import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn"; import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { findParsedTween, parsedImplicitEndValue, parsedTweenEase } from "./gsapParsedTween"; -import { planValueEdit } from "./gsapValueAtPlayhead"; +import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes"; +import { GsapEditBlockedError } from "./gsapEditOutcome"; +import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead"; /** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */ function play(script: string, at: number) { @@ -18,14 +20,20 @@ function play(script: string, at: number) { } /** Drags `#x` to `x` at `at`, writes the plan into the script, and replays the written file there. */ -function dragAndReplay(script: string, x: number, at: number) { +function dragAndReplay( + script: string, + x: number, + at: number, + selectedPct: number | null = null, + { y, backfill }: { y?: number; backfill?: Record } = {}, +) { const box = document.body.appendChild(document.createElement("div")); box.id = "x"; const { timeline, iframe } = play(script, at); - usePlayerStore.setState({ currentTime: at, activeKeyframePct: null }); + usePlayerStore.setState({ currentTime: at, activeKeyframePct: selectedPct }); const anim = parseGsapScriptAcorn(script).animations[0]!; const selection = { id: "x", selector: "#x", element: box } as DomEditSelection; - const plan = planValueEdit(selection, anim, { x }, iframe); + const plan = planValueEdit(selection, anim, { x, ...(y != null && { y }) }, iframe, { backfill }); timeline.kill(); if (!plan.ok) return { plan }; const written = replaceTweenWithKeyframesInScript(script, anim.id, plan.mutation)!; @@ -54,6 +62,64 @@ it("writes a delayed linear tween so GSAP shows the new value at the playhead, n expect(shown).toBe(200); }); +it("reads plain array nodes where the lane puts them, though GSAP fills in step durations", () => { + const source = script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"); + document.body.appendChild(document.createElement("div")).id = "x"; + const { timeline, iframe } = play(source, 0); + const frame = { ...iframe, contentDocument: document } as HTMLIFrameElement; + const read = readRuntimeKeyframes(frame, "#x"); + timeline.kill(); + const parsed = parseGsapScriptAcorn(source).animations[0]!.keyframes!.keyframes; + expect(read?.keyframes.map((kf) => kf.percentage)).toEqual(parsed.map((kf) => kf.percentage)); +}); + +// Three default 0.5 s steps stretched over 3 s: GSAP reaches the middle one at 2 s, where the parse +// places it at 50%. +it.each([ + ["at the playhead, timing array steps on their own timeline", undefined], + ["selected in the lane, in place", 50], +])("changes the middle array keyframe %s", (_, selectedPct) => { + const { plan, shown } = dragAndReplay( + script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"), + 130, + 2, + selectedPct, + ); + expect(plan.ok && plan.mutation.keyframes.map((kf) => kf.properties.x)).toEqual([60, 130, 180]); + expect(shown).toBeCloseTo(130, 2); +}); + +it("keeps an array step that leaves a channel out holding it, as GSAP played it", () => { + const hold = "keyframes: [{ x: 60, duration: 1 }, { duration: 1 }, { x: 180, duration: 1 }]"; + const { written } = dragAndReplay(script(hold), 97, 1); + const replay = play(written!, 2); + expect(gsap.getProperty("#x", "x")).toBe(97); + replay.timeline.kill(); +}); + +it("holds a newly animated channel at rest through a step list's opening pause", () => { + const pauseFirst = + "keyframes: [{ duration: 1 }, { x: 60, duration: 1 }, { x: 180, duration: 1 }]"; + const { written } = dragAndReplay(script(pauseFirst), 37, 2, null, { y: 11, backfill: { y: 0 } }); + for (const [t, y] of [ + [0.5, 0], + [2, 11], + ]) { + gsap.set("#x", { clearProps: "all" }); // a reloaded preview starts the tween from rest + const replay = play(written!, t!); + expect(gsap.getProperty("#x", "y")).toBeCloseTo(y!, 2); + replay.timeline.kill(); + } +}); + +it("holds a channel at its start until the array step that first animates it", () => { + const late = "keyframes: [{ x: 60 }, { x: 120, y: 50 }], duration: 2"; + const { written } = dragAndReplay(script(late), 200, 2); + const replay = play(written!, 1); + expect(gsap.getProperty("#x", "y")).toBe(0); + replay.timeline.kill(); +}); + it("keeps GSAP's default ease, by name, for a tween that authors none", () => { const { plan } = dragAndReplay(script("duration: 1, x: 100"), 60, 0.5); expect(plan.ok && plan.mutation.easeEach).toBe("power1.out"); @@ -177,3 +243,39 @@ it("writes an edit into a looping tween and keeps its repeat and yoyo, as main d expect(plan.ok).toBe(true); expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/); }); + +it("leaves a step list with a runBackwards step to the runtime: a rewrite would play it differently", async () => { + const flagged = script( + "keyframes: [{ x: 60, duration: 1, runBackwards: true }, { x: 180, duration: 1 }], ease: 'none'", + ); + const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" }); + const xAt = (src: string, t: number) => { + gsap.set(box, { clearProps: "all" }); + const replay = play(src, t); + const x = gsap.getProperty(box, "x"); + replay.timeline.kill(); + return x; + }; + // The flag plays the first step from 60 back to the start, so a list without it is another animation. + expect(xAt(flagged, 0.25)).not.toBe(xAt(flagged.replace(", runBackwards: true", ""), 0.25)); + const { timeline, iframe } = play(flagged, 1.5); + usePlayerStore.setState({ currentTime: 1.5, activeKeyframePct: null }); + const anim = parseGsapScriptAcorn(flagged).animations[0]!; + const commitMutation = vi.fn(async () => {}); + const selection = { id: "x", selector: "#x", element: box } as DomEditSelection; + + const edit = commitValueAtPlayhead( + selection, + anim, + { x: 90 }, + iframe, + { commitMutation }, + { + label: "Move", + }, + ); + + await expect(edit).rejects.toBeInstanceOf(GsapEditBlockedError); + timeline.kill(); + expect(commitMutation).not.toHaveBeenCalled(); +}); diff --git a/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts b/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts index ae5e46442c..612680f497 100644 --- a/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts +++ b/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts @@ -3,8 +3,8 @@ 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 { GSAP_EDIT_BLOCK_COPY, GsapEditBlockedError } from "./gsapEditOutcome"; +import { boxSteps, liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers"; import { tryGsapResizeIntercept } from "./gsapResizeIntercept"; import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge"; import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead"; @@ -166,31 +166,12 @@ describe("a move on a GSAP-animated layer, at the playhead", () => { }); 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 }); + // The parse rounds the first step to 66.7%; GSAP times it at 2 s of 3. + const { keys, live } = boxSteps([ + [2, { x: 60 }], + [1, { x: 120 }], + ]); + const { mutations } = await drag([keys], [live(el)], { 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([ @@ -527,6 +508,30 @@ describe("planValueAtPlayhead", () => { ]); }); + it("refuses a step that leaves a channel out when GSAP's start for it is unknown", () => { + const anim = kf([ + { percentage: 50, properties: {} }, + { percentage: 100, properties: { x: 20 } }, + ]); + const steps = { ...anim, keyframes: { ...anim.keyframes!, format: "object-array" as const } }; + expect(plan({ anim: steps, at: { percentage: 100 }, values: { x: 5 } })).toEqual({ + ok: false, + reason: "implicit-end-unknown", + }); + }); + + it("changes the nearer of two keyframes under 1% apart", () => { + const anim = kf([ + { percentage: 50, properties: { x: 100 } }, + { percentage: 50.5, properties: { x: 100 } }, + { percentage: 100, properties: { x: 100 } }, + ]); + const result = plan({ anim, at: { percentage: 50.5 }, values: { x: 150 } }); + expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([ + 100, 150, 100, + ]); + }); + describe("keyframes eased as a whole", () => { const eased = { ...kf([ @@ -563,6 +568,29 @@ describe("planValueAtPlayhead", () => { }); }); + it("changes an inner keyframe picked by its percentage, which the ease does not move", () => { + const anim = { + ...kf([ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + { percentage: 100, properties: { x: 300 } }, + ]), + ease: "power2.out", + }; + const result = plan({ anim, at: { percentage: 50 }, values: { x: 5 } }); + expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([ + 0, 5, 300, + ]); + expect(result.ok && result.mutation.ease).toBe("power2.out"); + }); + + it("refuses to add a keyframe at a percentage, whose time the ease would move", () => { + expect(plan({ anim: eased, at: { percentage: 40 }, values: { x: 5 } })).toEqual({ + ok: false, + reason: "eased-keyframes", + }); + }); + it("refuses to add a keyframe, whose time the ease would move", () => { expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({ ok: false, @@ -593,3 +621,77 @@ it("refuses a tween whose selector also animates a sibling, before it plans anyt }); sibling.remove(); }); + +describe("a keyframes array the percentage rewrite would change", () => { + const steps = ( + properties: Array>, + edit: Partial = {}, + ) => + planValueAtPlayhead({ + anim: tween({ + method: "to", + properties: {}, + resolvedStart: 0, + duration: 3, + keyframes: { + format: "object-array", + keyframes: properties.map((p, i) => ({ percentage: ((i + 1) / 3) * 100, properties: p })), + }, + }), + at: { percentage: 100 }, + values: { x: 50 }, + implicitEndValue: () => 0, + ...edit, + }); + + it.each([ + ["a step delay", { x: 100, delay: 0.5 }, "array-step-delay", "its own delay"], + ["a step callback", { x: 100, onComplete: "__raw:done" }, "array-step-callback", "runs code"], + [ + "an unlisted callback", + { x: 100, onInterrupt: "__raw:stop" }, + "array-step-callback", + "runs code", + ], + [ + "a step repeat", + { x: 100, repeat: 2 }, + "array-step-config", + "tween setting (like repeat or stagger)", + ], + ["a computed value", { x: "__raw:offset()" }, "array-step-computed", "comes from code"], + ["a relative value", { x: "-=40" }, "array-step-relative", '"+=40"'], + ["a random value", { x: "random(0, 300)" }, "array-step-random", "random()"], + [ + "a random value inside a string", + { filter: "blur(random(1, 9)px)" }, + "array-step-random", + "random()", + ], + ] as const)("refuses %s and says so", (_, step, reason, says) => { + expect(steps([{ x: 60 }, step, { x: 180 }])).toEqual({ ok: false, reason }); + const message = new GsapEditBlockedError("keyframes-uneditable", reason).message; + expect(message).toContain(says); + expect(message).toContain("Code tab"); + }); + + it("rewrites absolute values, colours included", () => { + const result = steps([{ x: 60, color: "#fff" }, { x: 120, color: "rgb(0, 0, 0)" }, { x: 180 }]); + expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([ + 60, 120, 50, + ]); + }); + + it("holds a pause step at the dropped value of a channel the drop starts animating", () => { + const result = steps([{ x: 60 }, {}, { x: 180 }], { + at: { percentage: 100 / 3 }, + values: { x: 37, y: 11 }, + backfill: { y: 0 }, + }); + expect(result.ok && result.mutation.keyframes.map((k) => k.properties)).toEqual([ + { x: 37, y: 11 }, + { x: 37, y: 11 }, + { x: 180, y: 0 }, + ]); + }); +}); diff --git a/packages/studio/src/hooks/gsapValueAtPlayhead.ts b/packages/studio/src/hooks/gsapValueAtPlayhead.ts index dc770c1c9b..3663958fe2 100644 --- a/packages/studio/src/hooks/gsapValueAtPlayhead.ts +++ b/packages/studio/src/hooks/gsapValueAtPlayhead.ts @@ -5,11 +5,14 @@ import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeComp import { roundTo3 } from "../utils/rounding"; import { materializeIfDynamic, + stepListBlock, parkPlayheadOnKeyframe, type GsapDragCommitCallbacks, } from "./gsapDragCommit"; import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome"; import { + exactKeyframePct, + nearestKeyframeIndex, findParsedTween, parsedImplicitEndValue, parsedTweenEase, @@ -86,6 +89,8 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus if (data) { if (data.format === "simple-array") return { reason: "simple-array-keyframes" }; const arrayStep = data.format === "object-array"; + const blocked = stepListBlock(anim); + if (blocked) return { reason: blocked }; return { runEase: data.ease ?? anim.ease, keyframes: data.keyframes.map((kf) => ({ @@ -129,7 +134,7 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") { } const keyframeAt = (keyframes: Keyframe[], percentage: number) => - keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH); + keyframes[nearestKeyframeIndex(keyframes, percentage)]; function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) { const hit = keyframeAt(keyframes, percentage); @@ -164,6 +169,27 @@ function heldEnds( return held; } +/** An array step without a channel holds it, where a percentage keyframe would tween through it. */ +function holdOmittedChannels( + steps: Keyframe[], + implicit: ImplicitEndValue, + backfilled: Record, +): boolean { + const props = new Set(steps.flatMap((kf) => Object.keys(kf.properties))); + for (const prop of props) { + let held: number | string | null | undefined; + for (const kf of steps) { + if (kf.properties[prop] != null) held = kf.properties[prop]; + else { + held ??= backfilled[prop] ?? implicit(prop, "start"); + if (held == null) return false; + kf.properties[prop] = held; + } + } + } + return true; +} + /** 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 @@ -176,6 +202,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan { if ("reason" in norm) return refuse(norm.reason); const keyframes = norm.keyframes; const authored = keyframes.length; + const steps = anim.keyframes?.format === "object-array" ? [...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) { @@ -184,11 +211,14 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan { } } const backfilled: Record = {}; + const pauseStepsHoldThePrevious = new Set( + steps.filter((kf) => Object.keys(kf.properties).length === 0), + ); for (const [prop, value] of Object.entries(edit.backfill ?? {})) { if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue; if (edit.holdFromStart && !keyframes.some((kf) => kf.percentage <= 0)) keyframes.unshift({ percentage: 0, properties: {} }); - for (const kf of keyframes) kf.properties[prop] = value; + for (const kf of keyframes) if (!pauseStepsHoldThePrevious.has(kf)) kf.properties[prop] = value; backfilled[prop] = value; } if (Object.keys(values).some((prop) => !keyframes.some((kf) => kf.properties[prop] != null))) @@ -204,7 +234,9 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan { if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) { const at = Math.min(100, Math.max(0, pct)); const hit = keyframeAt(keyframes, at); - if (eased && hit?.percentage !== 0 && hit?.percentage !== 100) return refuse("eased-keyframes"); + // The ease moves where a time lands, not a keyframe's own percentage or the tween's ends. + const onKeyframe = "percentage" in edit.at || hit?.percentage === 0 || hit?.percentage === 100; + if (eased && !(hit && onKeyframe)) return refuse("eased-keyframes"); const next = [...keyframes] .sort((a, b) => a.percentage - b.percentage) .find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH); @@ -231,6 +263,8 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan { upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear); } + if (!holdOmittedChannels(steps, edit.implicitEndValue, backfilled)) + return refuse("implicit-end-unknown"); keyframes.sort((a, b) => a.percentage - b.percentage); return { ok: true, @@ -263,7 +297,10 @@ export function planValueEdit( const timed = withExactStepTimes(anim, tween); return planValueAtPlayhead({ anim: withLiveTiming(timed, tween), - at: activeKeyframePct != null ? { percentage: activeKeyframePct } : { time: currentTime }, + at: + activeKeyframePct != null + ? { percentage: exactKeyframePct(anim, tween, activeKeyframePct) } + : { time: currentTime }, values, backfill, holdFromStart, @@ -286,6 +323,9 @@ export async function commitValueAtPlayhead( ): Promise { await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const tween = findParsedTween(iframe, selection.element, anim); + const parkAt = + activeKeyframePct == null ? null : exactKeyframePct(anim, tween, activeKeyframePct); const plan = planValueEdit(selection, anim, values, iframe, options); if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason }; await callbacks.commitMutation(selection, plan.mutation, { @@ -294,9 +334,9 @@ export async function commitValueAtPlayhead( beforeReload: options.beforeReload, ...(plan.added && { keyframeAction: "add" as const }), }); - if (activeKeyframePct != null) { + if (parkAt != null) { setActiveKeyframePct(null); - parkPlayheadOnKeyframe(anim, activeKeyframePct); + parkPlayheadOnKeyframe(anim, parkAt); } return { status: "persisted" }; } diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts index 0c827ab297..f11a915dd6 100644 --- a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts +++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; +import { commitWholePathOffset, type GsapDragCommitCallbacks } from "./gsapDragCommit"; import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; // Regression (#1808): with auto-keyframe recording off, a manual edit on an @@ -203,3 +203,74 @@ describe("commitWholePropertyOffset", () => { expect(mutations[0]).toMatchObject({ easeEach: "none" }); }); }); + +it.each([ + [{ rotation: "+=40" }, "array-step-relative"], + [{ rotation: 40, onComplete: "__raw:done" }, "array-step-callback"], + [{ rotation: "random(0, 90)" }, "array-step-random"], +])("refuses to rewrite a step list holding %o and writes nothing", async (last, detail) => { + const steps = [{ rotation: 10 }, last].map((properties, i) => ({ + percentage: (i + 1) * 50, + properties, + })); + const anim = { + id: "#box-steps", + targetSelector: "#box", + method: "to", + resolvedStart: 0, + duration: 2, + keyframes: { format: "object-array", keyframes: steps }, + } as unknown as GsapAnimation; + const { mutations, callbacks } = recordingCallbacks(); + const rotate = commitWholePropertyOffset( + selection(), + anim, + { rotation: 50 }, + 100, + null, + callbacks, + "Rotate", + ); + await expect(rotate).rejects.toMatchObject({ reason: "keyframes-uneditable", detail }); + expect(mutations).toEqual([]); +}); + +it("refuses to shift a layer's step list whose step has its own delay, writing nothing", async () => { + // No drag baseline on the element: the move starts from a zero offset. + const el = { + style: { getPropertyValue: () => "", setProperty: () => {} }, + getAttribute: () => null, + removeAttribute: () => {}, + getBoundingClientRect: () => ({ top: 0, left: 0 }), + }; + const anim = { + id: "#box-steps", + targetSelector: "#box", + method: "to", + resolvedStart: 0, + duration: 2, + keyframes: { + format: "object-array", + keyframes: [ + { percentage: 50, properties: { x: 100, delay: 0.5 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, + } as unknown as GsapAnimation; + const { mutations, callbacks } = recordingCallbacks(); + const sel = { ...selection(), element: el } as DomEditSelection; + const move = commitWholePathOffset( + sel, + anim, + { x: 40, y: 0 }, + { x: 0, y: 0 }, + null, + "#box", + callbacks, + ); + await expect(move).rejects.toMatchObject({ + reason: "keyframes-uneditable", + detail: "array-step-delay", + }); + expect(mutations).toEqual([]); +}); diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts index 05e280497f..c1322f3f6d 100644 --- a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts +++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts @@ -8,7 +8,11 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp import { roundTo3 } from "../utils/rounding"; import { keyframeEases, PROPERTY_DEFAULTS } from "./gsapShared"; import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; -import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit"; +import { + materializeIfDynamic, + refuseStepListRewrite, + type GsapDragCommitCallbacks, +} from "./gsapDragCommit"; /** * Generic sibling of commitWholePathOffset for property groups other than @@ -32,6 +36,7 @@ export async function commitWholePropertyOffset( // fallow-ignore-next-line code-duplication let effectiveAnim = anim; if (anim.keyframes) { + refuseStepListRewrite(anim); const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); if (newId) effectiveAnim = { ...anim, id: newId }; } diff --git a/packages/studio/src/hooks/useCommitPreflightCapabilities.ts b/packages/studio/src/hooks/useCommitPreflightCapabilities.ts index 3fea264e80..44459341c1 100644 --- a/packages/studio/src/hooks/useCommitPreflightCapabilities.ts +++ b/packages/studio/src/hooks/useCommitPreflightCapabilities.ts @@ -3,7 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBridge"; import { preflightGsapResizeIntercept } from "./gsapResizePreflight"; -import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome"; +import { gsapEditBlockMessage, type GsapEditOutcome } from "./gsapEditOutcome"; import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad"; import { getAnimationsForElement } from "./gsapElementMatch"; import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback"; @@ -44,7 +44,7 @@ const MANUAL_FLAGS = [ function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null { const outcome = preflight?.[check]; if (!outcome) return ""; - return outcome.status === "blocked" ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : null; + return outcome.status === "blocked" ? gsapEditBlockMessage(outcome.reason, outcome.detail) : null; } /** Closes each manual flag whose commit Studio would refuse, and says why. */ diff --git a/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx b/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx index e613f64629..296d1f2989 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +++ b/packages/studio/src/hooks/useGsapAwareEditing.groupPlan.test.tsx @@ -127,6 +127,31 @@ describe("a group drag plans every member before its first write", () => { act(() => h.root.unmount()); }); + it("with auto-keyframe off, refuses the whole group when one member's step list holds a step delay", async () => { + usePlayerStore.setState({ autoKeyframeEnabled: false }); + const steps = { + format: "object-array", + keyframes: [ + { percentage: 50, properties: { x: 100, y: 0, delay: 0.5 } }, + { percentage: 100, properties: { x: 200, y: 0 } }, + ], + }; + const h = mountGroup([ + positionTween("a"), + positionTween("b", { keyframes: steps } as Partial), + positionTween("c"), + ]); + + await expect(h.groupCommit(h.updates)).rejects.toMatchObject({ + reason: "keyframes-uneditable", + detail: "array-step-delay", + }); + + expect(h.written()).toEqual([]); + expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "b" })); + act(() => h.root.unmount()); + }); + it("refuses members animated in two files, since the batch writes one file", async () => { const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id))); const updates = h.updates.map((u, i) => ({