@hyperframes/studio 0.8.104 → 0.8.106

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 (225) hide show
  1. package/dist/assets/{hyperframes-player-DAoaajuM.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/index-CMiYVGv5.js +399 -0
  3. package/dist/assets/{index-WV9LroAD.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-BDK4rTMb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-HIGHVWYD.js → chunk-ICTZJPQQ.js} +3 -2
  9. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  10. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  11. package/dist/chunk-NCUBAVGK.js.map +1 -0
  12. package/dist/index.css +6 -1
  13. package/dist/index.css.map +1 -1
  14. package/dist/index.d.ts +15 -4
  15. package/dist/index.html +11 -2
  16. package/dist/index.js +10391 -9836
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +2 -2
  19. package/dist/styles/tailwind-preset.js.map +1 -1
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +2 -2
  22. package/package.json +9 -9
  23. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  24. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  25. package/src/captions/components/CaptionTimeline.tsx +3 -3
  26. package/src/captions/keyboard.test.ts +6 -0
  27. package/src/captions/keyboard.ts +2 -1
  28. package/src/components/AskAgentModal.tsx +1 -1
  29. package/src/components/LintModal.tsx +35 -35
  30. package/src/components/StudioErrorBoundary.tsx +1 -1
  31. package/src/components/StudioHeader.dom.test.tsx +35 -13
  32. package/src/components/StudioHeader.tsx +6 -2
  33. package/src/components/StudioToast.tsx +7 -16
  34. package/src/components/ThemeToggle.tsx +99 -0
  35. package/src/components/TimelineToolPicker.tsx +1 -1
  36. package/src/components/TimelineToolbar.tsx +11 -11
  37. package/src/components/dock/Dock.tsx +1 -1
  38. package/src/components/dock/dock.css +9 -1
  39. package/src/components/editor/AnimationCard.tsx +5 -5
  40. package/src/components/editor/AnimationCardParts.tsx +1 -1
  41. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  42. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  43. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  44. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  45. package/src/components/editor/DomEditOverlay.tsx +37 -5
  46. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  47. package/src/components/editor/EaseCurveSection.tsx +14 -19
  48. package/src/components/editor/EaseModeControls.tsx +2 -2
  49. package/src/components/editor/EaseParamFields.tsx +8 -8
  50. package/src/components/editor/FileTree.tsx +1 -1
  51. package/src/components/editor/FileTreeIcons.tsx +47 -26
  52. package/src/components/editor/FileTreeNodes.tsx +11 -8
  53. package/src/components/editor/GestureRecordControl.tsx +3 -3
  54. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  55. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  56. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  57. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  58. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  59. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  60. package/src/components/editor/LayersPanel.tsx +3 -3
  61. package/src/components/editor/PromotableControl.tsx +2 -2
  62. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  63. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  64. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  65. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  66. package/src/components/editor/SnapToolbar.tsx +5 -5
  67. package/src/components/editor/SourceEditor.tsx +2 -6
  68. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  69. package/src/components/editor/Transform3DCube.tsx +1 -1
  70. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  71. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  72. package/src/components/editor/easeCurveSvg.tsx +1 -1
  73. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  74. package/src/components/editor/manualEditsDom.ts +36 -3
  75. package/src/components/editor/manualEditsDomPatches.test.ts +2 -86
  76. package/src/components/editor/manualEditsDomPatches.ts +0 -30
  77. package/src/components/editor/manualEditsSnapshot.ts +1 -0
  78. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  79. package/src/components/editor/marqueeCommit.ts +6 -1
  80. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  81. package/src/components/editor/overlayFrameLoop.ts +21 -0
  82. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  83. package/src/components/editor/plainTranslate.ts +34 -3
  84. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  85. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  86. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  87. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  88. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  89. package/src/components/editor/propertyPanelFill.tsx +6 -6
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  92. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  93. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  94. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  97. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  98. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  99. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  101. package/src/components/editor/propertyPanelFont.tsx +71 -48
  102. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  106. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  107. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  108. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  109. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  110. package/src/components/editor/propertyPanelSections.tsx +2 -2
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  114. package/src/components/editor/studioCodeTheme.ts +99 -0
  115. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  116. package/src/components/editor/useDomEditNudge.ts +2 -2
  117. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  118. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  119. package/src/components/editor/useMotionPathData.ts +5 -1
  120. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  121. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  122. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  123. package/src/components/nle/NLEPreview.test.ts +8 -4
  124. package/src/components/nle/PreviewOverlays.tsx +1 -1
  125. package/src/components/nle/PreviewPane.tsx +3 -3
  126. package/src/components/nle/PreviewPoster.tsx +29 -1
  127. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  128. package/src/components/panels/SlideshowPanel.tsx +4 -4
  129. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  130. package/src/components/panels/VariablesBindElement.tsx +1 -1
  131. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  132. package/src/components/panels/VariablesPanel.tsx +4 -6
  133. package/src/components/panels/VariablesRowAction.tsx +1 -1
  134. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  135. package/src/components/renders/RenderQueue.tsx +2 -2
  136. package/src/components/renders/RenderQueueItem.tsx +3 -3
  137. package/src/components/sidebar/AssetCard.tsx +3 -3
  138. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  139. package/src/components/sidebar/AssetsTab.tsx +6 -6
  140. package/src/components/sidebar/AudioRow.tsx +5 -3
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  143. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  144. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  145. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  148. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  149. package/src/components/timelineToolbarStyles.ts +3 -3
  150. package/src/components/ui/Button.tsx +4 -4
  151. package/src/components/ui/HyperframesLogo.tsx +19 -18
  152. package/src/components/ui/Input.tsx +1 -1
  153. package/src/components/ui/Menu.tsx +4 -2
  154. package/src/hooks/appHotkeysDispatch.ts +2 -1
  155. package/src/hooks/elementOffsetStager.ts +8 -7
  156. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  157. package/src/hooks/useAppHotkeys.test.ts +32 -0
  158. package/src/hooks/useAppHotkeys.ts +1 -2
  159. package/src/hooks/useClipboard.source.test.ts +141 -23
  160. package/src/hooks/useClipboard.ts +154 -123
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.test.tsx +76 -72
  163. package/src/hooks/useDomEditSession.ts +7 -43
  164. package/src/hooks/useDomGeometryCommit.test.tsx +4 -1
  165. package/src/hooks/useDomGeometryCommit.ts +0 -1
  166. package/src/hooks/useDomGeometryCommits.test.tsx +177 -32
  167. package/src/hooks/useDomGeometryCommits.ts +4 -49
  168. package/src/hooks/useGsapTweenCache.ts +1 -2
  169. package/src/hooks/useInlineTextEdit.ts +16 -2
  170. package/src/hooks/useLivePreviewIframe.ts +18 -0
  171. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  172. package/src/hooks/usePreviewPersistence.ts +2 -5
  173. package/src/hooks/useRenderClipContent.ts +3 -3
  174. package/src/index.ts +2 -0
  175. package/src/livePreviewIframeExport.test.tsx +33 -0
  176. package/src/main.tsx +4 -1
  177. package/src/player/components/AudioWaveform.test.tsx +36 -15
  178. package/src/player/components/AudioWaveform.tsx +24 -7
  179. package/src/player/components/ClipContextMenu.tsx +1 -1
  180. package/src/player/components/CompositionThumbnail.tsx +23 -11
  181. package/src/player/components/EditModal.tsx +6 -6
  182. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  183. package/src/player/components/PlayerControls.tsx +3 -3
  184. package/src/player/components/ShortcutsPanel.tsx +8 -20
  185. package/src/player/components/TimelineCanvas.tsx +1 -1
  186. package/src/player/components/TimelineClip.test.tsx +38 -0
  187. package/src/player/components/TimelineClip.tsx +16 -8
  188. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  189. package/src/player/components/TimelineClipFades.tsx +368 -137
  190. package/src/player/components/TimelineEmptyState.tsx +2 -2
  191. package/src/player/components/TimelineLanes.tsx +0 -1
  192. package/src/player/components/TrackClipCount.tsx +1 -1
  193. package/src/player/components/VolumeControl.tsx +1 -1
  194. package/src/player/components/timelineClipChildren.tsx +1 -1
  195. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  196. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  197. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  198. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  199. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  200. package/src/player/lib/automationStoreSync.test.ts +18 -1
  201. package/src/player/lib/automationStoreSync.ts +29 -17
  202. package/src/player/lib/thumbnailScheduler.ts +4 -5
  203. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  204. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  205. package/src/player/sceneSwap.ts +18 -0
  206. package/src/styles/components.css +26 -0
  207. package/src/styles/hex-baseline.json +7 -19
  208. package/src/styles/studio.css +4 -7
  209. package/src/styles/tailwind-preset.shared.js +2 -1
  210. package/src/styles/theme.css +214 -147
  211. package/src/styles/theme.test.ts +13 -9
  212. package/src/utils/blockCategories.ts +41 -9
  213. package/src/utils/clipboardPayload.ts +12 -1
  214. package/src/utils/studioPendingEdits.ts +21 -0
  215. package/src/utils/studioTheme.test.tsx +141 -0
  216. package/src/utils/studioTheme.ts +34 -0
  217. package/src/utils/studioUiPreferences.ts +3 -0
  218. package/src/utils/typingTarget.test.ts +8 -1
  219. package/src/utils/typingTarget.ts +5 -0
  220. package/dist/assets/index-CQN3M56N.css +0 -1
  221. package/dist/assets/index-De2aO4XM.js +0 -399
  222. package/dist/chunk-62DDWOSH.js.map +0 -1
  223. package/dist/chunk-6GAO75IU.js.map +0 -1
  224. package/dist/chunk-HIGHVWYD.js.map +0 -1
  225. package/src/hooks/gsapTargetCache.ts +0 -62
@@ -13,6 +13,9 @@ import {
13
13
  readStudioRotation,
14
14
  } from "../components/editor/manualEdits";
15
15
  import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
16
+ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
17
+ import { writeTranslatePx } from "../components/editor/plainTranslate";
18
+ import { applyPatch } from "../utils/sourcePatcher";
16
19
  import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
17
20
  import { withInlineLayoutBox } from "./domSelectionTestHarness";
18
21
 
@@ -25,7 +28,6 @@ function mountCommits(
25
28
  let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
26
29
  function Probe() {
27
30
  commits = useDomGeometryCommits({
28
- previewIframeRef: { current: null },
29
31
  showToast: vi.fn(),
30
32
  commitPositionPatchToHtml,
31
33
  readOnlyPreview,
@@ -61,7 +63,6 @@ describe("useDomGeometryCommits rollback", () => {
61
63
 
62
64
  function Probe() {
63
65
  commits = useDomGeometryCommits({
64
- previewIframeRef: { current: null },
65
66
  showToast: vi.fn(),
66
67
  commitPositionPatchToHtml,
67
68
  readOnlyPreview: false,
@@ -70,9 +71,6 @@ describe("useDomGeometryCommits rollback", () => {
70
71
  }
71
72
 
72
73
  act(() => root.render(<Probe />));
73
- await expect(commits!.handleDomPathOffsetCommit(selection, { x: 50, y: 60 })).rejects.toBe(
74
- failure,
75
- );
76
74
  await expect(
77
75
  commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
78
76
  ).rejects.toBe(failure);
@@ -90,20 +88,6 @@ describe("useDomGeometryCommits read-only preview", () => {
90
88
  const selectionOn = (element: HTMLElement) =>
91
89
  ({ id: element.id, selector: `#${element.id}`, element }) as unknown as DomEditSelection;
92
90
 
93
- it("refuses a manual offset commit: no write, no history entry", async () => {
94
- const element = document.createElement("div");
95
- element.id = "ro-offset";
96
- document.body.append(element);
97
- applyStudioPathOffset(element, { x: 1, y: 2 });
98
- const commitPositionPatchToHtml =
99
- vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
100
- const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
101
- await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 99, y: 99 });
102
- expect(readStudioPathOffset(element)).toEqual({ x: 1, y: 2 });
103
- expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
104
- unmount();
105
- });
106
-
107
91
  it("refuses a manual box-size commit: no write, no history entry", async () => {
108
92
  const element = document.createElement("div");
109
93
  element.id = "ro-size";
@@ -131,19 +115,6 @@ describe("useDomGeometryCommits read-only preview", () => {
131
115
  expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
132
116
  unmount();
133
117
  });
134
-
135
- it("still commits an offset with the flag off", async () => {
136
- const element = document.createElement("div");
137
- element.id = "rw-offset";
138
- document.body.append(element);
139
- const commitPositionPatchToHtml = vi
140
- .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
141
- .mockResolvedValue(undefined);
142
- const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
143
- await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 5, y: 6 });
144
- expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
145
- unmount();
146
- });
147
118
  });
148
119
 
149
120
  describe("useDomGeometryCommits element position offset", () => {
@@ -230,6 +201,180 @@ describe("useDomGeometryCommits resize rollback", () => {
230
201
  });
231
202
  });
232
203
 
204
+ describe("a plain move of an element an older Studio moved by its offset vars", () => {
205
+ function legacyMoved() {
206
+ const element = document.createElement("div");
207
+ element.id = "legacy";
208
+ element.style.setProperty("translate", "10px 5px");
209
+ document.body.append(element);
210
+ applyStudioPathOffset(element, { x: 40, y: 30 });
211
+ const selection = { id: "legacy", selector: "#legacy", element } as unknown as DomEditSelection;
212
+ return { element, selection };
213
+ }
214
+ const legacyEnds = [
215
+ { type: "inline-style", property: "--hf-studio-offset-x", value: null },
216
+ { type: "inline-style", property: "--hf-studio-offset-y", value: null },
217
+ { type: "attribute", property: "data-hf-studio-path-offset", value: null },
218
+ ];
219
+ const keptMarks = [
220
+ "data-hf-studio-original-inline-translate",
221
+ "data-hf-studio-original-translate",
222
+ ];
223
+ const studioMarks = (el: HTMLElement) =>
224
+ el
225
+ .getAttributeNames()
226
+ .filter((name) => name.startsWith("data-hf-studio"))
227
+ .sort();
228
+
229
+ it("saves the move as its translate, ends the old offset, and a seek keeps it", async () => {
230
+ const { element, selection } = legacyMoved();
231
+ const commitPositionPatchToHtml = vi
232
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
233
+ .mockResolvedValue(undefined);
234
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
235
+
236
+ await commits().stageElementPositionOffset(selection, { x: 140, y: 30 }, true).save();
237
+ reapplyPositionEditsAfterSeek(document);
238
+
239
+ expect(element.style.getPropertyValue("translate")).toBe("140px 30px");
240
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
241
+ { type: "inline-style", property: "translate", value: "140px 30px" },
242
+ {
243
+ type: "attribute",
244
+ property: "data-hf-studio-original-inline-translate",
245
+ value: "10px 5px",
246
+ },
247
+ ...legacyEnds,
248
+ ]);
249
+ expect(studioMarks(element)).toEqual(keptMarks);
250
+ unmount();
251
+ });
252
+
253
+ it("resizes with the anchor as its translate, ends the old offset, and a seek keeps it", async () => {
254
+ const { element, selection } = legacyMoved();
255
+ const commit = vi
256
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
257
+ .mockResolvedValue(undefined);
258
+ const { commits, unmount } = mountCommits(commit);
259
+
260
+ await commits().handleDomBoxSizeCommit(selection, { width: 80, height: 60 }, { x: 60, y: 40 });
261
+ reapplyPositionEditsAfterSeek(document);
262
+
263
+ expect(element.style.getPropertyValue("translate")).toBe("60px 40px");
264
+ expect(commit.mock.calls[0]![1]).toEqual(expect.arrayContaining(legacyEnds));
265
+ expect(studioMarks(element)).toEqual(expect.arrayContaining(keptMarks));
266
+ expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
267
+ unmount();
268
+ });
269
+
270
+ it.each(["move", "resize"])(
271
+ "puts the old offset back whole when the %s save fails",
272
+ async (kind) => {
273
+ const { element, selection } = legacyMoved();
274
+ const translate = element.style.getPropertyValue("translate");
275
+ const { commits, unmount } = mountCommits(
276
+ vi.fn().mockRejectedValue(new Error("save failed")),
277
+ );
278
+
279
+ const save =
280
+ kind === "move"
281
+ ? commits().stageElementPositionOffset(selection, { x: 140, y: 30 }, true).save()
282
+ : commits().handleDomBoxSizeCommit(
283
+ selection,
284
+ { width: 80, height: 60 },
285
+ { x: 60, y: 40 },
286
+ );
287
+ await expect(save).rejects.toThrow();
288
+ expect(element.style.getPropertyValue("translate")).toBe(translate);
289
+ expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(true);
290
+ expect(readStudioPathOffset(element)).toEqual({ x: 40, y: 30 });
291
+ unmount();
292
+ },
293
+ );
294
+ });
295
+
296
+ describe("Reset after a plain move puts the author's translate back", () => {
297
+ function authoredLayer(translate: string | null, legacy = false) {
298
+ const element = document.createElement("div");
299
+ element.id = "layer";
300
+ if (translate) element.style.setProperty("translate", translate);
301
+ document.body.append(element);
302
+ if (legacy) applyStudioPathOffset(element, { x: 40, y: 30 });
303
+ const project = { file: element.outerHTML, history: [] as string[] };
304
+ const commit = vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>(
305
+ async (_selection, patches) => {
306
+ project.history.push(project.file);
307
+ project.file = patches.reduce((html, op) => applyPatch(html, "layer", op), project.file);
308
+ },
309
+ );
310
+ const selection = { id: "layer", selector: "#layer", element } as unknown as DomEditSelection;
311
+ return { element, selection, project, ...mountCommits(commit) };
312
+ }
313
+ const saved = (html: string) =>
314
+ new DOMParser().parseFromString(html, "text/html").getElementById("layer")!;
315
+ const studioLeftovers = (el: Element) => [
316
+ ...el.getAttributeNames().filter((name) => name.startsWith("data-hf-studio")),
317
+ ...((el as HTMLElement).style.cssText.match(/--hf-studio-[\w-]+/g) ?? []),
318
+ ];
319
+
320
+ it("restores an older Studio's layer to its authored 50% 10px, in the file and live", async () => {
321
+ const { element, selection, project, commits, unmount } = authoredLayer("50% 10px", true);
322
+
323
+ await commits().stageElementPositionOffset(selection, { x: 140, y: 30 }, true).save();
324
+ await commits().handleDomManualEditsReset(selection);
325
+
326
+ expect(saved(project.file).style.getPropertyValue("translate")).toBe("50% 10px");
327
+ expect(element.style.getPropertyValue("translate")).toBe("50% 10px");
328
+ expect(studioLeftovers(saved(project.file))).toEqual([]);
329
+ expect(studioLeftovers(element)).toEqual([]);
330
+ unmount();
331
+ });
332
+
333
+ it("restores the authored 12px 4px after a drag and a second move", async () => {
334
+ const { element, selection, project, commits, unmount } = authoredLayer("12px 4px");
335
+
336
+ writeTranslatePx(element, { x: 90, y: 0 });
337
+ await commits().stageElementPositionOffset(selection, { x: 140, y: 30 }, true).save();
338
+ await commits().stageElementPositionOffset(selection, { x: 200, y: 50 }, true).save();
339
+ await commits().handleDomManualEditsReset(selection);
340
+
341
+ expect(saved(project.file).style.getPropertyValue("translate")).toBe("12px 4px");
342
+ expect(element.style.getPropertyValue("translate")).toBe("12px 4px");
343
+ expect(studioLeftovers(saved(project.file))).toEqual([]);
344
+ unmount();
345
+ });
346
+
347
+ it("removes the translate of a layer that had none, in the file and live", async () => {
348
+ const { element, selection, project, commits, unmount } = authoredLayer(null);
349
+
350
+ await commits().stageElementPositionOffset(selection, { x: 140, y: 30 }, true).save();
351
+ await commits().handleDomManualEditsReset(selection);
352
+
353
+ expect(saved(project.file).style.getPropertyValue("translate")).toBe("");
354
+ expect(element.style.getPropertyValue("translate")).toBe("");
355
+ expect(studioLeftovers(saved(project.file))).toEqual([]);
356
+ unmount();
357
+ });
358
+
359
+ it("undo of the Reset brings the move back, and Reset still restores the author's", async () => {
360
+ const { selection, project, commits, unmount } = authoredLayer("12px 4px");
361
+
362
+ await commits().stageElementPositionOffset(selection, { x: 140, y: 30 }, true).save();
363
+ const moved = project.file;
364
+ await commits().handleDomManualEditsReset(selection);
365
+ project.file = project.history.pop()!;
366
+ expect(project.file).toBe(moved);
367
+
368
+ const reloaded = saved(project.file) as HTMLElement;
369
+ document.body.append(reloaded);
370
+ expect(reloaded.style.getPropertyValue("translate")).toBe("140px 30px");
371
+ await commits().handleDomManualEditsReset({ ...selection, element: reloaded });
372
+ expect(saved(project.file).style.getPropertyValue("translate")).toBe("12px 4px");
373
+ expect(reloaded.style.getPropertyValue("translate")).toBe("12px 4px");
374
+ unmount();
375
+ });
376
+ });
377
+
233
378
  describe("useDomGeometryCommits resize of a cropped element", () => {
234
379
  it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
235
380
  const element = withInlineLayoutBox(document.createElement("div"));
@@ -2,7 +2,6 @@ import type { RotationCommit } from "../components/editor/rotationDraft";
2
2
  import { useCallback } from "react";
3
3
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
4
4
  import {
5
- applyStudioPathOffset,
6
5
  applyStudioBoxSize,
7
6
  captureStudioPathOffset,
8
7
  captureStudioBoxSize,
@@ -17,35 +16,18 @@ import {
17
16
  import { stageElementOffset } from "./elementOffsetStager";
18
17
  import { savePlainRotation } from "./plainRotation";
19
18
  import { prepareCropResize } from "../components/editor/cropResize";
20
- import { translatePatch, writeTranslatePx } from "../components/editor/plainTranslate";
19
+ import { writePlainMove } from "../components/editor/plainTranslate";
21
20
  import {
22
- buildPathOffsetPatches,
23
21
  buildBoxSizePatches,
24
22
  buildClearPathOffsetPatches,
25
23
  buildClearBoxSizePatches,
26
24
  buildClearRotationPatches,
27
25
  } from "../components/editor/manualEditsDomPatches";
28
26
  import type { PatchOperation } from "../utils/sourcePatcher";
29
- import { isElementGsapTargeted } from "./gsapTargetCache";
30
-
31
- const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
32
- "This element is GSAP-animated — dragging via CSS would corrupt keyframes";
33
-
34
- function rejectGsapCssFallback(
35
- selection: DomEditSelection,
36
- previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
37
- showToast: (message: string, tone?: "error" | "info") => void,
38
- ): Promise<never> | null {
39
- if (!isElementGsapTargeted(previewIframeRef.current, selection.element)) return null;
40
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
41
- showToast(error.message, "error");
42
- return Promise.reject(error);
43
- }
44
27
 
45
28
  // ── Hook ──
46
29
 
47
30
  export interface UseDomGeometryCommitsParams {
48
- previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
49
31
  showToast: (message: string, tone?: "error" | "info") => void;
50
32
  commitPositionPatchToHtml: (
51
33
  selection: DomEditSelection,
@@ -56,7 +38,6 @@ export interface UseDomGeometryCommitsParams {
56
38
  }
57
39
 
58
40
  export function useDomGeometryCommits({
59
- previewIframeRef,
60
41
  showToast,
61
42
  commitPositionPatchToHtml,
62
43
  readOnlyPreview,
@@ -78,28 +59,6 @@ export function useDomGeometryCommits({
78
59
  [commitPositionPatchToHtml, readOnlyPreview, showToast],
79
60
  );
80
61
 
81
- const handleDomPathOffsetCommit = useCallback(
82
- (selection: DomEditSelection, next: { x: number; y: number }) => {
83
- if (readOnlyPreview) return Promise.resolve();
84
- // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
85
- // elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
86
- // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
87
- // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
88
- const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
89
- if (gsapFallback) return gsapFallback;
90
- const before = captureStudioPathOffset(selection.element);
91
- applyStudioPathOffset(selection.element, next);
92
- return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
93
- label: "Move layer",
94
- coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
95
- }).catch((error) => {
96
- restoreStudioPathOffset(selection.element, before);
97
- throw error;
98
- });
99
- },
100
- [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
101
- );
102
-
103
62
  const handleDomBoxSizeCommit = useCallback(
104
63
  (
105
64
  selection: DomEditSelection,
@@ -110,7 +69,7 @@ export function useDomGeometryCommits({
110
69
  if (readOnlyPreview) return Promise.resolve();
111
70
  const element = selection.element;
112
71
  const beforeSize = captureStudioBoxSize(element);
113
- const beforeTranslate = element.style.getPropertyValue("translate");
72
+ const beforeOffset = captureStudioPathOffset(element);
114
73
  const stageCrop = prepareCropResize(element);
115
74
  applyStudioBoxSize(element, next);
116
75
  const crop = stageCrop();
@@ -118,16 +77,13 @@ export function useDomGeometryCommits({
118
77
  // (as a move writes it) that keeps the centre planted.
119
78
  const patches = buildBoxSizePatches(element);
120
79
  if (crop) patches.push(crop.patch);
121
- if (offset) {
122
- writeTranslatePx(element, offset);
123
- patches.push(translatePatch(offset));
124
- }
80
+ if (offset) patches.push(...writePlainMove(element, offset));
125
81
  return commitPositionPatchToHtml(selection, patches, {
126
82
  label: "Resize layer box",
127
83
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
128
84
  }).catch((error) => {
129
85
  restoreStudioBoxSize(element, beforeSize);
130
- if (offset) element.style.setProperty("translate", beforeTranslate);
86
+ if (offset) restoreStudioPathOffset(element, beforeOffset);
131
87
  crop?.revert();
132
88
  restore?.();
133
89
  throw error;
@@ -173,7 +129,6 @@ export function useDomGeometryCommits({
173
129
 
174
130
  return {
175
131
  stageElementPositionOffset,
176
- handleDomPathOffsetCommit,
177
132
  handleDomBoxSizeCommit,
178
133
  handleDomRotationCommit,
179
134
  handleDomManualEditsReset,
@@ -336,8 +336,7 @@ export function usePopulateKeyframeCacheForFile(
336
336
  // fallow-ignore-next-line complexity
337
337
  const tryRuntimeScan = () => {
338
338
  if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
339
- const iframe =
340
- iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
339
+ const iframe = iframeRef?.current;
341
340
  if (!iframe) return false;
342
341
  // Clip dims per element so the scan converts tween-relative keyframes to
343
342
  // clip-relative (matching the static path) instead of timeline-relative.
@@ -1,6 +1,12 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
3
  import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
4
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
5
+ import {
6
+ beginStudioManualEditGesture,
7
+ endStudioManualEditGesture,
8
+ noteStudioManualEditSave,
9
+ } from "../components/editor/manualEditsDom";
4
10
 
5
11
  /**
6
12
  * Editing an element's text where it sits, in the composition itself.
@@ -43,6 +49,8 @@ export interface InlineTextEditSession {
43
49
  outline: string;
44
50
  /** The element's own outline offset, restored with the outline. */
45
51
  outlineOffset: string;
52
+ /** Holds the preview on screen while the edit is open. */
53
+ gesture?: string;
46
54
  }
47
55
 
48
56
  /** The live markup to persist and the session snapshot to restore on failure. */
@@ -86,7 +94,7 @@ export function useInlineTextEdit({
86
94
  onPause,
87
95
  }: {
88
96
  /** Where the edited text goes. The caller owns persistence. */
89
- onCommit: (commit: InlineTextEditCommit) => void;
97
+ onCommit: (commit: InlineTextEditCommit) => unknown;
90
98
  /** Stop playback, so the element is not animating under the caret. */
91
99
  onPause?: () => void;
92
100
  }): InlineTextEditControls {
@@ -106,6 +114,7 @@ export function useInlineTextEdit({
106
114
  framesRef.current = null;
107
115
  }
108
116
  openRef.current = null;
117
+ endStudioManualEditGesture(open.element, open.gesture);
109
118
  setSession(null);
110
119
  // An element removed from the document mid-session is not an error, it is
111
120
  // just nothing left to clean up.
@@ -132,6 +141,7 @@ export function useInlineTextEdit({
132
141
  original: element.innerHTML,
133
142
  outline: element.style.outline,
134
143
  outlineOffset: element.style.outlineOffset,
144
+ gesture: beginStudioManualEditGesture(element),
135
145
  };
136
146
  // Drawn on the element itself, not in Studio's overlay above it. This is
137
147
  // the only mark that says the caret is in the TEXT rather than the
@@ -168,10 +178,14 @@ export function useInlineTextEdit({
168
178
  // saved rather than something the server will quietly cut down.
169
179
  sanitizeRichTextChildren(open.element);
170
180
  const html = open.element.innerHTML;
181
+ // Counted and pending before the edit closes, so a reload waits for the save, not the mark.
182
+ let save: (saving: unknown) => void = () => {};
183
+ trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
184
+ noteStudioManualEditSave(open.element);
171
185
  teardown();
172
186
  // After teardown, so the commit path's own resync does not fight an
173
187
  // element that is still editable.
174
- onCommit({ element: open.element, html, previousHtml: open.original });
188
+ save(onCommit({ element: open.element, html, previousHtml: open.original }));
175
189
  }, [onCommit, teardown]);
176
190
 
177
191
  const cancel = useCallback(() => {
@@ -0,0 +1,18 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { onPreviewPromoted } from "../player/sceneSwap";
3
+
4
+ /** The one source for which preview is on screen: the host's iframe, then each reload promoted in its place. */
5
+ export function useLivePreviewIframe(host: HTMLIFrameElement | null): HTMLIFrameElement | null {
6
+ const [promoted, setPromoted] = useState<{ host: HTMLIFrameElement; live: HTMLIFrameElement }>();
7
+ const live = host && promoted?.host === host ? promoted.live : host;
8
+ const liveRef = useRef(live);
9
+ liveRef.current = live;
10
+ // eslint-disable-next-line no-restricted-syntax
11
+ useEffect(() => {
12
+ if (!host) return;
13
+ return onPreviewPromoted(host.ownerDocument, ({ retired, live: next }) => {
14
+ if (retired === liveRef.current) setPromoted({ host, live: next });
15
+ });
16
+ }, [host]);
17
+ return live;
18
+ }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+ import type { TimelineElement } from "../player/store/timelineElement";
7
+ import { mountReactHarness } from "./domSelectionTestHarness";
8
+ import { usePreviewPersistence } from "./usePreviewPersistence";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ usePlayerStore.getState().reset();
15
+ });
16
+
17
+ describe("undo that reloads the preview", () => {
18
+ it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
19
+ const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
20
+ usePlayerStore.getState().setElements(clips);
21
+ usePlayerStore.getState().setTimelineReady(true);
22
+ const reloadPreview = vi.fn();
23
+ let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
24
+ null;
25
+ function Harness() {
26
+ sync = usePreviewPersistence({
27
+ showToast: () => {},
28
+ readOptionalProjectFile: async () => "",
29
+ writeProjectFile: async () => {},
30
+ recordEdit: async () => {},
31
+ previewIframeRef: { current: null },
32
+ activeCompPathRef: { current: "index.html" },
33
+ reloadPreview,
34
+ }).syncHistoryPreviewAfterApply;
35
+ return null;
36
+ }
37
+ mountReactHarness(<Harness />);
38
+
39
+ // A nested composition's undo is not the active file's, so it reloads the preview.
40
+ const files = { "compositions/sub.html": { previous: "<p>1</p>", restored: "<p>2</p>" } };
41
+ await act(async () => sync!({ paths: Object.keys(files), files }));
42
+
43
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
44
+ const player = usePlayerStore.getState();
45
+ expect([player.elements, player.timelineReady]).toEqual([clips, true]);
46
+ });
47
+ });
@@ -207,16 +207,13 @@ export function usePreviewPersistence({
207
207
  reloadPreview,
208
208
  );
209
209
  if (strategy === "full") {
210
- const player = usePlayerStore.getState();
211
- player.setElements([]);
212
- player.setSelectedElementId(null);
213
- player.setTimelineReady(false);
210
+ usePlayerStore.getState().setSelectedElementId(null);
214
211
  return;
215
212
  }
216
213
  // A soft restore patched the reverted attributes onto the live preview, but the
217
214
  // player store keeps its own copy and that copy is what the automation lanes
218
215
  // draw — so without this an undone envelope edit stayed invisible until a
219
- // reload. The full path above clears the store and waits for discovery instead.
216
+ // reload. The full path above waits for the reloaded preview to report instead.
220
217
  syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
221
218
  },
222
219
  [previewIframeRef, activeCompPathRef, reloadPreview],
@@ -9,7 +9,7 @@ import {
9
9
  import type { TimelineElement } from "../player";
10
10
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
11
11
  import { audioPillFlags } from "../player/components/audioClipLink";
12
- import { AudioWaveform } from "../player/components/AudioWaveform";
12
+ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
13
13
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
14
14
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
15
15
  import { usePlayerStore } from "../player/store/playerStore";
@@ -135,7 +135,7 @@ export function useRenderClipContent({
135
135
  // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
136
136
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
137
137
  if (effectiveMode === "hidden") {
138
- return el.tag === "audio"
138
+ return rendersWaveform(el)
139
139
  ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
140
140
  : null;
141
141
  }
@@ -173,7 +173,7 @@ export function useRenderClipContent({
173
173
  // Audio clips — waveform visualization. Resolve these before the generic
174
174
  // activePreviewUrl thumbnail branch; audio rows need waveform data, not a
175
175
  // captured frame from the currently drilled composition preview.
176
- if (el.tag === "audio") {
176
+ if (rendersWaveform(el)) {
177
177
  return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
178
178
  }
179
179
 
package/src/index.ts CHANGED
@@ -181,6 +181,8 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
181
181
  // DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
182
182
  export { useDomEditSession } from "./hooks/useDomEditSession";
183
183
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
184
+ export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
185
+ export type { PreviewPromotion } from "./player/sceneSwap";
184
186
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
185
187
  export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
186
188
  export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
@@ -0,0 +1,33 @@
1
+ // @vitest-environment happy-dom
2
+ // Reads the preview on screen the way a host app does: by package name.
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, expectTypeOf, it } from "vitest";
6
+ import { useLivePreviewIframe, type PreviewPromotion } from "@hyperframes/studio";
7
+ import {
8
+ makePreview,
9
+ mountPlayerWithPreview,
10
+ paintShadow,
11
+ } from "./player/hooks/timelinePlayerTestHarness";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ it("gives a host the preview on screen after a real shadow reload is promoted", async () => {
16
+ expectTypeOf<PreviewPromotion>().toHaveProperty("live");
17
+ const host = makePreview("<h1>Title</h1>");
18
+ const player = mountPlayerWithPreview(host);
19
+ let live: HTMLIFrameElement | null = null;
20
+ function Probe() {
21
+ live = useLivePreviewIframe(host);
22
+ return null;
23
+ }
24
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
25
+ act(() => root.render(<Probe />));
26
+ act(() => player.getApi().refreshPlayer());
27
+ const shadow = makePreview("<h1>Title</h1>", "?_t=1");
28
+ await paintShadow(player.getApi, shadow);
29
+ expect(player.getApi().iframeRef.current).toBe(shadow);
30
+ expect(live).toBe(shadow);
31
+ act(() => root.unmount());
32
+ act(() => player.root.unmount());
33
+ });
package/src/main.tsx CHANGED
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { IconContext } from "@phosphor-icons/react";
4
4
  import { StudioApp } from "./App";
5
+ import { ShowThemeToggle } from "./components/ThemeToggle";
5
6
  import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
6
7
  import { readIconTokens } from "./styles/iconTokens";
7
8
  import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -128,7 +129,9 @@ createRoot(document.getElementById("root")!).render(
128
129
  Icons that pass their own size or weight still win. */}
129
130
  <IconContext.Provider value={readIconTokens()}>
130
131
  <StudioErrorBoundary>
131
- <StudioApp />
132
+ <ShowThemeToggle.Provider value>
133
+ <StudioApp />
134
+ </ShowThemeToggle.Provider>
132
135
  </StudioErrorBoundary>
133
136
  </IconContext.Provider>
134
137
  </StrictMode>,