@hyperframes/studio 0.6.121 → 0.7.0

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.
Files changed (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-20PAVS4k.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -128,18 +128,32 @@ export function findClosestByAttribute(
128
128
  }
129
129
  return null;
130
130
  }
131
+ // ─── Composition source resolution ───────────────────────────────────────────
131
132
 
132
- export function getElementDepth(el: HTMLElement): number {
133
- let depth = 0;
134
- let current = el.parentElement;
135
- while (current) {
136
- depth += 1;
137
- current = current.parentElement;
138
- }
139
- return depth;
133
+ // The runtime INLINES subcompositions and strips the source-file linkage from the
134
+ // mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
135
+ // `-file`), so a subcomp element's DOM ancestors no longer say which file it came
136
+ // from. This project-global map (composition-id → source file, built once from
137
+ // index.html's clips — see NLELayout) recovers it. The studio loads one project at a
138
+ // time, so module scope is the right lifetime; it's empty until set, in which case
139
+ // resolution falls back to the historical attribute-only behavior.
140
+ let compositionSourceMap: Map<string, string> = new Map();
141
+
142
+ export function setCompositionSourceMap(map: Map<string, string>): void {
143
+ compositionSourceMap = map;
140
144
  }
141
145
 
142
- // ─── Composition source resolution ───────────────────────────────────────────
146
+ function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
147
+ if (!ownerRoot || compositionSourceMap.size === 0) return undefined;
148
+ // The runtime may rename the mounted id to a runtime-unique one, preserving the
149
+ // authored id on `data-hf-original-composition-id` — prefer that, then the current id.
150
+ const authored = ownerRoot.getAttribute("data-hf-original-composition-id");
151
+ const current = ownerRoot.getAttribute("data-composition-id");
152
+ return (
153
+ (authored ? compositionSourceMap.get(authored) : undefined) ??
154
+ (current ? compositionSourceMap.get(current) : undefined)
155
+ );
156
+ }
143
157
 
144
158
  export function getSourceFileForElement(
145
159
  el: HTMLElement,
@@ -152,6 +166,7 @@ export function getSourceFileForElement(
152
166
  sourceHost?.getAttribute("data-composition-src") ??
153
167
  ownerRoot?.getAttribute("data-composition-file") ??
154
168
  ownerRoot?.getAttribute("data-composition-src") ??
169
+ sourceFromCompositionId(ownerRoot) ??
155
170
  activeCompositionPath ??
156
171
  "index.html";
157
172
 
@@ -24,11 +24,6 @@ describe("manual editing availability", () => {
24
24
  expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
25
25
  });
26
26
 
27
- it("enables GSAP drag intercept by default", async () => {
28
- const availability = await loadAvailabilityWithEnv({});
29
- expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
30
- });
31
-
32
27
  it("keeps color grading off by default", async () => {
33
28
  const availability = await loadAvailabilityWithEnv({});
34
29
  expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false);
@@ -41,13 +36,6 @@ describe("manual editing availability", () => {
41
36
  expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true);
42
37
  });
43
38
 
44
- it("disables GSAP drag intercept when env var is false", async () => {
45
- const availability = await loadAvailabilityWithEnv({
46
- VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "false",
47
- });
48
- expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
49
- });
50
-
51
39
  it("disables preview selection when the inspector panel flag is explicitly off", async () => {
52
40
  const availability = await loadAvailabilityWithEnv({
53
41
  VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
@@ -73,7 +73,7 @@ export const STUDIO_COLOR_GRADING_ENABLED = resolveStudioBooleanEnvFlag(
73
73
  export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
74
74
  env,
75
75
  ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
76
- false,
76
+ true,
77
77
  );
78
78
 
79
79
  export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
@@ -92,16 +92,6 @@ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
92
92
  false,
93
93
  );
94
94
 
95
- // When disabled (the default), drag/resize/rotate commits always take the CSS
96
- // persist path instead of being intercepted into GSAP script keyframe
97
- // mutations. The keyframe intercept rewrites timeline tweens from drag
98
- // gestures and is opt-in until its recording path is hardened.
99
- export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
100
- env,
101
- ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
102
- true,
103
- );
104
-
105
95
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
106
96
 
107
97
  // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
@@ -16,6 +16,7 @@ export {
16
16
  endStudioManualEditGesture,
17
17
  isStudioManualEditGestureCurrent,
18
18
  readStudioPathOffset,
19
+ readAppliedStudioPathOffset,
19
20
  readStudioBoxSize,
20
21
  readStudioRotation,
21
22
  applyStudioPathOffset,
@@ -70,6 +70,19 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb
70
70
  };
71
71
  }
72
72
 
73
+ /**
74
+ * The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can
75
+ * linger after GSAP re-bakes the element's transform (`translate:"none"`), so the
76
+ * raw var isn't a safe drag base — using it re-commits a phantom offset and flings
77
+ * the element off-screen. The offset only counts when the inline `translate` is the
78
+ * studio var-translate; otherwise it's dormant and the applied offset is zero.
79
+ */
80
+ export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } {
81
+ return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP)
82
+ ? readStudioPathOffset(element)
83
+ : { x: 0, y: 0 };
84
+ }
85
+
73
86
  export function readStudioBoxSize(element: HTMLElement): { width: number; height: number } {
74
87
  return {
75
88
  width: readPxCustomProperty(element, STUDIO_WIDTH_PROP),
@@ -250,6 +263,50 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
250
263
  }
251
264
  }
252
265
 
266
+ // GSAP owns the element's `transform` (it bakes x/y into a matrix and writes
267
+ // `translate: none` every tick). Folding the drag offset into a CSS `translate`
268
+ // — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the
269
+ // subsequent strip/reapply math compounds into a runaway matrix that flings the
270
+ // element off-canvas. So for GSAP-animated elements we keep `translate: none`
271
+ // and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
272
+ // still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
273
+ // reload. Returns true when handled as GSAP (caller must skip the CSS path).
274
+ function applyStudioPathOffsetViaGsap(
275
+ element: HTMLElement,
276
+ offset: { x: number; y: number },
277
+ ): boolean {
278
+ if (!gsapAnimatesProperty(element, "x", "y")) return false;
279
+ element.style.setProperty("translate", "none");
280
+ const win = element.ownerDocument.defaultView as
281
+ | (Window & {
282
+ gsap?: {
283
+ set: (el: Element, vars: Record<string, unknown>) => void;
284
+ getProperty: (el: Element, prop: string) => number;
285
+ };
286
+ })
287
+ | null;
288
+ if (win?.gsap) {
289
+ const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
290
+ const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
291
+ const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
292
+ const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
293
+ const gsapBaseX = Number.isFinite(baseX)
294
+ ? baseX
295
+ : (win.gsap.getProperty(element, "x") as number);
296
+ const gsapBaseY = Number.isFinite(baseY)
297
+ ? baseY
298
+ : (win.gsap.getProperty(element, "y") as number);
299
+ if (!Number.isFinite(baseX))
300
+ element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
301
+ if (!Number.isFinite(baseY))
302
+ element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
303
+ const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
304
+ const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
305
+ win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
306
+ }
307
+ return true;
308
+ }
309
+
253
310
  export function applyStudioPathOffset(
254
311
  element: HTMLElement,
255
312
  offset: { x: number; y: number },
@@ -257,6 +314,10 @@ export function applyStudioPathOffset(
257
314
  ): void {
258
315
  promoteInlineForTransform(element);
259
316
  writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
317
+ // GSAP elements: route through gsap.set, NOT a CSS translate (would corrupt the
318
+ // matrix). Symmetrical with applyStudioPathOffsetDraft — the commit path used to
319
+ // skip this branch, which is what flung dragged GSAP elements off-canvas.
320
+ if (applyStudioPathOffsetViaGsap(element, offset)) return;
260
321
  element.style.setProperty(
261
322
  "translate",
262
323
  composeTranslateValue(
@@ -274,45 +335,13 @@ export function applyStudioPathOffsetDraft(
274
335
  ): void {
275
336
  promoteInlineForTransform(element);
276
337
  writeStudioPathOffsetVars(element, offset, { updateBase: false });
277
-
278
- const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
279
- if (isGsapAnimated) {
280
- element.style.setProperty("translate", "none");
281
- const win = element.ownerDocument.defaultView as
282
- | (Window & {
283
- gsap?: {
284
- set: (el: Element, vars: Record<string, unknown>) => void;
285
- getProperty: (el: Element, prop: string) => number;
286
- };
287
- })
288
- | null;
289
- if (win?.gsap) {
290
- const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
291
- const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
292
- const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
293
- const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
294
- const gsapBaseX = Number.isFinite(baseX)
295
- ? baseX
296
- : (win.gsap.getProperty(element, "x") as number);
297
- const gsapBaseY = Number.isFinite(baseY)
298
- ? baseY
299
- : (win.gsap.getProperty(element, "y") as number);
300
- if (!Number.isFinite(baseX))
301
- element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
302
- if (!Number.isFinite(baseY))
303
- element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
304
- const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
305
- const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
306
- win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
307
- }
308
- } else {
309
- // Non-GSAP elements: use CSS translate as before.
310
- element.style.setProperty(
311
- "translate",
312
- composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
313
- );
314
- stripGsapTranslateFromTransform(element);
315
- }
338
+ if (applyStudioPathOffsetViaGsap(element, offset)) return;
339
+ // Non-GSAP elements: use CSS translate as before.
340
+ element.style.setProperty(
341
+ "translate",
342
+ composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
343
+ );
344
+ stripGsapTranslateFromTransform(element);
316
345
  }
317
346
 
318
347
  /* ── Box size apply ───────────────────────────────────────────────── */
@@ -0,0 +1,82 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { applyStudioPathOffset, applyStudioPathOffsetDraft } from "./manualEditsDom";
4
+
5
+ /**
6
+ * Regression: dragging a GSAP-animated element (e.g. a flat `to(#el, {x})` tween)
7
+ * must NOT fold the offset into a CSS `translate`. GSAP owns `style.transform`, so
8
+ * a CSS translate composes on top of it and the strip/reapply math compounds into
9
+ * a runaway matrix that flings the element off-canvas. Both the live draft and the
10
+ * commit must instead push the offset into GSAP's x/y via gsap.set and keep
11
+ * `translate: none`. Before the fix, the commit (applyStudioPathOffset) skipped the
12
+ * GSAP branch the draft already had — that asymmetry caused the off-canvas jump.
13
+ */
14
+
15
+ function makeGsapWindow(
16
+ el: HTMLElement,
17
+ gsapSet: (e: Element, v: Record<string, unknown>) => void,
18
+ ) {
19
+ const win = el.ownerDocument.defaultView as unknown as {
20
+ __timelines?: Record<string, unknown>;
21
+ gsap?: unknown;
22
+ };
23
+ win.__timelines = {
24
+ playground: {
25
+ getChildren: () => [{ targets: () => [el], vars: { x: -260 } }],
26
+ },
27
+ };
28
+ win.gsap = {
29
+ set: gsapSet,
30
+ getProperty: () => 0,
31
+ };
32
+ }
33
+
34
+ afterEach(() => {
35
+ const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
36
+ delete win.__timelines;
37
+ delete win.gsap;
38
+ });
39
+
40
+ describe("applyStudioPathOffset — GSAP-owned transform", () => {
41
+ it("non-GSAP element folds the offset into a CSS translate var()", () => {
42
+ const el = document.createElement("div");
43
+ document.body.appendChild(el);
44
+
45
+ applyStudioPathOffset(el, { x: -120, y: 40 });
46
+
47
+ expect(el.style.translate).toContain("var(--hf-studio-offset-x");
48
+ expect(el.style.getPropertyValue("--hf-studio-offset-x")).toBe("-120px");
49
+ expect(el.style.getPropertyValue("--hf-studio-offset-y")).toBe("40px");
50
+ });
51
+
52
+ it("GSAP element keeps translate:none and routes the offset through gsap.set", () => {
53
+ const el = document.createElement("div");
54
+ el.id = "puck-a";
55
+ document.body.appendChild(el);
56
+ const gsapSet = vi.fn();
57
+ makeGsapWindow(el, gsapSet);
58
+
59
+ applyStudioPathOffset(el, { x: -409, y: 398 });
60
+
61
+ // No CSS translate to collide with GSAP's transform.
62
+ expect(el.style.translate).toBe("none");
63
+ expect(el.style.translate).not.toContain("var(");
64
+ // Offset pushed into GSAP's x/y (gsapBase 0 + delta = the offset itself here).
65
+ expect(gsapSet).toHaveBeenCalledWith(el, { x: -409, y: 398 });
66
+ });
67
+
68
+ it("draft and commit treat a GSAP element identically (translate:none)", () => {
69
+ const el = document.createElement("div");
70
+ el.id = "puck-a";
71
+ document.body.appendChild(el);
72
+ makeGsapWindow(el, vi.fn());
73
+
74
+ applyStudioPathOffsetDraft(el, { x: -50, y: 10 });
75
+ const draftTranslate = el.style.translate;
76
+ applyStudioPathOffset(el, { x: -50, y: 10 });
77
+ const commitTranslate = el.style.translate;
78
+
79
+ expect(draftTranslate).toBe("none");
80
+ expect(commitTranslate).toBe("none");
81
+ });
82
+ });
@@ -193,6 +193,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
193
193
 
194
194
  element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
195
195
  element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
196
+ // Old projects bake the offset by referencing the vars in the inline
197
+ // `translate` longhand — that's what makes the offset "applied" and thus the
198
+ // valid drag base (readAppliedStudioPathOffset). A raw var with no applied
199
+ // translate is dormant and reads as zero. Assign the typed `.translate`
200
+ // accessor (happy-dom doesn't surface it via setProperty).
201
+ element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
196
202
  element.style.setProperty("transform", "translate(50px, -15px)");
197
203
 
198
204
  element.getBoundingClientRect = () => {
@@ -228,6 +234,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
228
234
  // Simulate GSAP baking a translate into transform each cycle
229
235
  for (let cycle = 0; cycle < 3; cycle++) {
230
236
  element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
237
+ // Mark the offset as APPLIED (the inline translate references the studio
238
+ // vars, the form an old project bakes) so readAppliedStudioPathOffset reads
239
+ // the var, not zero. Without this the var is dormant and reads as zero.
240
+ // Assign the typed `.translate` accessor (happy-dom doesn't surface it via
241
+ // setProperty).
242
+ element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
231
243
 
232
244
  const result = createManualOffsetDragMember({
233
245
  key: "test",
@@ -5,10 +5,71 @@ import {
5
5
  beginStudioManualEditGesture,
6
6
  captureStudioPathOffset,
7
7
  endStudioManualEditGesture,
8
- readStudioPathOffset,
8
+ readAppliedStudioPathOffset,
9
9
  restoreStudioPathOffset,
10
10
  type StudioPathOffsetSnapshot,
11
11
  } from "./manualEdits";
12
+ import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
13
+
14
+ interface OffsetDragGsap {
15
+ set: (el: Element, vars: Record<string, number | string>) => void;
16
+ getProperty: (el: Element, prop: string) => number;
17
+ }
18
+
19
+ function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
20
+ const win = element.ownerDocument.defaultView as
21
+ | (Window & { gsap?: Partial<OffsetDragGsap> })
22
+ | null;
23
+ const gsap = win?.gsap;
24
+ return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
25
+ }
26
+
27
+ /**
28
+ * Live drag preview through the GSAP channel — the SAME channel the commit
29
+ * lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
30
+ * dragging equals what gets written (plan R3/R4). Reuses the commit's
31
+ * base+delta+rotation math so preview and commit agree by construction. Returns
32
+ * true when handled via gsap; false when gsap is unavailable (caller falls back
33
+ * to the CSS draft).
34
+ */
35
+ function applyOffsetDragDraftViaGsap(
36
+ element: HTMLElement,
37
+ offset: { x: number; y: number },
38
+ ): boolean {
39
+ const gsap = getOffsetDragGsap(element);
40
+ if (!gsap) return false;
41
+ // GSAP owns the transform; neutralize the CSS translate longhand so the two
42
+ // channels can't compose into a doubled position.
43
+ 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);
49
+ gsap.set(element, { x: newX, y: newY });
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Live rotation preview through the GSAP channel — the SAME channel the commit
55
+ * lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
56
+ * GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
57
+ * the two channels from composing. `angle` is the absolute target rotation. Returns
58
+ * false when gsap is unavailable (caller falls back to the CSS draft).
59
+ */
60
+ export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
61
+ const gsap = getOffsetDragGsap(element);
62
+ if (!gsap) return false;
63
+ element.style.setProperty("rotate", "none");
64
+ gsap.set(element, { rotation: angle });
65
+ return true;
66
+ }
67
+
68
+ /** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
69
+ export function readGsapRotation(element: HTMLElement): number {
70
+ const gsap = getOffsetDragGsap(element);
71
+ return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
72
+ }
12
73
 
13
74
  const DEFAULT_OFFSET_PROBE_PX = 100;
14
75
  const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
@@ -241,7 +302,10 @@ export function createManualOffsetDragMember(input: {
241
302
  element: HTMLElement;
242
303
  rect: ManualOffsetDragRect;
243
304
  }): ManualOffsetDragMemberResult {
244
- const initialOffset = readStudioPathOffset(input.element);
305
+ // Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
306
+ // var — see readAppliedStudioPathOffset. This keeps the commit purely relative
307
+ // (applied + delta) so a stale offset can't fling the element off-screen.
308
+ const initialOffset = readAppliedStudioPathOffset(input.element);
245
309
  input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
246
310
  input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
247
311
 
@@ -335,7 +399,12 @@ export function applyManualOffsetDragDraft(
335
399
  dy: number,
336
400
  ): { x: number; y: number } {
337
401
  const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
338
- applyStudioPathOffsetDraft(member.element, offset);
402
+ // Position is single-sourced on the GSAP timeline; preview through gsap.set so
403
+ // the live draft matches the committed `tl.set`/keyframe. CSS draft only when
404
+ // gsap is unavailable (no preview iframe runtime).
405
+ if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
406
+ applyStudioPathOffsetDraft(member.element, offset);
407
+ }
339
408
  return offset;
340
409
  }
341
410
 
@@ -345,7 +414,13 @@ export function applyManualOffsetDragCommit(
345
414
  dy: number,
346
415
  ): { x: number; y: number } {
347
416
  const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
348
- applyStudioPathOffset(member.element, offset);
417
+ // Optimistic visual through the GSAP channel (same as the live draft and the
418
+ // committed `tl.set`), so the element holds its dropped position until the
419
+ // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
420
+ // CSS apply only when gsap is unavailable.
421
+ if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
422
+ applyStudioPathOffset(member.element, offset);
423
+ }
349
424
  return offset;
350
425
  }
351
426
 
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { editableAnimationId } from "./motionPathSelection";
4
+ import {
5
+ commitNode,
6
+ commitAddWaypoint,
7
+ commitAddKeyframe,
8
+ commitRemoveWaypoint,
9
+ commitCreatePath,
10
+ } from "./motionPathCommit";
11
+
12
+ const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
13
+ ({
14
+ id: "a1",
15
+ targetSelector: "#el",
16
+ method: "to",
17
+ position: 0,
18
+ properties: {},
19
+ ...over,
20
+ }) as GsapAnimation;
21
+
22
+ describe("editableAnimationId", () => {
23
+ it("picks the arc animation for an arc path", () => {
24
+ const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
25
+ expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
26
+ });
27
+
28
+ it("picks a position-keyframe animation for a linear path", () => {
29
+ const kf = anim({
30
+ id: "kf1",
31
+ propertyGroup: "position",
32
+ keyframes: {
33
+ format: "percentage",
34
+ keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
35
+ } as never,
36
+ });
37
+ expect(editableAnimationId([kf], "linear")).toBe("kf1");
38
+ });
39
+
40
+ it("returns null for dynamic (unresolved) tweens — read-only", () => {
41
+ const dyn = anim({
42
+ id: "dyn",
43
+ arcPath: { enabled: true, autoRotate: false, segments: [] },
44
+ hasUnresolvedKeyframes: true,
45
+ });
46
+ expect(editableAnimationId([dyn], "arc")).toBeNull();
47
+ });
48
+
49
+ it("returns null for non-literal (helper) provenance — read-only", () => {
50
+ const helper = anim({
51
+ id: "h",
52
+ arcPath: { enabled: true, autoRotate: false, segments: [] },
53
+ provenance: { kind: "helper" } as never,
54
+ });
55
+ expect(editableAnimationId([helper], "arc")).toBeNull();
56
+ });
57
+
58
+ it("returns null when nothing matches", () => {
59
+ expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("commitNode", () => {
64
+ it("routes a keyframe node to update-keyframe by percentage", async () => {
65
+ const commit = vi.fn().mockResolvedValue(undefined);
66
+ await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
67
+ expect(commit).toHaveBeenCalledWith(
68
+ { type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
69
+ expect.objectContaining({ softReload: true }),
70
+ );
71
+ });
72
+
73
+ it("routes a waypoint node to update-motion-path-point by index", async () => {
74
+ const commit = vi.fn().mockResolvedValue(undefined);
75
+ await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
76
+ expect(commit).toHaveBeenCalledWith(
77
+ { type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
78
+ expect.objectContaining({ softReload: true }),
79
+ );
80
+ });
81
+ });
82
+
83
+ describe("commitAddWaypoint / commitRemoveWaypoint", () => {
84
+ it("adds a waypoint at an index with coordinates", async () => {
85
+ const commit = vi.fn().mockResolvedValue(undefined);
86
+ await commitAddWaypoint("a1", 1, 120, -40, commit);
87
+ expect(commit).toHaveBeenCalledWith(
88
+ { type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
89
+ expect.objectContaining({ softReload: true }),
90
+ );
91
+ });
92
+
93
+ it("removes a waypoint by index", async () => {
94
+ const commit = vi.fn().mockResolvedValue(undefined);
95
+ await commitRemoveWaypoint("a1", 2, commit);
96
+ expect(commit).toHaveBeenCalledWith(
97
+ { type: "remove-motion-path-point", animationId: "a1", index: 2 },
98
+ expect.objectContaining({ softReload: true }),
99
+ );
100
+ });
101
+ });
102
+
103
+ describe("commitAddKeyframe", () => {
104
+ it("inserts an x/y keyframe at a tween-relative percentage", async () => {
105
+ const commit = vi.fn().mockResolvedValue(undefined);
106
+ await commitAddKeyframe("a1", 42.5, 80, -20, commit);
107
+ expect(commit).toHaveBeenCalledWith(
108
+ { type: "add-keyframe", animationId: "a1", percentage: 42.5, properties: { x: 80, y: -20 } },
109
+ expect.objectContaining({ softReload: true }),
110
+ );
111
+ });
112
+ });
113
+
114
+ describe("commitCreatePath", () => {
115
+ it("authors a new motionPath to a destination at a given time", async () => {
116
+ const commit = vi.fn().mockResolvedValue(undefined);
117
+ await commitCreatePath("#title", 2.0, 300, -120, commit);
118
+ expect(commit).toHaveBeenCalledWith(
119
+ {
120
+ type: "add-motion-path",
121
+ targetSelector: "#title",
122
+ position: 2.0,
123
+ duration: 1.5,
124
+ x: 300,
125
+ y: -120,
126
+ },
127
+ expect.objectContaining({ softReload: true }),
128
+ );
129
+ });
130
+ });