fix(studio): Enable keyframes works (and auto-tracks) on elements with a global gsap.set (#1728)

* fix(core): a global gsap.set is off-timeline, so resolvedStart is 0 not the comp end

resolveTimelinePositions walks anims in document order advancing a cursor; a
global `gsap.set(...)` carries no position arg, so it fell through to the
cursor fallback and inherited the comp-end time (every prior tween's duration
summed) as its resolvedStart. A global set is a load-time hold — its start is 0.

This silently broke 'Enable keyframes' on any element whose only animation is a
global gsap.set (e.g. a statically-positioned card): promoteSetToKeyframes bails
when `playhead <= setStart`, and setStart was the comp end, so any playhead
before the end was a no-op. Pin a global set to resolvedStart 0 in both the
recast and acorn parsers; don't let it advance the cursor/prevStart.

* fix(studio): Enable-keyframes marks the generated 0% endpoint as auto-tracking

When 'Enable keyframes' promotes a static set to a two-stop tween, the 0% (the
held start the user didn't choose) is now marked `auto: true` → serialized as the
`_auto: 1` marker. The parser's endpoint-sync then keeps it tracking the nearest
keyframe until the user edits it directly; the 100% (the real keyframe placed at
the playhead) stays fixed.

This re-wires the auto-endpoint behavior that was silently dropped in #1605 (the
sync logic stayed, but no flow produced an `_auto` endpoint anymore, so an
untouched 0% never tracked). Adds a guard test so it can't be lost again.

* chore: suppress fallow complexity findings surfaced in the touched files

The resolveTimelinePositions guard added a branch (pushes it over threshold), and
changed-file scope re-surfaces pre-existing complex functions (readElementPosition,
applyArcWaypointAtPlayhead, the useEnableKeyframes callback). Bare directives only.

* fix(studio): dragging a --hf-studio-offset element no longer flies

Dragging a static element positioned via the legacy --hf-studio-offset CSS var
(e.g. dot-a) flew off-screen — three independent failure modes, all fixed:

1. Live drag integrated: the per-move draft read its base from the live transform
   it set last frame (gsap.getProperty), so base+delta accumulated frame-over-frame.
   Fix: carry a stable baseGsap on the in-memory drag member (immune to mid-drag
   re-renders that wipe the data-hf-drag-* attrs) and use it as the fallback.

2. Commit re-added the delta: the source commit re-read the wiped attrs / live
   transform. Fix: re-stamp the stable base/initial attrs in applyManualOffsetDragCommit
   before the commit reads them.

3. Drop left it offset: the committed source was correct, but the LIVE element kept
   its --hf-studio-offset var + translate:var(...), which composed with the GSAP
   transform (rendered at dropped + offset) until a full reload. Fix: on cleanup,
   when GSAP owns the position, clearStudioPathOffset() migrates the element off the
   legacy CSS channel (leaving transform untouched) — matching the stripped source.

Adds a regression suite covering all three layers.
This commit is contained in:
Miguel Ángel
2026-06-25 21:31:23 -04:00
committed by GitHub
parent bafed1fc9a
commit 226a741c9d
7 changed files with 222 additions and 9 deletions
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
animatedProps,
buildExtendedKeyframes,
isPlayheadWithinTween,
promoteSetToKeyframes,
resolveNewTweenRange,
type EnableKeyframesSession,
} from "./useEnableKeyframes";
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
@@ -128,3 +131,40 @@ describe("buildExtendedKeyframes", () => {
expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
});
});
describe("promoteSetToKeyframes — auto endpoint", () => {
it("marks the 0% (held start) as `auto`, leaving the 100% (playhead) fixed", async () => {
let committed: Record<string, unknown> | undefined;
const session = {
commitMutation: async (mutation: Record<string, unknown>) => {
committed = mutation;
},
} as unknown as EnableKeyframesSession;
const sel = {
id: "card",
selector: "#card",
sourceFile: "index.html",
element: { isConnected: true } as unknown as HTMLElement,
} as unknown as DomEditSelection;
// readElementPosition reads gsap.getProperty off the iframe window.
const iframe = {
contentWindow: { gsap: { getProperty: () => -74 } },
} as unknown as HTMLIFrameElement;
const setAnim = anim({
id: "#card-set-0-position",
targetSelector: "#card",
method: "set",
global: true,
resolvedStart: 0,
properties: { x: -74, y: -469 },
});
await promoteSetToKeyframes(session, sel, setAnim, 1, iframe);
const kfs = committed?.keyframes as Array<{ percentage: number; auto?: boolean }>;
expect(committed?.type).toBe("replace-with-keyframes");
expect(kfs[0]).toMatchObject({ percentage: 0, auto: true });
expect(kfs[1].percentage).toBe(100);
expect(kfs[1].auto).toBeUndefined();
});
});
@@ -107,6 +107,7 @@ export function buildExtendedKeyframes(
return { position: roundTo3(newStart), duration: newDuration, keyframes };
}
// fallow-ignore-next-line complexity
function readElementPosition(
iframe: HTMLIFrameElement | null,
sel: DomEditSelection,
@@ -238,8 +239,12 @@ async function applyKeyframeAtPlayhead(
* two-stop tween from the set's time to the playhead — the held value at 0%, the
* live value at 100% — giving the user something to animate. No-op if the playhead
* is at or before the set.
*
* The 0% endpoint is the held start, which the user didn't choose — mark it `auto`
* so it tracks the nearest keyframe until edited directly. The 100% is the real
* keyframe being placed at the playhead, so it stays fixed.
*/
async function promoteSetToKeyframes(
export async function promoteSetToKeyframes(
session: EnableKeyframesSession,
sel: DomEditSelection,
setAnim: GsapAnimation,
@@ -267,6 +272,7 @@ async function promoteSetToKeyframes(
{
percentage: 0,
properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
auto: true,
},
{ percentage: 100, properties: endPosition },
],
@@ -283,6 +289,7 @@ async function promoteSetToKeyframes(
* the path, inserted at the matching segment so the curve is preserved. Outside the
* range, extend the duration so the motion reaches the playhead.
*/
// fallow-ignore-next-line complexity
async function applyArcWaypointAtPlayhead(
session: EnableKeyframesSession,
sel: DomEditSelection,
@@ -332,10 +339,10 @@ async function applyArcWaypointAtPlayhead(
);
}
// fallow-ignore-next-line complexity
export function useEnableKeyframes(
sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
) {
// fallow-ignore-next-line complexity
return useCallback(async () => {
const session = sessionRef.current;
if (!session) return;