@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -183,6 +183,159 @@ export interface SoftReloadOptions {
183
183
  authoredHtml?: string;
184
184
  }
185
185
 
186
+ /** One file's restore from the edit-history store: before (live) / after (target) bytes. */
187
+ export interface UndoRestoreFile {
188
+ previous: string;
189
+ restored: string;
190
+ }
191
+ function idElementMap(doc: Document): Map<string, Element> {
192
+ const map = new Map<string, Element>();
193
+ for (const el of doc.querySelectorAll("[id]")) {
194
+ const id = el.getAttribute("id");
195
+ if (id) map.set(id, el);
196
+ }
197
+ return map;
198
+ }
199
+
200
+ // Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that
201
+ // differ only in id'd attributes/inline-style/script text normalize equal; any
202
+ // residual difference is beyond soft-reload's reach → caller full-reloads.
203
+ function normalizeSoftResidual(doc: Document): void {
204
+ for (const el of doc.querySelectorAll("[id]")) {
205
+ const id = el.getAttribute("id");
206
+ for (const name of [...el.getAttributeNames()]) {
207
+ if (name !== "id") el.removeAttribute(name);
208
+ }
209
+ if (id) el.setAttribute("id", id);
210
+ }
211
+ for (const script of findGsapScriptElements(doc)) script.textContent = "";
212
+ }
213
+
214
+ // Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline
215
+ // style and/or the GSAP script; returns the changed ids to sync onto the live
216
+ // DOM. Structural/text diffs → null → the caller full-reloads. Pure.
217
+ export function diffSoftReloadableRestore(
218
+ previous: string,
219
+ restored: string,
220
+ ): { changedElementIds: string[] } | null {
221
+ let prevDoc: Document;
222
+ let nextDoc: Document;
223
+ try {
224
+ prevDoc = new DOMParser().parseFromString(previous, "text/html");
225
+ nextDoc = new DOMParser().parseFromString(restored, "text/html");
226
+ } catch {
227
+ return null;
228
+ }
229
+ const prevById = idElementMap(prevDoc);
230
+ const nextById = idElementMap(nextDoc);
231
+ // A different id set means an element was added or removed (e.g. a split, a
232
+ // delete) — structural, so soft-reload can't express it.
233
+ if (prevById.size !== nextById.size) return null;
234
+ const changedElementIds: string[] = [];
235
+ for (const [id, nextEl] of nextById) {
236
+ const prevEl = prevById.get(id);
237
+ if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
238
+ // A change inside the element (text / children) is out of soft scope; only
239
+ // its own attributes may differ. (GSAP scripts are handled via re-run.)
240
+ if (prevEl.innerHTML !== nextEl.innerHTML) return null;
241
+ if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id);
242
+ }
243
+ // Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed.
244
+ normalizeSoftResidual(prevDoc);
245
+ normalizeSoftResidual(nextDoc);
246
+ if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
247
+ return { changedElementIds };
248
+ }
249
+
250
+ /** Copy every attribute from `source` onto the live `target`, dropping extras. */
251
+ function syncElementAttributes(target: Element, source: Element): void {
252
+ for (const name of [...target.getAttributeNames()]) {
253
+ if (!source.hasAttribute(name)) target.removeAttribute(name);
254
+ }
255
+ for (const name of source.getAttributeNames()) {
256
+ target.setAttribute(name, source.getAttribute(name) ?? "");
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
262
+ * remount (which blanks the frame black and re-flashes the WebGL context). Only
263
+ * the active composition — the document living in the root iframe — is eligible;
264
+ * a sub-comp or multi-file restore falls back to `reloadPreview`.
265
+ *
266
+ * The restore is soft-applied when its only differences are id'd-element
267
+ * attributes / inline-style and/or the GSAP script (see diffSoftReloadableRestore):
268
+ * 1. Each changed element's attribute surface (inline style, data-start /
269
+ * -duration, the studio manual-offset props + flags) is synced onto the live
270
+ * element — so a canvas-position revert lands on the live DOM the runtime's
271
+ * seek-reapply reads from, not just on disk.
272
+ * 2. The restored GSAP script is re-run in place via applySoftReload, which
273
+ * re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits.
274
+ * With no single script, the manual-edit reapply is invoked directly.
275
+ *
276
+ * Returns "soft" when applied in place, "full" when it escalated to reloadPreview
277
+ * (ineligible restore, missing target, or a permanent soft-reload failure).
278
+ */
279
+ export function applyUndoRestoreToPreview(
280
+ iframe: HTMLIFrameElement | null,
281
+ activeCompPath: string | null,
282
+ files: Record<string, UndoRestoreFile> | undefined,
283
+ currentTime: number,
284
+ reloadPreview: () => void,
285
+ ): "soft" | "full" {
286
+ const paths = files ? Object.keys(files) : [];
287
+ // Soft path only covers the single active-comp document in the root iframe.
288
+ if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) {
289
+ reloadPreview();
290
+ return "full";
291
+ }
292
+ const doc = iframe.contentDocument;
293
+ const win = iframe.contentWindow as IframeWindow | null;
294
+ if (!doc || !win) {
295
+ reloadPreview();
296
+ return "full";
297
+ }
298
+ const { previous, restored } = files[activeCompPath]!;
299
+ const diff = diffSoftReloadableRestore(previous, restored);
300
+ if (!diff) {
301
+ reloadPreview();
302
+ return "full";
303
+ }
304
+
305
+ // Sync each changed element's attributes onto the live DOM from the restored
306
+ // markup, so the runtime's seek-reapply (which reads inline offset props off
307
+ // the live element) folds the REVERTED values, not the stale current ones.
308
+ const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html"));
309
+ for (const id of diff.changedElementIds) {
310
+ const liveEl = doc.getElementById(id);
311
+ const restoredEl = restoredById.get(id);
312
+ if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl);
313
+ }
314
+
315
+ const script = extractGsapScriptText(restored);
316
+ if (script) {
317
+ const result = applySoftReload(iframe, script, {
318
+ onAsyncFailure: reloadPreview,
319
+ currentTimeOverride: currentTime,
320
+ });
321
+ if (result === "cannot-soft-reload") {
322
+ reloadPreview();
323
+ return "full";
324
+ }
325
+ return "soft";
326
+ }
327
+ // No single GSAP script to re-run — the change was pure attribute/style. Re-fold
328
+ // manual edits and hold the playhead so the synced attributes take visible effect.
329
+ try {
330
+ win.__player?.seek?.(currentTime);
331
+ win.__hfStudioManualEditsApply?.();
332
+ } catch {
333
+ reloadPreview();
334
+ return "full";
335
+ }
336
+ return "soft";
337
+ }
338
+
186
339
  export function applySoftReload(
187
340
  iframe: HTMLIFrameElement | null,
188
341
  scriptText: string,
@@ -1,9 +1,10 @@
1
- export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
1
+ export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
2
2
  export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3
3
  export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
4
4
  export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
5
5
  export const LUT_EXT = /\.cube$/i;
6
- export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
6
+ export const MEDIA_EXT =
7
+ /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
7
8
 
8
9
  export function isMediaFile(path: string): boolean {
9
10
  return MEDIA_EXT.test(path);
@@ -0,0 +1,55 @@
1
+ // Resize/gesture diagnostics — grep [hf-resize]. Off by default; opt in per
2
+ // session with `localStorage.setItem("hf-resize-debug", "1")` (then reload).
3
+ // Granular per-move/per-gesture tracing that complements the always-on
4
+ // [hf-commit] transaction telemetry in gestureTransaction.ts.
5
+ let moveN = 0;
6
+ let enabled: boolean | null = null;
7
+
8
+ function isEnabled(): boolean {
9
+ if (enabled === null) {
10
+ try {
11
+ enabled = localStorage.getItem("hf-resize-debug") === "1";
12
+ } catch {
13
+ enabled = false;
14
+ }
15
+ }
16
+ return enabled;
17
+ }
18
+
19
+ export function logResize(stage: string, data: Record<string, unknown>): void {
20
+ if (!isEnabled()) return;
21
+ console.log(
22
+ `[hf-resize] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...data })}`,
23
+ );
24
+ }
25
+
26
+ /** Per-pointermove logging, throttled: first move then every 8th. */
27
+ export function logResizeMove(data: Record<string, unknown>): void {
28
+ if (!isEnabled()) return;
29
+ moveN += 1;
30
+ if (moveN % 8 === 1) logResize("move", { n: moveN, ...data });
31
+ }
32
+
33
+ export function resetResizeMoveLog(): void {
34
+ moveN = 0;
35
+ }
36
+
37
+ /** Snapshot the element's live geometry now and again after 200ms (jump detector). */
38
+ export function logResizeSettle(el: HTMLElement, tag: string): void {
39
+ if (!isEnabled()) return;
40
+ const snap = (phase: string) => {
41
+ const r = el.getBoundingClientRect();
42
+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
43
+ logResize("settle", {
44
+ tag,
45
+ phase,
46
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
47
+ cssW: cs?.width,
48
+ cssH: cs?.height,
49
+ transform: cs?.transform,
50
+ inlineStyle: el.getAttribute("style"),
51
+ });
52
+ };
53
+ snap("t0");
54
+ setTimeout(() => snap("t200"), 200);
55
+ }
@@ -1,5 +1,10 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, expect, it } from "vitest";
2
- import { extendRootDurationInSource } from "./rootDuration";
3
+ import {
4
+ extendRootDurationInSource,
5
+ patchRootCompositionDuration,
6
+ readRootCompositionDuration,
7
+ } from "./rootDuration";
3
8
 
4
9
  describe("extendRootDurationInSource", () => {
5
10
  it("extends data-duration when the new end is bigger than the root duration", () => {
@@ -31,4 +36,88 @@ describe("extendRootDurationInSource", () => {
31
36
  expect(patched).toContain(`<div data-duration="3"></div>`);
32
37
  expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
33
38
  });
39
+
40
+ // Reviewer round-2 finding #3: the old regex was attribute-ORDER-dependent and
41
+ // double-quotes-only, so these hand-authored variants silently no-op'd.
42
+ it("extends when data-duration is declared BEFORE data-composition-id", () => {
43
+ const source = `<div data-duration="4" data-composition-id="main"></div>`;
44
+ expect(extendRootDurationInSource(source, 9)).toBe(
45
+ `<div data-duration="9" data-composition-id="main"></div>`,
46
+ );
47
+ });
48
+
49
+ it("extends when attributes use single quotes", () => {
50
+ const source = `<div data-composition-id='main' data-duration='4'></div>`;
51
+ expect(extendRootDurationInSource(source, 9)).toBe(
52
+ `<div data-composition-id='main' data-duration='9'></div>`,
53
+ );
54
+ });
55
+
56
+ it("extends with swapped order AND single quotes AND extra whitespace", () => {
57
+ const source = `<div data-duration = '4' data-composition-id = 'main' >x</div>`;
58
+ expect(extendRootDurationInSource(source, 9)).toBe(
59
+ `<div data-duration = '9' data-composition-id = 'main' >x</div>`,
60
+ );
61
+ });
62
+ });
63
+
64
+ describe("readRootCompositionDuration", () => {
65
+ it("reads the root duration regardless of attribute order or quote style", () => {
66
+ expect(
67
+ readRootCompositionDuration(`<div data-composition-id="main" data-duration="4"></div>`),
68
+ ).toBe(4);
69
+ expect(
70
+ readRootCompositionDuration(`<div data-duration="4" data-composition-id="main"></div>`),
71
+ ).toBe(4);
72
+ expect(
73
+ readRootCompositionDuration(`<div data-composition-id='main' data-duration='4.5'></div>`),
74
+ ).toBe(4.5);
75
+ });
76
+
77
+ it("reads the FIRST composition when several are present", () => {
78
+ const source = [
79
+ `<div data-composition-id="root" data-duration="10"></div>`,
80
+ `<div data-composition-id="nested" data-duration="2"></div>`,
81
+ ].join("\n");
82
+ expect(readRootCompositionDuration(source)).toBe(10);
83
+ });
84
+
85
+ it("returns null when there is no composition root", () => {
86
+ expect(readRootCompositionDuration(`<div data-duration="4"></div>`)).toBeNull();
87
+ });
88
+
89
+ it("returns null when the root has no data-duration attribute", () => {
90
+ expect(readRootCompositionDuration(`<div data-composition-id="main"></div>`)).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe("patchRootCompositionDuration", () => {
95
+ it("rewrites only the root's data-duration value, preserving surrounding bytes", () => {
96
+ const source = [
97
+ `<!doctype html>`,
98
+ `<div data-composition-id="main" data-duration="4" data-width="640">`,
99
+ ` <img src="a.png" data-duration="3" />`,
100
+ `</div>`,
101
+ ].join("\n");
102
+ const patched = patchRootCompositionDuration(source, "8");
103
+ expect(patched).toBe(
104
+ [
105
+ `<!doctype html>`,
106
+ `<div data-composition-id="main" data-duration="8" data-width="640">`,
107
+ ` <img src="a.png" data-duration="3" />`,
108
+ `</div>`,
109
+ ].join("\n"),
110
+ );
111
+ });
112
+
113
+ it("keeps single-quote style when rewriting", () => {
114
+ expect(
115
+ patchRootCompositionDuration(`<div data-composition-id='main' data-duration='4'></div>`, "8"),
116
+ ).toBe(`<div data-composition-id='main' data-duration='8'></div>`);
117
+ });
118
+
119
+ it("is a no-op when the root has no data-duration attribute", () => {
120
+ const source = `<div data-composition-id="main"></div>`;
121
+ expect(patchRootCompositionDuration(source, "8")).toBe(source);
122
+ });
34
123
  });
@@ -1,17 +1,80 @@
1
1
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
2
2
 
3
- export function extendRootDurationInSource(source: string, newEnd: number): string {
4
- const rootDurMatch = source.match(
5
- /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
3
+ /**
4
+ * Matches the opening tag of the ROOT composition element — the first tag that
5
+ * carries a `data-composition-id` attribute, regardless of where the attribute
6
+ * sits in the tag or how its value is quoted. `[^>]*` keeps the match inside a
7
+ * single tag, so the first hit is the first composition in document order (the
8
+ * same element `doc.querySelector("[data-composition-id]")` resolves to).
9
+ */
10
+ const ROOT_COMPOSITION_OPEN_TAG_RE = /<[^>]*\bdata-composition-id(?=[\s=/>])[^>]*>/i;
11
+
12
+ /**
13
+ * Matches a `data-duration="..."` attribute inside a single opening tag. Quote
14
+ * style is captured (backreferenced), so both `"` and `'` round-trip, and the
15
+ * `\s*` around `=` tolerates author whitespace.
16
+ */
17
+ const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
18
+
19
+ /**
20
+ * Read the ROOT composition's raw `data-duration`.
21
+ *
22
+ * Parses the source with DOMParser and locates the root the same way the rest of
23
+ * the timeline code does — the first `[data-composition-id]` element in document
24
+ * order — then reads its `data-duration`. Because it works on the parsed tree,
25
+ * attribute order and quote style are irrelevant, unlike the previous
26
+ * order-dependent, double-quotes-only regex.
27
+ *
28
+ * Returns `null` when there is no root composition or the root has no
29
+ * `data-duration` attribute at all. When the attribute is present but not a
30
+ * number the parsed `NaN` is returned as-is, so callers reproduce the old
31
+ * regex's "attribute matched but value unusable" behavior.
32
+ *
33
+ * Deterministic and render-safe: DOMParser is the only DOM global used.
34
+ */
35
+ export function readRootCompositionDuration(source: string): number | null {
36
+ const root = new DOMParser()
37
+ .parseFromString(source, "text/html")
38
+ .querySelector("[data-composition-id]");
39
+ const raw = root?.getAttribute("data-duration");
40
+ if (raw == null) return null;
41
+ return Number.parseFloat(raw);
42
+ }
43
+
44
+ /**
45
+ * Rewrite the ROOT composition's `data-duration` value, preserving the rest of
46
+ * the document byte-for-byte.
47
+ *
48
+ * We deliberately do NOT re-serialize the parsed Document: a DOMParser round-trip
49
+ * injects `<html>/<head>/<body>`, forces double quotes, self-closes void
50
+ * elements, and drops the original indentation — it reformats unrelated markup.
51
+ * Instead we locate the root opening tag and rewrite only its `data-duration`
52
+ * value in place, keeping the author's quote style. The targeted splice is
53
+ * attribute-order-, quote-, and whitespace-agnostic.
54
+ *
55
+ * No-op (returns `source` unchanged) when there is no root composition tag or the
56
+ * root tag has no `data-duration` attribute to replace.
57
+ */
58
+ export function patchRootCompositionDuration(source: string, newValue: string): string {
59
+ const rootTag = ROOT_COMPOSITION_OPEN_TAG_RE.exec(source);
60
+ if (!rootTag) return source;
61
+ const patchedTag = rootTag[0].replace(
62
+ DATA_DURATION_ATTR_RE,
63
+ (_full, prefix: string, quote: string) => `${prefix}${quote}${newValue}${quote}`,
6
64
  );
7
- if (rootDurMatch) {
8
- const rootDur = parseFloat(rootDurMatch[2]!);
9
- if (newEnd > rootDur) {
10
- return source.replace(
11
- rootDurMatch[0],
12
- `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
13
- );
14
- }
15
- }
16
- return source;
65
+ if (patchedTag === rootTag[0]) return source;
66
+ return (
67
+ source.slice(0, rootTag.index) + patchedTag + source.slice(rootTag.index + rootTag[0].length)
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Grow-only ratchet: extend the root composition's `data-duration` to `newEnd`
73
+ * when `newEnd` is larger than the current root duration. No-op otherwise (and
74
+ * when there is no root duration to compare against).
75
+ */
76
+ export function extendRootDurationInSource(source: string, newEnd: number): string {
77
+ const current = readRootCompositionDuration(source);
78
+ if (current == null || !(newEnd > current)) return source;
79
+ return patchRootCompositionDuration(source, formatTimelineAttributeNumber(newEnd));
17
80
  }
@@ -18,6 +18,7 @@ export interface CutoverDeps {
18
18
  label: string;
19
19
  kind: EditHistoryKind;
20
20
  coalesceKey?: string;
21
+ coalesceMs?: number;
21
22
  files: Record<string, { before: string; after: string }>;
22
23
  }) => Promise<void>;
23
24
  };
@@ -84,6 +85,8 @@ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
84
85
  interface CutoverOptions {
85
86
  label?: string;
86
87
  coalesceKey?: string;
88
+ /** Coalesce window (ms); Infinity folds across a slow round-trip. */
89
+ coalesceMs?: number;
87
90
  /** Skip the preview reload (mirrors the server path's skipRefresh). */
88
91
  skipRefresh?: boolean;
89
92
  }
@@ -110,6 +113,7 @@ export async function persistSdkSerialize(
110
113
  label: options?.label ?? "Edit layer",
111
114
  kind: "manual",
112
115
  ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
116
+ ...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}),
113
117
  files: { [targetPath]: { before: originalContent, after } },
114
118
  });
115
119
  if (deps.refresh) deps.refresh(after);
@@ -1,25 +1,55 @@
1
+ import type { MutableRefObject } from "react";
1
2
  import type { EditHistoryKind } from "./editHistory";
3
+ import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
4
+
5
+ export interface RecordEditInput {
6
+ label: string;
7
+ kind: EditHistoryKind;
8
+ coalesceKey?: string;
9
+ coalesceMs?: number;
10
+ files: Record<string, { before: string; after: string }>;
11
+ }
12
+
13
+ export interface DomEditCommitBaseParams {
14
+ activeCompPath: string | null;
15
+ showToast: (message: string, tone?: "error" | "info") => void;
16
+ writeProjectFile: (path: string, content: string) => Promise<void>;
17
+ domEditSaveTimestampRef: MutableRefObject<number>;
18
+ editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
19
+ projectIdRef: MutableRefObject<string | null>;
20
+ reloadPreview: () => void;
21
+ clearDomSelection: () => void;
22
+ }
2
23
 
3
24
  interface SaveProjectFilesWithHistoryInput {
4
25
  projectId: string;
5
26
  label: string;
6
27
  kind: EditHistoryKind;
7
28
  coalesceKey?: string;
29
+ coalesceMs?: number;
8
30
  files: Record<string, string>;
9
31
  readFile: (path: string) => Promise<string>;
10
32
  writeFile: (path: string, content: string) => Promise<void>;
11
- recordEdit: (entry: {
12
- label: string;
13
- kind: EditHistoryKind;
14
- coalesceKey?: string;
15
- files: Record<string, { before: string; after: string }>;
16
- }) => Promise<void>;
33
+ recordEdit: (entry: RecordEditInput) => Promise<void>;
34
+ }
35
+
36
+ export async function readProjectFileContent(pid: string, path: string): Promise<string> {
37
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
38
+ if (!response.ok) {
39
+ throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
40
+ }
41
+ const data = (await response.json()) as { content?: string };
42
+ if (typeof data.content !== "string") {
43
+ throw new Error(`Missing file contents for ${path}`);
44
+ }
45
+ return data.content;
17
46
  }
18
47
 
19
48
  export async function saveProjectFilesWithHistory({
20
49
  label,
21
50
  kind,
22
51
  coalesceKey,
52
+ coalesceMs,
23
53
  files,
24
54
  readFile,
25
55
  writeFile,
@@ -43,7 +73,7 @@ export async function saveProjectFilesWithHistory({
43
73
  writtenPaths.push(path);
44
74
  }
45
75
 
46
- await recordEdit({ label, kind, coalesceKey, files: snapshots });
76
+ await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots });
47
77
  } catch (error) {
48
78
  try {
49
79
  for (const path of writtenPaths.reverse()) {
@@ -308,3 +308,65 @@ export async function resolveDroppedAssetDuration(
308
308
  media.load();
309
309
  return duration;
310
310
  }
311
+
312
+ export async function resolveDroppedAssetDimensions(
313
+ projectId: string,
314
+ assetPath: string,
315
+ kind: TimelineAssetKind,
316
+ ): Promise<{ width: number; height: number } | null> {
317
+ if (kind === "audio") return null;
318
+ const src = `/api/projects/${projectId}/preview/${assetPath}`;
319
+
320
+ if (kind === "image") {
321
+ return new Promise((resolve) => {
322
+ const img = new Image();
323
+ const timeout = window.setTimeout(() => resolve(null), 3000);
324
+ img.addEventListener(
325
+ "load",
326
+ () => {
327
+ window.clearTimeout(timeout);
328
+ resolve(
329
+ img.naturalWidth > 0 && img.naturalHeight > 0
330
+ ? { width: img.naturalWidth, height: img.naturalHeight }
331
+ : null,
332
+ );
333
+ },
334
+ { once: true },
335
+ );
336
+ img.addEventListener(
337
+ "error",
338
+ () => {
339
+ window.clearTimeout(timeout);
340
+ resolve(null);
341
+ },
342
+ { once: true },
343
+ );
344
+ img.src = src;
345
+ });
346
+ }
347
+
348
+ return new Promise((resolve) => {
349
+ const video = document.createElement("video");
350
+ video.preload = "metadata";
351
+ const timeout = window.setTimeout(() => resolve(null), 3000);
352
+ const finalize = (value: { width: number; height: number } | null) => {
353
+ window.clearTimeout(timeout);
354
+ video.src = "";
355
+ video.load();
356
+ resolve(value);
357
+ };
358
+ video.addEventListener(
359
+ "loadedmetadata",
360
+ () => {
361
+ finalize(
362
+ video.videoWidth > 0 && video.videoHeight > 0
363
+ ? { width: video.videoWidth, height: video.videoHeight }
364
+ : null,
365
+ );
366
+ },
367
+ { once: true },
368
+ );
369
+ video.addEventListener("error", () => finalize(null), { once: true });
370
+ video.src = src;
371
+ });
372
+ }
@@ -25,6 +25,15 @@ function stubRect(el: Element, rect: DOMRect): void {
25
25
  el.getBoundingClientRect = () => rect;
26
26
  }
27
27
 
28
+ /** Create and attach a preview iframe, returning it with its (asserted) document. */
29
+ function createPreviewIframe(): { iframe: HTMLIFrameElement; doc: Document } {
30
+ const iframe = document.createElement("iframe");
31
+ document.body.append(iframe);
32
+ const doc = iframe.contentDocument;
33
+ if (!doc) throw new Error("Expected iframe document");
34
+ return { iframe, doc };
35
+ }
36
+
28
37
  describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
29
38
  const viewport = { width: 1920, height: 1080 };
30
39
 
@@ -106,10 +115,7 @@ describe("pauseStudioPreviewPlayback", () => {
106
115
 
107
116
  describe("getPreviewTargetFromPointer", () => {
108
117
  it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
109
- const iframe = document.createElement("iframe");
110
- document.body.append(iframe);
111
- const doc = iframe.contentDocument;
112
- if (!doc) throw new Error("Expected iframe document");
118
+ const { iframe, doc } = createPreviewIframe();
113
119
 
114
120
  doc.body.innerHTML = `
115
121
  <main id="scene" data-composition-id="scene">
@@ -141,10 +147,7 @@ describe("getPreviewTargetFromPointer", () => {
141
147
  });
142
148
 
143
149
  it("honors a CSS-class pointer-events:auto opt-in under a pointer-events:none ancestor", () => {
144
- const iframe = document.createElement("iframe");
145
- document.body.append(iframe);
146
- const doc = iframe.contentDocument;
147
- if (!doc) throw new Error("Expected iframe document");
150
+ const { iframe, doc } = createPreviewIframe();
148
151
 
149
152
  doc.head.innerHTML = `<style>.clickable { pointer-events: auto; }</style>`;
150
153
  doc.body.innerHTML = `
@@ -172,4 +175,58 @@ describe("getPreviewTargetFromPointer", () => {
172
175
 
173
176
  iframe.remove();
174
177
  });
178
+
179
+ it("selects a full-bleed <video> instead of skipping to the element behind it", () => {
180
+ const { iframe, doc } = createPreviewIframe();
181
+
182
+ doc.body.innerHTML = `
183
+ <main id="scene" data-composition-id="scene">
184
+ <div id="backdrop"></div>
185
+ <video id="hero"></video>
186
+ </main>
187
+ `;
188
+
189
+ const scene = doc.getElementById("scene");
190
+ const backdrop = doc.getElementById("backdrop");
191
+ const hero = doc.getElementById("hero");
192
+ if (!scene || !backdrop || !hero) throw new Error("Expected preview fixture elements");
193
+
194
+ stubRect(iframe, domRect(0, 0, 400, 300));
195
+ stubRect(scene, domRect(0, 0, 400, 300));
196
+ stubRect(backdrop, domRect(0, 0, 400, 300));
197
+ // Full-bleed hero video painted on top of a full-bleed backdrop.
198
+ stubRect(hero, domRect(0, 0, 400, 300));
199
+ doc.elementsFromPoint = () => [hero, backdrop, scene];
200
+
201
+ // Before the fix the video was full-bleed-excluded and the picker fell through
202
+ // to the backdrop (or null). It must now return the video itself.
203
+ expect(getPreviewTargetFromPointer(iframe, 200, 150, "index.html")).toBe(hero);
204
+
205
+ iframe.remove();
206
+ });
207
+
208
+ it("still excludes a full-bleed non-media container so clicks reach inner content", () => {
209
+ const { iframe, doc } = createPreviewIframe();
210
+
211
+ doc.body.innerHTML = `
212
+ <main id="scene" data-composition-id="scene">
213
+ <div id="wrapper"><h1 id="headline">Title</h1></div>
214
+ </main>
215
+ `;
216
+
217
+ const scene = doc.getElementById("scene");
218
+ const wrapper = doc.getElementById("wrapper");
219
+ const headline = doc.getElementById("headline");
220
+ if (!scene || !wrapper || !headline) throw new Error("Expected preview fixture elements");
221
+
222
+ stubRect(iframe, domRect(0, 0, 400, 300));
223
+ stubRect(scene, domRect(0, 0, 400, 300));
224
+ stubRect(wrapper, domRect(0, 0, 400, 300));
225
+ stubRect(headline, domRect(40, 40, 160, 48));
226
+ doc.elementsFromPoint = () => [headline, wrapper, scene];
227
+
228
+ expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
229
+
230
+ iframe.remove();
231
+ });
175
232
  });