@hyperframes/studio 0.8.55 → 0.8.57

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 (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -1,5 +1,10 @@
1
+ import {
2
+ readPreviewCompositionSize,
3
+ type PreviewCompositionSize,
4
+ } from "../../utils/previewCompositionSize";
1
5
  import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
6
  import { Player } from "../../player";
7
+ import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
3
8
  import {
4
9
  DEFAULT_PREVIEW_ZOOM,
5
10
  canStartPreviewPan,
@@ -13,10 +18,17 @@ import {
13
18
  } from "./previewZoom";
14
19
  import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
15
20
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
21
+ import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
16
22
  interface NLEPreviewProps {
17
23
  projectId: string;
18
24
  iframeRef: RefObject<HTMLIFrameElement | null>;
19
25
  onIframeLoad: () => void;
26
+ previewSlots: PreviewIframeSlot[];
27
+ onShadowIframeLoad: (gen: number) => void;
28
+ onShadowReadyChange: (gen: number, ready: boolean) => void;
29
+ onShadowError: (gen: number, message: string) => void;
30
+ setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
31
+ resetPreviewSlots: () => void;
20
32
  onCompositionLoadingChange?: (loading: boolean) => void;
21
33
  portrait?: boolean;
22
34
  directUrl?: string;
@@ -40,10 +52,15 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
40
52
  const ZOOM_SETTLE_MS = 200;
41
53
  const PREVIEW_STAGE_INSET_PX = 16;
42
54
 
43
- interface PreviewCompositionSize {
44
- width: number;
45
- height: number;
46
- }
55
+ // clip-path as well as visibility: the player's loading overlay sets its own
56
+ // visibility:visible and would otherwise paint over the live frame.
57
+ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
58
+ position: "absolute",
59
+ inset: 0,
60
+ visibility: "hidden",
61
+ clipPath: "inset(100%)",
62
+ pointerEvents: "none",
63
+ };
47
64
 
48
65
  function isPreviewAtFit(state: PreviewZoomState): boolean {
49
66
  return (
@@ -64,27 +81,6 @@ function loadInitialZoom(): PreviewZoomState {
64
81
  : DEFAULT_PREVIEW_ZOOM;
65
82
  }
66
83
 
67
- // fallow-ignore-next-line complexity
68
- function readPreviewCompositionSize(
69
- iframe: HTMLIFrameElement | null,
70
- ): PreviewCompositionSize | null {
71
- try {
72
- const doc = iframe?.contentDocument;
73
- const root =
74
- doc?.querySelector("[data-composition-id][data-width][data-height]") ??
75
- doc?.querySelector("[data-width][data-height]");
76
- if (!root) return null;
77
- const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
78
- const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
79
- if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
80
- return null;
81
- }
82
- return { width, height };
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
84
  export function resolvePreviewStageSize(
89
85
  viewportWidth: number,
90
86
  viewportHeight: number,
@@ -122,6 +118,12 @@ export const NLEPreview = memo(function NLEPreview({
122
118
  projectId,
123
119
  iframeRef,
124
120
  onIframeLoad,
121
+ previewSlots,
122
+ onShadowIframeLoad,
123
+ onShadowReadyChange,
124
+ onShadowError,
125
+ setShadowIframeNode,
126
+ resetPreviewSlots,
125
127
  onCompositionLoadingChange,
126
128
  portrait,
127
129
  directUrl,
@@ -136,6 +138,17 @@ export const NLEPreview = memo(function NLEPreview({
136
138
  useEffect(() => {
137
139
  onStageRef?.(stageRef);
138
140
  }, [onStageRef]);
141
+
142
+ // Composition switch: drop any in-flight shadow reload (skipped on first mount).
143
+ const previousActiveKeyRef = useRef(activeKey);
144
+ useEffect(() => {
145
+ if (previousActiveKeyRef.current === activeKey) return;
146
+ previousActiveKeyRef.current = activeKey;
147
+ resetPreviewSlots();
148
+ }, [activeKey, resetPreviewSlots]);
149
+
150
+ const liveGenRef = useRef<number | null>(null);
151
+ const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
139
152
  const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
140
153
  const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
141
154
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
@@ -299,6 +312,18 @@ export const NLEPreview = memo(function NLEPreview({
299
312
  }
300
313
  }, [writeTransform]);
301
314
 
315
+ // A promotion does not re-fire Player.onLoad: re-sync the local iframe ref, size and zoom.
316
+ useEffect(() => {
317
+ const live = previewSlots.find((slot) => slot.role === "live");
318
+ if (!live || live.gen === liveGenRef.current) return;
319
+ const isPromotion = liveGenRef.current !== null;
320
+ liveGenRef.current = live.gen;
321
+ if (!isPromotion) return;
322
+ previewIframeRef.current = iframeRef.current;
323
+ updateCompositionSizeFromPreview();
324
+ applyInitialZoom();
325
+ }, [previewSlots, iframeRef, updateCompositionSizeFromPreview, applyInitialZoom]);
326
+
302
327
  useEffect(() => {
303
328
  const viewport = viewportRef.current;
304
329
  if (!viewport) return;
@@ -469,25 +494,44 @@ export const NLEPreview = memo(function NLEPreview({
469
494
  style={{ position: "absolute", inset: 0, zIndex: 0 }}
470
495
  />
471
496
  )}
472
- <Player
473
- key={activeKey}
474
- ref={setPreviewIframeRef}
475
- projectId={directUrl ? undefined : projectId}
476
- directUrl={directUrl}
477
- onLoad={() => {
478
- updateCompositionSizeFromPreview();
479
- onIframeLoad();
480
- applyInitialZoom();
481
- }}
482
- onCompositionLoadingChange={onCompositionLoadingChange}
483
- portrait={portrait}
484
- suppressLoadingOverlay={suppressLoadingOverlay}
485
- style={
486
- directUrl?.includes("/components/")
487
- ? { position: "absolute", inset: 0, zIndex: 1 }
488
- : undefined
489
- }
490
- />
497
+ {previewSlots.map((slot) =>
498
+ slot.role === "live" ? (
499
+ <Player
500
+ key={`${activeKey}-${slot.gen}`}
501
+ ref={setPreviewIframeRef}
502
+ projectId={directUrl ? undefined : projectId}
503
+ directUrl={directUrl}
504
+ onLoad={() => {
505
+ updateCompositionSizeFromPreview();
506
+ onIframeLoad();
507
+ applyInitialZoom();
508
+ }}
509
+ onCompositionLoadingChange={onCompositionLoadingChange}
510
+ onPainted={(details) => reportPreviewFirstFrame(slot, details)}
511
+ portrait={portrait}
512
+ suppressLoadingOverlay={suppressLoadingOverlay}
513
+ style={
514
+ directUrl?.includes("/components/")
515
+ ? { position: "absolute", inset: 0, zIndex: 1 }
516
+ : undefined
517
+ }
518
+ />
519
+ ) : (
520
+ // Loads hidden behind the live slot until promoted.
521
+ <Player
522
+ key={`${activeKey}-${slot.gen}`}
523
+ ref={setShadowIframeNode}
524
+ directUrl={slot.url}
525
+ onLoad={() => onShadowIframeLoad(slot.gen)}
526
+ onReadyToShowChange={(ready) => onShadowReadyChange(slot.gen, ready)}
527
+ onPainted={(details) => reportPreviewFirstFrame(slot, details)}
528
+ onPreviewError={(message) => onShadowError(slot.gen, message)}
529
+ portrait={portrait}
530
+ suppressLoadingOverlay
531
+ style={SHADOW_IFRAME_STYLE}
532
+ />
533
+ ),
534
+ )}
491
535
  </div>
492
536
  </div>
493
537
  <div
@@ -3,21 +3,13 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { getPreviewState, iframeRef } from "./PreviewOverlaysTestMocks";
6
7
  import { PreviewOverlays } from "./PreviewOverlays";
7
8
 
8
- (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+ const previewState = getPreviewState();
9
10
 
10
- const previewState = vi.hoisted(() => ({ captionEditMode: false }));
11
- const iframeRef = { current: null as HTMLIFrameElement | null };
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
13
- vi.mock("../../contexts/StudioContext", () => ({
14
- useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
15
- useStudioPlaybackContext: () => ({
16
- captionEditMode: previewState.captionEditMode,
17
- compositionLoading: false,
18
- isPlaying: false,
19
- }),
20
- }));
21
13
  vi.mock("../../contexts/DomEditContext", () => ({
22
14
  useDomEditSelectionContext: () => ({
23
15
  domEditHoverSelection: null,
@@ -41,30 +33,7 @@ vi.mock("../../contexts/DomEditContext", () => ({
41
33
  handleDomZIndexReorderCommit: vi.fn(),
42
34
  }),
43
35
  }));
44
- vi.mock("../../captions/store", () => {
45
- const state = {
46
- model: null,
47
- dismissed: false,
48
- syncError: null,
49
- clearSelection: vi.fn(),
50
- setDismissed: vi.fn(),
51
- setEditMode: vi.fn(),
52
- setSyncError: vi.fn(),
53
- };
54
- return {
55
- useCaptionStore: Object.assign(
56
- (selector: (value: typeof state) => unknown) => selector(state),
57
- { getState: () => state },
58
- ),
59
- };
60
- });
61
- vi.mock("../../hooks/useCompositionDimensions", () => ({
62
- useCompositionDimensions: () => null,
63
- }));
64
- vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
65
- vi.mock("./useCanvasZOrderTimelineMirror", () => ({
66
- useCanvasZOrderTimelineMirror: () => vi.fn(),
67
- }));
36
+
68
37
  vi.mock("../editor/TopologyLens", async () => {
69
38
  const { createElement } = await import("react");
70
39
  return {
@@ -1,17 +1,18 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback } from "react";
2
2
  import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
3
3
  import { useCaptionStore } from "../../captions/store";
4
4
  import { DomEditOverlay } from "../editor/DomEditOverlay";
5
5
  import { TopologyLens } from "../editor/TopologyLens";
6
6
  import { MotionPathOverlay } from "../editor/MotionPathOverlay";
7
7
  import { SnapToolbar } from "../editor/SnapToolbar";
8
+ import { GridOverlay } from "../editor/GridOverlay";
9
+ import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
8
10
  import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
9
11
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
10
12
  import {
11
13
  useDomEditActionsContext,
12
14
  useDomEditSelectionContext,
13
15
  } from "../../contexts/DomEditContext";
14
- import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
15
16
  import { readHfId, type DomEditSelection } from "../editor/domEditing";
16
17
  import { buildStableSelector } from "../editor/domEditingDom";
17
18
  import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
@@ -140,6 +141,8 @@ export function PreviewOverlays({
140
141
  const { activeCompPath, previewIframeRef } = useStudioShellContext();
141
142
  const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
142
143
  const compositionDimensions = useCompositionDimensions(previewIframeRef);
144
+ const readOnly = usePreviewReadOnly();
145
+ const previewCaptionEditMode = captionEditMode && !readOnly;
143
146
 
144
147
  // Caption edit mode is entered automatically when captions are detected;
145
148
  // these give the author an explicit way OUT (and back in). Without them the
@@ -179,17 +182,6 @@ export function PreviewOverlays({
179
182
  } = useDomEditActionsContext();
180
183
  const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
181
184
 
182
- // fallow-ignore-next-line complexity
183
- const [snapPrefs, setSnapPrefs] = useState(() => {
184
- const p = readStudioUiPreferences();
185
- return {
186
- snapEnabled: p.snapEnabled ?? true,
187
- gridVisible: p.gridVisible ?? false,
188
- gridSpacing: p.gridSpacing ?? 50,
189
- snapToGrid: p.snapToGrid ?? false,
190
- };
191
- });
192
-
193
185
  if (blockPreview) {
194
186
  return (
195
187
  <>
@@ -216,7 +208,7 @@ export function PreviewOverlays({
216
208
  );
217
209
  }
218
210
 
219
- if (captionEditMode) {
211
+ if (previewCaptionEditMode) {
220
212
  return (
221
213
  <>
222
214
  <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
@@ -263,11 +255,14 @@ export function PreviewOverlays({
263
255
  return (
264
256
  <>
265
257
  <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
258
+ <GridOverlay />
266
259
  <DomEditOverlay
267
260
  iframeRef={previewIframeRef}
268
261
  activeCompositionPath={activeCompPath}
269
262
  hoverSelection={
270
- !captionEditMode && !compositionLoading && !isPlaying ? domEditHoverSelection : null
263
+ !previewCaptionEditMode && !compositionLoading && !isPlaying
264
+ ? domEditHoverSelection
265
+ : null
271
266
  }
272
267
  selection={shouldShowSelectedDomBounds ? domEditSelection : null}
273
268
  groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
@@ -320,19 +315,19 @@ export function PreviewOverlays({
320
315
  }),
321
316
  }).catch(() => undefined);
322
317
  }}
323
- gridVisible={snapPrefs.gridVisible}
324
- gridSpacing={snapPrefs.gridSpacing}
325
318
  recordingState={recordingState}
326
319
  onToggleRecording={onToggleRecording}
327
320
  onMarqueeSelect={applyMarqueeSelection}
328
321
  />
329
- <SnapToolbar onSnapChange={setSnapPrefs} />
330
- <MotionPathOverlay
331
- iframeRef={previewIframeRef}
332
- selection={shouldShowMotionPath ? domEditSelection : null}
333
- compositionSize={compositionDimensions}
334
- isPlaying={isPlaying}
335
- />
322
+ <SnapToolbar />
323
+ {!readOnly && (
324
+ <MotionPathOverlay
325
+ iframeRef={previewIframeRef}
326
+ selection={shouldShowMotionPath ? domEditSelection : null}
327
+ compositionSize={compositionDimensions}
328
+ isPlaying={isPlaying}
329
+ />
330
+ )}
336
331
  {gestureOverlay}
337
332
  {captionModelPresent && captionDismissed && (
338
333
  <button
@@ -0,0 +1,40 @@
1
+ import { vi } from "vitest";
2
+
3
+ const previewState = vi.hoisted(() => ({ captionEditMode: false }));
4
+ export function getPreviewState() {
5
+ return previewState;
6
+ }
7
+ export const iframeRef = { current: null as HTMLIFrameElement | null };
8
+
9
+ vi.mock("../../contexts/StudioContext", () => ({
10
+ useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
11
+ useStudioPlaybackContext: () => ({
12
+ captionEditMode: previewState.captionEditMode,
13
+ compositionLoading: false,
14
+ isPlaying: false,
15
+ }),
16
+ }));
17
+ vi.mock("../../captions/store", () => {
18
+ const state = {
19
+ model: null,
20
+ dismissed: false,
21
+ syncError: null,
22
+ clearSelection: vi.fn(),
23
+ setDismissed: vi.fn(),
24
+ setEditMode: vi.fn(),
25
+ setSyncError: vi.fn(),
26
+ };
27
+ return {
28
+ useCaptionStore: Object.assign(
29
+ (selector: (value: typeof state) => unknown) => selector(state),
30
+ { getState: () => state },
31
+ ),
32
+ };
33
+ });
34
+ vi.mock("../../hooks/useCompositionDimensions", () => ({
35
+ useCompositionDimensions: () => null,
36
+ }));
37
+ vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
38
+ vi.mock("./useCanvasZOrderTimelineMirror", () => ({
39
+ useCanvasZOrderTimelineMirror: () => vi.fn(),
40
+ }));
@@ -7,6 +7,7 @@ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
7
7
  import { useNLEContext } from "./NLEContext";
8
8
  import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9
9
  import { PreviewGuides } from "../editor/PreviewGuides";
10
+ import { PreviewOverlayProvider } from "../editor/PreviewOverlayProvider";
10
11
 
11
12
  function subscribeFullscreen(cb: () => void) {
12
13
  document.addEventListener("fullscreenchange", cb);
@@ -60,6 +61,12 @@ export function PreviewPane({
60
61
  togglePlay,
61
62
  seek,
62
63
  onIframeLoad,
64
+ previewSlots,
65
+ onShadowIframeLoad,
66
+ onShadowReadyChange,
67
+ onShadowError,
68
+ setShadowIframeNode,
69
+ resetPreviewSlots,
63
70
  compositionStack,
64
71
  handleNavigateComposition,
65
72
  setCompositionLoading,
@@ -131,6 +138,12 @@ export function PreviewPane({
131
138
  projectId={projectId}
132
139
  iframeRef={iframeRef}
133
140
  onIframeLoad={onIframeLoad}
141
+ previewSlots={previewSlots}
142
+ onShadowIframeLoad={onShadowIframeLoad}
143
+ onShadowReadyChange={onShadowReadyChange}
144
+ onShadowError={onShadowError}
145
+ setShadowIframeNode={setShadowIframeNode}
146
+ resetPreviewSlots={resetPreviewSlots}
134
147
  onCompositionLoadingChange={setCompositionLoading}
135
148
  portrait={portrait}
136
149
  directUrl={directUrl}
@@ -143,8 +156,10 @@ export function PreviewPane({
143
156
  )}
144
157
  <AssetPreviewOverlay />
145
158
  </div>
146
- <PreviewGuides iframeRef={iframeRef} />
147
- {!isFullscreen && previewOverlay}
159
+ <PreviewOverlayProvider>
160
+ <PreviewGuides />
161
+ {!isFullscreen && previewOverlay}
162
+ </PreviewOverlayProvider>
148
163
  </div>
149
164
  {/* Transport row: no own background or border — the controls sit flat on
150
165
  the preview panel's surface (CapCut-style). */}
@@ -255,7 +255,11 @@ export function TimelinePane({
255
255
  );
256
256
 
257
257
  return (
258
- <div className="relative flex h-full flex-col" aria-disabled={timelineDisabled || undefined}>
258
+ <div
259
+ className="relative flex h-full flex-col"
260
+ data-studio-timeline="true"
261
+ aria-disabled={timelineDisabled || undefined}
262
+ >
259
263
  <div
260
264
  className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
261
265
  onDoubleClick={(e) => {
@@ -0,0 +1,129 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { getPreviewState, iframeRef } from "./PreviewOverlaysTestMocks";
7
+ import { PreviewOverlays } from "./PreviewOverlays";
8
+ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
9
+ import { PreviewReadOnlyProvider } from "../editor/previewReadOnlyContext";
10
+ import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
11
+
12
+ const previewState = getPreviewState();
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ vi.mock("../../contexts/DomEditContext", () => ({
17
+ useDomEditSelectionContext: () => ({
18
+ domEditHoverSelection: null,
19
+ domEditSelection: null,
20
+ domEditGroupSelections: [],
21
+ }),
22
+ useDomEditActionsContext: () => ({}),
23
+ }));
24
+ vi.mock("../editor/TopologyLens", () => ({ TopologyLens: () => null }));
25
+ vi.mock("../../captions/components/CaptionOverlay", () => ({
26
+ CaptionOverlay: () => <i data-testid="caption-overlay" />,
27
+ }));
28
+ vi.mock("../editor/DomEditOverlay", () => ({
29
+ DomEditOverlay: () => <i data-testid="dom-edit-overlay" />,
30
+ }));
31
+ vi.mock("../editor/MotionPathOverlay", () => ({
32
+ MotionPathOverlay: () => <i data-testid="motion-path" />,
33
+ }));
34
+ vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
35
+ vi.mock("../editor/GridOverlay", () => ({ GridOverlay: () => null }));
36
+
37
+ let root: Root;
38
+ let host: HTMLDivElement;
39
+
40
+ function mount(node: React.ReactNode, readOnly = false): void {
41
+ host = document.createElement("div");
42
+ iframeRef.current = document.createElement("iframe");
43
+ document.body.append(host, iframeRef.current);
44
+ root = createRoot(host);
45
+ act(() =>
46
+ root.render(<PreviewReadOnlyProvider readOnly={readOnly}>{node}</PreviewReadOnlyProvider>),
47
+ );
48
+ }
49
+
50
+ afterEach(() => {
51
+ act(() => root.unmount());
52
+ previewState.captionEditMode = false;
53
+ document.body.replaceChildren();
54
+ });
55
+
56
+ const overlays = () => (
57
+ <PreviewOverlays shouldShowMotionPath={true} shouldShowSelectedDomBounds={true} />
58
+ );
59
+ const has = (id: string) => host.querySelector(`[data-testid="${id}"]`) !== null;
60
+
61
+ describe("PreviewOverlays with the preview read-only", () => {
62
+ it("control: with the flag off the motion path can be edited", () => {
63
+ mount(overlays());
64
+ expect(has("motion-path")).toBe(true);
65
+ });
66
+
67
+ it("does not mount the motion path editor", () => {
68
+ mount(overlays(), true);
69
+ expect(has("motion-path")).toBe(false);
70
+ expect(has("dom-edit-overlay")).toBe(true);
71
+ });
72
+
73
+ it("control: with the flag off caption mode mounts the caption editor", () => {
74
+ previewState.captionEditMode = true;
75
+ mount(overlays());
76
+ expect(has("caption-overlay")).toBe(true);
77
+ });
78
+
79
+ it("does not mount the caption editor, and selection stays on the canvas overlay", () => {
80
+ previewState.captionEditMode = true;
81
+ mount(overlays(), true);
82
+ expect(has("caption-overlay")).toBe(false);
83
+ expect(has("dom-edit-overlay")).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("usePreviewBlockDrop with the preview read-only", () => {
88
+ function dropOnPreview(
89
+ onBlockDrop: (name: string, pos: { left: number; top: number }) => void,
90
+ readOnly = false,
91
+ ) {
92
+ const stage = document.createElement("div");
93
+ stage.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect;
94
+ let handlers: ReturnType<typeof usePreviewBlockDrop> | null = null;
95
+ function Probe() {
96
+ handlers = usePreviewBlockDrop({
97
+ stageRef: { current: stage },
98
+ compositionSize: { width: 1000, height: 1000 },
99
+ onBlockDrop,
100
+ });
101
+ return null;
102
+ }
103
+ mount(<Probe />, readOnly);
104
+ const event = {
105
+ clientX: 50,
106
+ clientY: 50,
107
+ preventDefault: vi.fn(),
108
+ dataTransfer: {
109
+ types: [TIMELINE_BLOCK_MIME],
110
+ getData: () => JSON.stringify({ name: "lower-third" }),
111
+ },
112
+ };
113
+ act(() => handlers!.handleDrop(event as never));
114
+ return event;
115
+ }
116
+
117
+ it("control: with the flag off a dropped block is placed", () => {
118
+ const onBlockDrop = vi.fn();
119
+ dropOnPreview(onBlockDrop);
120
+ expect(onBlockDrop).toHaveBeenCalledWith("lower-third", { left: 500, top: 500 });
121
+ });
122
+
123
+ it("does not place a dropped block", () => {
124
+ const onBlockDrop = vi.fn();
125
+ const event = dropOnPreview(onBlockDrop, true);
126
+ expect(onBlockDrop).not.toHaveBeenCalled();
127
+ expect(event.preventDefault).not.toHaveBeenCalled();
128
+ });
129
+ });
@@ -24,7 +24,7 @@ import {
24
24
  afterEach(() => {
25
25
  document.body.innerHTML = "";
26
26
  // Wrapped in act: mounted harnesses subscribe to the store via
27
- // useTimelineRowElements, so the reset re-renders them.
27
+ // the store timeline elements, so the reset re-renders them.
28
28
  act(() => usePlayerStore.getState().setElements([]));
29
29
  });
30
30
 
@@ -1,6 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { usePlayerStore } from "../../player";
3
- import { useTimelineRowElements } from "../../player/hooks/useTimelineRowElements";
4
3
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
5
4
  import {
6
5
  displayTrackOrder,
@@ -41,10 +40,8 @@ export interface MirrorZOrderInput {
41
40
  * commitZMirrorLaneMove) — the shared key is unique per gesture
42
41
  * (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped.
43
42
  *
44
- * Element source: `useTimelineRowElements()` — the same expanded display
45
- * set the Timeline renders and the resolver expects (post-normalizeToZones
46
- * lanes, expanded sub-comp children on their synthetic rows). No new expansion
47
- * is built here.
43
+ * Element source: the store's timeline elements, the same set the Timeline
44
+ * renders and the resolver expects. No alternate row expansion is built here.
48
45
  *
49
46
  * The mirror persists through the SAME machinery as a timeline lane drag
50
47
  * (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded
@@ -141,7 +138,7 @@ function useMirrorLaneMoveCommit(): (
141
138
  coalesceKey: string,
142
139
  resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove,
143
140
  ) => Promise<boolean> {
144
- const elements = useTimelineRowElements();
141
+ const elements = usePlayerStore((state) => state.elements);
145
142
  const elementsRef = useRef(elements);
146
143
  elementsRef.current = elements;
147
144
  const { onMoveElements } = useTimelineEditContextOptional();
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useRef, useState, type RefObject } from "react";
2
2
  import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+ import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
3
4
 
4
5
  interface UsePreviewBlockDropOptions {
5
6
  portrait?: boolean;
@@ -67,8 +68,9 @@ export function usePreviewBlockDrop({
67
68
  portrait,
68
69
  compositionSize,
69
70
  stageRef,
70
- onBlockDrop,
71
+ onBlockDrop: onBlockDropProp,
71
72
  }: UsePreviewBlockDropOptions) {
73
+ const onBlockDrop = usePreviewReadOnly() ? undefined : onBlockDropProp;
72
74
  const [isDragOver, setIsDragOver] = useState(false);
73
75
  // dragenter/dragleave fire for every internal element boundary; a depth
74
76
  // counter keeps the drop indicator steady instead of flickering.