@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, it, expect, vi } from "vitest";
3
+ import { afterEach, describe, it, expect, vi } from "vitest";
4
4
  import {
5
+ readNestedFiles,
5
6
  applySoftReload,
6
7
  applySoftReloadFinalization,
7
8
  ensureMotionPathPluginLoaded,
@@ -131,10 +132,14 @@ describe("applySoftReload", () => {
131
132
  const container = document.createElement("div");
132
133
  container.appendChild(scriptEl);
133
134
 
135
+ const root = document.createElement("div");
136
+ root.setAttribute("data-composition-id", "root");
137
+ root.appendChild(orphan);
138
+
134
139
  const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
135
140
  (iframe as unknown as { contentDocument: unknown }).contentDocument = {
136
141
  querySelectorAll: (sel: string) =>
137
- sel === "script:not([src])" ? [scriptEl] : sel === "[style*='transform']" ? [orphan] : [],
142
+ sel === "script:not([src])" ? [scriptEl] : sel.includes("composition-id") ? [root] : [],
138
143
  createElement: (tag: string) => document.createElement(tag),
139
144
  body: container,
140
145
  head: document.createElement("div"),
@@ -427,6 +432,8 @@ describe("ensureMotionPathPluginLoaded", () => {
427
432
  // from the parse-time stamp. Otherwise a runtime transient (the color-grading
428
433
  // hide's 0, a mid-flight tween value) becomes a permanent tween bound.
429
434
  describe("applySoftReload authored-style restore", () => {
435
+ afterEach(() => document.body.replaceChildren());
436
+
430
437
  function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
431
438
  const scriptEl = document.createElement("script");
432
439
  scriptEl.textContent =
@@ -581,4 +588,233 @@ describe("applySoftReload authored-style restore", () => {
581
588
  applySoftReload(iframe, SCRIPT_TEXT);
582
589
  expect(transformAtRebind).toBe("");
583
590
  });
591
+
592
+ // The preview inlines a sub-composition into a host that names its file.
593
+ function inlinedIn(file: string, el: HTMLElement): HTMLElement {
594
+ const host = document.createElement("div");
595
+ host.setAttribute("data-composition-file", file);
596
+ host.appendChild(el);
597
+ document.body.appendChild(host);
598
+ return el;
599
+ }
600
+
601
+ function answerQueries(iframe: HTMLIFrameElement, styled: Element, root: Element): void {
602
+ const doc = iframe.contentDocument as unknown as { querySelectorAll: (s: string) => unknown };
603
+ const query = doc.querySelectorAll;
604
+ doc.querySelectorAll = (sel: string) =>
605
+ sel.includes("transform") ? [styled] : sel.includes("composition-id") ? [root] : query(sel);
606
+ }
607
+ const SUB_FILE = `<template id="sub-template"><div id="sub" data-composition-id="sub">
608
+ <div id="nroot" data-hf-id="hf-n" style="left: 560px; top: 300px"></div></div></template>`;
609
+
610
+ it("gives a root tween's nested target what its own file authors for what GSAP wrote", () => {
611
+ const el = document.createElement("div");
612
+ el.id = "nroot";
613
+ el.setAttribute("data-hf-id", "hf-n");
614
+ // GSAP folded the stylesheet translate into its transform, masked it, and wrote the tweened width.
615
+ el.style.cssText =
616
+ "left: 560px; top: 300px; width: 337px; translate: none; transform: translate(12.5px, 0px)";
617
+ inlinedIn("compositions/sub.html", el);
618
+ const tween = { targets: () => [el], vars: { x: 50, width: 400, duration: 4 } };
619
+ const { iframe } = buildIframeWithTarget(el, {
620
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
621
+ });
622
+ const authoredHtml = `<html><body><div id="root"></div></body></html>`;
623
+ const nestedFiles = new Map([["compositions/sub.html", SUB_FILE]]);
624
+
625
+ expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml, nestedFiles })).toBe("applied");
626
+ expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
627
+ });
628
+
629
+ it("puts back a root element's tweened width from the file instead of the live value", () => {
630
+ const el = document.createElement("div");
631
+ el.setAttribute("data-hf-id", "hf-1");
632
+ el.style.cssText = "left: 700px; width: 366px";
633
+ const tween = { targets: () => [el], vars: { width: 450, duration: 4 } };
634
+ const { iframe } = buildIframeWithTarget(el, {
635
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
636
+ });
637
+ const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 700px"></div></body></html>`;
638
+
639
+ expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
640
+ expect(el.getAttribute("style")).toBe("left: 700px;");
641
+ });
642
+
643
+ it("leaves what a nested composition's own timeline animates to that timeline", () => {
644
+ const el = inlinedIn("compositions/sub.html", document.createElement("div"));
645
+ el.id = "nroot";
646
+ el.style.cssText = "translate: none; transform: translate(40px, 10px)";
647
+ const sub = { getChildren: () => [{ targets: () => [el], vars: { x: 100 } }] };
648
+ const { iframe } = buildIframeWithTarget(el, {
649
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [sub] }, sub },
650
+ });
651
+ const readFile = vi.fn(async () => SUB_FILE);
652
+
653
+ expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
654
+ applySoftReload(iframe, SCRIPT_TEXT);
655
+
656
+ expect(readFile).not.toHaveBeenCalled();
657
+ expect(el.style.cssText).toBe("translate: none; transform: translate(40px, 10px);");
658
+ });
659
+
660
+ it("a nested composition's reload leaves a GSAP transform in the top-level file alone", () => {
661
+ const host = document.createElement("div");
662
+ host.setAttribute("data-composition-id", "root");
663
+ host.setAttribute("data-composition-file", "compositions/sub.html");
664
+ const outside = Object.assign(document.createElement("div"), { _gsap: {} });
665
+ outside.style.cssText = "rotate: none; transform: rotate(30deg)";
666
+ const { iframe } = buildIframeWithTarget(host, {
667
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [] } },
668
+ });
669
+ answerQueries(iframe, outside, host);
670
+
671
+ applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
672
+
673
+ expect(outside.style.cssText).toBe("rotate: none; transform: rotate(30deg);");
674
+ });
675
+
676
+ it("a nested composition's reload leaves its element that the top-level timeline animates alone", () => {
677
+ const host = document.createElement("div");
678
+ host.setAttribute("data-composition-id", "root");
679
+ host.setAttribute("data-composition-file", "compositions/sub.html");
680
+ const el = Object.assign(document.createElement("div"), { _gsap: {} });
681
+ // The top-level tween folded the stylesheet translate into its transform.
682
+ el.style.cssText = "translate: none; transform: translate(23.75px, 5px)";
683
+ host.appendChild(el);
684
+ const main = { getChildren: () => [{ targets: () => [el], vars: { x: 50 } }] };
685
+ const { iframe } = buildIframeWithTarget(host, {
686
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [] }, main },
687
+ });
688
+ answerQueries(iframe, el, host);
689
+
690
+ applySoftReload(iframe, SCRIPT_TEXT, {
691
+ authoredHtml: SUB_FILE,
692
+ });
693
+
694
+ expect(el.style.cssText).toBe("translate: none; transform: translate(23.75px, 5px);");
695
+ });
696
+
697
+ it("a nested composition's own reload restores its element from the file just written", () => {
698
+ const host = document.createElement("div");
699
+ host.setAttribute("data-composition-id", "root");
700
+ host.setAttribute("data-composition-file", "compositions/sub.html");
701
+ const el = document.createElement("div");
702
+ el.id = "nroot";
703
+ el.setAttribute("data-hf-id", "hf-n");
704
+ el.style.cssText = "left: 560px; top: 300px; width: 337px";
705
+ host.appendChild(el);
706
+ const tween = { targets: () => [el], vars: { width: 400 } };
707
+ const { iframe } = buildIframeWithTarget(el, {
708
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
709
+ });
710
+ answerQueries(iframe, el, host);
711
+ const readFile = vi.fn(async () => SUB_FILE);
712
+
713
+ expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
714
+ applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
715
+
716
+ expect(readFile).not.toHaveBeenCalled();
717
+ expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
718
+ });
719
+
720
+ it("reads only the other composition files a top-level re-run resets elements of", async () => {
721
+ const el = inlinedIn("compositions/sub.html", document.createElement("div"));
722
+ const tween = { targets: () => [el], vars: { width: 400 } };
723
+ const { iframe } = buildIframeWithTarget(el, {
724
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
725
+ });
726
+ const readFile = vi.fn(async () => SUB_FILE);
727
+
728
+ const files = await readNestedFiles(iframe, SCRIPT_TEXT, readFile);
729
+
730
+ expect(readFile).toHaveBeenCalledTimes(1);
731
+ expect(files).toEqual(new Map([["compositions/sub.html", SUB_FILE]]));
732
+ });
733
+
734
+ it("escalates, touching nothing, when a nested file the reset needs could not be read", () => {
735
+ const el = inlinedIn("compositions/sub.html", document.createElement("div"));
736
+ el.style.cssText = "width: 337px; translate: none";
737
+ const tween = { targets: () => [el], vars: { width: 400 } };
738
+ const set = vi.fn();
739
+ const { iframe } = buildIframeWithTarget(el, {
740
+ gsap: { timeline: vi.fn(), set },
741
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
742
+ });
743
+
744
+ expect(applySoftReload(iframe, SCRIPT_TEXT, { nestedFiles: null })).toBe("cannot-soft-reload");
745
+ expect(set).not.toHaveBeenCalled();
746
+ expect(el.style.cssText).toBe("width: 337px; translate: none;");
747
+ });
748
+
749
+ it("keeps the runtime's own inline writes: only what GSAP wrote comes back from the file", () => {
750
+ const el = document.createElement("div");
751
+ el.setAttribute("data-hf-id", "hf-1");
752
+ // The runtime's layout pass wrote position and a data-width size; GSAP wrote the mask and transform.
753
+ el.style.cssText =
754
+ "left: 10px; position: absolute; width: 300px; translate: none; transform: translate(50px, 0px)";
755
+ const tween = { targets: () => [el], vars: { x: 50 } };
756
+ const { iframe } = buildIframeWithTarget(el, {
757
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
758
+ });
759
+ const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 10px"></div></body></html>`;
760
+
761
+ applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
762
+
763
+ expect(el.getAttribute("style")).toBe("left: 10px; position: absolute; width: 300px;");
764
+ });
765
+ });
766
+
767
+ describe("applySoftReload over a composition's own markup", () => {
768
+ // A live preview whose top-level timeline has no children; `_gsap` marks what a script set.
769
+ function reloadRoot(markup: string, held: string[]) {
770
+ const doc = document.implementation.createHTMLDocument("");
771
+ doc.body.innerHTML = `${markup}<script>${SCRIPT_TEXT}</script>`;
772
+ for (const id of held) Object.assign(doc.getElementById(id)!, { _gsap: {} });
773
+ const set = vi.fn();
774
+ const contentWindow = {
775
+ gsap: { timeline: vi.fn(), set },
776
+ __hfForceTimelineRebind: vi.fn(),
777
+ __timelines: { root: { kill: vi.fn(), getChildren: () => [] } } as Record<string, unknown>,
778
+ __player: { getTime: () => 0, seek: vi.fn() },
779
+ };
780
+ const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
781
+ const cleared = () => (set.mock.calls[0]?.[0] as Element[] | undefined)?.map((el) => el.id);
782
+ return { doc, iframe, cleared };
783
+ }
784
+
785
+ it("resets an element a standalone gsap.set holds even when no inline transform is left", () => {
786
+ const { iframe, cleared } = reloadRoot(
787
+ `<div data-composition-id="root"><div id="held" style="left: 5px"></div><div id="plain"></div></div>`,
788
+ ["held"],
789
+ );
790
+
791
+ applySoftReload(iframe, SCRIPT_TEXT);
792
+
793
+ expect(cleared()).toEqual(["held"]);
794
+ });
795
+
796
+ it("leaves what a nested composition's script holds alone, but resets that composition's host", () => {
797
+ const { doc, iframe, cleared } = reloadRoot(
798
+ `<div data-composition-id="root"><div id="host" data-composition-id="sub">` +
799
+ `<div id="nested" style="transform: translate(90px, 60px)"></div></div></div>`,
800
+ ["host", "nested"],
801
+ );
802
+
803
+ applySoftReload(iframe, SCRIPT_TEXT);
804
+
805
+ expect(cleared()).toEqual(["host"]);
806
+ expect(doc.getElementById("nested")!.style.transform).toBe("translate(90px, 60px)");
807
+ });
808
+
809
+ it("reloads in full when the re-run builds its DOM a second time", () => {
810
+ const { doc, iframe } = reloadRoot(`<div id="root" data-composition-id="root"></div>`, []);
811
+ const append = doc.body.appendChild.bind(doc.body);
812
+ // What a script's createElement or template clone does each time it runs.
813
+ doc.body.appendChild = <T extends Node>(node: T): T => {
814
+ doc.getElementById("root")!.appendChild(doc.createElement("div"));
815
+ return append(node);
816
+ };
817
+
818
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
819
+ });
584
820
  });
@@ -1,6 +1,7 @@
1
1
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
2
- import { findAuthoredElement, parseSavedSource } from "./authoredSource";
2
+ import { findAuthoredElement } from "./authoredSource";
3
3
  import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
4
+ import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
4
5
 
5
6
  type IframeWindow = Window & {
6
7
  __timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
@@ -131,15 +132,28 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
131
132
  return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
132
133
  }
133
134
 
134
- // GSAP masks a folded CSS translate/rotate/scale with `none`; a fresh load has only what the file authors.
135
- function restoreAuthoredTransforms(
135
+ // GSAP masks a folded CSS translate/rotate/scale with `none`, and a tween writes what it animates
136
+ // inline; a fresh load has only what the file authors. Opacity has its own restore.
137
+ function restoreAuthoredStyle(
136
138
  style: CSSStyleDeclaration,
137
139
  authored: CSSStyleDeclaration | null,
140
+ tweened: Set<string>,
138
141
  ) {
139
142
  style.transform = authored?.transform ?? "";
140
143
  if (!authored) return;
141
- for (const prop of ["translate", "rotate", "scale"]) {
142
- style.setProperty(prop, authored.getPropertyValue(prop));
144
+ for (const prop of ["translate", "rotate", "scale", ...tweened]) {
145
+ if (prop === "opacity") continue;
146
+ style.setProperty(prop, authored.getPropertyValue(prop), authored.getPropertyPriority(prop));
147
+ }
148
+ }
149
+
150
+ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
151
+ try {
152
+ if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
153
+ else reload();
154
+ return true;
155
+ } catch {
156
+ return false;
143
157
  }
144
158
  }
145
159
 
@@ -196,6 +210,8 @@ export interface SoftReloadOptions {
196
210
  /** After-write file HTML — the primary source for the authored opacity and transform restore. */
197
211
  authoredHtml?: string;
198
212
  reparse?: Element[];
213
+ /** Other composition files a reset element is written in, by path; null when one could not be read. */
214
+ nestedFiles?: Map<string, string> | null;
199
215
  }
200
216
 
201
217
  /**
@@ -247,6 +263,12 @@ export function applySoftReloadFinalization(
247
263
  }
248
264
  }
249
265
 
266
+ function timelineKeys(scriptText: string): string[] {
267
+ return [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
268
+ .map((m) => m[1]!)
269
+ .filter((key) => key !== "__proxied");
270
+ }
271
+
250
272
  export function applySoftReload(
251
273
  iframe: HTMLIFrameElement | null,
252
274
  scriptText: string,
@@ -259,6 +281,8 @@ export function applySoftReload(
259
281
  const doc = iframe.contentDocument;
260
282
  if (!win || !doc) return "cannot-soft-reload";
261
283
  if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
284
+ // Without the file an element is written in, only a full load restores it.
285
+ if (options.nestedFiles === null) return "cannot-soft-reload";
262
286
 
263
287
  // Which composition(s) does this script rebuild? A soft reload re-runs ONE
264
288
  // composition's GSAP script, which re-registers its own window.__timelines[key].
@@ -267,9 +291,7 @@ export function applySoftReload(
267
291
  // of the global timeline — so tearing down ALL of them (or the global timeline's
268
292
  // children) and re-running a single script wipes every OTHER composition,
269
293
  // reverting its edits. Scope the teardown to the keys THIS script re-registers.
270
- const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
271
- .map((m) => m[1]!)
272
- .filter((key) => key !== "__proxied");
294
+ const targetKeys = timelineKeys(scriptText);
273
295
  if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
274
296
  const gsapScripts = findGsapScriptElements(doc);
275
297
  if (gsapScripts.length === 0) return "cannot-soft-reload";
@@ -302,6 +324,7 @@ export function applySoftReload(
302
324
  // once the plugin loads; the alternative (returning false) would trigger a
303
325
  // full iframe reload that destroys the very WebGL context we're preserving.
304
326
  let deferredToAsync = false;
327
+ let builtDom = false;
305
328
 
306
329
  // Authored-opacity resolution for the restore loop below. Three-state:
307
330
  // "0.98" — the element's authored inline opacity
@@ -311,16 +334,10 @@ export function applySoftReload(
311
334
  // The just-written file (`authoredHtml`) is the current truth; the runtime's
312
335
  // parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
313
336
  // covers elements the file lookup can't resolve. Parsed lazily, at most once.
314
- let authoredDoc: Document | null | undefined;
337
+ const docFor = fileDocs(compositionFile(doc, targetKeys), authoredHtml, options.nestedFiles);
315
338
  const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
316
- if (authoredDoc === undefined) {
317
- try {
318
- authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
319
- } catch {
320
- authoredDoc = null;
321
- }
322
- }
323
- const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
339
+ const fileDoc = docFor(authoringFile(el));
340
+ const source = fileDoc ? findAuthoredElement(fileDoc, el) : null;
324
341
  // The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
325
342
  return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
326
343
  };
@@ -330,29 +347,14 @@ export function applySoftReload(
330
347
  // fallow-ignore-next-line complexity
331
348
  const doReload = () => {
332
349
  const timelines = win.__timelines;
333
- const allTargets: Element[] = [...reparse];
350
+ const targets = collectResetTargets(win, doc, targetKeys, reparse);
334
351
 
335
352
  // Kill ONLY the target composition's timeline(s) — leaving every other
336
353
  // composition's timeline (and its children on the global timeline) intact.
337
354
  if (timelines) {
338
355
  for (const key of targetKeys) {
339
- const tl = timelines[key] as
340
- | {
341
- kill?: () => void;
342
- clear?: () => void;
343
- getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
344
- }
345
- | undefined;
356
+ const tl = timelines[key] as { kill?: () => void; clear?: () => void } | undefined;
346
357
  if (!tl) continue;
347
- if (tl.getChildren) {
348
- try {
349
- for (const child of tl.getChildren(true)) {
350
- if (typeof child.targets === "function") {
351
- for (const t of child.targets()) allTargets.push(t);
352
- }
353
- }
354
- } catch {}
355
- }
356
358
  try {
357
359
  // kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
358
360
  tl.clear?.();
@@ -362,29 +364,11 @@ export function applySoftReload(
362
364
  }
363
365
  }
364
366
 
365
- // Also reset elements carrying a GSAP-applied inline `transform` that the
366
- // timeline-children sweep above missed — a dragged element whose position
367
- // was a standalone `gsap.set` (never a timeline child), or one whose
368
- // keyframes were just removed (no longer in any timeline). Their last
369
- // `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
370
- // and the sweep above can't see it, so the element renders offset from its
371
- // source position (matching the overlay) until a full reload. The clear
372
- // below runs BEFORE the re-run, which re-applies the transform for any
373
- // element the new script still animates.
374
- const seenTargets = new Set<Element>(allTargets);
375
- for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
376
- // Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
377
- // never strip an authored, non-GSAP inline transform.
378
- if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
379
- seenTargets.add(el);
380
- allTargets.push(el);
381
- }
382
- }
383
-
384
367
  // Reset GSAP's internal transform cache so from() tweens don't read stale
385
368
  // end values. `clearProps: "all"` is needed to flush the cache, but it also
386
369
  // nukes the element's CSS base (position, width, height, etc.) from the
387
- // HTML `style=""` attribute. Save → clear → restore → authored transform props.
370
+ // HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
371
+ const allTargets = [...targets.keys()];
388
372
  if (allTargets.length > 0 && win.gsap?.set) {
389
373
  const saved: Array<[HTMLElement, string]> = [];
390
374
  for (const el of allTargets) {
@@ -399,7 +383,7 @@ export function applySoftReload(
399
383
  for (const [el, css] of saved) {
400
384
  const s = el.style;
401
385
  s.cssText = css;
402
- restoreAuthoredTransforms(s, findAuthoredStyle(el));
386
+ restoreAuthoredStyle(s, findAuthoredStyle(el), targets.get(el)!);
403
387
  // The restored cssText carries RUNTIME opacity, not authored opacity:
404
388
  // a mid-flight tween's interpolated value, or the color-grading hide
405
389
  // (`opacity: 0 !important`). The re-run script's tweens re-initialize
@@ -430,7 +414,14 @@ export function applySoftReload(
430
414
  }
431
415
  const s = doc.createElement("script");
432
416
  s.textContent = `(function(){${scriptText}\n})();`;
417
+ const ownScriptNode = 1;
418
+ const before = doc.body.querySelectorAll("*").length + ownScriptNode;
433
419
  doc.body.appendChild(s);
420
+ // A script that builds DOM builds it again on every run; only a full load starts from the file.
421
+ // Not seen: text-only writes, nodes put in the head or a shadow root, nodes added
422
+ // later, and a builder that removes its old nodes first (which needs no full load).
423
+ builtDom = doc.body.querySelectorAll("*").length > before;
424
+ if (builtDom && deferredToAsync) onAsyncFailure?.();
434
425
  finalizeSoftReload(win, currentTime);
435
426
  };
436
427
 
@@ -479,23 +470,47 @@ export function applySoftReload(
479
470
  executeScript();
480
471
  };
481
472
 
482
- try {
483
- if (win.__hfSuppressSceneMutations) {
484
- win.__hfSuppressSceneMutations(doReload);
485
- } else {
486
- doReload();
487
- }
488
- // When MotionPath needs async loading, the script hasn't executed yet —
489
- // skip the __timelines check and report success optimistically (the script
490
- // WILL run on plugin load; onAsyncFailure covers the CDN-error case).
491
- if (deferredToAsync) return "applied";
492
- // The re-run executed. If the target keys read back, we're done; otherwise
493
- // it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
494
- // as "verify-failed" so callers know NOT to escalate.
495
- return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
496
- } catch {
497
- // The synchronous re-run threw — the preview is now genuinely broken (target
498
- // timeline killed, script not re-registered). Escalate to a full reload.
499
- return "cannot-soft-reload";
500
- }
473
+ const run = () => runSuppressed(win, doReload);
474
+ // The synchronous re-run threw or built DOM a second time: the preview is now
475
+ // genuinely broken (no timeline, or duplicated nodes). Escalate to a full reload.
476
+ if (!run() || builtDom) return "cannot-soft-reload";
477
+ // When MotionPath needs async loading, the script hasn't executed yet —
478
+ // skip the __timelines check and report success optimistically (the script
479
+ // WILL run on plugin load; onAsyncFailure covers the CDN-error case).
480
+ if (deferredToAsync) return "applied";
481
+ // The re-run executed. If the target keys read back, we're done; otherwise
482
+ // it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
483
+ // as "verify-failed" so callers know NOT to escalate.
484
+ return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
485
+ }
486
+
487
+ /** Other composition files for a soft reload: undefined when none are needed, null when a read failed. */
488
+ export type NestedFiles = Map<string, string> | null | undefined;
489
+
490
+ export async function settleNestedReads(
491
+ reads: Promise<Map<string, string>> | null,
492
+ ): Promise<NestedFiles> {
493
+ return reads ? reads.catch(() => null) : undefined;
494
+ }
495
+
496
+ /**
497
+ * Reads the composition files, other than the reloaded one, that the elements `scriptText`'s
498
+ * re-run resets are written in; null when there are none. Read them before the soft reload.
499
+ */
500
+ export function readNestedFiles(
501
+ iframe: HTMLIFrameElement | null,
502
+ scriptText: string,
503
+ readFile: (path: string) => Promise<string>,
504
+ ): Promise<Map<string, string>> | null {
505
+ const win = iframe?.contentWindow as IframeWindow | null;
506
+ const doc = iframe?.contentDocument;
507
+ if (!win || !doc) return null;
508
+ const keys = timelineKeys(scriptText);
509
+ const own = compositionFile(doc, keys);
510
+ const files = [...collectResetTargets(win, doc, keys).keys()].map(authoringFile);
511
+ const paths = [...new Set(files)].filter((file): file is string => file !== null && file !== own);
512
+ if (paths.length === 0) return null;
513
+ return Promise.all(paths.map((path) => readFile(path))).then(
514
+ (texts) => new Map(paths.map((path, i) => [path, texts[i]!])),
515
+ );
501
516
  }
@@ -170,9 +170,10 @@ describe("applyUndoRestoreToPreview", () => {
170
170
 
171
171
  describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
172
172
  const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
173
+ const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
173
174
  const undoScriptEdit = (live: string, authored: string, edit: string) => {
174
175
  const { iframe, contentWindow, doc } = buildLiveIframe(
175
- `${live}<script>${script(edit)}</script>`,
176
+ `${root(live)}<script>${script(edit)}</script>`,
176
177
  );
177
178
  const clearProps = (targets: HTMLElement[]) =>
178
179
  targets.forEach((t) => t.removeAttribute("style"));
@@ -180,8 +181,8 @@ describe("applyUndoRestoreToPreview", () => {
180
181
  for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
181
182
  const files = {
182
183
  [ROOT]: {
183
- previous: wrap(`${authored}<script>${script(edit)}</script>`),
184
- restored: wrap(`${authored}<script>${script("")}</script>`),
184
+ previous: wrap(`${root(authored)}<script>${script(edit)}</script>`),
185
+ restored: wrap(`${root(authored)}<script>${script("")}</script>`),
185
186
  },
186
187
  };
187
188
  expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
@@ -7,6 +7,7 @@ import {
7
7
  applySoftReloadFinalization,
8
8
  extractGsapScriptText,
9
9
  findGsapScriptElements,
10
+ readNestedFiles,
10
11
  } from "./gsapSoftReload";
11
12
  import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
12
13
  import { findAuthoredElement, parseSavedSource } from "./authoredSource";
@@ -324,6 +325,18 @@ export function showRestoreInPlace(
324
325
  return null;
325
326
  }
326
327
 
328
+ /** The other composition files a re-run of the restored script resets elements of; null when none. */
329
+ export function readUndoNestedFiles(
330
+ iframe: HTMLIFrameElement | null,
331
+ activeCompPath: string | null,
332
+ files: RestoreFiles | undefined,
333
+ readFile: (path: string) => Promise<string>,
334
+ ): Promise<Map<string, string>> | null {
335
+ const active = files?.[activeCompPath ?? "index.html"];
336
+ const script = active ? extractGsapScriptText(active.restored) : null;
337
+ return script ? readNestedFiles(iframe, script, readFile) : null;
338
+ }
339
+
327
340
  /**
328
341
  * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
329
342
  * remount (which blanks the frame black and re-flashes the WebGL context). Eligible
@@ -358,6 +371,7 @@ export function applyUndoRestoreToPreview(
358
371
  files: RestoreFiles | undefined,
359
372
  currentTime: number,
360
373
  reload: () => void,
374
+ nestedFiles?: Map<string, string> | null,
361
375
  ): "soft" | "full" {
362
376
  // The master view carries a NULL activeCompPath but the root iframe shows
363
377
  // index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
@@ -398,6 +412,7 @@ export function applyUndoRestoreToPreview(
398
412
  currentTimeOverride: currentTime,
399
413
  authoredHtml: active.restored,
400
414
  reparse,
415
+ nestedFiles,
401
416
  });
402
417
  if (result === "cannot-soft-reload") {
403
418
  reloadPreview();
@@ -0,0 +1,34 @@
1
+ import { create } from "zustand";
2
+ import { STUDIO_PREVIEW_FPS } from "../player/lib/time";
3
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
4
+
5
+ interface LinkedClipPreferences {
6
+ /** Premiere's Linked Selection: clicks and edits reach every link partner. */
7
+ linkedSelection: boolean;
8
+ setLinkedSelection: (enabled: boolean) => void;
9
+ /** Red offset badges on a video and audio from one source that drifted apart. */
10
+ syncIndicatorsVisible: boolean;
11
+ setSyncIndicatorsVisible: (visible: boolean) => void;
12
+ compositionFps: number;
13
+ setCompositionFps: (fps: number) => void;
14
+ }
15
+
16
+ export const useLinkedClipPreferences = create<LinkedClipPreferences>((set) => ({
17
+ linkedSelection: readStudioUiPreferences().linkedSelectionEnabled ?? true,
18
+ setLinkedSelection: (enabled) => {
19
+ writeStudioUiPreferences({ linkedSelectionEnabled: enabled });
20
+ set({ linkedSelection: enabled });
21
+ },
22
+ syncIndicatorsVisible: readStudioUiPreferences().syncIndicatorsVisible ?? true,
23
+ setSyncIndicatorsVisible: (visible) => {
24
+ writeStudioUiPreferences({ syncIndicatorsVisible: visible });
25
+ set({ syncIndicatorsVisible: visible });
26
+ },
27
+ compositionFps: STUDIO_PREVIEW_FPS,
28
+ setCompositionFps: (fps) => {
29
+ if (Number.isFinite(fps) && fps > 0) set({ compositionFps: fps });
30
+ },
31
+ }));
32
+
33
+ export const isLinkedSelectionOn = (): boolean =>
34
+ useLinkedClipPreferences.getState().linkedSelection;