@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.55",
3
+ "version": "0.8.57",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.55",
55
- "@hyperframes/sdk": "0.8.55",
56
- "@hyperframes/parsers": "0.8.55",
57
- "@hyperframes/studio-server": "0.8.55",
58
- "@hyperframes/player": "0.8.55"
54
+ "@hyperframes/core": "0.8.57",
55
+ "@hyperframes/player": "0.8.57",
56
+ "@hyperframes/parsers": "0.8.57",
57
+ "@hyperframes/sdk": "0.8.57",
58
+ "@hyperframes/studio-server": "0.8.57"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.55"
74
+ "@hyperframes/producer": "0.8.57"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
package/src/App.tsx CHANGED
@@ -7,6 +7,7 @@ import { usePlayerStore } from "./player";
7
7
  import { StudioOverlays } from "./components/StudioOverlays";
8
8
  import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
9
9
  import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
10
+ import { ProjectUnreachableBanner } from "./components/ProjectUnreachableBanner";
10
11
  import { useCaptionStore } from "./captions/store";
11
12
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
12
13
  import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -63,8 +64,16 @@ import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
63
64
  import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
64
65
  import { useActiveComposition } from "./hooks/useActiveComposition";
65
66
  const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
67
+
68
+ export interface StudioAppProps {
69
+ /** Clicks still select and report; the preview cannot move, edit or delete anything. */
70
+ readOnlyPreview?: boolean;
71
+ /** Short text shown on disabled hand-edit controls while `readOnlyPreview` is set. */
72
+ readOnlyPreviewReason?: string;
73
+ }
74
+
66
75
  // fallow-ignore-next-line complexity
67
- export function StudioApp() {
76
+ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: StudioAppProps = {}) {
68
77
  const { projectId, resolving, waitingForServer } = useServerConnection();
69
78
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
70
79
  useStudioSessionStart(projectId, resolving, waitingForServer);
@@ -252,6 +261,7 @@ export function StudioApp() {
252
261
  activeCompPath,
253
262
  forceReloadSdkSession: sdkHandle.forceReload,
254
263
  onToggleRecording: () => handleToggleRecordingRef.current(),
264
+ readOnlyPreview,
255
265
  });
256
266
  const domEditSession = useDomEditSession({
257
267
  projectId,
@@ -291,6 +301,7 @@ export function StudioApp() {
291
301
  publishSdkSession: sdkHandle.publish,
292
302
  forceReloadSdkSession: sdkHandle.forceReload,
293
303
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
304
+ readOnlyPreview,
294
305
  });
295
306
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
296
307
  handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
@@ -346,6 +357,7 @@ export function StudioApp() {
346
357
  previewIframeRef,
347
358
  showToast,
348
359
  isGestureRecordingRef,
360
+ readOnlyPreview,
349
361
  });
350
362
  handleToggleRecordingRef.current = handleToggleRecording;
351
363
  const canvasRectRef = useRef<DOMRect | null>(null);
@@ -464,7 +476,12 @@ export function StudioApp() {
464
476
  />
465
477
  )}
466
478
  <ExternalFileConflictBanner coordinator={externalFileChanges} />
479
+ {sdkHandle.unreachableProject && (
480
+ <ProjectUnreachableBanner projectId={sdkHandle.unreachableProject} />
481
+ )}
467
482
  <EditorShell
483
+ readOnlyPreview={readOnlyPreview}
484
+ readOnlyPreviewReason={readOnlyPreviewReason}
468
485
  panels={
469
486
  <>
470
487
  <StudioLeftPanels
@@ -0,0 +1,116 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // A host that mounts EditorShell directly — no StudioApp, no App.tsx — must
4
+ // still get its descendants gated purely from the readOnlyPreview prop.
5
+
6
+ import React, { act } from "react";
7
+ import { createRoot } from "react-dom/client";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import "./EditorShellTestMocks";
10
+ import { EditorShell } from "./EditorShell";
11
+ import { usePreviewBlockDrop } from "./nle/usePreviewBlockDrop";
12
+ import { TIMELINE_BLOCK_MIME } from "../utils/timelineAssetDrop";
13
+
14
+ vi.mock("../hooks/useTimelineSelectionPreviewSync", () => ({
15
+ useTimelineSelectionPreviewSync: vi.fn(),
16
+ }));
17
+ vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
18
+
19
+ // The real hook — this is the actual Tier-1 consumer a host relies on to gate
20
+ // a drop onto the preview. Only PreviewPane's own DOM/player chrome is mocked.
21
+ const stage = document.createElement("div");
22
+ stage.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect;
23
+ const dropHandlers: { current: ReturnType<typeof usePreviewBlockDrop> | null } = { current: null };
24
+ vi.mock("./nle/PreviewPane", () => ({
25
+ PreviewPane: (props: { onPreviewBlockDrop?: (name: string, pos: unknown) => void }) => {
26
+ dropHandlers.current = usePreviewBlockDrop({
27
+ stageRef: { current: stage },
28
+ compositionSize: { width: 1000, height: 1000 },
29
+ onBlockDrop: props.onPreviewBlockDrop,
30
+ });
31
+ return null;
32
+ },
33
+ }));
34
+
35
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
36
+
37
+ let root: ReturnType<typeof createRoot> | null = null;
38
+
39
+ afterEach(() => {
40
+ if (root) act(() => root!.unmount());
41
+ root = null;
42
+ document.body.innerHTML = "";
43
+ });
44
+
45
+ function mountShellAlone(
46
+ readOnlyPreview: boolean,
47
+ onPreviewBlockDrop: (...args: unknown[]) => void,
48
+ ) {
49
+ const host = document.createElement("div");
50
+ document.body.append(host);
51
+ root = createRoot(host);
52
+ act(() =>
53
+ root!.render(
54
+ <EditorShell
55
+ panels={null}
56
+ timelineToolbar={null}
57
+ renderClipContent={() => null}
58
+ handleTimelineElementDelete={vi.fn()}
59
+ handleTimelineAssetDrop={vi.fn()}
60
+ handleTimelineFileDrop={vi.fn()}
61
+ handleTimelineElementMove={vi.fn()}
62
+ handleTimelineElementsMove={vi.fn()}
63
+ handleTimelineElementResize={vi.fn()}
64
+ handleTimelineGroupResize={vi.fn()}
65
+ handleToggleTrackHidden={vi.fn()}
66
+ setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
67
+ handleBlockedTimelineEdit={vi.fn()}
68
+ handleTimelineElementSplit={vi.fn()}
69
+ handleRazorSplit={vi.fn()}
70
+ handleRazorSplitAll={vi.fn()}
71
+ onCopyClip={vi.fn(() => false)}
72
+ onPasteClip={vi.fn(async () => {})}
73
+ onDuplicateClip={vi.fn(async () => false)}
74
+ canPasteClip={vi.fn(() => false)}
75
+ setCompIdToSrc={vi.fn()}
76
+ setCompositionLoading={vi.fn()}
77
+ shouldShowMotionPath={false}
78
+ shouldShowSelectedDomBounds={false}
79
+ handlePreviewBlockDrop={onPreviewBlockDrop}
80
+ readOnlyPreview={readOnlyPreview}
81
+ />,
82
+ ),
83
+ );
84
+ return { host, root };
85
+ }
86
+
87
+ function dropEvent() {
88
+ return {
89
+ clientX: 50,
90
+ clientY: 50,
91
+ preventDefault: vi.fn(),
92
+ dataTransfer: {
93
+ types: [TIMELINE_BLOCK_MIME],
94
+ getData: () => JSON.stringify({ name: "lower-third" }),
95
+ },
96
+ };
97
+ }
98
+
99
+ describe("a host mounting EditorShell alone (no StudioApp, no App.tsx)", () => {
100
+ it("gates a Tier-1 descendant's drop handling purely from the prop", () => {
101
+ const onBlockDrop = vi.fn();
102
+ mountShellAlone(true, onBlockDrop);
103
+ const event = dropEvent();
104
+ act(() => dropHandlers.current!.handleDrop(event as never));
105
+ expect(onBlockDrop).not.toHaveBeenCalled();
106
+ expect(event.preventDefault).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it("control: with the flag off, the same descendant still places a drop", () => {
110
+ const onBlockDrop = vi.fn();
111
+ mountShellAlone(false, onBlockDrop);
112
+ const event = dropEvent();
113
+ act(() => dropHandlers.current!.handleDrop(event as never));
114
+ expect(onBlockDrop).toHaveBeenCalledWith("lower-third", { left: 500, top: 500 });
115
+ });
116
+ });
@@ -3,54 +3,27 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import "./EditorShellTestMocks";
6
7
  import { EditorShell } from "./EditorShell";
8
+ import { usePreviewReadOnly } from "./editor/previewReadOnlyContext";
7
9
 
8
10
  const hookMocks = vi.hoisted(() => ({
9
11
  useTimelineSelectionPreviewSync: vi.fn(),
10
12
  }));
13
+ const previewOverlaysProbe = vi.hoisted(() => ({ readOnly: null as boolean | null }));
11
14
 
12
15
  vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks);
13
- vi.mock("../contexts/StudioContext", () => ({
14
- useStudioPlaybackContext: () => ({
15
- captionEditMode: false,
16
- refreshKey: 0,
17
- refreshPreviewDocumentVersion: vi.fn(),
18
- timelineElements: [],
19
- }),
20
- useStudioShellContext: () => ({
21
- projectId: "project-1",
22
- activeCompPath: "index.html",
23
- setActiveCompPath: vi.fn(),
24
- handlePreviewIframeRef: vi.fn(),
25
- showToast: vi.fn(),
26
- }),
16
+ vi.mock("./nle/PreviewPane", () => ({
17
+ // Renders its previewOverlay prop for real, so the mocked PreviewOverlays
18
+ // below (which reads the read-only context) actually mounts.
19
+ PreviewPane: (props: { previewOverlay?: React.ReactNode }) => props.previewOverlay ?? null,
27
20
  }));
28
- vi.mock("../contexts/DomEditContext", () => ({
29
- useDomEditActionsContext: () => ({
30
- handleTimelineElementSelect: vi.fn(),
31
- buildDomSelectionForTimelineElement: vi.fn(),
32
- applyDomSelection: vi.fn(),
33
- applyMarqueeSelection: vi.fn(),
34
- }),
35
- useDomEditSelectionContext: () => ({
36
- domEditSelection: null,
37
- domEditGroupSelections: [],
38
- }),
39
- }));
40
- vi.mock("./nle/NLEContext", () => ({
41
- NLEProvider: ({ children }: { children: React.ReactNode }) => children,
42
- useNLEContext: () => ({
43
- compositionStack: [],
44
- updateCompositionStack: vi.fn(),
45
- }),
46
- }));
47
- vi.mock("./nle/useTimelineEditCallbacks", () => ({
48
- useTimelineEditCallbacks: () => ({}),
21
+ vi.mock("./nle/PreviewOverlays", () => ({
22
+ PreviewOverlays: () => {
23
+ previewOverlaysProbe.readOnly = usePreviewReadOnly();
24
+ return null;
25
+ },
49
26
  }));
50
- vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
51
- vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
52
- vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
53
- vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
54
27
 
55
28
  Object.assign(globalThis, {
56
29
  IS_REACT_ACT_ENVIRONMENT: true,
@@ -66,6 +39,36 @@ afterEach(() => {
66
39
  hookMocks.useTimelineSelectionPreviewSync.mockClear();
67
40
  });
68
41
 
42
+ const shell = (readOnlyPreview?: boolean) => (
43
+ <EditorShell
44
+ panels={null}
45
+ timelineToolbar={null}
46
+ renderClipContent={() => null}
47
+ handleTimelineElementDelete={vi.fn()}
48
+ handleTimelineAssetDrop={vi.fn()}
49
+ handleTimelineFileDrop={vi.fn()}
50
+ handleTimelineElementMove={vi.fn()}
51
+ handleTimelineElementsMove={vi.fn()}
52
+ handleTimelineElementResize={vi.fn()}
53
+ handleTimelineGroupResize={vi.fn()}
54
+ handleToggleTrackHidden={vi.fn()}
55
+ setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
56
+ handleBlockedTimelineEdit={vi.fn()}
57
+ handleTimelineElementSplit={vi.fn()}
58
+ handleRazorSplit={vi.fn()}
59
+ handleRazorSplitAll={vi.fn()}
60
+ onCopyClip={vi.fn(() => false)}
61
+ onPasteClip={vi.fn(async () => {})}
62
+ onDuplicateClip={vi.fn(async () => false)}
63
+ canPasteClip={vi.fn(() => false)}
64
+ setCompIdToSrc={vi.fn()}
65
+ setCompositionLoading={vi.fn()}
66
+ shouldShowMotionPath={false}
67
+ shouldShowSelectedDomBounds={false}
68
+ readOnlyPreview={readOnlyPreview}
69
+ />
70
+ );
71
+
69
72
  describe("EditorShell timeline selection sync", () => {
70
73
  it("keeps the timeline store mirrored into the preview selection", () => {
71
74
  const host = document.createElement("div");
@@ -73,34 +76,7 @@ describe("EditorShell timeline selection sync", () => {
73
76
  const root = createRoot(host);
74
77
 
75
78
  act(() => {
76
- root.render(
77
- <EditorShell
78
- panels={null}
79
- timelineToolbar={null}
80
- renderClipContent={() => null}
81
- handleTimelineElementDelete={vi.fn()}
82
- handleTimelineAssetDrop={vi.fn()}
83
- handleTimelineFileDrop={vi.fn()}
84
- handleTimelineElementMove={vi.fn()}
85
- handleTimelineElementsMove={vi.fn()}
86
- handleTimelineElementResize={vi.fn()}
87
- handleTimelineGroupResize={vi.fn()}
88
- handleToggleTrackHidden={vi.fn()}
89
- setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
90
- handleBlockedTimelineEdit={vi.fn()}
91
- handleTimelineElementSplit={vi.fn()}
92
- handleRazorSplit={vi.fn()}
93
- handleRazorSplitAll={vi.fn()}
94
- onCopyClip={vi.fn(() => false)}
95
- onPasteClip={vi.fn(async () => {})}
96
- onDuplicateClip={vi.fn(async () => false)}
97
- canPasteClip={vi.fn(() => false)}
98
- setCompIdToSrc={vi.fn()}
99
- setCompositionLoading={vi.fn()}
100
- shouldShowMotionPath={false}
101
- shouldShowSelectedDomBounds={false}
102
- />,
103
- );
79
+ root.render(shell());
104
80
  });
105
81
 
106
82
  expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce();
@@ -115,4 +91,18 @@ describe("EditorShell timeline selection sync", () => {
115
91
 
116
92
  act(() => root.unmount());
117
93
  });
94
+
95
+ it("threads the read-only prop into descendants via context, and follows it across renders", () => {
96
+ const host = document.createElement("div");
97
+ document.body.append(host);
98
+ const root = createRoot(host);
99
+
100
+ act(() => root.render(shell(true)));
101
+ expect(previewOverlaysProbe.readOnly).toBe(true);
102
+ act(() => root.render(shell(false)));
103
+ expect(previewOverlaysProbe.readOnly).toBe(false);
104
+ act(() => root.render(shell(true)));
105
+ expect(previewOverlaysProbe.readOnly).toBe(true);
106
+ act(() => root.unmount());
107
+ });
118
108
  });
@@ -2,6 +2,7 @@ import { useCallback, type ReactNode } from "react";
2
2
  import { PreviewPane } from "./nle/PreviewPane";
3
3
  import { TimelinePane } from "./nle/TimelinePane";
4
4
  import { PreviewOverlays } from "./nle/PreviewOverlays";
5
+ import { PreviewReadOnlyProvider } from "./editor/previewReadOnlyContext";
5
6
  import {
6
7
  useTimelineEditCallbacks,
7
8
  type TimelineEditCallbackDeps,
@@ -69,6 +70,10 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
69
70
  * fullscreen and during a block preview; below the selection overlay past z-index 10.
70
71
  */
71
72
  gestureOverlay?: ReactNode;
73
+ /** Clicks still select and report; the preview cannot move, edit or delete anything. */
74
+ readOnlyPreview?: boolean;
75
+ /** Short text shown on disabled hand-edit controls while `readOnlyPreview` is set. */
76
+ readOnlyPreviewReason?: string;
72
77
  }
73
78
 
74
79
  // The dockable shell: every panel lives in one Dock, arranged by the user's
@@ -109,6 +114,8 @@ export function EditorShell({
109
114
  onToggleRecording,
110
115
  blockPreview,
111
116
  gestureOverlay,
117
+ readOnlyPreview = false,
118
+ readOnlyPreviewReason,
112
119
  }: EditorShellProps) {
113
120
  const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } =
114
121
  useStudioShellContext();
@@ -156,57 +163,60 @@ export function EditorShell({
156
163
  });
157
164
 
158
165
  return (
159
- <div className="flex flex-col flex-1 min-h-0">
160
- <TimelineEditProvider value={timelineEditCallbacks}>
161
- <NLEProvider
162
- projectId={projectId}
163
- refreshKey={refreshKey}
164
- activeCompositionPath={activeCompPath}
165
- onIframeRef={handlePreviewIframeRef}
166
- onCompIdToSrcChange={setCompIdToSrc}
167
- onCompositionLoadingChange={setCompositionLoading}
168
- onCompositionChange={(compPath) => {
169
- // Sync activeCompPath when the user drills down via the timeline or
170
- // navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to
171
- // avoid circular refresh cascades (activeCompPath → stack → onChange).
172
- if (compPath !== activeCompPath) {
173
- setActiveCompPath(compPath);
174
- refreshPreviewDocumentVersion();
175
- }
176
- }}
177
- >
178
- <EditorShellBody
166
+ <PreviewReadOnlyProvider readOnly={readOnlyPreview} reason={readOnlyPreviewReason}>
167
+ <div className="flex flex-col flex-1 min-h-0">
168
+ <TimelineEditProvider value={timelineEditCallbacks}>
169
+ <NLEProvider
179
170
  projectId={projectId}
180
- panels={panels}
181
- captionEditMode={captionEditMode}
182
- onSelectTimelineElement={handleTimelineElementSelect}
183
- onPreviewBlockDrop={handlePreviewBlockDrop}
184
- timelineToolbar={timelineToolbar}
185
- renderClipContent={renderClipContent}
186
- onFileDrop={handleTimelineFileDrop}
187
- onAssetDrop={handleTimelineAssetDrop}
188
- onBlockDrop={handleTimelineBlockDrop}
189
- onCompositionDrop={handleTimelineCompositionDrop}
190
- onDeleteElement={handleTimelineElementDelete}
191
- onCopyClip={onCopyClip}
192
- onPasteClip={onPasteClip}
193
- onDuplicateClip={onDuplicateClip}
194
- canPasteClip={canPasteClip}
195
- previewOverlay={
196
- <PreviewOverlays
197
- shouldShowMotionPath={shouldShowMotionPath}
198
- shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
199
- blockPreview={blockPreview}
200
- isGestureRecording={isGestureRecording}
201
- recordingState={recordingState}
202
- onToggleRecording={onToggleRecording}
203
- gestureOverlay={gestureOverlay}
204
- />
205
- }
206
- />
207
- </NLEProvider>
208
- </TimelineEditProvider>
209
- </div>
171
+ refreshKey={refreshKey}
172
+ activeCompositionPath={activeCompPath}
173
+ onIframeRef={handlePreviewIframeRef}
174
+ onCompIdToSrcChange={setCompIdToSrc}
175
+ onPreviewReloadFailed={(message) => showToast(message, "error")}
176
+ onCompositionLoadingChange={setCompositionLoading}
177
+ onCompositionChange={(compPath) => {
178
+ // Sync activeCompPath when the user drills down via the timeline or
179
+ // navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to
180
+ // avoid circular refresh cascades (activeCompPath → stack → onChange).
181
+ if (compPath !== activeCompPath) {
182
+ setActiveCompPath(compPath);
183
+ refreshPreviewDocumentVersion();
184
+ }
185
+ }}
186
+ >
187
+ <EditorShellBody
188
+ projectId={projectId}
189
+ panels={panels}
190
+ captionEditMode={captionEditMode}
191
+ onSelectTimelineElement={handleTimelineElementSelect}
192
+ onPreviewBlockDrop={handlePreviewBlockDrop}
193
+ timelineToolbar={timelineToolbar}
194
+ renderClipContent={renderClipContent}
195
+ onFileDrop={handleTimelineFileDrop}
196
+ onAssetDrop={handleTimelineAssetDrop}
197
+ onBlockDrop={handleTimelineBlockDrop}
198
+ onCompositionDrop={handleTimelineCompositionDrop}
199
+ onDeleteElement={handleTimelineElementDelete}
200
+ onCopyClip={onCopyClip}
201
+ onPasteClip={onPasteClip}
202
+ onDuplicateClip={onDuplicateClip}
203
+ canPasteClip={canPasteClip}
204
+ previewOverlay={
205
+ <PreviewOverlays
206
+ shouldShowMotionPath={shouldShowMotionPath}
207
+ shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
208
+ blockPreview={blockPreview}
209
+ isGestureRecording={isGestureRecording}
210
+ recordingState={recordingState}
211
+ onToggleRecording={onToggleRecording}
212
+ gestureOverlay={gestureOverlay}
213
+ />
214
+ }
215
+ />
216
+ </NLEProvider>
217
+ </TimelineEditProvider>
218
+ </div>
219
+ </PreviewReadOnlyProvider>
210
220
  );
211
221
  }
212
222
 
@@ -255,7 +265,7 @@ function EditorShellBody({
255
265
  onDuplicateClip,
256
266
  canPasteClip,
257
267
  }: EditorShellBodyProps) {
258
- const { compositionStack, updateCompositionStack } = useNLEContext();
268
+ const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
259
269
 
260
270
  // The caption track's blocks are seek targets; CaptionTimeline took an onSeek
261
271
  // prop that nothing ever passed, so clicking a block did nothing.
@@ -275,6 +285,7 @@ function EditorShellBody({
275
285
 
276
286
  return (
277
287
  <div
288
+ ref={containerRef}
278
289
  // Shell canvas is a step LIGHTER than the near-black panel cards so the
279
290
  // gaps between panels read as visible seams (CapCut-style).
280
291
  className="flex flex-col flex-1 min-h-0 bg-panel-surface"
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { vi } from "vitest";
3
+
4
+ vi.mock("../contexts/StudioContext", () => ({
5
+ useStudioPlaybackContext: () => ({
6
+ captionEditMode: false,
7
+ refreshKey: 0,
8
+ refreshPreviewDocumentVersion: vi.fn(),
9
+ timelineElements: [],
10
+ }),
11
+ useStudioShellContext: () => ({
12
+ projectId: "project-1",
13
+ activeCompPath: "index.html",
14
+ setActiveCompPath: vi.fn(),
15
+ handlePreviewIframeRef: vi.fn(),
16
+ showToast: vi.fn(),
17
+ }),
18
+ }));
19
+
20
+ vi.mock("../contexts/DomEditContext", () => ({
21
+ useDomEditActionsContext: () => ({
22
+ handleTimelineElementSelect: vi.fn(),
23
+ buildDomSelectionForTimelineElement: vi.fn(),
24
+ applyDomSelection: vi.fn(),
25
+ applyMarqueeSelection: vi.fn(),
26
+ }),
27
+ useDomEditSelectionContext: () => ({
28
+ domEditSelection: null,
29
+ domEditGroupSelections: [],
30
+ }),
31
+ }));
32
+
33
+ vi.mock("./nle/NLEContext", () => ({
34
+ NLEProvider: ({ children }: { children: React.ReactNode }) => children,
35
+ useNLEContext: () => ({
36
+ compositionStack: [],
37
+ updateCompositionStack: vi.fn(),
38
+ containerRef: { current: null },
39
+ }),
40
+ }));
41
+
42
+ vi.mock("./nle/useTimelineEditCallbacks", () => ({
43
+ useTimelineEditCallbacks: () => ({}),
44
+ }));
45
+ vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
46
+ vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));