@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
@@ -30,6 +30,26 @@ import {
30
30
  } from "../utils/authoredSource";
31
31
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
32
32
 
33
+ type InPlace = { inPlace?: boolean };
34
+
35
+ const pasteStart = (
36
+ payload: Extract<ClipboardPayload, { kind: "timeline-clip" }>,
37
+ targetPath: string,
38
+ playhead: number,
39
+ options?: InPlace,
40
+ ) =>
41
+ options?.inPlace && payload.copiedInComposition === targetPath
42
+ ? Math.min(...payload.clips.map((clip) => clip.start))
43
+ : playhead;
44
+
45
+ const pasteWords = (payload: ClipboardPayload) =>
46
+ payload.kind === "timeline-clip"
47
+ ? {
48
+ label: clipLabel("Paste", payload.clips.length),
49
+ toast: clipToast("Pasted", payload.clips.length),
50
+ }
51
+ : { label: "Paste element", toast: "Pasted element" };
52
+
33
53
  interface RecordEditInput {
34
54
  label: string;
35
55
  coalesceKey?: string;
@@ -160,10 +180,17 @@ const hfIdsInFile = (content: string) =>
160
180
  export function pasteElementHtml(
161
181
  content: string,
162
182
  payload: { html: string; originSelector?: string; originSelectorIndex?: number },
183
+ fromThisFile = false,
163
184
  ): string {
164
185
  const reminted = remintHfIds(payload.html, new DOMParser(), hfIdsInFile(content));
165
186
  const deduped = deduplicateIds(reminted, collectHtmlIds(content));
166
- return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
187
+ const result = insertAsSibling(
188
+ content,
189
+ deduped,
190
+ payload.originSelector,
191
+ payload.originSelectorIndex,
192
+ );
193
+ return fromThisFile ? carryLook(result, renamedIds(reminted, deduped), 0) : result;
167
194
  }
168
195
 
169
196
  /** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
@@ -306,6 +333,7 @@ export function useClipboard({
306
333
  clips,
307
334
  sourceFile,
308
335
  projectId: projectIdRef.current ?? undefined,
336
+ copiedInComposition: activeCompPath || "index.html",
309
337
  };
310
338
  });
311
339
  }, [activeCompPath, findSelectedClips, readClips, showToast]);
@@ -324,6 +352,7 @@ export function useClipboard({
324
352
  kind: "dom-element",
325
353
  html: savedMarkupElseLive(parseSavedSource(content), live, sourceFile),
326
354
  sourceFile,
355
+ projectId: projectIdRef.current ?? undefined,
327
356
  originSelector: domSelection.selector,
328
357
  originSelectorIndex: domSelection.selectorIndex,
329
358
  };
@@ -361,134 +390,136 @@ export function useClipboard({
361
390
 
362
391
  // Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
363
392
  // fallow-ignore-next-line complexity
364
- const handlePaste = useCallback(async () => {
365
- const payload = await clipboardRef.current;
366
- if (!payload) {
367
- showToast("Nothing to paste.", "info");
368
- return;
369
- }
370
- const pid = projectIdRef.current;
371
- if (!pid) return;
372
-
373
- const targetPath = activeCompPath || "index.html";
374
- try {
375
- let pastedIds: string[] = [];
376
- const paste = (originalContent: string) => {
377
- if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
378
- const { currentTime, elements } = usePlayerStore.getState();
379
- const pasted = pasteTimelineClips(
380
- originalContent,
381
- payload.clips,
382
- currentTime,
383
- elements,
384
- payload.sourceFile === targetPath && payload.projectId === pid,
385
- );
386
- pastedIds = pasted.ids;
387
- // A clip pasted past the current composition end would exist in the
388
- // file but never appear on the timeline or in playback/export (the
389
- // root's data-duration is what actually bounds the render).
390
- return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
391
- };
392
-
393
- const label =
394
- payload.kind === "timeline-clip"
395
- ? clipLabel("Paste", payload.clips.length)
396
- : "Paste element";
397
-
398
- await saveProjectFilesWithHistory({
399
- projectId: pid,
400
- label,
401
- files: { [targetPath]: paste },
402
- readFile: (path) => readFileContent(pid, path),
403
- writeFile: writeProjectFile,
404
- recordEdit,
405
- });
406
-
407
- // CapCut: the pasted clip(s) become the selection; the playhead does not
408
- // move. reloadPreview is a bare refresh-key bump with no selection
409
- // snapshot of its own; calling setSelection before it is what makes the
410
- // post-reload store state land on the pasted ids instead of stale ones.
411
- if (pastedIds.length > 0) {
412
- usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
393
+ const handlePaste = useCallback(
394
+ async (options?: InPlace) => {
395
+ const payload = await clipboardRef.current;
396
+ if (!payload) {
397
+ showToast("Nothing to paste.", "info");
398
+ return;
413
399
  }
414
- reloadPreview();
415
- showToast(
416
- payload.kind === "timeline-clip"
417
- ? clipToast("Pasted", payload.clips.length)
418
- : "Pasted element",
419
- "info",
420
- );
421
- } catch (error) {
422
- const message = error instanceof Error ? error.message : "Failed to paste";
423
- showToast(message);
424
- }
425
- }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
400
+ const pid = projectIdRef.current;
401
+ if (!pid) return;
402
+
403
+ const targetPath = activeCompPath || "index.html";
404
+ try {
405
+ let pastedIds: string[] = [];
406
+ const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
407
+ const paste = (originalContent: string) => {
408
+ if (payload.kind !== "timeline-clip")
409
+ return pasteElementHtml(originalContent, payload, fromThisFile);
410
+ const { currentTime, elements } = usePlayerStore.getState();
411
+ const pasted = pasteTimelineClips(
412
+ originalContent,
413
+ payload.clips,
414
+ pasteStart(payload, targetPath, currentTime, options),
415
+ elements,
416
+ fromThisFile,
417
+ );
418
+ pastedIds = pasted.ids;
419
+ // A clip pasted past the current composition end would exist in the
420
+ // file but never appear on the timeline or in playback/export (the
421
+ // root's data-duration is what actually bounds the render).
422
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
423
+ };
426
424
 
427
- // Duplicates the current selection in place, immediately after it, without
428
- // touching the clipboard — a pending copy must survive a Cmd+D. Shares
429
- // pasteTimelineClips with handlePaste; only the anchor and clip source
430
- // differ (the selection's own end here, the playhead there).
431
- const handleDuplicate = useCallback(async (): Promise<boolean> => {
432
- const targets = findSelectedClips();
433
- if (!targets) return false;
434
- const pid = projectIdRef.current;
435
- if (!pid) return false;
436
-
437
- const { elements } = targets;
438
- const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
439
- const targetPath = pathOf(elements[0]!);
440
- // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
441
- const anchorTime = toAuthoredStart(
442
- elements[0]!,
443
- Math.max(...elements.map((el) => el.start + el.duration)),
444
- );
425
+ const { label, toast } = pasteWords(payload);
426
+
427
+ await saveProjectFilesWithHistory({
428
+ projectId: pid,
429
+ label,
430
+ files: { [targetPath]: paste },
431
+ readFile: (path) => readFileContent(pid, path),
432
+ writeFile: writeProjectFile,
433
+ recordEdit,
434
+ });
435
+
436
+ // CapCut: the pasted clip(s) become the selection; the playhead does not
437
+ // move. reloadPreview is a bare refresh-key bump with no selection
438
+ // snapshot of its own; calling setSelection before it is what makes the
439
+ // post-reload store state land on the pasted ids instead of stale ones.
440
+ if (pastedIds.length > 0) {
441
+ usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
442
+ }
443
+ reloadPreview();
444
+ showToast(toast, "info");
445
+ } catch (error) {
446
+ const message = error instanceof Error ? error.message : "Failed to paste";
447
+ showToast(message);
448
+ }
449
+ },
450
+ [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile],
451
+ );
445
452
 
446
- try {
447
- const clips = await readClips(targets);
448
- let ids: string[] = [];
449
- const duplicate = (originalContent: string) => {
450
- const liveElements = usePlayerStore
451
- .getState()
452
- .elements.filter((el) => pathOf(el) === targetPath)
453
- .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
454
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
455
- ids = pasted.ids;
456
- return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
457
- };
453
+ // Duplicates the selection right after it, or with `inPlace` at its own time on a track of its own,
454
+ // without touching the clipboard — a pending copy must survive a Cmd+D. Shares
455
+ // pasteTimelineClips with handlePaste; only the anchor and clip source differ (the
456
+ // selection's end or start here, the playhead or the copy's own time there).
457
+ const handleDuplicate = useCallback(
458
+ async (options?: InPlace): Promise<boolean> => {
459
+ const targets = findSelectedClips();
460
+ if (!targets) return false;
461
+ const pid = projectIdRef.current;
462
+ if (!pid) return false;
463
+
464
+ const { elements } = targets;
465
+ const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
466
+ const targetPath = pathOf(elements[0]!);
467
+ // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
468
+ const anchorTime = toAuthoredStart(
469
+ elements[0]!,
470
+ options?.inPlace
471
+ ? Math.min(...elements.map((el) => el.start))
472
+ : Math.max(...elements.map((el) => el.start + el.duration)),
473
+ );
458
474
 
459
- await saveProjectFilesWithHistory({
460
- projectId: pid,
461
- label: clipLabel("Duplicate", clips.length),
462
- files: { [targetPath]: duplicate },
463
- readFile: (path) => readFileContent(pid, path),
464
- writeFile: writeProjectFile,
465
- recordEdit,
466
- });
475
+ try {
476
+ const clips = await readClips(targets);
477
+ let ids: string[] = [];
478
+ const duplicate = (originalContent: string) => {
479
+ const liveElements = usePlayerStore
480
+ .getState()
481
+ .elements.filter((el) => pathOf(el) === targetPath)
482
+ .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
483
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
484
+ ids = pasted.ids;
485
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
486
+ };
467
487
 
468
- // The duplicate becomes the selection, mirroring CapCut's own paste
469
- // convention — there is no CapCut Duplicate to match directly (Cmd+D is
470
- // a no-op there); this is our own choice for consistency with paste.
471
- // Select before reloading, same reason as handlePaste above.
472
- if (ids.length > 0) {
473
- usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
488
+ await saveProjectFilesWithHistory({
489
+ projectId: pid,
490
+ label: clipLabel("Duplicate", clips.length),
491
+ files: { [targetPath]: duplicate },
492
+ readFile: (path) => readFileContent(pid, path),
493
+ writeFile: writeProjectFile,
494
+ recordEdit,
495
+ });
496
+
497
+ // The duplicate becomes the selection, mirroring CapCut's own paste
498
+ // convention — there is no CapCut Duplicate to match directly (Cmd+D is
499
+ // a no-op there); this is our own choice for consistency with paste.
500
+ // Select before reloading, same reason as handlePaste above.
501
+ if (ids.length > 0) {
502
+ usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
503
+ }
504
+ reloadPreview();
505
+ showToast(clipToast("Duplicated", clips.length), "info");
506
+ return true;
507
+ } catch (error) {
508
+ const message = error instanceof Error ? error.message : "Failed to duplicate";
509
+ showToast(message);
510
+ return false;
474
511
  }
475
- reloadPreview();
476
- showToast(clipToast("Duplicated", clips.length), "info");
477
- return true;
478
- } catch (error) {
479
- const message = error instanceof Error ? error.message : "Failed to duplicate";
480
- showToast(message);
481
- return false;
482
- }
483
- }, [
484
- activeCompPath,
485
- findSelectedClips,
486
- readClips,
487
- recordEdit,
488
- reloadPreview,
489
- showToast,
490
- writeProjectFile,
491
- ]);
512
+ },
513
+ [
514
+ activeCompPath,
515
+ findSelectedClips,
516
+ readClips,
517
+ recordEdit,
518
+ reloadPreview,
519
+ showToast,
520
+ writeProjectFile,
521
+ ],
522
+ );
492
523
 
493
524
  const handleCut = useCallback(async (): Promise<boolean> => {
494
525
  const selected = getSelectedElements();
@@ -271,12 +271,10 @@ export function useDomEditCommits({
271
271
 
272
272
  const {
273
273
  stageElementPositionOffset,
274
- handleDomPathOffsetCommit,
275
274
  handleDomBoxSizeCommit,
276
275
  handleDomRotationCommit,
277
276
  handleDomManualEditsReset,
278
277
  } = useDomGeometryCommits({
279
- previewIframeRef,
280
278
  showToast,
281
279
  commitPositionPatchToHtml,
282
280
  readOnlyPreview,
@@ -316,7 +314,6 @@ export function useDomEditCommits({
316
314
  handleDomRemoveTextField,
317
315
  stageElementPositionOffset,
318
316
  commitPositionPatchToHtml,
319
- handleDomPathOffsetCommit,
320
317
  handleDomBoxSizeCommit,
321
318
  handleDomRotationCommit,
322
319
  handleDomManualEditsReset,
@@ -9,6 +9,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import type { TimelineElement } from "../player";
11
11
  import type { UseDomEditSessionParams } from "./useDomEditSession";
12
+ import { makeSelection } from "./domSelectionTestHarness";
12
13
 
13
14
  const styleOp = (property: string, value: string): PatchOperation => ({
14
15
  type: "inline-style",
@@ -132,26 +133,30 @@ vi.mock("./useDomEditCommits", () => ({
132
133
  };
133
134
  },
134
135
  }));
136
+ const iframesHandedOn: Array<{ hook: string; iframe: unknown }> = [];
135
137
  vi.mock("./useDomSelection", () => ({
136
- useDomSelection: () => ({
137
- domEditSelection: domEditSelectionRef.current,
138
- domEditGroupSelections: [],
139
- domEditHoverSelection: null,
140
- activeGroupElement: null,
141
- domEditSelectionRef,
142
- domEditGroupSelectionsRef,
143
- setActiveGroupElement: vi.fn(),
144
- applyDomSelection: vi.fn(),
145
- clearDomSelection: vi.fn(),
146
- buildDomSelectionFromTarget: vi.fn(),
147
- resolveDomSelectionFromPreviewPoint: vi.fn(),
148
- resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
149
- updateDomEditHoverSelection: vi.fn(),
150
- buildDomSelectionForTimelineElement: vi.fn(),
151
- handleTimelineElementSelect: vi.fn(),
152
- refreshDomEditSelectionFromPreview: vi.fn(),
153
- applyMarqueeSelection: vi.fn(),
154
- }),
138
+ useDomSelection: ({ previewIframe }: { previewIframe: unknown }) => {
139
+ iframesHandedOn.push({ hook: "selection", iframe: previewIframe });
140
+ return {
141
+ domEditSelection: domEditSelectionRef.current,
142
+ domEditGroupSelections: [],
143
+ domEditHoverSelection: null,
144
+ activeGroupElement: null,
145
+ domEditSelectionRef,
146
+ domEditGroupSelectionsRef,
147
+ setActiveGroupElement: vi.fn(),
148
+ applyDomSelection: vi.fn(),
149
+ clearDomSelection: vi.fn(),
150
+ buildDomSelectionFromTarget: vi.fn(),
151
+ resolveDomSelectionFromPreviewPoint: vi.fn(),
152
+ resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
153
+ updateDomEditHoverSelection: vi.fn(),
154
+ buildDomSelectionForTimelineElement: vi.fn(),
155
+ handleTimelineElementSelect: vi.fn(),
156
+ refreshDomEditSelectionFromPreview: vi.fn(),
157
+ applyMarqueeSelection: vi.fn(),
158
+ };
159
+ },
155
160
  }));
156
161
  vi.mock("./useAskAgentModal", () => ({
157
162
  useAskAgentModal: () => ({
@@ -208,33 +213,36 @@ vi.mock("./useGroupCommits", () => ({
208
213
  }),
209
214
  }));
210
215
  vi.mock("./useDomEditWiring", () => ({
211
- useDomEditWiring: () => ({
212
- onClickToSource: vi.fn(),
213
- selectedGsapAnimations: [],
214
- gsapMultipleTimelines: false,
215
- gsapUnsupportedTimelinePattern: false,
216
- trackGsapInteractionFailure: vi.fn(),
217
- makeFetchFallback: vi.fn(),
218
- handleGsapUpdateProperty: vi.fn(),
219
- handleGsapUpdateMeta: vi.fn(),
220
- handleGsapDeleteAnimation: vi.fn(),
221
- handleGsapDeleteAllForElement: vi.fn(),
222
- handleGsapAddAnimation: vi.fn(),
223
- handleGsapAddProperty: vi.fn(),
224
- handleGsapRemoveProperty: vi.fn(),
225
- handleGsapUpdateFromProperty: vi.fn(),
226
- handleGsapAddFromProperty: vi.fn(),
227
- handleGsapRemoveFromProperty: vi.fn(),
228
- handleGsapAddKeyframe: vi.fn(),
229
- handleGsapAddKeyframeBatch: vi.fn(),
230
- handleGsapRemoveKeyframe: vi.fn(),
231
- handleGsapMoveKeyframeToPlayhead: vi.fn(),
232
- handleGsapMoveKeyframe: vi.fn(),
233
- handleGsapResizeKeyframedTween: vi.fn(),
234
- handleGsapConvertToKeyframes: vi.fn(),
235
- handleGsapRemoveAllKeyframes: vi.fn(),
236
- handleResetSelectedElementKeyframes: vi.fn(),
237
- }),
216
+ useDomEditWiring: ({ previewIframe }: { previewIframe: unknown }) => {
217
+ iframesHandedOn.push({ hook: "wiring", iframe: previewIframe });
218
+ return {
219
+ onClickToSource: vi.fn(),
220
+ selectedGsapAnimations: [],
221
+ gsapMultipleTimelines: false,
222
+ gsapUnsupportedTimelinePattern: false,
223
+ trackGsapInteractionFailure: vi.fn(),
224
+ makeFetchFallback: vi.fn(),
225
+ handleGsapUpdateProperty: vi.fn(),
226
+ handleGsapUpdateMeta: vi.fn(),
227
+ handleGsapDeleteAnimation: vi.fn(),
228
+ handleGsapDeleteAllForElement: vi.fn(),
229
+ handleGsapAddAnimation: vi.fn(),
230
+ handleGsapAddProperty: vi.fn(),
231
+ handleGsapRemoveProperty: vi.fn(),
232
+ handleGsapUpdateFromProperty: vi.fn(),
233
+ handleGsapAddFromProperty: vi.fn(),
234
+ handleGsapRemoveFromProperty: vi.fn(),
235
+ handleGsapAddKeyframe: vi.fn(),
236
+ handleGsapAddKeyframeBatch: vi.fn(),
237
+ handleGsapRemoveKeyframe: vi.fn(),
238
+ handleGsapMoveKeyframeToPlayhead: vi.fn(),
239
+ handleGsapMoveKeyframe: vi.fn(),
240
+ handleGsapResizeKeyframedTween: vi.fn(),
241
+ handleGsapConvertToKeyframes: vi.fn(),
242
+ handleGsapRemoveAllKeyframes: vi.fn(),
243
+ handleResetSelectedElementKeyframes: vi.fn(),
244
+ };
245
+ },
238
246
  }));
239
247
  vi.mock("./usePreviewInteraction", () => ({
240
248
  usePreviewInteraction: () => ({
@@ -310,32 +318,9 @@ describe("onReorderShadow source filter", () => {
310
318
  describe("bulk segment ease commits", () => {
311
319
  it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
312
320
  const { useDomEditSession } = await import("./useDomEditSession");
313
- const selection: DomEditSelection = {
314
- id: "hero",
315
- element: document.createElement("div"),
316
- label: "Hero",
317
- tagName: "DIV",
318
- sourceFile: "index.html",
319
- compositionPath: "index.html",
320
- isCompositionHost: false,
321
- isInsideLockedComposition: false,
322
- boundingBox: { x: 0, y: 0, width: 100, height: 100 },
323
- textContent: null,
324
- dataAttributes: {},
325
- inlineStyles: {},
326
- computedStyles: {},
327
- textFields: [],
328
- capabilities: {
329
- canSelect: true,
330
- canEditStyles: true,
331
- canCrop: true,
332
- canMove: true,
333
- canResize: true,
334
- canApplyManualOffset: true,
335
- canApplyManualSize: true,
336
- canApplyManualRotation: true,
337
- },
338
- };
321
+ const hero = document.createElement("div");
322
+ hero.id = "hero";
323
+ const selection = makeSelection("Hero", hero);
339
324
  domEditSelectionRef.current = selection;
340
325
  gsapCommitMutation.mockClear();
341
326
  gsapCommitMutation.batch.mockClear();
@@ -601,3 +586,22 @@ describe("handleDomEditElementDelete routing", () => {
601
586
  expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
602
587
  });
603
588
  });
589
+
590
+ describe("a shadow reload promoted without a host re-render", () => {
591
+ it("hands the promoted iframe to the session's selection and preview sync", async () => {
592
+ const { useDomEditSession } = await import("./useDomEditSession");
593
+ const { announcePreviewPromoted } = await import("../player/sceneSwap");
594
+ const [host, promoted] = [0, 1].map(() => document.createElement("iframe"));
595
+ function Probe() {
596
+ useDomEditSession(createSessionParams({ previewIframe: host! }));
597
+ return null;
598
+ }
599
+ const root = createRoot(document.createElement("div"));
600
+ act(() => root.render(<Probe />));
601
+ act(() => announcePreviewPromoted({ retired: host!, live: promoted! }));
602
+ const last = (hook: string) => iframesHandedOn.findLast((seen) => seen.hook === hook)?.iframe;
603
+ expect(last("selection")).toBe(promoted);
604
+ expect(last("wiring")).toBe(promoted);
605
+ act(() => root.unmount());
606
+ });
607
+ });
@@ -24,6 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
24
24
  import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
26
  import type { DomEditTimelineParams } from "./useDomSelectionTypes";
27
+ import { useLivePreviewIframe } from "./useLivePreviewIframe";
27
28
  // Re-exported: the delete rule lives in its own module; callers and its test import it from here.
28
29
  export { membersForDelete };
29
30
 
@@ -86,14 +87,13 @@ export function useDomEditSession({
86
87
  importedFontAssetsRef,
87
88
  projectDir,
88
89
  projectIdRef,
89
- previewIframe,
90
+ previewIframe: hostPreviewIframe,
90
91
  refreshKey,
91
92
  previewDocumentVersion,
92
93
  rightPanelTab,
93
94
  applyStudioManualEditsToPreviewRef,
94
95
  syncPreviewHotkeys,
95
96
  reloadPreview,
96
- setRefreshKey: _setRefreshKey,
97
97
  openSourceForSelection,
98
98
  sdkSession,
99
99
  publishSdkSession,
@@ -103,7 +103,7 @@ export function useDomEditSession({
103
103
  }: UseDomEditSessionParams) {
104
104
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
105
105
  const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
106
- void _setRefreshKey;
106
+ const previewIframe = useLivePreviewIframe(hostPreviewIframe);
107
107
  const {
108
108
  domEditSelection,
109
109
  domEditGroupSelections,
@@ -173,29 +173,11 @@ export function useDomEditSession({
173
173
 
174
174
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
175
175
 
176
- // ── GSAP script commits ──
177
-
178
176
  const {
179
177
  commitMutation: gsapCommitMutation,
180
- updateGsapProperty,
181
- updateGsapMeta,
182
- deleteGsapAnimation,
183
- deleteAllForSelector,
184
- addGsapAnimation,
185
- addGsapProperty,
186
- removeGsapProperty,
187
- updateGsapFromProperty,
188
- addGsapFromProperty,
189
- removeGsapFromProperty,
190
- addKeyframe,
191
- addKeyframeBatch,
192
- removeKeyframe,
193
- moveKeyframe,
194
- resizeKeyframedTween,
195
- convertToKeyframes,
196
- removeAllKeyframes,
197
178
  setArcPath,
198
179
  updateArcSegment,
180
+ ...gsapScriptEdits
199
181
  } = useGsapScriptCommits({
200
182
  projectIdRef,
201
183
  activeCompPath,
@@ -211,8 +193,6 @@ export function useDomEditSession({
211
193
  forceReloadSdkSession,
212
194
  });
213
195
 
214
- // ── DOM commit handlers ──
215
-
216
196
  const {
217
197
  resolveImportedFontAsset,
218
198
  handleDomStyleCommit,
@@ -430,23 +410,7 @@ export function useDomEditSession({
430
410
  applyDomSelection,
431
411
  buildDomSelectionFromTarget,
432
412
  openSourceForSelection,
433
- updateGsapProperty,
434
- updateGsapMeta,
435
- deleteGsapAnimation,
436
- deleteAllForSelector,
437
- addGsapAnimation,
438
- addGsapProperty,
439
- removeGsapProperty,
440
- updateGsapFromProperty,
441
- addGsapFromProperty,
442
- removeGsapFromProperty,
443
- addKeyframe,
444
- addKeyframeBatch,
445
- removeKeyframe,
446
- moveKeyframe,
447
- resizeKeyframedTween,
448
- convertToKeyframes,
449
- removeAllKeyframes,
413
+ ...gsapScriptEdits,
450
414
  handleDomManualEditsReset,
451
415
  });
452
416
  const {
@@ -492,8 +456,8 @@ export function useDomEditSession({
492
456
  handleDomBoxSizeCommit,
493
457
  handleDomRotationCommit: handleDomCssRotationCommit,
494
458
  commitPositionPatchToHtml,
495
- addGsapAnimation,
496
- convertToKeyframes,
459
+ addGsapAnimation: gsapScriptEdits.addGsapAnimation,
460
+ convertToKeyframes: gsapScriptEdits.convertToKeyframes,
497
461
  setArcPath,
498
462
  updateArcSegment,
499
463
  });
@@ -168,7 +168,10 @@ describe("useDomGeometryCommit, from the package entry", () => {
168
168
  expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
169
169
  expect(calls.patches).toEqual([
170
170
  expect.objectContaining({
171
- operations: [{ type: "inline-style", property: "translate", value: "130.5px 90px" }],
171
+ operations: [
172
+ { type: "inline-style", property: "translate", value: "130.5px 90px" },
173
+ { type: "attribute", property: "data-hf-studio-original-inline-translate", value: "" },
174
+ ],
172
175
  }),
173
176
  ]);
174
177
  expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
@@ -111,7 +111,6 @@ export function useDomGeometryCommit({
111
111
  );
112
112
  const { stageElementPositionOffset, handleDomBoxSizeCommit, handleDomRotationCommit } =
113
113
  useDomGeometryCommits({
114
- previewIframeRef: iframeRef,
115
114
  showToast,
116
115
  commitPositionPatchToHtml: commitWithFreshQueue,
117
116
  readOnlyPreview: false,