@hyperframes/studio 0.8.94 → 0.8.96

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 (33) hide show
  1. package/dist/assets/{hyperframes-player-Bkm-3G56.js → hyperframes-player-ClfxdXeC.js} +1 -1
  2. package/dist/assets/{index-n2ZRiO44.js → index-BNQLP5_v.js} +1 -1
  3. package/dist/assets/{index-BupvpoiM.js → index-CSHEHeOo.js} +176 -176
  4. package/dist/assets/{index-BUn5H_np.js → index-D8ztHPd3.js} +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +109 -43
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
  11. package/src/components/editor/DomEditCropHandles.tsx +7 -10
  12. package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
  13. package/src/components/editor/domEditOverlayGestures.ts +16 -0
  14. package/src/components/editor/groupDragMove.ts +13 -6
  15. package/src/components/editor/manualEditsSeekReapply.ts +5 -1
  16. package/src/components/editor/manualOffsetDrag.test.ts +56 -0
  17. package/src/components/editor/manualOffsetDrag.ts +16 -9
  18. package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
  19. package/src/components/editor/snapEngine.ts +3 -2
  20. package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
  21. package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
  22. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
  23. package/src/player/components/Timeline.test.ts +3 -3
  24. package/src/player/components/TimelineCanvas.tsx +30 -2
  25. package/src/player/components/TimelineProvider.tsx +1 -0
  26. package/src/player/components/timelineDragDrop.test.tsx +14 -12
  27. package/src/player/components/timelineLayout.test.ts +40 -11
  28. package/src/player/components/timelineLayout.ts +6 -6
  29. package/src/player/components/useTimelineProviderState.tsx +3 -1
  30. package/src/utils/gsapSoftReload.test.ts +51 -2
  31. package/src/utils/gsapSoftReload.ts +24 -10
  32. package/src/utils/gsapUndoRestore.test.ts +49 -0
  33. package/src/utils/gsapUndoRestore.ts +15 -0
@@ -51,6 +51,7 @@ export type TimelineCanvasState = Omit<
51
51
  playheadRef: React.RefObject<HTMLDivElement | null>;
52
52
  laneGapStrips: TimelineLaneGapStrips[];
53
53
  dropPreview: TimelineDropPlacement | null;
54
+ acceptsMediaDrop: boolean;
54
55
  setRangeSelection: (value: TimelineRangeSelection | null) => void;
55
56
  setResizingClip: (value: ResizingClipState | null) => void;
56
57
  setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
@@ -12,6 +12,8 @@ import { configureTimelineTestViewport } from "./timelineTestViewport";
12
12
 
13
13
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
14
 
15
+ const ROW0_MID_Y = getTimelineRowTop(0) + TRACK_H / 2;
16
+
15
17
  interface DropTransfer {
16
18
  types: string[];
17
19
  files: File[];
@@ -124,7 +126,7 @@ describe("useTimelineAssetDrop", () => {
124
126
  const parent = document.createElement("div");
125
127
  const child = document.createElement("div");
126
128
  parent.append(child);
127
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
129
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
128
130
  act(() =>
129
131
  view.api.handleAssetDragLeave({
130
132
  relatedTarget: child,
@@ -160,8 +162,8 @@ describe("useTimelineAssetDrop", () => {
160
162
  const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
161
163
 
162
164
  act(() => {
163
- view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
164
- view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
165
+ view.api.handleAssetDragOver(dragEvent(transfer, 80, ROW0_MID_Y));
166
+ view.api.handleAssetDrop(dragEvent(transfer, 80, ROW0_MID_Y));
165
167
  });
166
168
 
167
169
  // pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
@@ -175,12 +177,12 @@ describe("useTimelineAssetDrop", () => {
175
177
  const view = renderHarness(onAssetDrop, 1);
176
178
  const transfer = assetTransfer("not-json");
177
179
 
178
- act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
180
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y)));
179
181
  expect(view.api.isDragOver).toBe(true);
180
182
  view.rerender(2);
181
183
  expect(view.api.isDragOver).toBe(false);
182
184
 
183
- act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
185
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y)));
184
186
  expect(onAssetDrop).not.toHaveBeenCalled();
185
187
  act(() => view.root.unmount());
186
188
  });
@@ -202,8 +204,8 @@ describe("useTimelineAssetDrop", () => {
202
204
  };
203
205
 
204
206
  act(() => {
205
- view.api.handleAssetDragOver(dragEvent(transfer, 400, 100));
206
- view.api.handleAssetDrop(dragEvent(transfer, 400, 100));
207
+ view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y));
208
+ view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y));
207
209
  });
208
210
 
209
211
  expect(onAssetDrop).not.toHaveBeenCalled();
@@ -214,7 +216,7 @@ describe("useTimelineAssetDrop", () => {
214
216
 
215
217
  it("clears an escaped drag after StrictMode effect replay", () => {
216
218
  const view = renderHarness(vi.fn(), 1, { strict: true });
217
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
219
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
218
220
  expect(view.api.isDragOver).toBe(true);
219
221
 
220
222
  act(() => window.dispatchEvent(new Event("dragend")));
@@ -227,11 +229,11 @@ describe("useTimelineAssetDrop", () => {
227
229
  const view = renderHarness(onAssetDrop);
228
230
  const payload = JSON.stringify({ path: "assets/a.png" });
229
231
 
230
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
232
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
231
233
  const preview = view.api.dropPreview;
232
234
  expect(preview).toEqual({ start: 10, track: 0 });
233
235
 
234
- act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
236
+ act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
235
237
  expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
236
238
  expect(view.api.dropPreview).toBeNull();
237
239
  act(() => view.root.unmount());
@@ -239,8 +241,8 @@ describe("useTimelineAssetDrop", () => {
239
241
 
240
242
  it("moves the preview with the pointer and drops it when the drag leaves", () => {
241
243
  const view = renderHarness(vi.fn());
242
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
243
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
244
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
245
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, ROW0_MID_Y)));
244
246
  expect(view.api.dropPreview?.start).toBe(20);
245
247
 
246
248
  act(() =>
@@ -19,6 +19,7 @@ import {
19
19
  getTimelineBeatEntries,
20
20
  } from "./timelineLayout";
21
21
  import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
22
+ import { resolveInsertRow } from "./timelineCollision";
22
23
  import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
23
24
 
24
25
  describe("horizontal timeline window", () => {
@@ -124,27 +125,27 @@ describe("variable timeline row geometry", () => {
124
125
 
125
126
  describe("collapsed timeline row geometry characterization", () => {
126
127
  it.each([
127
- [0, 74],
128
- [1, 122],
129
- [4, 266],
128
+ [0, 32],
129
+ [1, 80],
130
+ [4, 224],
130
131
  ])("keeps row %i at content y=%i", (row, expectedTop) => {
131
132
  expect(getTimelineRowTop(row)).toBe(expectedTop);
132
133
  });
133
134
 
134
135
  it.each([
135
- [74, 0],
136
- [86, 0.25],
137
- [146, 1.5],
138
- [290, 4.5],
136
+ [32, 0],
137
+ [44, 0.25],
138
+ [104, 1.5],
139
+ [248, 4.5],
139
140
  ])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
140
141
  expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
141
142
  });
142
143
 
143
144
  it.each([
144
- [0, 146],
145
- [1, 194],
146
- [3, 290],
147
- [5, 386],
145
+ [0, 80],
146
+ [1, 128],
147
+ [3, 224],
148
+ [5, 320],
148
149
  ])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
149
150
  expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
150
151
  });
@@ -294,6 +295,34 @@ describe("host track padding", () => {
294
295
  });
295
296
  });
296
297
 
298
+ describe("default track pads", () => {
299
+ const rows = [TRACK_H, TRACK_H, TRACK_H];
300
+ const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
301
+
302
+ it("sits the first row 8px under the ruler and leaves one track below the last", () => {
303
+ expect(getTimelineRowTop(0)).toBe(RULER_H + 8);
304
+ expect(getTimelineCanvasHeight(rows)).toBe(lastLaneBottom + TRACK_H);
305
+ });
306
+
307
+ it("opens a new track from anywhere in the bottom lane", () => {
308
+ const drop = {
309
+ rectLeft: 0,
310
+ rectTop: 0,
311
+ scrollLeft: 0,
312
+ scrollTop: 0,
313
+ contentOrigin: GUTTER,
314
+ pixelsPerSecond: 100,
315
+ rowHeights: rows,
316
+ trackOrder: [0, 1, 2],
317
+ };
318
+ for (const y of [lastLaneBottom, getTimelineCanvasHeight(rows) - 1]) {
319
+ expect(resolveTimelineAssetDrop(drop, GUTTER, y).track).toBe(3);
320
+ expect(resolveInsertRow(getTimelineRowFromY(y, rows), rows.length)).toBe(3);
321
+ }
322
+ expect(resolveTimelineAssetDrop(drop, GUTTER, lastLaneBottom - 1).track).toBe(2);
323
+ });
324
+ });
325
+
297
326
  describe("getTimelineScrubTime", () => {
298
327
  const at = (clientX: number, duration = 10) =>
299
328
  getTimelineScrubTime({
@@ -58,13 +58,13 @@ export function getTimelineLaneTop(laneIndex: number): number {
58
58
  * Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
59
59
  * it per Timeline; rows always read TimelineRowGeometry.padding, never these.
60
60
  *
61
- * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
62
- * (~half a track height) so the first clip isn't jammed under the ruler.
63
- * - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
64
- * enough to comfortably drag a clip into the void to create a new bottom lane.
61
+ * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
62
+ * just enough that the first clip isn't jammed under the ruler.
63
+ * - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
64
+ * dropping a clip or file there creates a new bottom track.
65
65
  */
66
- export const TRACKS_TOP_PAD = 50;
67
- export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
66
+ export const TRACKS_TOP_PAD = 8;
67
+ export const TRACKS_BOTTOM_PAD = TRACK_H;
68
68
  export interface TimelineTrackPadding {
69
69
  top?: number;
70
70
  bottom?: number;
@@ -66,7 +66,7 @@ export function useTimelineProviderState({
66
66
  showAudioEffects = true,
67
67
  showKeyframes = true,
68
68
  trackPadding,
69
- sessionEpoch = 0,
69
+ sessionEpoch: sessionEpochProp,
70
70
  previewIframeRef,
71
71
  onZIndexReorder,
72
72
  }: TimelineProps = {}): TimelineContextValue {
@@ -108,6 +108,7 @@ export function useTimelineProviderState({
108
108
  const currentTime = usePlayerStore((s) => s.currentTime);
109
109
  const beatDragging = usePlayerStore((s) => s.beatDragging);
110
110
  const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
111
+ const sessionEpoch = sessionEpochProp ?? timelineSessionEpoch;
111
112
  const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
112
113
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
113
114
  const playheadRef = useRef<HTMLDivElement>(null);
@@ -433,6 +434,7 @@ export function useTimelineProviderState({
433
434
  marqueeRect,
434
435
  laneGapStrips,
435
436
  dropPreview: assetDrop.dropPreview,
437
+ acceptsMediaDrop: !!(onFileDrop || onAssetDrop),
436
438
  theme,
437
439
  showAudioEffects,
438
440
  displayTrackOrder: displayLayout.displayTrackOrder,
@@ -426,8 +426,8 @@ describe("ensureMotionPathPluginLoaded", () => {
426
426
  // to its AUTHORED value — from the after-write file HTML when provided, else
427
427
  // from the parse-time stamp. Otherwise a runtime transient (the color-grading
428
428
  // hide's 0, a mid-flight tween value) becomes a permanent tween bound.
429
- describe("applySoftReload authored-opacity restore", () => {
430
- function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
429
+ describe("applySoftReload authored-style restore", () => {
430
+ function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
431
431
  const scriptEl = document.createElement("script");
432
432
  scriptEl.textContent =
433
433
  'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
@@ -532,4 +532,53 @@ describe("applySoftReload authored-opacity restore", () => {
532
532
 
533
533
  expect(restoreOpacity(el)).toBe("");
534
534
  });
535
+
536
+ it("restores the file's inline translate, rotate and scale and drops GSAP's transform", () => {
537
+ const el = document.createElement("div");
538
+ el.setAttribute("data-hf-id", "hf-1");
539
+ el.style.cssText =
540
+ "left: 10px; translate: none; rotate: none; scale: none; transform: translate(9px, 9px)";
541
+ const { iframe } = buildIframeWithTarget(el);
542
+ const authoredHtml = `<html><body><div data-hf-id="hf-1" style="translate: 60px 40px; rotate: 15deg; scale: 1.5"></div></body></html>`;
543
+ expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
544
+ const read = (prop: string) => el.style.getPropertyValue(prop);
545
+ expect(["translate", "rotate", "scale", "transform"].map(read)).toEqual([
546
+ "60px 40px",
547
+ "15deg",
548
+ "1.5",
549
+ "",
550
+ ]);
551
+ });
552
+
553
+ it("keeps an SVG child's authored inline transform", () => {
554
+ const el = document.createElementNS("http://www.w3.org/2000/svg", "rect");
555
+ el.setAttribute("data-hf-id", "hf-1");
556
+ el.setAttribute("style", "transform: translate(9px, 9px) rotate(20deg)");
557
+ const { iframe } = buildIframeWithTarget(el);
558
+ const authoredHtml = `<html><body><svg><rect data-hf-id="hf-1" style="transform: rotate(20deg)"></rect></svg></body></html>`;
559
+ applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
560
+ expect(el.style.transform).toBe("rotate(20deg)");
561
+ });
562
+
563
+ it("the finalize seek cannot paint the killed timeline over the restored transform", () => {
564
+ const el = document.createElement("div");
565
+ el.style.cssText = "translate: none; transform: translate3d(77.5px, 40px, 0px)";
566
+ const children = [{ targets: () => [el] }];
567
+ const killed = {
568
+ kill: vi.fn(),
569
+ getChildren: () => children,
570
+ clear: () => void children.splice(0),
571
+ };
572
+ // The runtime still seeks the timeline it captured at load until the rebind swaps it.
573
+ const seek = () =>
574
+ children.forEach(() => (el.style.transform = "translate3d(77.5px, 0px, 0px)"));
575
+ let transformAtRebind: string | null = null;
576
+ const { iframe } = buildIframeWithTarget(el, {
577
+ __timelines: { root: killed },
578
+ __player: { getTime: () => 1, seek },
579
+ __hfForceTimelineRebind: () => (transformAtRebind = el.style.transform),
580
+ });
581
+ applySoftReload(iframe, SCRIPT_TEXT);
582
+ expect(transformAtRebind).toBe("");
583
+ });
535
584
  });
@@ -131,6 +131,18 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
131
131
  return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
132
132
  }
133
133
 
134
+ // GSAP masks a folded CSS translate/rotate/scale with `none`; a fresh load has only what the file authors.
135
+ function restoreAuthoredTransforms(
136
+ style: CSSStyleDeclaration,
137
+ authored: CSSStyleDeclaration | null,
138
+ ) {
139
+ style.transform = authored?.transform ?? "";
140
+ if (!authored) return;
141
+ for (const prop of ["translate", "rotate", "scale"]) {
142
+ style.setProperty(prop, authored.getPropertyValue(prop));
143
+ }
144
+ }
145
+
134
146
  /**
135
147
  * Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
136
148
  * (the preview genuinely can't be soft-updated — escalate to a full reload) from
@@ -181,7 +193,7 @@ export interface SoftReloadOptions {
181
193
  onAsyncFailure?: () => void;
182
194
  /** Seek target for the rebuilt timeline; defaults to the iframe player time. */
183
195
  currentTimeOverride?: number;
184
- /** After-write file HTML — the primary source for authored-opacity restore. */
196
+ /** After-write file HTML — the primary source for the authored opacity and transform restore. */
185
197
  authoredHtml?: string;
186
198
  }
187
199
 
@@ -299,7 +311,7 @@ export function applySoftReload(
299
311
  // parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
300
312
  // covers elements the file lookup can't resolve. Parsed lazily, at most once.
301
313
  let authoredDoc: Document | null | undefined;
302
- const findAuthoredSource = (el: HTMLElement): Element | null => {
314
+ const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
303
315
  if (authoredDoc === undefined) {
304
316
  try {
305
317
  authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
@@ -307,13 +319,12 @@ export function applySoftReload(
307
319
  authoredDoc = null;
308
320
  }
309
321
  }
310
- return authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
311
- };
312
- const readAuthoredOpacity = (el: HTMLElement): string | null => {
313
- const source = findAuthoredSource(el);
314
- if (source instanceof HTMLElement) return source.style.opacity;
315
- return readStampedAuthoredOpacity(el);
322
+ const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
323
+ // The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
324
+ return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
316
325
  };
326
+ const readAuthoredOpacity = (el: HTMLElement): string | null =>
327
+ findAuthoredStyle(el)?.opacity ?? readStampedAuthoredOpacity(el);
317
328
 
318
329
  // fallow-ignore-next-line complexity
319
330
  const doReload = () => {
@@ -327,6 +338,7 @@ export function applySoftReload(
327
338
  const tl = timelines[key] as
328
339
  | {
329
340
  kill?: () => void;
341
+ clear?: () => void;
330
342
  getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
331
343
  }
332
344
  | undefined;
@@ -341,6 +353,8 @@ export function applySoftReload(
341
353
  } catch {}
342
354
  }
343
355
  try {
356
+ // kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
357
+ tl.clear?.();
344
358
  tl.kill?.();
345
359
  } catch {}
346
360
  delete timelines[key];
@@ -369,7 +383,7 @@ export function applySoftReload(
369
383
  // Reset GSAP's internal transform cache so from() tweens don't read stale
370
384
  // end values. `clearProps: "all"` is needed to flush the cache, but it also
371
385
  // nukes the element's CSS base (position, width, height, etc.) from the
372
- // HTML `style=""` attribute. Save → clear → restore → strip `transform`.
386
+ // HTML `style=""` attribute. Save → clear → restore → authored transform props.
373
387
  if (allTargets.length > 0 && win.gsap?.set) {
374
388
  const saved: Array<[HTMLElement, string]> = [];
375
389
  for (const el of allTargets) {
@@ -384,7 +398,7 @@ export function applySoftReload(
384
398
  for (const [el, css] of saved) {
385
399
  const s = el.style;
386
400
  s.cssText = css;
387
- s.removeProperty("transform");
401
+ restoreAuthoredTransforms(s, findAuthoredStyle(el));
388
402
  // The restored cssText carries RUNTIME opacity, not authored opacity:
389
403
  // a mid-flight tween's interpolated value, or the color-grading hide
390
404
  // (`opacity: 0 !important`). The re-run script's tweens re-initialize
@@ -161,6 +161,55 @@ describe("applyUndoRestoreToPreview", () => {
161
161
  },
162
162
  );
163
163
 
164
+ describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
165
+ const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
166
+ const undoScriptEdit = (live: string, authored: string, edit: string) => {
167
+ const { iframe, contentWindow, doc } = buildLiveIframe(
168
+ `${live}<script>${script(edit)}</script>`,
169
+ );
170
+ const clearProps = (targets: HTMLElement[]) =>
171
+ targets.forEach((t) => t.removeAttribute("style"));
172
+ Object.assign(contentWindow.gsap, { set: clearProps });
173
+ for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
174
+ const files = {
175
+ [ROOT]: {
176
+ previous: wrap(`${authored}<script>${script(edit)}</script>`),
177
+ restored: wrap(`${authored}<script>${script("")}</script>`),
178
+ },
179
+ };
180
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
181
+ return doc;
182
+ };
183
+
184
+ it("drops the size marks a resize left on the live element", () => {
185
+ const doc = undoScriptEdit(
186
+ `<div id="a" data-hf-studio-box-size="true" style="width: 502px; --hf-studio-width: 502px; --hf-studio-height: 334px">t</div>`,
187
+ `<div id="a" style="width: 300px">t</div>`,
188
+ `tl.set("#a",{width:502,height:334},0);`,
189
+ );
190
+ expect(doc.getElementById("a")!.hasAttribute("data-hf-studio-box-size")).toBe(false);
191
+ expect(doc.getElementById("a")!.getAttribute("style")).toBe("width: 300px");
192
+ });
193
+
194
+ it("keeps the authored inline rotation of an element GSAP moved", () => {
195
+ const doc = undoScriptEdit(
196
+ `<div id="r" style="left: 10px; transform: translate(116px, 72px) rotate(20deg)">t</div>`,
197
+ `<div id="r" style="left: 10px; transform: rotate(20deg)">t</div>`,
198
+ `gsap.set("#r",{x:116,y:72});`,
199
+ );
200
+ expect(doc.getElementById("r")!.style.transform).toBe("rotate(20deg)");
201
+ });
202
+
203
+ it("drops the translate mask GSAP wrote over a stylesheet translate", () => {
204
+ const doc = undoScriptEdit(
205
+ `<h1 id="t" style="opacity: 0.5; translate: none; transform: translate(116px, -113px)">t</h1>`,
206
+ `<h1 id="t">t</h1>`,
207
+ `gsap.set("#t",{x:116,y:-113});`,
208
+ );
209
+ expect(doc.getElementById("t")!.style.getPropertyValue("translate")).toBe("");
210
+ });
211
+ });
212
+
164
213
  it("full-reloads without partially restoring when any changed live target is missing", () => {
165
214
  const { iframe, doc } = buildLiveIframe(`<div id="a" style="z-index: 8">a</div>`);
166
215
  const reloadPreview = vi.fn();
@@ -8,6 +8,8 @@ import {
8
8
  extractGsapScriptText,
9
9
  findGsapScriptElements,
10
10
  } from "./gsapSoftReload";
11
+ import { findAuthoredElement, parseSavedSource } from "./authoredSource";
12
+ import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
11
13
  import { markScenesStale } from "../player/sceneSwap";
12
14
 
13
15
  type PreviewWindow = Window & {
@@ -135,6 +137,17 @@ function syncElementAttributes(target: Element, source: Element): void {
135
137
  }
136
138
  }
137
139
 
140
+ // A gesture folded into the script leaves marks on the live element that every seek would re-impose.
141
+ function syncStaleEditMarks(doc: Document, restored: string): void {
142
+ const restoredDoc = parseSavedSource(restored);
143
+ for (const live of doc.querySelectorAll(STUDIO_EDIT_ATTRS.map((attr) => `[${attr}]`).join())) {
144
+ const source = findAuthoredElement(restoredDoc, live);
145
+ if (source && STUDIO_EDIT_ATTRS.some((a) => live.getAttribute(a) !== source.getAttribute(a))) {
146
+ syncElementAttributes(live, source);
147
+ }
148
+ }
149
+ }
150
+
138
151
  function readGsapScriptTexts(html: string): string[] {
139
152
  const doc = new DOMParser().parseFromString(html, "text/html");
140
153
  return findGsapScriptElements(doc).map((script) => script.textContent ?? "");
@@ -248,9 +261,11 @@ export function applyUndoRestoreToPreview(
248
261
  const restoredScript = extractGsapScriptText(restored);
249
262
  const previousScript = extractGsapScriptText(previous);
250
263
  if (restoredScript && restoredScript !== previousScript) {
264
+ syncStaleEditMarks(doc, restored);
251
265
  const result = applySoftReload(iframe, restoredScript, {
252
266
  onAsyncFailure: reloadPreview,
253
267
  currentTimeOverride: currentTime,
268
+ authoredHtml: restored,
254
269
  });
255
270
  if (result === "cannot-soft-reload") {
256
271
  reloadPreview();