@hyperframes/studio 0.6.52 → 0.6.54

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 (31) hide show
  1. package/dist/assets/index-CKJCBFsG.js +138 -0
  2. package/dist/assets/index-ZdgB8MFr.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/components/StudioFeedbackBar.tsx +208 -0
  6. package/src/components/StudioPreviewArea.tsx +97 -92
  7. package/src/components/StudioRightPanel.tsx +18 -0
  8. package/src/components/editor/AnimationCard.tsx +325 -0
  9. package/src/components/editor/EaseCurveSection.tsx +213 -0
  10. package/src/components/editor/GsapAnimationSection.tsx +112 -0
  11. package/src/components/editor/PropertyPanel.tsx +48 -18
  12. package/src/components/editor/domEditingTypes.ts +2 -0
  13. package/src/components/editor/gsapAnimationConstants.ts +130 -0
  14. package/src/components/editor/manualEditingAvailability.ts +6 -0
  15. package/src/components/editor/manualEdits.test.ts +101 -0
  16. package/src/components/editor/manualEdits.ts +22 -9
  17. package/src/components/editor/manualEditsDom.ts +22 -21
  18. package/src/components/editor/manualOffsetDrag.test.ts +35 -22
  19. package/src/components/editor/manualOffsetDrag.ts +1 -7
  20. package/src/components/editor/propertyPanelPrimitives.tsx +6 -1
  21. package/src/contexts/DomEditContext.tsx +27 -0
  22. package/src/hooks/useDomEditSession.ts +98 -2
  23. package/src/hooks/useDomSelection.ts +8 -0
  24. package/src/hooks/useGsapScriptCommits.ts +303 -0
  25. package/src/hooks/useGsapTweenCache.ts +80 -0
  26. package/src/hooks/usePreviewPersistence.ts +1 -0
  27. package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +2 -1
  29. package/src/telemetry/events.ts +32 -0
  30. package/dist/assets/index-Bvy50smZ.js +0 -138
  31. package/dist/assets/index-SKRp8mGz.css +0 -1
@@ -1,12 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
3
3
  import { type DomEditSelection } from "./domEditing";
4
- import {
5
- readStudioBoxSize,
6
- readStudioPathOffset,
7
- readStudioRotation,
8
- readGsapTranslateFromTransform,
9
- } from "./manualEdits";
4
+ import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
10
5
  import type { ImportedFontAsset } from "./fontAssets";
11
6
  import {
12
7
  EMPTY_STYLES,
@@ -18,12 +13,13 @@ import {
18
13
  import { MetricField, Section } from "./propertyPanelPrimitives";
19
14
  import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
20
15
  import { TextSection, StyleSections } from "./propertyPanelSections";
16
+ import { GsapAnimationSection } from "./GsapAnimationSection";
17
+ import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
21
18
 
22
19
  // Re-export helpers that external consumers import from this module
23
20
  export {
24
21
  buildStrokeStyleUpdates,
25
22
  buildStrokeWidthStyleUpdates,
26
- clampPanelNumber,
27
23
  getCssFilterFunctionPx,
28
24
  getClipPathInsetPx,
29
25
  inferBoxShadowPreset,
@@ -54,6 +50,18 @@ interface PropertyPanelProps {
54
50
  onImportAssets?: (files: FileList) => Promise<string[]>;
55
51
  fontAssets?: ImportedFontAsset[];
56
52
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
53
+ gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
54
+ gsapMultipleTimelines?: boolean;
55
+ gsapUnsupportedTimelinePattern?: boolean;
56
+ onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
57
+ onUpdateGsapMeta?: (
58
+ animId: string,
59
+ updates: { duration?: number; ease?: string; position?: number },
60
+ ) => void;
61
+ onDeleteGsapAnimation?: (animId: string) => void;
62
+ onAddGsapProperty?: (animId: string, prop: string) => void;
63
+ onRemoveGsapProperty?: (animId: string, prop: string) => void;
64
+ onAddGsapAnimation?: (method: "to" | "from" | "set") => void;
57
65
  }
58
66
 
59
67
  /* ------------------------------------------------------------------ */
@@ -146,6 +154,15 @@ export const PropertyPanel = memo(function PropertyPanel({
146
154
  onImportAssets,
147
155
  fontAssets = [],
148
156
  onImportFonts,
157
+ gsapAnimations = [],
158
+ gsapMultipleTimelines,
159
+ gsapUnsupportedTimelinePattern,
160
+ onUpdateGsapProperty,
161
+ onUpdateGsapMeta,
162
+ onDeleteGsapAnimation,
163
+ onAddGsapProperty,
164
+ onRemoveGsapProperty,
165
+ onAddGsapAnimation,
149
166
  }: PropertyPanelProps) {
150
167
  const styles = element?.computedStyles ?? EMPTY_STYLES;
151
168
 
@@ -186,11 +203,6 @@ export const PropertyPanel = memo(function PropertyPanel({
186
203
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
187
204
  const showEditableSections = element.capabilities.canEditStyles;
188
205
  const manualOffset = readStudioPathOffset(element.element);
189
- const gsapTranslate = readGsapTranslateFromTransform(element.element);
190
- const visualOffset = {
191
- x: manualOffset.x + gsapTranslate.x,
192
- y: manualOffset.y + gsapTranslate.y,
193
- };
194
206
  const manualSize = readStudioBoxSize(element.element);
195
207
  const resolvedWidth =
196
208
  manualSize.width > 0
@@ -204,11 +216,10 @@ export const PropertyPanel = memo(function PropertyPanel({
204
216
  const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
205
217
  const parsed = parsePxMetricValue(nextValue);
206
218
  if (parsed == null) return;
207
- const currentRaw = readStudioPathOffset(element.element);
208
- const currentGsap = readGsapTranslateFromTransform(element.element);
219
+ const current = readStudioPathOffset(element.element);
209
220
  onSetManualOffset(element, {
210
- x: axis === "x" ? parsed - currentGsap.x : currentRaw.x,
211
- y: axis === "y" ? parsed - currentGsap.y : currentRaw.y,
221
+ x: axis === "x" ? parsed : current.x,
222
+ y: axis === "y" ? parsed : current.y,
212
223
  });
213
224
  };
214
225
 
@@ -300,14 +311,14 @@ export const PropertyPanel = memo(function PropertyPanel({
300
311
  <div className={RESPONSIVE_GRID}>
301
312
  <MetricField
302
313
  label="X"
303
- value={formatPxMetricValue(visualOffset.x)}
314
+ value={formatPxMetricValue(manualOffset.x)}
304
315
  disabled={manualOffsetEditingDisabled}
305
316
  scrub
306
317
  onCommit={(next) => commitManualOffset("x", next)}
307
318
  />
308
319
  <MetricField
309
320
  label="Y"
310
- value={formatPxMetricValue(visualOffset.y)}
321
+ value={formatPxMetricValue(manualOffset.y)}
311
322
  disabled={manualOffsetEditingDisabled}
312
323
  scrub
313
324
  onCommit={(next) => commitManualOffset("y", next)}
@@ -342,6 +353,25 @@ export const PropertyPanel = memo(function PropertyPanel({
342
353
  </div>
343
354
  </Section>
344
355
 
356
+ {STUDIO_GSAP_PANEL_ENABLED &&
357
+ onUpdateGsapProperty &&
358
+ onUpdateGsapMeta &&
359
+ onDeleteGsapAnimation &&
360
+ onAddGsapProperty &&
361
+ onAddGsapAnimation && (
362
+ <GsapAnimationSection
363
+ animations={gsapAnimations}
364
+ multipleTimelines={gsapMultipleTimelines}
365
+ unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
366
+ onUpdateProperty={onUpdateGsapProperty}
367
+ onUpdateMeta={onUpdateGsapMeta}
368
+ onDeleteAnimation={onDeleteGsapAnimation}
369
+ onAddProperty={onAddGsapProperty}
370
+ onRemoveProperty={onRemoveGsapProperty ?? (() => {})}
371
+ onAddAnimation={onAddGsapAnimation}
372
+ />
373
+ )}
374
+
345
375
  {showEditableSections && (
346
376
  <StyleSections
347
377
  projectId={projectId}
@@ -1,4 +1,5 @@
1
1
  import type { PatchTarget } from "../../utils/sourcePatcher";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
3
 
3
4
  export const CURATED_STYLE_PROPERTIES = [
4
5
  "position",
@@ -86,6 +87,7 @@ export interface DomEditSelection extends PatchTarget {
86
87
  computedStyles: Record<string, string>;
87
88
  textFields: DomEditTextField[];
88
89
  capabilities: DomEditCapabilities;
90
+ gsapAnimations?: GsapAnimation[];
89
91
  }
90
92
 
91
93
  export interface DomEditLayerItem {
@@ -0,0 +1,130 @@
1
+ import { controlPointsForGsapEase } from "./studioMotion";
2
+
3
+ export const METHOD_LABELS: Record<string, string> = {
4
+ set: "Set",
5
+ to: "Animate",
6
+ from: "Animate In",
7
+ fromTo: "Animate",
8
+ };
9
+
10
+ export const METHOD_TOOLTIPS: Record<string, string> = {
11
+ set: "Instantly snap to these values — no transition",
12
+ to: "Smoothly animate the element to these target values",
13
+ from: "Element starts at these values and transitions to its normal state",
14
+ fromTo: "Animate from one state to another",
15
+ };
16
+
17
+ export const PROP_LABELS: Record<string, string> = {
18
+ x: "Move X",
19
+ y: "Move Y",
20
+ width: "Width",
21
+ height: "Height",
22
+ rotation: "Rotate",
23
+ opacity: "Opacity",
24
+ scale: "Scale",
25
+ scaleX: "Scale X",
26
+ scaleY: "Scale Y",
27
+ autoAlpha: "Visibility",
28
+ visibility: "Visible",
29
+ scaleX_alias: "Stretch X",
30
+ };
31
+
32
+ export const PROP_UNITS: Record<string, string> = {
33
+ x: "px",
34
+ y: "px",
35
+ width: "px",
36
+ height: "px",
37
+ rotation: "°",
38
+ opacity: "%",
39
+ scale: "×",
40
+ scaleX: "×",
41
+ scaleY: "×",
42
+ autoAlpha: "%",
43
+ visibility: "",
44
+ };
45
+
46
+ export const PROP_TOOLTIPS: Record<string, string> = {
47
+ x: "Move left/right (negative = left, positive = right)",
48
+ y: "Move up/down (negative = up, positive = down)",
49
+ opacity: "How visible (0 = invisible, 1 = fully visible)",
50
+ scale: "Size multiplier (1 = normal, 2 = double, 0.5 = half)",
51
+ scaleX: "Horizontal stretch (1 = normal)",
52
+ scaleY: "Vertical stretch (1 = normal)",
53
+ rotation: "Spin angle (360 = full rotation)",
54
+ width: "Element width",
55
+ height: "Element height",
56
+ autoAlpha: "Like opacity but hides element completely at 0",
57
+ visibility: "Show or hide the element",
58
+ };
59
+
60
+ export const EASE_LABELS: Record<string, string> = {
61
+ none: "Constant speed",
62
+ "power1.out": "Gentle slowdown",
63
+ "power2.out": "Smooth slowdown",
64
+ "power3.out": "Snappy slowdown",
65
+ "power4.out": "Sharp slowdown",
66
+ "power1.in": "Gentle speedup",
67
+ "power2.in": "Smooth speedup",
68
+ "power3.in": "Strong speedup",
69
+ "power4.in": "Sharp speedup",
70
+ "power1.inOut": "Gentle ease",
71
+ "power2.inOut": "Smooth ease",
72
+ "power3.inOut": "Strong ease",
73
+ "power4.inOut": "Sharp ease",
74
+ "back.out": "Overshoot & settle",
75
+ "back.in": "Pull back & go",
76
+ "back.inOut": "Pull & overshoot",
77
+ "elastic.out": "Springy bounce",
78
+ "elastic.in": "Wind up spring",
79
+ "elastic.inOut": "Full spring",
80
+ "bounce.out": "Drop & bounce",
81
+ "bounce.in": "Reverse bounce",
82
+ "bounce.inOut": "Double bounce",
83
+ "expo.out": "Very snappy stop",
84
+ "expo.in": "Very slow start",
85
+ "expo.inOut": "Dramatic ease",
86
+ };
87
+
88
+ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
89
+ none: [0, 0, 1, 1],
90
+ "power1.out": [0, 0, 0.58, 1],
91
+ "power2.out": [0.16, 1, 0.3, 1],
92
+ "power3.out": [0.08, 0.82, 0.17, 1],
93
+ "power4.out": [0.06, 0.73, 0.09, 1],
94
+ "power1.in": [0.42, 0, 1, 1],
95
+ "power2.in": [0.55, 0.06, 0.68, 0.19],
96
+ "power3.in": [0.6, 0.04, 0.98, 0.34],
97
+ "power4.in": [0.7, 0, 0.84, 0],
98
+ "power1.inOut": [0.42, 0, 0.58, 1],
99
+ "power2.inOut": [0.45, 0.05, 0.55, 0.95],
100
+ "power3.inOut": [0.65, 0.05, 0.35, 1],
101
+ "power4.inOut": [0.76, 0, 0.24, 1],
102
+ "back.out": [0.34, 1.56, 0.64, 1],
103
+ "back.in": [0.36, 0, 0.66, -0.56],
104
+ "back.inOut": [0.68, -0.55, 0.27, 1.55],
105
+ "expo.out": [0.16, 1, 0.3, 1],
106
+ "expo.in": [0.7, 0, 0.84, 0],
107
+ "expo.inOut": [0.87, 0, 0.13, 1],
108
+ };
109
+
110
+ export function parseCustomEaseFromString(ease: string): {
111
+ x1: number;
112
+ y1: number;
113
+ x2: number;
114
+ y2: number;
115
+ } {
116
+ const match = ease.match(/^custom\((.+)\)$/);
117
+ if (!match) return controlPointsForGsapEase("power2.out");
118
+ const data = match[1];
119
+ const nums = data.match(/[\d.]+/g)?.map(Number);
120
+ if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out");
121
+ return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
122
+ }
123
+
124
+ export const ADD_METHODS = ["to", "from", "set"] as const;
125
+
126
+ export const ADD_METHOD_LABELS: Record<string, string> = {
127
+ to: "Animate",
128
+ from: "Animate In",
129
+ set: "Set Instantly",
130
+ };
@@ -65,6 +65,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
65
65
  true,
66
66
  );
67
67
 
68
+ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
69
+ env,
70
+ ["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"],
71
+ false,
72
+ );
73
+
68
74
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
69
75
 
70
76
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -516,3 +516,104 @@ describe("studio manual edits", () => {
516
516
  expect(frames).toHaveLength(0);
517
517
  });
518
518
  });
519
+
520
+ describe("applyStudioPathOffset sets correct attribute name", () => {
521
+ it("sets data-hf-studio-path-offset without double data- prefix", () => {
522
+ const window = new Window();
523
+ const el = window.document.createElement("div");
524
+ window.document.body.append(el);
525
+
526
+ applyStudioPathOffset(el, { x: 100, y: 50 });
527
+
528
+ expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
529
+ expect(el.getAttribute("data-data-hf-studio-path-offset")).toBeNull();
530
+ });
531
+
532
+ it("stores offset in CSS vars alongside the attribute marker", () => {
533
+ const window = new Window();
534
+ const el = window.document.createElement("div");
535
+ window.document.body.append(el);
536
+
537
+ applyStudioPathOffset(el, { x: 50, y: 25 });
538
+
539
+ expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
540
+ expect(el.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("50px");
541
+ expect(el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("25px");
542
+ expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
543
+ });
544
+
545
+ it("corrects offset applied on top of legacy double-prefix element", () => {
546
+ const window = new Window();
547
+ const el = window.document.createElement("div");
548
+ el.setAttribute("data-data-hf-studio-path-offset", "true");
549
+ el.style.setProperty(STUDIO_OFFSET_X_PROP, "200px");
550
+ el.style.setProperty(STUDIO_OFFSET_Y_PROP, "-30px");
551
+ window.document.body.append(el);
552
+
553
+ applyStudioPathOffset(el, { x: 200, y: -30 });
554
+
555
+ expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
556
+ expect(readStudioPathOffset(el)).toEqual({ x: 200, y: -30 });
557
+ expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
558
+ });
559
+ });
560
+
561
+ describe("applyStudioPathOffset strips GSAP double-counted translate", () => {
562
+ it("strips GSAP transform translate when applying offset", () => {
563
+ const window = new Window();
564
+ const element = window.document.createElement("div");
565
+ window.document.body.append(element);
566
+
567
+ // Simulate GSAP having baked translate into the transform matrix
568
+ element.style.setProperty("transform", "matrix(1, 0, 0, 1, 200, 0)");
569
+
570
+ applyStudioPathOffset(element, { x: 200, y: 0 });
571
+
572
+ // The transform translate should be stripped (GSAP's 200px removed)
573
+ const transform = element.style.getPropertyValue("transform");
574
+ if (transform && transform !== "none") {
575
+ const m = new window.DOMMatrix(transform);
576
+ expect(m.m41).toBe(0);
577
+ expect(m.m42).toBe(0);
578
+ }
579
+ // The offset should be stored in CSS vars
580
+ expect(readStudioPathOffset(element).x).toBe(200);
581
+ });
582
+
583
+ it("subtracts only the studio offset from GSAP transform, preserving animation values", () => {
584
+ const window = new Window();
585
+ const element = window.document.createElement("div");
586
+ window.document.body.append(element);
587
+
588
+ // GSAP has scale + baked translate (offset 50) + animation contribution (-70)
589
+ // Total m42 = 50 + (-70) = -20
590
+ element.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 0, -20)");
591
+
592
+ applyStudioPathOffset(element, { x: 0, y: 50 });
593
+
594
+ const transform = element.style.getPropertyValue("transform");
595
+ if (transform && transform !== "none") {
596
+ const m = new window.DOMMatrix(transform);
597
+ expect(m.a).toBeCloseTo(0.5);
598
+ expect(m.d).toBeCloseTo(0.5);
599
+ // Only the studio offset (50) is subtracted, animation contribution (-70) preserved
600
+ expect(m.m41).toBe(0);
601
+ expect(m.m42).toBe(-70);
602
+ }
603
+ expect(readStudioPathOffset(element).y).toBe(50);
604
+ });
605
+
606
+ it("offset survives repeated applyStudioPathOffset calls without drift", () => {
607
+ const window = new Window();
608
+ const element = window.document.createElement("div");
609
+ window.document.body.append(element);
610
+
611
+ // Apply offset 3 times with same value (simulates reapply hook firing multiple times)
612
+ applyStudioPathOffset(element, { x: 100, y: -20 });
613
+ applyStudioPathOffset(element, { x: 100, y: -20 });
614
+ applyStudioPathOffset(element, { x: 100, y: -20 });
615
+
616
+ expect(readStudioPathOffset(element).x).toBe(100);
617
+ expect(readStudioPathOffset(element).y).toBe(-20);
618
+ });
619
+ });
@@ -3,9 +3,7 @@ export {
3
3
  STUDIO_OFFSET_X_PROP,
4
4
  STUDIO_OFFSET_Y_PROP,
5
5
  STUDIO_WIDTH_PROP,
6
- STUDIO_HEIGHT_PROP,
7
6
  STUDIO_ROTATION_PROP,
8
- type StudioManualEditSeekWindow,
9
7
  type StudioBoxSizeSnapshot,
10
8
  type StudioRotationSnapshot,
11
9
  type StudioPathOffsetSnapshot,
@@ -20,7 +18,6 @@ export {
20
18
  readStudioPathOffset,
21
19
  readStudioBoxSize,
22
20
  readStudioRotation,
23
- readGsapTranslateFromTransform,
24
21
  applyStudioPathOffset,
25
22
  applyStudioPathOffsetDraft,
26
23
  applyStudioBoxSize,
@@ -28,8 +25,6 @@ export {
28
25
  applyStudioRotation,
29
26
  applyStudioRotationDraft,
30
27
  reapplyPositionEditsAfterSeek,
31
- buildMotionPatches,
32
- buildClearMotionPatches,
33
28
  } from "./manualEditsDom";
34
29
 
35
30
  export {
@@ -51,7 +46,6 @@ import {
51
46
  STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP,
52
47
  } from "./manualEditsTypes";
53
48
  import { finiteNumber } from "./manualEditsParsing";
54
- import { isStudioManualEditGestureActive } from "./manualEditsDom";
55
49
 
56
50
  /* ── Seek/play reapply wrappers ───────────────────────────────────── */
57
51
  function markWrapped(fn: (...args: unknown[]) => unknown): void {
@@ -262,6 +256,28 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
262
256
  wrapApplyAfterFunction(studioWin, timeline, "pause") || wrappedNamedTimelinePause;
263
257
  }
264
258
 
259
+ // Auto-wrap timelines registered AFTER this install runs. GSAP compositions
260
+ // register via `window.__timelines[id] = tl` which may happen after the
261
+ // Studio hook runs. The Proxy intercepts new registrations and wraps
262
+ // seek/play/pause immediately, closing the gap that causes translate doubling.
263
+ if (studioWin.__timelines && !(studioWin.__timelines as Record<string, unknown>).__proxied) {
264
+ const original = studioWin.__timelines;
265
+ studioWin.__timelines = new Proxy(original, {
266
+ set(target, prop, value) {
267
+ target[prop as string] = value;
268
+ if (typeof value === "object" && value !== null) {
269
+ const tl = value as Record<string, unknown>;
270
+ wrapSeekReapplyFunction(studioWin, tl, "seek");
271
+ wrapPlayReapplyFunction(studioWin, tl, "play");
272
+ wrapApplyAfterFunction(studioWin, tl, "pause");
273
+ studioWin.__hfStudioManualEditsApply?.();
274
+ }
275
+ return true;
276
+ },
277
+ });
278
+ (studioWin.__timelines as Record<string, unknown>).__proxied = true;
279
+ }
280
+
265
281
  if (isStudioManualEditPlaybackActive(studioWin)) {
266
282
  startStudioManualEditPlaybackReapply(studioWin);
267
283
  }
@@ -280,6 +296,3 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
280
296
  wrappedNamedTimelinePause
281
297
  );
282
298
  }
283
-
284
- // Re-export for internal use (seek hooks need this)
285
- export { isStudioManualEditGestureActive };
@@ -48,7 +48,7 @@ export function endStudioManualEditGesture(element: HTMLElement, token?: string)
48
48
  element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
49
49
  }
50
50
 
51
- export function isStudioManualEditGestureActive(element: HTMLElement): boolean {
51
+ function isStudioManualEditGestureActive(element: HTMLElement): boolean {
52
52
  return element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
53
53
  }
54
54
 
@@ -213,26 +213,15 @@ function writeStudioPathOffsetVars(
213
213
 
214
214
  // GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it
215
215
  // into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
216
- // writes `translate`, both properties compose additively, doubling the visual offset. This helper
217
- // zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted.
216
+ // writes `translate`, both properties compose additively, doubling the visual offset.
217
+ //
218
+ // This helper subtracts only the baked studio offset from m41/m42, preserving any GSAP animation
219
+ // contribution (e.g. a tween animating y: -20). The studio offset is read from the CSS custom
220
+ // properties which tell us exactly how much was baked from the CSS translate.
218
221
  function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
219
222
  return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
220
223
  }
221
224
 
222
- export function readGsapTranslateFromTransform(element: HTMLElement): { x: number; y: number } {
223
- const transform = element.style.getPropertyValue("transform");
224
- if (!transform || transform === "none") return { x: 0, y: 0 };
225
- const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
226
- ?.DOMMatrix;
227
- if (!DOMMatrixCtor) return { x: 0, y: 0 };
228
- try {
229
- const m = new DOMMatrixCtor(transform);
230
- return { x: m.m41, y: m.m42 };
231
- } catch {
232
- return { x: 0, y: 0 };
233
- }
234
- }
235
-
236
225
  function stripGsapTranslateFromTransform(element: HTMLElement): void {
237
226
  const transform = element.style.getPropertyValue("transform");
238
227
  if (!transform || transform === "none") return;
@@ -242,9 +231,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
242
231
  try {
243
232
  const m = new DOMMatrixCtor(transform);
244
233
  if (m.m41 === 0 && m.m42 === 0) return;
245
- m.m41 = 0;
246
- m.m42 = 0;
247
- if (isIdentityAfterTranslateStrip(m)) {
234
+ const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
235
+ const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
236
+ m.m41 -= offsetX;
237
+ m.m42 -= offsetY;
238
+ if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
248
239
  element.style.removeProperty("transform");
249
240
  } else {
250
241
  element.style.setProperty("transform", m.toString());
@@ -493,9 +484,19 @@ export {
493
484
  function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
494
485
  const ctor = doc.defaultView?.HTMLElement;
495
486
  if (!ctor) return [];
496
- return Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
487
+ const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
497
488
  (el): el is HTMLElement => el instanceof ctor,
498
489
  );
490
+ // Handle legacy HTML files where attributes were persisted with a double data- prefix
491
+ const legacyAttr = `data-${attr}`;
492
+ for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
493
+ if (el instanceof ctor && !el.hasAttribute(attr)) {
494
+ el.setAttribute(attr, "true");
495
+ el.removeAttribute(legacyAttr);
496
+ elements.push(el);
497
+ }
498
+ }
499
+ return elements;
499
500
  }
500
501
 
501
502
  function reapplyPathOffsets(doc: Document): void {
@@ -1,8 +1,10 @@
1
1
  import { Window } from "happy-dom";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
+ applyManualOffsetDragCommit,
4
5
  applyManualOffsetDragMatrix,
5
6
  createManualOffsetDragMember,
7
+ endManualOffsetDragMembers,
6
8
  invertManualOffsetDragMatrix,
7
9
  measureManualOffsetDragScreenToOffsetMatrix,
8
10
  resolveManualOffsetForPointerDelta,
@@ -140,8 +142,8 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
140
142
  });
141
143
  });
142
144
 
143
- describe("createManualOffsetDragMember GSAP translate compensation", () => {
144
- it("folds GSAP translate from element.style.transform into initialOffset", () => {
145
+ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
146
+ it("ignores GSAP transform — initialOffset comes from CSS vars only", () => {
145
147
  const window = new Window();
146
148
  const element = window.document.createElement("div");
147
149
  window.document.body.append(element);
@@ -164,14 +166,18 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => {
164
166
  expect(result.ok).toBe(true);
165
167
  if (!result.ok) return;
166
168
  expect(result.member.initialOffset.x).toBe(0);
167
- expect(result.member.initialOffset.y).toBe(-20);
169
+ expect(result.member.initialOffset.y).toBe(0);
168
170
  });
169
171
 
170
- it("leaves initialOffset unchanged when no GSAP transform is present", () => {
172
+ it("reads only the CSS var offset, not GSAP transform", () => {
171
173
  const window = new Window();
172
174
  const element = window.document.createElement("div");
173
175
  window.document.body.append(element);
174
176
 
177
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
178
+ element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
179
+ element.style.setProperty("transform", "translate(50px, -15px)");
180
+
175
181
  element.getBoundingClientRect = () => {
176
182
  const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
177
183
  const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
@@ -187,35 +193,42 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => {
187
193
 
188
194
  expect(result.ok).toBe(true);
189
195
  if (!result.ok) return;
190
- expect(result.member.initialOffset.x).toBe(0);
191
- expect(result.member.initialOffset.y).toBe(0);
196
+ expect(result.member.initialOffset.x).toBe(30);
197
+ expect(result.member.initialOffset.y).toBe(10);
192
198
  });
193
199
 
194
- it("combines existing manual offset with GSAP translate", () => {
200
+ it("does not accumulate drift across multiple drag cycles", () => {
195
201
  const window = new Window();
196
202
  const element = window.document.createElement("div");
197
203
  window.document.body.append(element);
198
204
 
199
- element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
200
- element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
201
- element.style.setProperty("transform", "translate(50px, -15px)");
202
-
203
205
  element.getBoundingClientRect = () => {
204
206
  const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
205
207
  const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
206
208
  return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50);
207
209
  };
208
210
 
209
- const result = createManualOffsetDragMember({
210
- key: "test",
211
- selection: { element } as never,
212
- element,
213
- rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
214
- });
215
-
216
- expect(result.ok).toBe(true);
217
- if (!result.ok) return;
218
- expect(result.member.initialOffset.x).toBe(80);
219
- expect(result.member.initialOffset.y).toBe(-5);
211
+ // Simulate GSAP baking a translate into transform each cycle
212
+ for (let cycle = 0; cycle < 3; cycle++) {
213
+ element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
214
+
215
+ const result = createManualOffsetDragMember({
216
+ key: "test",
217
+ selection: { element } as never,
218
+ element,
219
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
220
+ });
221
+
222
+ expect(result.ok).toBe(true);
223
+ if (!result.ok) return;
224
+ // initialOffset should always be the CSS var value, never inflated by GSAP transform
225
+ const currentRawX =
226
+ Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
227
+ expect(result.member.initialOffset.x).toBe(currentRawX);
228
+
229
+ // Simulate drag commit: apply a small offset
230
+ applyManualOffsetDragCommit(result.member, 10, 0);
231
+ endManualOffsetDragMembers([result.member]);
232
+ }
220
233
  });
221
234
  });
@@ -5,7 +5,6 @@ import {
5
5
  beginStudioManualEditGesture,
6
6
  captureStudioPathOffset,
7
7
  endStudioManualEditGesture,
8
- readGsapTranslateFromTransform,
9
8
  readStudioPathOffset,
10
9
  restoreStudioPathOffset,
11
10
  type StudioPathOffsetSnapshot,
@@ -232,12 +231,7 @@ export function createManualOffsetDragMember(input: {
232
231
  element: HTMLElement;
233
232
  rect: ManualOffsetDragRect;
234
233
  }): ManualOffsetDragMemberResult {
235
- const rawOffset = readStudioPathOffset(input.element);
236
- const gsapTranslate = readGsapTranslateFromTransform(input.element);
237
- const initialOffset = {
238
- x: rawOffset.x + gsapTranslate.x,
239
- y: rawOffset.y + gsapTranslate.y,
240
- };
234
+ const initialOffset = readStudioPathOffset(input.element);
241
235
  const initialPathOffset = captureStudioPathOffset(input.element);
242
236
  const gestureToken = beginStudioManualEditGesture(input.element);
243
237
  const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);