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
18 changes: 18 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,24 @@ describe("parseGsapScript", () => {
});

describe("resolvedStart — timeline position resolution", () => {
it("a global gsap.set is off-timeline: resolvedStart is 0, not the comp-end cursor", () => {
// The trailing global `gsap.set` carries no position; the cursor has advanced
// to ~3 by the time it's reached. It must NOT inherit that as its start — it's
// a load-time hold at 0. (Regression: setStart=cursor blocked Enable-keyframes.)
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#a", { x: 100, duration: 3 }, 0);
gsap.set("#card", { x: -74, y: -469 });
`;
const result = parseGsapScript(script);
const set = result.animations.find((a) => a.targetSelector === "#card");
expect(set?.method).toBe("set");
expect(set?.global).toBe(true);
expect(set?.resolvedStart).toBe(0);
// The off-timeline set must not perturb the real tween's position either.
expect(result.animations.find((a) => a.targetSelector === "#a")?.resolvedStart).toBe(0);
});

it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => {
const script = `
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,15 @@ function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
let cursor = 0;
let prevStart = 0;
for (const anim of anims) {
// A global `gsap.set(...)` is off-timeline — it's applied once at load, not
// sequenced on the master timeline. It carries no position arg, so the
// cursor-based fallback below would otherwise hand it the comp-end time
// (every prior tween's duration summed). Pin it to 0 (its load-time start)
// and don't let it advance the cursor/prevStart for following tweens.
if (anim.method === "set" && anim.global) {
anim.resolvedStart = 0;
continue;
}
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
let start: number | null;

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/parsers/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,10 +995,19 @@ function applyTimelineDefaults(
}
}

// fallow-ignore-next-line complexity
function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
let cursor = 0;
let prevStart = 0;
for (const anim of anims) {
// A global `gsap.set(...)` is off-timeline — applied once at load, not
// sequenced on the master timeline. It carries no position arg, so the
// cursor fallback would otherwise hand it the comp-end time. Pin it to 0
// (its load-time start) and don't advance the cursor/prevStart.
if (anim.method === "set" && anim.global) {
anim.resolvedStart = 0;
continue;
}
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
let start: number | null;

Expand Down
99 changes: 99 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Window } from "happy-dom";
import { describe, expect, it } from "vitest";
import {
applyManualOffsetDragCommit,
applyManualOffsetDragDraft,
applyManualOffsetDragMatrix,
createManualOffsetDragMember,
endManualOffsetDragMembers,
Expand Down Expand Up @@ -261,3 +262,101 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
}
});
});

// ── GSAP-element drag: the dot-a "flies" regressions ────────────────────────
// A static element positioned via the legacy `--hf-studio-offset` CSS var, dragged
// in a GSAP composition. Three independent failure modes, each fixed:
// 1. live drag integrated off-screen (base read from the live transform)
// 2. commit re-added the delta (stamped base wiped by a mid-drag re-render)
// 3. drop left the element offset (stale --hf-studio-offset var composing with
// the committed GSAP transform until a full reload)
function makeGsapDot(offsetX = 94, offsetY = 2) {
const window = new Window();
const element = window.document.createElement("div");
element.id = "dot-a";
element.setAttribute("data-hf-studio-path-offset", "true");
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${offsetX}px`);
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${offsetY}px`);
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
window.document.body.append(element);
// Constant rect → the screen-to-offset probe can't measure movement → member
// uses the deterministic preview-scale fallback matrix. Both branches set baseGsap.
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
const sets: Array<Record<string, unknown>> = [];
const win = element.ownerDocument.defaultView as unknown as {
gsap?: unknown;
__timelines?: unknown;
};
win.gsap = {
set: (el: HTMLElement, vars: Record<string, unknown>) => {
sets.push({ ...vars });
if (typeof vars.x === "number") {
el.style.setProperty("transform", `translate(${vars.x}px, ${(vars.y as number) ?? 0}px)`);
}
},
// getProperty reads the LIVE transform — the exact value the old code fed back
// into `base + delta`, integrating the element off-screen.
getProperty: (el: HTMLElement, prop: string) => {
const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(
el.style.getPropertyValue("transform") || "",
);
if (!m) return 0;
return prop === "x" ? Number.parseFloat(m[1]!) : Number.parseFloat(m[2]!);
},
};
const member = () => {
const result = createManualOffsetDragMember({
key: "dot",
selection: { element } as never,
element,
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
});
if (!result.ok) throw new Error("member not created");
return result.member;
};
return { element, sets, member };
}

describe("GSAP-element drag — dot-a flies regressions", () => {
it("live draft uses the stable gesture-start base, so repeated moves don't integrate", () => {
const { element, member } = makeGsapDot();
const m = member();
// Simulate a mid-drag re-render wiping the stamped base attr → the draft must
// fall back to the in-memory member.baseGsap, NOT the live (mutating) transform.
element.removeAttribute("data-hf-drag-gsap-base-x");
element.removeAttribute("data-hf-drag-gsap-base-y");
applyManualOffsetDragDraft(m, -50, 0);
const first = element.style.getPropertyValue("transform");
applyManualOffsetDragDraft(m, -50, 0);
const second = element.style.getPropertyValue("transform");
// Same pointer delta → same committed transform. The old bug integrated (the
// second frame added the delta on top of the first frame's result).
expect(second).toBe(first);
});

it("commit re-stamps the stable base/initial attrs even after they're wiped", () => {
const { element, member } = makeGsapDot();
const m = member();
element.removeAttribute("data-hf-drag-gsap-base-x");
element.removeAttribute("data-hf-drag-initial-offset-x");
applyManualOffsetDragCommit(m, -50, 0);
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe(String(m.baseGsap.x));
expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe(String(m.initialOffset.x));
});

it("a GSAP-committed drag migrates the element off --hf-studio-offset", () => {
const { element, member } = makeGsapDot();
expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("94px");
const m = member();
applyManualOffsetDragCommit(m, -160, 0);
endManualOffsetDragMembers([m]);
// The legacy CSS-offset channel is fully cleared (single-sourced in GSAP): the
// var is removed, so any lingering `translate: var(--hf-studio-offset-x, 0px)`
// resolves to its 0px fallback and can no longer compose with the GSAP transform.
expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
expect(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
// ...and the position survives in the GSAP transform (no stale var to compose).
expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/);
});
});
45 changes: 38 additions & 7 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
applyStudioPathOffsetDraft,
beginStudioManualEditGesture,
captureStudioPathOffset,
clearStudioPathOffset,
endStudioManualEditGesture,
readAppliedStudioPathOffset,
restoreStudioPathOffset,
Expand Down Expand Up @@ -35,17 +36,17 @@ function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
function applyOffsetDragDraftViaGsap(
element: HTMLElement,
offset: { x: number; y: number },
baseGsap: { x: number; y: number },
): boolean {
const gsap = getOffsetDragGsap(element);
if (!gsap) return false;
// GSAP owns the transform; neutralize the CSS translate longhand so the two
// channels can't compose into a doubled position.
element.style.setProperty("translate", "none");
const fallbackBase = {
x: Number(gsap.getProperty(element, "x")) || 0,
y: Number(gsap.getProperty(element, "y")) || 0,
};
const { newX, newY } = computeDraggedGsapPosition(element, offset, fallbackBase);
// Use the STABLE gesture-start base (captured in JS), NOT `gsap.getProperty`.
// After `translate: none`, getProperty reads the transform we set last frame,
// so `base + delta` would integrate frame-over-frame and fling the element.
const { newX, newY } = computeDraggedGsapPosition(element, offset, baseGsap);
gsap.set(element, { x: newX, y: newY });
return true;
}
Expand Down Expand Up @@ -96,6 +97,14 @@ export interface ManualOffsetDragMember {
selection: DomEditSelection;
element: HTMLElement;
initialOffset: { x: number; y: number };
/**
* The element's GSAP x/y at gesture start, captured in JS so a mid-drag
* re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
* attrs) can't drop the base. Without this the draft falls back to the LIVE
* transform — i.e. the value it set last frame — and `base + delta` integrates,
* making the element accelerate away ("flies"). See applyOffsetDragDraftViaGsap.
*/
baseGsap: { x: number; y: number };
initialPathOffset: StudioPathOffsetSnapshot;
gestureToken: string;
screenToOffset: ManualOffsetDragMatrix;
Expand Down Expand Up @@ -343,6 +352,7 @@ export function createManualOffsetDragMember(input: {
scaleX: input.rect.editScaleX,
scaleY: input.rect.editScaleY,
});
const baseGsap = { x: gsapX, y: gsapY };
if (!measured.ok) {
// Fallback: when GSAP transforms interfere with probe measurement, use
// the preview scale as an approximation. The commit path reads the actual
Expand All @@ -357,6 +367,7 @@ export function createManualOffsetDragMember(input: {
selection: input.selection,
element: input.element,
initialOffset,
baseGsap,
initialPathOffset,
gestureToken,
screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
Expand All @@ -372,6 +383,7 @@ export function createManualOffsetDragMember(input: {
selection: input.selection,
element: input.element,
initialOffset,
baseGsap,
initialPathOffset,
gestureToken,
screenToOffset: measured.matrix,
Expand Down Expand Up @@ -402,7 +414,7 @@ export function applyManualOffsetDragDraft(
// Position is single-sourced on the GSAP timeline; preview through gsap.set so
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
// gsap is unavailable (no preview iframe runtime).
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
applyStudioPathOffsetDraft(member.element, offset);
}
return offset;
Expand All @@ -413,12 +425,22 @@ export function applyManualOffsetDragCommit(
dx: number,
dy: number,
): { x: number; y: number } {
// Re-stamp the STABLE gesture-start base/offset before the source commit reads
// them. A mid-drag re-render can wipe these attrs; the commit converts the drop
// offset → gsap x/y via computeDraggedGsapPosition, which without the base falls
// back to the live (already-dragged) transform and re-adds the delta — so the
// element flies off-screen the instant you drop it. The member holds the true
// gesture-start values in JS, immune to the re-render.
member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
// Optimistic visual through the GSAP channel (same as the live draft and the
// committed `tl.set`), so the element holds its dropped position until the
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
// CSS apply only when gsap is unavailable.
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
applyStudioPathOffset(member.element, offset);
}
return offset;
Expand Down Expand Up @@ -451,6 +473,15 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
if (member.element.style.getPropertyValue("translate") === "none") {
member.element.style.removeProperty("translate");
}
// Migration: when GSAP owns the position (the committed value lives in the
// GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
// Clear it on the LIVE element — otherwise the leftover `translate:
// var(--hf-studio-offset)` composes with the GSAP transform and the element
// renders offset by the stale value until a full page reload (the source is
// already stripped). clearStudioPathOffset leaves `transform` untouched.
if (getOffsetDragGsap(member.element)) {
clearStudioPathOffset(member.element);
}
resumeGsapTimelines(member.element);
}
}
Expand Down
40 changes: 40 additions & 0 deletions packages/studio/src/hooks/useEnableKeyframes.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
});
});
11 changes: 9 additions & 2 deletions packages/studio/src/hooks/useEnableKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -267,6 +272,7 @@ async function promoteSetToKeyframes(
{
percentage: 0,
properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
auto: true,
},
{ percentage: 100, properties: endPosition },
],
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading