@hyperframes/studio 0.6.53 → 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.
@@ -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);
@@ -103,6 +103,8 @@ export function MetricField({
103
103
  disabled,
104
104
  liveCommit,
105
105
  scrub,
106
+ suffix,
107
+ tooltip,
106
108
  onCommit,
107
109
  }: {
108
110
  label: string;
@@ -110,6 +112,8 @@ export function MetricField({
110
112
  disabled?: boolean;
111
113
  liveCommit?: boolean;
112
114
  scrub?: boolean;
115
+ suffix?: string;
116
+ tooltip?: string;
113
117
  onCommit: (nextValue: string) => void;
114
118
  }) {
115
119
  const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
@@ -151,7 +155,7 @@ export function MetricField({
151
155
  : ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
152
156
 
153
157
  return (
154
- <div className={FIELD}>
158
+ <div className={FIELD} title={tooltip}>
155
159
  <div className="flex min-w-0 items-center gap-3">
156
160
  <span {...scrubProps}>{label}</span>
157
161
  <CommitField
@@ -160,6 +164,7 @@ export function MetricField({
160
164
  liveCommit={liveCommit}
161
165
  onCommit={onCommit}
162
166
  />
167
+ {suffix && <span className="flex-shrink-0 text-[10px] text-neutral-600">{suffix}</span>}
163
168
  </div>
164
169
  </div>
165
170
  );
@@ -53,6 +53,15 @@ export function DomEditProvider({
53
53
  setAgentModalOpen,
54
54
  setAgentPromptSelectionContext,
55
55
  setAgentModalAnchorPoint,
56
+ selectedGsapAnimations,
57
+ gsapMultipleTimelines,
58
+ gsapUnsupportedTimelinePattern,
59
+ handleGsapUpdateProperty,
60
+ handleGsapUpdateMeta,
61
+ handleGsapDeleteAnimation,
62
+ handleGsapAddAnimation,
63
+ handleGsapAddProperty,
64
+ handleGsapRemoveProperty,
56
65
  },
57
66
  children,
58
67
  }: {
@@ -101,6 +110,15 @@ export function DomEditProvider({
101
110
  setAgentModalOpen,
102
111
  setAgentPromptSelectionContext,
103
112
  setAgentModalAnchorPoint,
113
+ selectedGsapAnimations,
114
+ gsapMultipleTimelines,
115
+ gsapUnsupportedTimelinePattern,
116
+ handleGsapUpdateProperty,
117
+ handleGsapUpdateMeta,
118
+ handleGsapDeleteAnimation,
119
+ handleGsapAddAnimation,
120
+ handleGsapAddProperty,
121
+ handleGsapRemoveProperty,
104
122
  }),
105
123
  [
106
124
  domEditSelection,
@@ -143,6 +161,15 @@ export function DomEditProvider({
143
161
  setAgentModalOpen,
144
162
  setAgentPromptSelectionContext,
145
163
  setAgentModalAnchorPoint,
164
+ selectedGsapAnimations,
165
+ gsapMultipleTimelines,
166
+ gsapUnsupportedTimelinePattern,
167
+ handleGsapUpdateProperty,
168
+ handleGsapUpdateMeta,
169
+ handleGsapDeleteAnimation,
170
+ handleGsapAddAnimation,
171
+ handleGsapAddProperty,
172
+ handleGsapRemoveProperty,
146
173
  ],
147
174
  );
148
175
  return <DomEditContext value={stable}>{children}</DomEditContext>;
@@ -1,7 +1,11 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
- import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
3
+ import {
4
+ STUDIO_INSPECTOR_PANELS_ENABLED,
5
+ STUDIO_GSAP_PANEL_ENABLED,
6
+ } from "../components/editor/manualEditingAvailability";
4
7
  import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
8
+ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
5
9
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
6
10
  import type { EditHistoryKind } from "../utils/editHistory";
7
11
  import type { RightPanelTab } from "../utils/studioHelpers";
@@ -11,6 +15,8 @@ import { useAskAgentModal } from "./useAskAgentModal";
11
15
  import { useDomSelection } from "./useDomSelection";
12
16
  import { usePreviewInteraction } from "./usePreviewInteraction";
13
17
  import { useDomEditCommits } from "./useDomEditCommits";
18
+ import { useGsapScriptCommits } from "./useGsapScriptCommits";
19
+ import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache";
14
20
 
15
21
  // ── Types ──
16
22
 
@@ -185,6 +191,37 @@ export function useDomEditSession({
185
191
  onClickToSource,
186
192
  });
187
193
 
194
+ // ── GSAP script editing ──
195
+
196
+ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
197
+
198
+ const {
199
+ animations: selectedGsapAnimations,
200
+ multipleTimelines: gsapMultipleTimelines,
201
+ unsupportedTimelinePattern: gsapUnsupportedTimelinePattern,
202
+ } = useGsapAnimationsForElement(
203
+ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
204
+ domEditSelection?.sourceFile || activeCompPath || "index.html",
205
+ domEditSelection?.id ?? null,
206
+ gsapCacheVersion,
207
+ );
208
+
209
+ const {
210
+ updateGsapProperty,
211
+ updateGsapMeta,
212
+ deleteGsapAnimation,
213
+ addGsapAnimation,
214
+ addGsapProperty,
215
+ removeGsapProperty,
216
+ } = useGsapScriptCommits({
217
+ projectIdRef,
218
+ activeCompPath,
219
+ editHistory,
220
+ domEditSaveTimestampRef,
221
+ reloadPreview,
222
+ onCacheInvalidate: bumpGsapCache,
223
+ });
224
+
188
225
  // ── Commit handlers (delegated to useDomEditCommits) ──
189
226
 
190
227
  const {
@@ -224,7 +261,53 @@ export function useDomEditSession({
224
261
  buildDomSelectionFromTarget,
225
262
  });
226
263
 
227
- // ── Effects ──
264
+ const handleGsapUpdateProperty = useCallback(
265
+ (animId: string, prop: string, value: number | string) => {
266
+ if (!domEditSelection) return;
267
+ updateGsapProperty(domEditSelection, animId, prop, value);
268
+ },
269
+ [domEditSelection, updateGsapProperty],
270
+ );
271
+
272
+ const handleGsapUpdateMeta = useCallback(
273
+ (animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
274
+ if (!domEditSelection) return;
275
+ updateGsapMeta(domEditSelection, animId, updates);
276
+ },
277
+ [domEditSelection, updateGsapMeta],
278
+ );
279
+
280
+ const handleGsapDeleteAnimation = useCallback(
281
+ (animId: string) => {
282
+ if (!domEditSelection) return;
283
+ deleteGsapAnimation(domEditSelection, animId);
284
+ },
285
+ [domEditSelection, deleteGsapAnimation],
286
+ );
287
+
288
+ const handleGsapAddAnimation = useCallback(
289
+ (method: "to" | "from" | "set") => {
290
+ if (!domEditSelection) return;
291
+ addGsapAnimation(domEditSelection, method, currentTime);
292
+ },
293
+ [domEditSelection, addGsapAnimation, currentTime],
294
+ );
295
+
296
+ const handleGsapAddProperty = useCallback(
297
+ (animId: string, prop: string) => {
298
+ if (!domEditSelection) return;
299
+ addGsapProperty(domEditSelection, animId, prop);
300
+ },
301
+ [domEditSelection, addGsapProperty],
302
+ );
303
+
304
+ const handleGsapRemoveProperty = useCallback(
305
+ (animId: string, prop: string) => {
306
+ if (!domEditSelection) return;
307
+ removeGsapProperty(domEditSelection, animId, prop);
308
+ },
309
+ [domEditSelection, removeGsapProperty],
310
+ );
228
311
 
229
312
  // Sync selection from preview document on load / refresh
230
313
  // eslint-disable-next-line no-restricted-syntax
@@ -243,6 +326,8 @@ export function useDomEditSession({
243
326
  }
244
327
  if (!doc) return;
245
328
 
329
+ reapplyPositionEditsAfterSeek(doc);
330
+
246
331
  const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
247
332
  if (!nextElement) {
248
333
  applyDomSelection(null, { revealPanel: false });
@@ -345,5 +430,16 @@ export function useDomEditSession({
345
430
  setAgentModalOpen,
346
431
  setAgentPromptSelectionContext,
347
432
  setAgentModalAnchorPoint,
433
+
434
+ // GSAP script editing
435
+ selectedGsapAnimations,
436
+ gsapMultipleTimelines,
437
+ gsapUnsupportedTimelinePattern,
438
+ handleGsapUpdateProperty,
439
+ handleGsapUpdateMeta,
440
+ handleGsapDeleteAnimation,
441
+ handleGsapAddAnimation,
442
+ handleGsapAddProperty,
443
+ handleGsapRemoveProperty,
348
444
  };
349
445
  }
@@ -16,6 +16,7 @@ import {
16
16
  resolveDomEditSelection,
17
17
  type DomEditSelection,
18
18
  } from "../components/editor/domEditing";
19
+ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
19
20
 
20
21
  // ── Types ──
21
22
 
@@ -218,6 +219,11 @@ export function useDomSelection({
218
219
  ) => {
219
220
  const iframe = previewIframeRef.current;
220
221
  if (!iframe || captionEditMode) return null;
222
+ try {
223
+ if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument);
224
+ } catch {
225
+ /* cross-origin guard */
226
+ }
221
227
  const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
222
228
  if (!target) return null;
223
229
  return buildDomSelectionFromTarget(target, {
@@ -245,6 +251,8 @@ export function useDomSelection({
245
251
  }
246
252
  if (!doc) return null;
247
253
 
254
+ reapplyPositionEditsAfterSeek(doc);
255
+
248
256
  const targetElement = findElementForTimelineElement(doc, element, {
249
257
  activeCompositionPath: activeCompPath,
250
258
  compIdToSrc,