@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -3,9 +3,8 @@
3
3
  import { describe, it, expect, vi } from "vitest";
4
4
  import {
5
5
  applySoftReload,
6
+ applySoftReloadFinalization,
6
7
  ensureMotionPathPluginLoaded,
7
- diffSoftReloadableRestore,
8
- applyUndoRestoreToPreview,
9
8
  } from "./gsapSoftReload";
10
9
 
11
10
  const SCRIPT_TEXT = `
@@ -66,6 +65,7 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
66
65
  iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
67
66
  contentWindow,
68
67
  mockTimeline,
68
+ container,
69
69
  };
70
70
  }
71
71
 
@@ -288,6 +288,58 @@ describe("applySoftReload", () => {
288
288
  });
289
289
  });
290
290
 
291
+ // ── Finalization-only path: seek → rebind → manual edits, with NO script
292
+ // execution — the flashless sync for timing edits that changed no script.
293
+ describe("applySoftReloadFinalization", () => {
294
+ it("seeks, rebinds, and reapplies manual edits without touching any script", () => {
295
+ const { iframe, contentWindow, container, mockTimeline } = buildMockIframe();
296
+ const scriptsBefore = container.querySelectorAll("script").length;
297
+
298
+ expect(applySoftReloadFinalization(iframe, 2.0)).toBe(true);
299
+
300
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
301
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
302
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1);
303
+ // No script executed or removed; the live timeline was never killed.
304
+ expect(container.querySelectorAll("script").length).toBe(scriptsBefore);
305
+ expect(mockTimeline.kill).not.toHaveBeenCalled();
306
+ expect(contentWindow.__timelines.root).toBe(mockTimeline);
307
+ });
308
+
309
+ it("runs inside __hfSuppressSceneMutations when the runtime provides it", () => {
310
+ const suppress = vi.fn(<T>(fn: () => T): T => fn());
311
+ const { iframe, contentWindow } = buildMockIframe({
312
+ __hfSuppressSceneMutations: suppress,
313
+ });
314
+
315
+ expect(applySoftReloadFinalization(iframe, 1.5)).toBe(true);
316
+
317
+ expect(suppress).toHaveBeenCalledTimes(1);
318
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it("does NOT require gsap — a script-less runtime with the rebind hook works", () => {
322
+ const { iframe, contentWindow } = buildMockIframe({ gsap: undefined });
323
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(true);
324
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
325
+ });
326
+
327
+ it("returns false when the iframe or the rebind hook is unavailable", () => {
328
+ expect(applySoftReloadFinalization(null, 0)).toBe(false);
329
+ const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
330
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
331
+ });
332
+
333
+ it("returns false when the rebind throws (caller full-reloads)", () => {
334
+ const { iframe } = buildMockIframe({
335
+ __hfForceTimelineRebind: vi.fn(() => {
336
+ throw new Error("runtime mid-teardown");
337
+ }),
338
+ });
339
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
340
+ });
341
+ });
342
+
291
343
  function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
292
344
  const head = document.createElement("div");
293
345
  const appendedScripts: HTMLScriptElement[] = [];
@@ -452,116 +504,3 @@ describe("applySoftReload authored-opacity restore", () => {
452
504
  expect(restoreOpacity(el)).toBe("");
453
505
  });
454
506
  });
455
-
456
- // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
457
-
458
- const wrap = (body: string) => `<html><body>${body}</body></html>`;
459
-
460
- describe("diffSoftReloadableRestore", () => {
461
- it("reports the changed id for an attribute/inline-style-only diff", () => {
462
- const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
463
- const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
464
- expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
465
- });
466
-
467
- it("treats a structural change (added element) as NOT soft-reloadable", () => {
468
- const prev = wrap(`<div id="a">t</div>`);
469
- const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
470
- expect(diffSoftReloadableRestore(prev, next)).toBeNull();
471
- });
472
-
473
- it("treats an element text/child change as NOT soft-reloadable", () => {
474
- const prev = wrap(`<div id="a">one</div>`);
475
- const next = wrap(`<div id="a">two</div>`);
476
- expect(diffSoftReloadableRestore(prev, next)).toBeNull();
477
- });
478
-
479
- it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
480
- const prev = wrap(
481
- `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
482
- );
483
- const next = wrap(
484
- `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
485
- );
486
- expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
487
- });
488
- });
489
-
490
- function buildLiveIframe(bodyHtml: string) {
491
- const doc = document.implementation.createHTMLDocument("");
492
- doc.body.innerHTML = bodyHtml;
493
- const contentWindow = {
494
- gsap: { timeline: () => {} },
495
- __hfForceTimelineRebind: () => {},
496
- __timelines: {} as Record<string, unknown>,
497
- __player: { getTime: () => 3, seek: vi.fn() },
498
- __hfStudioManualEditsApply: vi.fn(),
499
- };
500
- return {
501
- iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
502
- contentWindow,
503
- doc,
504
- };
505
- }
506
-
507
- describe("applyUndoRestoreToPreview", () => {
508
- const ROOT = "index.html";
509
-
510
- it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
511
- const { iframe, contentWindow, doc } = buildLiveIframe(
512
- `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
513
- );
514
- const reloadPreview = vi.fn();
515
- const files = {
516
- [ROOT]: {
517
- previous: wrap(
518
- `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
519
- ),
520
- restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
521
- },
522
- };
523
- const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
524
- expect(outcome).toBe("soft");
525
- expect(reloadPreview).not.toHaveBeenCalled();
526
- // Live element reverted to the restored inline style.
527
- expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
528
- // No GSAP script in the restore → the manual-edit reapply runs, playhead held.
529
- expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
530
- expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
531
- });
532
-
533
- it("full-reloads a multi-file restore", () => {
534
- const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
535
- const reloadPreview = vi.fn();
536
- const files = {
537
- [ROOT]: {
538
- previous: wrap(`<div id="a" style="x">t</div>`),
539
- restored: wrap(`<div id="a">t</div>`),
540
- },
541
- "scenes/intro.html": { previous: "a", restored: "b" },
542
- };
543
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
544
- expect(reloadPreview).toHaveBeenCalledTimes(1);
545
- });
546
-
547
- it("full-reloads a structural restore (split/delete undo)", () => {
548
- const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
549
- const reloadPreview = vi.fn();
550
- const files = {
551
- [ROOT]: {
552
- previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
553
- restored: wrap(`<div id="a">t</div>`),
554
- },
555
- };
556
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
557
- expect(reloadPreview).toHaveBeenCalledTimes(1);
558
- });
559
-
560
- it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
561
- const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
562
- const reloadPreview = vi.fn();
563
- const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
564
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
565
- expect(reloadPreview).toHaveBeenCalledTimes(1);
566
- });
567
- });
@@ -90,7 +90,7 @@ function isGsapScript(text: string): boolean {
90
90
  );
91
91
  }
92
92
 
93
- function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
93
+ export function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
94
94
  const results: HTMLScriptElement[] = [];
95
95
  const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
96
96
  for (const script of scripts) {
@@ -102,8 +102,9 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
102
102
  /**
103
103
  * Extract the GSAP timeline script text from a serialized HTML document, for
104
104
  * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
105
- * are present (ambiguous — caller should fall back to a full reload), matching
106
- * applySoftReload's own single-script requirement.
105
+ * are present (ambiguous — a serialized snapshot can't say WHICH script a
106
+ * single rewritten text corresponds to; caller should fall back to a full
107
+ * reload), matching applySoftReload's own single-script requirement.
107
108
  */
108
109
  export function extractGsapScriptText(html: string): string | null {
109
110
  const doc = new DOMParser().parseFromString(html, "text/html");
@@ -183,157 +184,53 @@ export interface SoftReloadOptions {
183
184
  authoredHtml?: string;
184
185
  }
185
186
 
186
- /** One file's restore from the edit-history store: before (live) / after (target) bytes. */
187
- export interface UndoRestoreFile {
188
- previous: string;
189
- restored: string;
190
- }
191
- function idElementMap(doc: Document): Map<string, Element> {
192
- const map = new Map<string, Element>();
193
- for (const el of doc.querySelectorAll("[id]")) {
194
- const id = el.getAttribute("id");
195
- if (id) map.set(id, el);
196
- }
197
- return map;
198
- }
199
-
200
- // Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that
201
- // differ only in id'd attributes/inline-style/script text normalize equal; any
202
- // residual difference is beyond soft-reload's reach → caller full-reloads.
203
- function normalizeSoftResidual(doc: Document): void {
204
- for (const el of doc.querySelectorAll("[id]")) {
205
- const id = el.getAttribute("id");
206
- for (const name of [...el.getAttributeNames()]) {
207
- if (name !== "id") el.removeAttribute(name);
208
- }
209
- if (id) el.setAttribute("id", id);
210
- }
211
- for (const script of findGsapScriptElements(doc)) script.textContent = "";
212
- }
213
-
214
- // Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline
215
- // style and/or the GSAP script; returns the changed ids to sync onto the live
216
- // DOM. Structural/text diffs → null → the caller full-reloads. Pure.
217
- export function diffSoftReloadableRestore(
218
- previous: string,
219
- restored: string,
220
- ): { changedElementIds: string[] } | null {
221
- let prevDoc: Document;
222
- let nextDoc: Document;
223
- try {
224
- prevDoc = new DOMParser().parseFromString(previous, "text/html");
225
- nextDoc = new DOMParser().parseFromString(restored, "text/html");
226
- } catch {
227
- return null;
228
- }
229
- const prevById = idElementMap(prevDoc);
230
- const nextById = idElementMap(nextDoc);
231
- // A different id set means an element was added or removed (e.g. a split, a
232
- // delete) — structural, so soft-reload can't express it.
233
- if (prevById.size !== nextById.size) return null;
234
- const changedElementIds: string[] = [];
235
- for (const [id, nextEl] of nextById) {
236
- const prevEl = prevById.get(id);
237
- if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
238
- // A change inside the element (text / children) is out of soft scope; only
239
- // its own attributes may differ. (GSAP scripts are handled via re-run.)
240
- if (prevEl.innerHTML !== nextEl.innerHTML) return null;
241
- if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id);
242
- }
243
- // Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed.
244
- normalizeSoftResidual(prevDoc);
245
- normalizeSoftResidual(nextDoc);
246
- if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
247
- return { changedElementIds };
248
- }
249
-
250
- /** Copy every attribute from `source` onto the live `target`, dropping extras. */
251
- function syncElementAttributes(target: Element, source: Element): void {
252
- for (const name of [...target.getAttributeNames()]) {
253
- if (!source.hasAttribute(name)) target.removeAttribute(name);
254
- }
255
- for (const name of source.getAttributeNames()) {
256
- target.setAttribute(name, source.getAttribute(name) ?? "");
257
- }
187
+ /**
188
+ * The soft reload's finalization step, shared with the rebind-only preview sync
189
+ * below: seek → force timeline rebind → reapply studio manual edits.
190
+ *
191
+ * Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
192
+ * (see init.ts) renders the freshly-created timeline at whatever the
193
+ * runtime's internal scrub position already is, not at whatever we pass
194
+ * here afterward — a redundant seek() call after rebind can be a GSAP
195
+ * no-op if the timeline already reports being at that time internally.
196
+ */
197
+ function finalizeSoftReload(win: IframeWindow, currentTime: number): void {
198
+ win.__player?.seek?.(currentTime);
199
+ win.__hfForceTimelineRebind?.();
200
+ win.__hfStudioManualEditsApply?.();
258
201
  }
259
202
 
260
203
  /**
261
- * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
262
- * remount (which blanks the frame black and re-flashes the WebGL context). Only
263
- * the active composition the document living in the root iframe — is eligible;
264
- * a sub-comp or multi-file restore falls back to `reloadPreview`.
204
+ * Run ONLY applySoftReload's finalization (seek __hfForceTimelineRebind
205
+ * manual-edits reapply) against the live iframe executing NO scripts and
206
+ * touching NO script elements. `__hfForceTimelineRebind` makes the runtime
207
+ * re-derive every clip's visibility window from the live DOM's `data-start` /
208
+ * `data-duration` attributes (init.ts: bindRootTimelineIfAvailable +
209
+ * syncTimedElementVisibility), so this is the flashless sync for a timing edit
210
+ * whose attributes were already live-patched and whose GSAP scripts are
211
+ * unchanged (`window.__timelines` still valid). Works for compositions with
212
+ * zero GSAP scripts too — the rebind hook is installed unconditionally by the
213
+ * runtime, independent of any animation library.
265
214
  *
266
- * The restore is soft-applied when its only differences are id'd-element
267
- * attributes / inline-style and/or the GSAP script (see diffSoftReloadableRestore):
268
- * 1. Each changed element's attribute surface (inline style, data-start /
269
- * -duration, the studio manual-offset props + flags) is synced onto the live
270
- * element — so a canvas-position revert lands on the live DOM the runtime's
271
- * seek-reapply reads from, not just on disk.
272
- * 2. The restored GSAP script is re-run in place via applySoftReload, which
273
- * re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits.
274
- * With no single script, the manual-edit reapply is invoked directly.
275
- *
276
- * Returns "soft" when applied in place, "full" when it escalated to reloadPreview
277
- * (ineligible restore, missing target, or a permanent soft-reload failure).
215
+ * Returns false when the iframe/runtime hook is unavailable or the run threw
216
+ * the caller should escalate to a full reload.
278
217
  */
279
- export function applyUndoRestoreToPreview(
218
+ export function applySoftReloadFinalization(
280
219
  iframe: HTMLIFrameElement | null,
281
- activeCompPath: string | null,
282
- files: Record<string, UndoRestoreFile> | undefined,
283
220
  currentTime: number,
284
- reloadPreview: () => void,
285
- ): "soft" | "full" {
286
- const paths = files ? Object.keys(files) : [];
287
- // Soft path only covers the single active-comp document in the root iframe.
288
- if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) {
289
- reloadPreview();
290
- return "full";
291
- }
292
- const doc = iframe.contentDocument;
293
- const win = iframe.contentWindow as IframeWindow | null;
294
- if (!doc || !win) {
295
- reloadPreview();
296
- return "full";
297
- }
298
- const { previous, restored } = files[activeCompPath]!;
299
- const diff = diffSoftReloadableRestore(previous, restored);
300
- if (!diff) {
301
- reloadPreview();
302
- return "full";
303
- }
304
-
305
- // Sync each changed element's attributes onto the live DOM from the restored
306
- // markup, so the runtime's seek-reapply (which reads inline offset props off
307
- // the live element) folds the REVERTED values, not the stale current ones.
308
- const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html"));
309
- for (const id of diff.changedElementIds) {
310
- const liveEl = doc.getElementById(id);
311
- const restoredEl = restoredById.get(id);
312
- if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl);
313
- }
314
-
315
- const script = extractGsapScriptText(restored);
316
- if (script) {
317
- const result = applySoftReload(iframe, script, {
318
- onAsyncFailure: reloadPreview,
319
- currentTimeOverride: currentTime,
320
- });
321
- if (result === "cannot-soft-reload") {
322
- reloadPreview();
323
- return "full";
324
- }
325
- return "soft";
326
- }
327
- // No single GSAP script to re-run — the change was pure attribute/style. Re-fold
328
- // manual edits and hold the playhead so the synced attributes take visible effect.
221
+ ): boolean {
222
+ const win = iframe?.contentWindow as IframeWindow | null;
223
+ if (!win?.__hfForceTimelineRebind) return false;
329
224
  try {
330
- win.__player?.seek?.(currentTime);
331
- win.__hfStudioManualEditsApply?.();
225
+ if (win.__hfSuppressSceneMutations) {
226
+ win.__hfSuppressSceneMutations(() => finalizeSoftReload(win, currentTime));
227
+ } else {
228
+ finalizeSoftReload(win, currentTime);
229
+ }
230
+ return true;
332
231
  } catch {
333
- reloadPreview();
334
- return "full";
232
+ return false;
335
233
  }
336
- return "soft";
337
234
  }
338
235
 
339
236
  export function applySoftReload(
@@ -523,14 +420,7 @@ export function applySoftReload(
523
420
  const s = doc.createElement("script");
524
421
  s.textContent = `(function(){${scriptText}\n})();`;
525
422
  doc.body.appendChild(s);
526
- // Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
527
- // (see init.ts) renders the freshly-created timeline at whatever the
528
- // runtime's internal scrub position already is, not at whatever we pass
529
- // here afterward — a redundant seek() call after rebind can be a GSAP
530
- // no-op if the timeline already reports being at that time internally.
531
- win.__player?.seek?.(currentTime);
532
- win.__hfForceTimelineRebind?.();
533
- win.__hfStudioManualEditsApply?.();
423
+ finalizeSoftReload(win, currentTime);
534
424
  };
535
425
 
536
426
  const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);