@hyperframes/studio 0.7.8 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,9 +45,9 @@
45
45
  "dompurify": "^3.2.4",
46
46
  "marked": "^14.1.4",
47
47
  "mediabunny": "^1.45.3",
48
- "@hyperframes/player": "0.7.8",
49
- "@hyperframes/core": "0.7.8",
50
- "@hyperframes/sdk": "0.7.8"
48
+ "@hyperframes/core": "0.7.9",
49
+ "@hyperframes/sdk": "0.7.9",
50
+ "@hyperframes/player": "0.7.9"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/react": "19",
@@ -62,7 +62,7 @@
62
62
  "vite": "^6.4.2",
63
63
  "vitest": "^3.2.4",
64
64
  "zustand": "^5.0.0",
65
- "@hyperframes/producer": "0.7.8"
65
+ "@hyperframes/producer": "0.7.9"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": "19",
@@ -2,6 +2,7 @@ import { Window } from "happy-dom";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  applyManualOffsetDragCommit,
5
+ applyManualOffsetDragDraft,
5
6
  applyManualOffsetDragMatrix,
6
7
  createManualOffsetDragMember,
7
8
  endManualOffsetDragMembers,
@@ -261,3 +262,101 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
261
262
  }
262
263
  });
263
264
  });
265
+
266
+ // ── GSAP-element drag: the dot-a "flies" regressions ────────────────────────
267
+ // A static element positioned via the legacy `--hf-studio-offset` CSS var, dragged
268
+ // in a GSAP composition. Three independent failure modes, each fixed:
269
+ // 1. live drag integrated off-screen (base read from the live transform)
270
+ // 2. commit re-added the delta (stamped base wiped by a mid-drag re-render)
271
+ // 3. drop left the element offset (stale --hf-studio-offset var composing with
272
+ // the committed GSAP transform until a full reload)
273
+ function makeGsapDot(offsetX = 94, offsetY = 2) {
274
+ const window = new Window();
275
+ const element = window.document.createElement("div");
276
+ element.id = "dot-a";
277
+ element.setAttribute("data-hf-studio-path-offset", "true");
278
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, `${offsetX}px`);
279
+ element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${offsetY}px`);
280
+ element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
281
+ window.document.body.append(element);
282
+ // Constant rect → the screen-to-offset probe can't measure movement → member
283
+ // uses the deterministic preview-scale fallback matrix. Both branches set baseGsap.
284
+ element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
285
+ const sets: Array<Record<string, unknown>> = [];
286
+ const win = element.ownerDocument.defaultView as unknown as {
287
+ gsap?: unknown;
288
+ __timelines?: unknown;
289
+ };
290
+ win.gsap = {
291
+ set: (el: HTMLElement, vars: Record<string, unknown>) => {
292
+ sets.push({ ...vars });
293
+ if (typeof vars.x === "number") {
294
+ el.style.setProperty("transform", `translate(${vars.x}px, ${(vars.y as number) ?? 0}px)`);
295
+ }
296
+ },
297
+ // getProperty reads the LIVE transform — the exact value the old code fed back
298
+ // into `base + delta`, integrating the element off-screen.
299
+ getProperty: (el: HTMLElement, prop: string) => {
300
+ const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(
301
+ el.style.getPropertyValue("transform") || "",
302
+ );
303
+ if (!m) return 0;
304
+ return prop === "x" ? Number.parseFloat(m[1]!) : Number.parseFloat(m[2]!);
305
+ },
306
+ };
307
+ const member = () => {
308
+ const result = createManualOffsetDragMember({
309
+ key: "dot",
310
+ selection: { element } as never,
311
+ element,
312
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
313
+ });
314
+ if (!result.ok) throw new Error("member not created");
315
+ return result.member;
316
+ };
317
+ return { element, sets, member };
318
+ }
319
+
320
+ describe("GSAP-element drag — dot-a flies regressions", () => {
321
+ it("live draft uses the stable gesture-start base, so repeated moves don't integrate", () => {
322
+ const { element, member } = makeGsapDot();
323
+ const m = member();
324
+ // Simulate a mid-drag re-render wiping the stamped base attr → the draft must
325
+ // fall back to the in-memory member.baseGsap, NOT the live (mutating) transform.
326
+ element.removeAttribute("data-hf-drag-gsap-base-x");
327
+ element.removeAttribute("data-hf-drag-gsap-base-y");
328
+ applyManualOffsetDragDraft(m, -50, 0);
329
+ const first = element.style.getPropertyValue("transform");
330
+ applyManualOffsetDragDraft(m, -50, 0);
331
+ const second = element.style.getPropertyValue("transform");
332
+ // Same pointer delta → same committed transform. The old bug integrated (the
333
+ // second frame added the delta on top of the first frame's result).
334
+ expect(second).toBe(first);
335
+ });
336
+
337
+ it("commit re-stamps the stable base/initial attrs even after they're wiped", () => {
338
+ const { element, member } = makeGsapDot();
339
+ const m = member();
340
+ element.removeAttribute("data-hf-drag-gsap-base-x");
341
+ element.removeAttribute("data-hf-drag-initial-offset-x");
342
+ applyManualOffsetDragCommit(m, -50, 0);
343
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe(String(m.baseGsap.x));
344
+ expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe(String(m.initialOffset.x));
345
+ });
346
+
347
+ it("a GSAP-committed drag migrates the element off --hf-studio-offset", () => {
348
+ const { element, member } = makeGsapDot();
349
+ expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("94px");
350
+ const m = member();
351
+ applyManualOffsetDragCommit(m, -160, 0);
352
+ endManualOffsetDragMembers([m]);
353
+ // The legacy CSS-offset channel is fully cleared (single-sourced in GSAP): the
354
+ // var is removed, so any lingering `translate: var(--hf-studio-offset-x, 0px)`
355
+ // resolves to its 0px fallback and can no longer compose with the GSAP transform.
356
+ expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
357
+ expect(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
358
+ expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
359
+ // ...and the position survives in the GSAP transform (no stale var to compose).
360
+ expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/);
361
+ });
362
+ });
@@ -4,6 +4,7 @@ import {
4
4
  applyStudioPathOffsetDraft,
5
5
  beginStudioManualEditGesture,
6
6
  captureStudioPathOffset,
7
+ clearStudioPathOffset,
7
8
  endStudioManualEditGesture,
8
9
  readAppliedStudioPathOffset,
9
10
  restoreStudioPathOffset,
@@ -35,17 +36,17 @@ function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
35
36
  function applyOffsetDragDraftViaGsap(
36
37
  element: HTMLElement,
37
38
  offset: { x: number; y: number },
39
+ baseGsap: { x: number; y: number },
38
40
  ): boolean {
39
41
  const gsap = getOffsetDragGsap(element);
40
42
  if (!gsap) return false;
41
43
  // GSAP owns the transform; neutralize the CSS translate longhand so the two
42
44
  // channels can't compose into a doubled position.
43
45
  element.style.setProperty("translate", "none");
44
- const fallbackBase = {
45
- x: Number(gsap.getProperty(element, "x")) || 0,
46
- y: Number(gsap.getProperty(element, "y")) || 0,
47
- };
48
- const { newX, newY } = computeDraggedGsapPosition(element, offset, fallbackBase);
46
+ // Use the STABLE gesture-start base (captured in JS), NOT `gsap.getProperty`.
47
+ // After `translate: none`, getProperty reads the transform we set last frame,
48
+ // so `base + delta` would integrate frame-over-frame and fling the element.
49
+ const { newX, newY } = computeDraggedGsapPosition(element, offset, baseGsap);
49
50
  gsap.set(element, { x: newX, y: newY });
50
51
  return true;
51
52
  }
@@ -96,6 +97,14 @@ export interface ManualOffsetDragMember {
96
97
  selection: DomEditSelection;
97
98
  element: HTMLElement;
98
99
  initialOffset: { x: number; y: number };
100
+ /**
101
+ * The element's GSAP x/y at gesture start, captured in JS so a mid-drag
102
+ * re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
103
+ * attrs) can't drop the base. Without this the draft falls back to the LIVE
104
+ * transform — i.e. the value it set last frame — and `base + delta` integrates,
105
+ * making the element accelerate away ("flies"). See applyOffsetDragDraftViaGsap.
106
+ */
107
+ baseGsap: { x: number; y: number };
99
108
  initialPathOffset: StudioPathOffsetSnapshot;
100
109
  gestureToken: string;
101
110
  screenToOffset: ManualOffsetDragMatrix;
@@ -343,6 +352,7 @@ export function createManualOffsetDragMember(input: {
343
352
  scaleX: input.rect.editScaleX,
344
353
  scaleY: input.rect.editScaleY,
345
354
  });
355
+ const baseGsap = { x: gsapX, y: gsapY };
346
356
  if (!measured.ok) {
347
357
  // Fallback: when GSAP transforms interfere with probe measurement, use
348
358
  // the preview scale as an approximation. The commit path reads the actual
@@ -357,6 +367,7 @@ export function createManualOffsetDragMember(input: {
357
367
  selection: input.selection,
358
368
  element: input.element,
359
369
  initialOffset,
370
+ baseGsap,
360
371
  initialPathOffset,
361
372
  gestureToken,
362
373
  screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
@@ -372,6 +383,7 @@ export function createManualOffsetDragMember(input: {
372
383
  selection: input.selection,
373
384
  element: input.element,
374
385
  initialOffset,
386
+ baseGsap,
375
387
  initialPathOffset,
376
388
  gestureToken,
377
389
  screenToOffset: measured.matrix,
@@ -402,7 +414,7 @@ export function applyManualOffsetDragDraft(
402
414
  // Position is single-sourced on the GSAP timeline; preview through gsap.set so
403
415
  // the live draft matches the committed `tl.set`/keyframe. CSS draft only when
404
416
  // gsap is unavailable (no preview iframe runtime).
405
- if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
417
+ if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
406
418
  applyStudioPathOffsetDraft(member.element, offset);
407
419
  }
408
420
  return offset;
@@ -413,12 +425,22 @@ export function applyManualOffsetDragCommit(
413
425
  dx: number,
414
426
  dy: number,
415
427
  ): { x: number; y: number } {
428
+ // Re-stamp the STABLE gesture-start base/offset before the source commit reads
429
+ // them. A mid-drag re-render can wipe these attrs; the commit converts the drop
430
+ // offset → gsap x/y via computeDraggedGsapPosition, which without the base falls
431
+ // back to the live (already-dragged) transform and re-adds the delta — so the
432
+ // element flies off-screen the instant you drop it. The member holds the true
433
+ // gesture-start values in JS, immune to the re-render.
434
+ member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
435
+ member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
436
+ member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
437
+ member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
416
438
  const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
417
439
  // Optimistic visual through the GSAP channel (same as the live draft and the
418
440
  // committed `tl.set`), so the element holds its dropped position until the
419
441
  // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
420
442
  // CSS apply only when gsap is unavailable.
421
- if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
443
+ if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
422
444
  applyStudioPathOffset(member.element, offset);
423
445
  }
424
446
  return offset;
@@ -451,6 +473,15 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
451
473
  if (member.element.style.getPropertyValue("translate") === "none") {
452
474
  member.element.style.removeProperty("translate");
453
475
  }
476
+ // Migration: when GSAP owns the position (the committed value lives in the
477
+ // GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
478
+ // Clear it on the LIVE element — otherwise the leftover `translate:
479
+ // var(--hf-studio-offset)` composes with the GSAP transform and the element
480
+ // renders offset by the stale value until a full page reload (the source is
481
+ // already stripped). clearStudioPathOffset leaves `transform` untouched.
482
+ if (getOffsetDragGsap(member.element)) {
483
+ clearStudioPathOffset(member.element);
484
+ }
454
485
  resumeGsapTimelines(member.element);
455
486
  }
456
487
  }
@@ -1,10 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
4
  import {
4
5
  animatedProps,
5
6
  buildExtendedKeyframes,
6
7
  isPlayheadWithinTween,
8
+ promoteSetToKeyframes,
7
9
  resolveNewTweenRange,
10
+ type EnableKeyframesSession,
8
11
  } from "./useEnableKeyframes";
9
12
 
10
13
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
@@ -128,3 +131,40 @@ describe("buildExtendedKeyframes", () => {
128
131
  expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
129
132
  });
130
133
  });
134
+
135
+ describe("promoteSetToKeyframes — auto endpoint", () => {
136
+ it("marks the 0% (held start) as `auto`, leaving the 100% (playhead) fixed", async () => {
137
+ let committed: Record<string, unknown> | undefined;
138
+ const session = {
139
+ commitMutation: async (mutation: Record<string, unknown>) => {
140
+ committed = mutation;
141
+ },
142
+ } as unknown as EnableKeyframesSession;
143
+ const sel = {
144
+ id: "card",
145
+ selector: "#card",
146
+ sourceFile: "index.html",
147
+ element: { isConnected: true } as unknown as HTMLElement,
148
+ } as unknown as DomEditSelection;
149
+ // readElementPosition reads gsap.getProperty off the iframe window.
150
+ const iframe = {
151
+ contentWindow: { gsap: { getProperty: () => -74 } },
152
+ } as unknown as HTMLIFrameElement;
153
+ const setAnim = anim({
154
+ id: "#card-set-0-position",
155
+ targetSelector: "#card",
156
+ method: "set",
157
+ global: true,
158
+ resolvedStart: 0,
159
+ properties: { x: -74, y: -469 },
160
+ });
161
+
162
+ await promoteSetToKeyframes(session, sel, setAnim, 1, iframe);
163
+
164
+ const kfs = committed?.keyframes as Array<{ percentage: number; auto?: boolean }>;
165
+ expect(committed?.type).toBe("replace-with-keyframes");
166
+ expect(kfs[0]).toMatchObject({ percentage: 0, auto: true });
167
+ expect(kfs[1].percentage).toBe(100);
168
+ expect(kfs[1].auto).toBeUndefined();
169
+ });
170
+ });
@@ -107,6 +107,7 @@ export function buildExtendedKeyframes(
107
107
  return { position: roundTo3(newStart), duration: newDuration, keyframes };
108
108
  }
109
109
 
110
+ // fallow-ignore-next-line complexity
110
111
  function readElementPosition(
111
112
  iframe: HTMLIFrameElement | null,
112
113
  sel: DomEditSelection,
@@ -238,8 +239,12 @@ async function applyKeyframeAtPlayhead(
238
239
  * two-stop tween from the set's time to the playhead — the held value at 0%, the
239
240
  * live value at 100% — giving the user something to animate. No-op if the playhead
240
241
  * is at or before the set.
242
+ *
243
+ * The 0% endpoint is the held start, which the user didn't choose — mark it `auto`
244
+ * so it tracks the nearest keyframe until edited directly. The 100% is the real
245
+ * keyframe being placed at the playhead, so it stays fixed.
241
246
  */
242
- async function promoteSetToKeyframes(
247
+ export async function promoteSetToKeyframes(
243
248
  session: EnableKeyframesSession,
244
249
  sel: DomEditSelection,
245
250
  setAnim: GsapAnimation,
@@ -267,6 +272,7 @@ async function promoteSetToKeyframes(
267
272
  {
268
273
  percentage: 0,
269
274
  properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
275
+ auto: true,
270
276
  },
271
277
  { percentage: 100, properties: endPosition },
272
278
  ],
@@ -283,6 +289,7 @@ async function promoteSetToKeyframes(
283
289
  * the path, inserted at the matching segment so the curve is preserved. Outside the
284
290
  * range, extend the duration so the motion reaches the playhead.
285
291
  */
292
+ // fallow-ignore-next-line complexity
286
293
  async function applyArcWaypointAtPlayhead(
287
294
  session: EnableKeyframesSession,
288
295
  sel: DomEditSelection,
@@ -332,10 +339,10 @@ async function applyArcWaypointAtPlayhead(
332
339
  );
333
340
  }
334
341
 
335
- // fallow-ignore-next-line complexity
336
342
  export function useEnableKeyframes(
337
343
  sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
338
344
  ) {
345
+ // fallow-ignore-next-line complexity
339
346
  return useCallback(async () => {
340
347
  const session = sessionRef.current;
341
348
  if (!session) return;