@hyperframes/studio 0.8.85 → 0.8.87

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 (95) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
  7. package/dist/chunk-7PLTC6SV.js.map +1 -0
  8. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  9. package/dist/chunk-PQFEGZWM.js.map +1 -0
  10. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  11. package/dist/index.d.ts +207 -161
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2084 -1705
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +1 -1
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.test.tsx +53 -2
  19. package/src/components/TimelineToolbar.tsx +119 -101
  20. package/src/components/editor/DomEditOverlay.tsx +1 -2
  21. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  23. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  24. package/src/components/editor/LayersPanel.test.ts +14 -0
  25. package/src/components/editor/LayersPanel.tsx +2 -2
  26. package/src/components/editor/TopologyLens.tsx +2 -2
  27. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  28. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  29. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  30. package/src/components/editor/domEditing.test.ts +29 -0
  31. package/src/components/editor/domEditingDom.ts +1 -0
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  34. package/src/components/editor/marqueeCommit.ts +13 -1
  35. package/src/components/editor/useColorGradingController.test.ts +24 -0
  36. package/src/components/editor/useColorGradingController.ts +3 -2
  37. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  38. package/src/components/nle/useCompositionStack.test.tsx +179 -0
  39. package/src/components/nle/useCompositionStack.ts +26 -20
  40. package/src/hooks/domStyleCommit.ts +129 -0
  41. package/src/hooks/gsapEditOutcome.ts +2 -2
  42. package/src/hooks/gsapElementMatch.ts +38 -0
  43. package/src/hooks/gsapResizeIntercept.ts +10 -31
  44. package/src/hooks/gsapResizePreflight.ts +56 -0
  45. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  46. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  47. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  48. package/src/hooks/useDomEditCommits.ts +17 -182
  49. package/src/hooks/useDomEditPersist.ts +225 -0
  50. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  51. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  52. package/src/hooks/useDomEditSession.test.tsx +51 -1
  53. package/src/hooks/useDomEditSession.ts +11 -2
  54. package/src/hooks/useDomEditTextCommits.ts +15 -99
  55. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  56. package/src/hooks/useDomStyleCommit.ts +74 -0
  57. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  59. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  60. package/src/hooks/useGsapAwareEditing.ts +1 -1
  61. package/src/hooks/useGsapTweenCache.ts +2 -48
  62. package/src/index.ts +10 -1
  63. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  64. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  65. package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
  66. package/src/player/components/Timeline.test.ts +32 -19
  67. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  68. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  69. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  70. package/src/player/components/TimelineGroupRow.tsx +4 -1
  71. package/src/player/components/TimelineLanes.tsx +5 -5
  72. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  73. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  74. package/src/player/components/TimelineTypes.ts +1 -0
  75. package/src/player/components/timelineLaneProps.ts +1 -0
  76. package/src/player/components/timelineLayout.ts +16 -23
  77. package/src/player/components/timelineMountTestEnv.ts +29 -0
  78. package/src/player/components/useTimelineGeometry.test.tsx +105 -0
  79. package/src/player/components/useTimelineGeometry.ts +18 -10
  80. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  81. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  82. package/src/player/components/useTimelinePlayhead.ts +1 -1
  83. package/src/player/components/useTimelineProviderState.tsx +6 -12
  84. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  85. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  86. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  87. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  88. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  89. package/src/player/sceneSwap.test.ts +95 -0
  90. package/src/player/sceneSwap.ts +53 -0
  91. package/src/player/store/liveTime.ts +6 -1
  92. package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
  93. package/dist/chunk-3IOCVWB6.js.map +0 -1
  94. package/dist/chunk-7VDQIGOU.js.map +0 -1
  95. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.85",
3
+ "version": "0.8.87",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.85",
64
- "@hyperframes/player": "0.8.85",
65
- "@hyperframes/parsers": "0.8.85",
66
- "@hyperframes/shader-transitions": "0.8.85",
67
- "@hyperframes/studio-server": "0.8.85",
68
- "@hyperframes/sdk": "0.8.85"
63
+ "@hyperframes/core": "0.8.87",
64
+ "@hyperframes/parsers": "0.8.87",
65
+ "@hyperframes/player": "0.8.87",
66
+ "@hyperframes/shader-transitions": "0.8.87",
67
+ "@hyperframes/studio-server": "0.8.87",
68
+ "@hyperframes/sdk": "0.8.87"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.85"
84
+ "@hyperframes/producer": "0.8.87"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -24,17 +24,22 @@ vi.mock("../contexts/StudioContext", () => ({
24
24
 
25
25
  afterEach(() => {
26
26
  document.body.innerHTML = "";
27
- usePlayerStore.setState({ autoKeyframeEnabled: true, thumbnailMode: "adaptive" });
27
+ usePlayerStore.setState({
28
+ autoKeyframeEnabled: true,
29
+ thumbnailMode: "adaptive",
30
+ zoomMode: "fit",
31
+ });
28
32
  });
29
33
 
30
34
  function renderToolbar(
31
35
  domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
36
+ props: Partial<React.ComponentProps<typeof TimelineToolbar>> = {},
32
37
  ) {
33
38
  const host = document.createElement("div");
34
39
  document.body.append(host);
35
40
  const root = createRoot(host);
36
41
  act(() => {
37
- root.render(<TimelineToolbar domEditSession={domEditSession} />);
42
+ root.render(<TimelineToolbar domEditSession={domEditSession} {...props} />);
38
43
  });
39
44
  return { host, root };
40
45
  }
@@ -175,6 +180,52 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
175
180
  expect(button?.disabled).toBe(false);
176
181
  act(() => root.unmount());
177
182
  });
183
+
184
+ const keyframeControls = (host: HTMLElement) => [
185
+ host.querySelector('button[aria-label="Add keyframe at playhead"]'),
186
+ host.querySelector('button[aria-label="Auto-record manual edits as keyframes"]'),
187
+ ];
188
+ /** True when the keyframe shortcut claimed K, so playback never saw it. */
189
+ const pressK = () => {
190
+ const event = new KeyboardEvent("keydown", { key: "k", bubbles: true, cancelable: true });
191
+ act(() => {
192
+ window.dispatchEvent(event);
193
+ });
194
+ return event.defaultPrevented;
195
+ };
196
+
197
+ it("shows Add keyframe and auto-record, and K adds a keyframe, by default", () => {
198
+ const { host, root } = renderToolbar(sessionFor("div"));
199
+ expect(keyframeControls(host).every(Boolean)).toBe(true);
200
+ expect(pressK()).toBe(true);
201
+ act(() => root.unmount());
202
+ });
203
+
204
+ it("hides both controls for a host without keyframes, turns auto-record off and leaves K alone", () => {
205
+ const { host, root } = renderToolbar(sessionFor("div"), { showKeyframes: false });
206
+ expect(keyframeControls(host)).toEqual([null, null]);
207
+ expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
208
+ expect(pressK()).toBe(false);
209
+ act(() => root.unmount());
210
+ });
211
+ });
212
+
213
+ describe("TimelineToolbar Fit", () => {
214
+ it("shows Fit as a named icon and says whether fit is on", () => {
215
+ const { host, root } = renderToolbar();
216
+ const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
217
+ expect(fit()?.textContent).toBe("");
218
+ expect(fit()?.querySelector("svg")).not.toBeNull();
219
+ act(() => fit()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
220
+ expect(fit()?.getAttribute("aria-pressed")).toBe("true");
221
+ act(() =>
222
+ host
223
+ .querySelector('button[aria-label="Zoom in"]')
224
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
225
+ );
226
+ expect(fit()?.getAttribute("aria-pressed")).toBe("false");
227
+ act(() => root.unmount());
228
+ });
178
229
  });
179
230
 
180
231
  describe("TimelineToolbar audio meters", () => {
@@ -1,5 +1,11 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
2
+ import {
3
+ ArrowsOutLineHorizontal,
4
+ Image,
5
+ Magnet,
6
+ MagnifyingGlassMinus,
7
+ MagnifyingGlassPlus,
8
+ } from "@phosphor-icons/react";
3
9
  import {
4
10
  useEnableKeyframes,
5
11
  isPlayheadWithinTween,
@@ -38,6 +44,8 @@ export interface TimelineToolbarProps {
38
44
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
39
45
  history?: TimelineHistoryButtonsProps;
40
46
  showAddBeat?: boolean;
47
+ /** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
48
+ showKeyframes?: boolean;
41
49
  }
42
50
 
43
51
  interface KeyframeToggleState {
@@ -134,6 +142,7 @@ export function TimelineToolbar({
134
142
  onSplitElement,
135
143
  history,
136
144
  showAddBeat = true,
145
+ showKeyframes = true,
137
146
  }: TimelineToolbarProps) {
138
147
  const activeTool = usePlayerStore((s) => s.activeTool);
139
148
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
@@ -174,10 +183,14 @@ export function TimelineToolbar({
174
183
  // Wire the "Add keyframe (K)" shortcut the toolbar advertises. Active only when
175
184
  // there's a keyframeable selection; otherwise K stays JKL-pause in playback.
176
185
  useKeyframeKeyboard({
177
- enabled: Boolean(onToggleKeyframe),
186
+ enabled: showKeyframes && Boolean(onToggleKeyframe),
178
187
  onAddKeyframe: onToggleKeyframe,
179
188
  });
180
189
 
190
+ useEffect(() => {
191
+ if (!showKeyframes) setAutoKeyframeEnabled(false);
192
+ }, [showKeyframes, setAutoKeyframeEnabled]);
193
+
181
194
  // "N" toggles timeline snapping (industry convention: Resolve/FCP).
182
195
  // Skip when typing in an input/contenteditable.
183
196
  useEffect(() => {
@@ -270,107 +283,110 @@ export function TimelineToolbar({
270
283
  </button>
271
284
  </Tooltip>
272
285
  )}
273
- {/* Always rendered (CapCut-style): with no keyframeable selection the
274
- button fades to a disabled state instead of unmounting, so the
275
- toolbar layout never shifts. */}
276
- <Tooltip
277
- label={
278
- keyframePathEndpoint
279
- ? "Motion path endpoints cannot be removed"
280
- : !onToggleKeyframe
281
- ? "Select an animated element to add keyframes"
282
- : keyframeIsMotionPath
283
- ? keyframeWillExtend
284
- ? "Extend motion path to playhead (K)"
285
- : keyframeState === "active"
286
- ? "Remove waypoint from motion path (K)"
287
- : "Add waypoint to motion path (K)"
288
- : keyframeState === "active"
289
- ? "Remove keyframe at playhead (K)"
290
- : keyframeState === "inactive"
286
+ {showKeyframes && (
287
+ <>
288
+ {/* With no keyframeable selection the button fades to disabled instead of
289
+ unmounting, so the toolbar layout never shifts (CapCut-style). */}
290
+ <Tooltip
291
+ label={
292
+ keyframePathEndpoint
293
+ ? "Motion path endpoints cannot be removed"
294
+ : !onToggleKeyframe
295
+ ? "Select an animated element to add keyframes"
296
+ : keyframeIsMotionPath
291
297
  ? keyframeWillExtend
292
- ? "Add keyframe at playhead, extends animation (K)"
293
- : "Add keyframe at playhead (K)"
294
- : "Add keyframe (K)"
295
- }
296
- >
297
- <button
298
- type="button"
299
- disabled={!onToggleKeyframe}
300
- onClick={onToggleKeyframe}
301
- aria-label={
302
- keyframePathEndpoint
303
- ? "Motion path endpoint"
304
- : keyframeIsMotionPath
305
- ? keyframeState === "active"
306
- ? "Remove motion path waypoint"
307
- : keyframeWillExtend
308
- ? "Extend motion path to playhead"
309
- : "Add motion path waypoint"
310
- : keyframeState === "active"
311
- ? "Remove keyframe at playhead"
312
- : "Add keyframe at playhead"
313
- }
314
- className={
315
- !onToggleKeyframe
316
- ? flatDisabled
317
- : `${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
318
- keyframeState === "active"
319
- ? "text-studio-accent"
320
- : keyframeState === "inactive"
321
- ? "text-neutral-400 hover:text-studio-accent"
322
- : "text-neutral-600 hover:text-neutral-400"
323
- }`
324
- }
325
- >
326
- <svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
327
- {keyframeState === "active" ? (
328
- <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
329
- ) : (
330
- <path
331
- d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
332
- fill="none"
333
- stroke="currentColor"
334
- strokeWidth="1.2"
335
- />
336
- )}
337
- </svg>
338
- </button>
339
- </Tooltip>
340
- <Tooltip
341
- label={
342
- autoKeyframeEnabled
343
- ? "Auto-record manual edits as keyframes (click to turn off)"
344
- : "Manual edits will not be recorded as keyframes (click to turn on)"
345
- }
346
- >
347
- <button
348
- type="button"
349
- onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
350
- aria-label="Auto-record manual edits as keyframes"
351
- aria-pressed={autoKeyframeEnabled}
352
- className={`${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
353
- autoKeyframeEnabled
354
- ? "text-red-400 hover:text-red-300"
355
- : "text-neutral-600 hover:text-neutral-400"
356
- }`}
357
- >
358
- <svg width="16" height="16" viewBox="0 0 10 10" fill="none">
359
- {/* Same diamond outline as the Add-keyframe icon, with a
298
+ ? "Extend motion path to playhead (K)"
299
+ : keyframeState === "active"
300
+ ? "Remove waypoint from motion path (K)"
301
+ : "Add waypoint to motion path (K)"
302
+ : keyframeState === "active"
303
+ ? "Remove keyframe at playhead (K)"
304
+ : keyframeState === "inactive"
305
+ ? keyframeWillExtend
306
+ ? "Add keyframe at playhead, extends animation (K)"
307
+ : "Add keyframe at playhead (K)"
308
+ : "Add keyframe (K)"
309
+ }
310
+ >
311
+ <button
312
+ type="button"
313
+ disabled={!onToggleKeyframe}
314
+ onClick={onToggleKeyframe}
315
+ aria-label={
316
+ keyframePathEndpoint
317
+ ? "Motion path endpoint"
318
+ : keyframeIsMotionPath
319
+ ? keyframeState === "active"
320
+ ? "Remove motion path waypoint"
321
+ : keyframeWillExtend
322
+ ? "Extend motion path to playhead"
323
+ : "Add motion path waypoint"
324
+ : keyframeState === "active"
325
+ ? "Remove keyframe at playhead"
326
+ : "Add keyframe at playhead"
327
+ }
328
+ className={
329
+ !onToggleKeyframe
330
+ ? flatDisabled
331
+ : `${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
332
+ keyframeState === "active"
333
+ ? "text-studio-accent"
334
+ : keyframeState === "inactive"
335
+ ? "text-neutral-400 hover:text-studio-accent"
336
+ : "text-neutral-600 hover:text-neutral-400"
337
+ }`
338
+ }
339
+ >
340
+ <svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
341
+ {keyframeState === "active" ? (
342
+ <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
343
+ ) : (
344
+ <path
345
+ d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
346
+ fill="none"
347
+ stroke="currentColor"
348
+ strokeWidth="1.2"
349
+ />
350
+ )}
351
+ </svg>
352
+ </button>
353
+ </Tooltip>
354
+ <Tooltip
355
+ label={
356
+ autoKeyframeEnabled
357
+ ? "Auto-record manual edits as keyframes (click to turn off)"
358
+ : "Manual edits will not be recorded as keyframes (click to turn on)"
359
+ }
360
+ >
361
+ <button
362
+ type="button"
363
+ onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
364
+ aria-label="Auto-record manual edits as keyframes"
365
+ aria-pressed={autoKeyframeEnabled}
366
+ className={`${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
367
+ autoKeyframeEnabled
368
+ ? "text-red-400 hover:text-red-300"
369
+ : "text-neutral-600 hover:text-neutral-400"
370
+ }`}
371
+ >
372
+ <svg width="16" height="16" viewBox="0 0 10 10" fill="none">
373
+ {/* Same diamond outline as the Add-keyframe icon, with a
360
374
  record-style dot inside: filled = auto-recording,
361
375
  hollow = manual edits won't be keyframed. */}
362
- <path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
363
- <circle
364
- cx="5"
365
- cy="5"
366
- r="1.8"
367
- fill={autoKeyframeEnabled ? "currentColor" : "none"}
368
- stroke="currentColor"
369
- strokeWidth="1"
370
- />
371
- </svg>
372
- </button>
373
- </Tooltip>
376
+ <path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
377
+ <circle
378
+ cx="5"
379
+ cy="5"
380
+ r="1.8"
381
+ fill={autoKeyframeEnabled ? "currentColor" : "none"}
382
+ stroke="currentColor"
383
+ strokeWidth="1"
384
+ />
385
+ </svg>
386
+ </button>
387
+ </Tooltip>
388
+ </>
389
+ )}
374
390
  {onSplitElement &&
375
391
  (() => {
376
392
  // Render the button unconditionally (disabled when unusable):
@@ -495,13 +511,15 @@ export function TimelineToolbar({
495
511
  <button
496
512
  type="button"
497
513
  onClick={() => setZoomMode("fit")}
514
+ aria-label="Fit timeline to width"
515
+ aria-pressed={zoomMode === "fit"}
498
516
  className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
499
517
  zoomMode === "fit"
500
518
  ? "bg-studio-accent/10 text-studio-accent"
501
519
  : "text-neutral-400 hover:bg-white/6 hover:text-neutral-200"
502
520
  }`}
503
521
  >
504
- Fit
522
+ <ArrowsOutLineHorizontal size={16} aria-hidden="true" />
505
523
  </button>
506
524
  </Tooltip>
507
525
  <Tooltip label="Zoom out">
@@ -47,7 +47,7 @@ export {
47
47
  } from "./domEditOverlayGestures";
48
48
  export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
49
49
 
50
- interface DomEditOverlayProps {
50
+ export interface DomEditOverlayProps {
51
51
  iframeRef: RefObject<HTMLIFrameElement | null>;
52
52
  activeCompositionPath: string | null;
53
53
  selection: DomEditSelection | null;
@@ -518,7 +518,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
518
518
  boxClipPath={boxClipPath}
519
519
  selectionKey={selectionKey}
520
520
  groupSelectionCount={groupSelections.length}
521
- blockedMoveRef={blockedMoveRef}
522
521
  gestures={gestures}
523
522
  onStyleCommit={onStyleCommitRef.current}
524
523
  onBoxMouseDown={suppressBoxMouseDown}
@@ -6,6 +6,7 @@ import { expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "./domEditing";
7
7
  import "./domEditOverlayTestMocks";
8
8
  import { DomEditOverlay } from "./DomEditOverlay";
9
+ import { DomEditOverlay as PackageDomEditOverlay } from "../../index";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -74,3 +75,7 @@ it("rotates the hover box with the element", () => {
74
75
  it("leaves the hover box untransformed at angle zero", () => {
75
76
  expect(renderHover(0)).toBe("");
76
77
  });
78
+
79
+ it("is exported from the package entry, so a host can mount it with its own commits", () => {
80
+ expect(PackageDomEditOverlay).toBe(DomEditOverlay);
81
+ });
@@ -53,7 +53,6 @@ describe("DomEditSelectionChrome crop composition", () => {
53
53
  boxClipPath={undefined}
54
54
  selectionKey="headline"
55
55
  groupSelectionCount={0}
56
- blockedMoveRef={createRef()}
57
56
  gestures={{ startGesture: vi.fn() } as never}
58
57
  onStyleCommit={vi.fn()}
59
58
  onBoxMouseDown={vi.fn()}
@@ -109,7 +108,6 @@ describe("DomEditSelectionChrome crop composition", () => {
109
108
  boxClipPath={undefined}
110
109
  selectionKey="clip"
111
110
  groupSelectionCount={0}
112
- blockedMoveRef={createRef()}
113
111
  gestures={{ startGesture: vi.fn() } as never}
114
112
  onStyleCommit={vi.fn()}
115
113
  onBoxMouseDown={vi.fn()}
@@ -170,7 +168,6 @@ describe("DomEditSelectionChrome while editing text", () => {
170
168
  boxClipPath={undefined}
171
169
  selectionKey="copy"
172
170
  groupSelectionCount={0}
173
- blockedMoveRef={createRef()}
174
171
  gestures={{ startGesture: vi.fn() } as never}
175
172
  onStyleCommit={vi.fn()}
176
173
  onBoxMouseDown={vi.fn()}
@@ -1,7 +1,7 @@
1
1
  import type { RefObject } from "react";
2
2
  import { type DomEditSelection } from "./domEditing";
3
3
  import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
4
- import type { BlockedMoveState, ResizeHandle } from "./domEditOverlayGestures";
4
+ import type { ResizeHandle } from "./domEditOverlayGestures";
5
5
  import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
6
  import { DomEditCropHandles } from "./DomEditCropHandles";
7
7
  import { DomEditRotateHandle } from "./DomEditRotateHandle";
@@ -103,7 +103,7 @@ export function DomEditGroupChrome({
103
103
  cursor: canManipulate && groupCanMove ? "move" : "default",
104
104
  }}
105
105
  onPointerDown={(e) => {
106
- if (!canManipulate || !groupCanMove || e.shiftKey) return;
106
+ if (!canManipulate || e.shiftKey) return;
107
107
  gestures.startGroupDrag(e);
108
108
  }}
109
109
  onMouseDown={onBoxMouseDown}
@@ -123,7 +123,6 @@ interface DomEditSelectionChromeProps {
123
123
  boxClipPath: string | undefined;
124
124
  selectionKey: string;
125
125
  groupSelectionCount: number;
126
- blockedMoveRef: RefObject<BlockedMoveState | null>;
127
126
  gestures: GestureHandlers;
128
127
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
129
128
  onBoxMouseDown: (e: React.MouseEvent) => void;
@@ -152,7 +151,6 @@ export function DomEditSelectionChrome({
152
151
  boxClipPath,
153
152
  selectionKey,
154
153
  groupSelectionCount,
155
- blockedMoveRef,
156
154
  gestures,
157
155
  onStyleCommit,
158
156
  onBoxMouseDown,
@@ -218,15 +216,7 @@ export function DomEditSelectionChrome({
218
216
  gestures.startGesture("drag", e);
219
217
  return;
220
218
  }
221
- e.preventDefault();
222
- e.stopPropagation();
223
- e.currentTarget.setPointerCapture(e.pointerId);
224
- blockedMoveRef.current = {
225
- pointerId: e.pointerId,
226
- startX: e.clientX,
227
- startY: e.clientY,
228
- notified: false,
229
- };
219
+ gestures.startBlockedMove(e, selection);
230
220
  }}
231
221
  onMouseDown={onBoxMouseDown}
232
222
  onClick={onBoxClick}
@@ -9,6 +9,7 @@ import type { DomEditLayerItem } from "./domEditingTypes";
9
9
  import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
10
10
  import { isLayerDraggable } from "./useLayerDrag";
11
11
  import { liveTime } from "../../player";
12
+ import { sceneSwapFor } from "../../player/sceneSwap";
12
13
 
13
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
15
 
@@ -292,4 +293,17 @@ describe("LayersPanel preview promotion", () => {
292
293
  });
293
294
  expect(collectedRootIds()).toEqual(["b"]);
294
295
  });
296
+ it("recollects layers when edited scenes are swapped into the live document", async () => {
297
+ const a = makeIframe("a");
298
+ Object.defineProperty(a, "contentWindow", { value: { __hfSwapScenes: async () => {} } });
299
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html></html>"));
300
+ mocks.previewIframeRef.current = a;
301
+ usePreviewIframeStore.setState({ iframe: a });
302
+ act(() => root.render(createElement(LayersPanel)));
303
+ mocks.collect.mockClear();
304
+
305
+ await act(() => sceneSwapFor(a)!("/preview", () => true));
306
+ expect(collectedRootIds()).toEqual(["a"]);
307
+ fetchSpy.mockRestore();
308
+ });
295
309
  });
@@ -1,4 +1,5 @@
1
1
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
2
+ import { onPreviewContentReplaced } from "../../player/sceneSwap";
2
3
  import { memo, useState, useCallback, useEffect, useRef } from "react";
3
4
  import {
4
5
  collectDomEditLayerItems,
@@ -164,8 +165,7 @@ export const LayersPanel = memo(function LayersPanel() {
164
165
  prevDocVersionRef.current += 1;
165
166
  collectLayers();
166
167
  };
167
- iframe.addEventListener("load", handleLoad);
168
- return () => iframe.removeEventListener("load", handleLoad);
168
+ return onPreviewContentReplaced(iframe, handleLoad);
169
169
  }, [previewIframeRef, livePreviewIframe, collectLayers]);
170
170
 
171
171
  useEffect(() => {
@@ -1,4 +1,5 @@
1
1
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
2
+ import { onPreviewContentReplaced } from "../../player/sceneSwap";
2
3
  import {
3
4
  useEffect,
4
5
  useLayoutEffect,
@@ -119,8 +120,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
119
120
  const iframe = iframeRef.current;
120
121
  if (!iframe || !callId) return;
121
122
  const dismiss = () => studioEditLifecycle.dismiss(callId);
122
- iframe.addEventListener("load", dismiss);
123
- return () => iframe.removeEventListener("load", dismiss);
123
+ return onPreviewContentReplaced(iframe, dismiss);
124
124
  }, [callId, iframeRef, livePreviewIframe]);
125
125
 
126
126
  useEffect(() => {