@hyperframes/studio 0.8.55 → 0.8.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/assets/{hyperframes-player-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
  2. package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
  3. package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
  4. package/dist/assets/{index-BEIRytvV.js → index-DFlILHJe.js} +1 -1
  5. package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
  6. package/dist/index.d.ts +16 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3111 -2998
  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 +14 -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 +59 -50
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/editor/DomEditOverlay.tsx +19 -40
  18. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  19. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  20. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  21. package/src/components/editor/PropertyPanel.tsx +4 -3
  22. package/src/components/editor/SnapToolbar.test.tsx +1 -0
  23. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  25. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  26. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  27. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  29. package/src/components/editor/useDomEditNudge.ts +9 -0
  30. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  31. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  32. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  33. package/src/components/nle/PreviewOverlays.tsx +15 -8
  34. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  35. package/src/components/nle/TimelinePane.tsx +5 -1
  36. package/src/components/nle/previewReadOnly.test.tsx +128 -0
  37. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  38. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  39. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  40. package/src/hooks/appHotkeysDispatch.ts +16 -1
  41. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  42. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  43. package/src/hooks/timelineTrackVisibility.ts +7 -8
  44. package/src/hooks/useAppHotkeys.test.ts +71 -3
  45. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  46. package/src/hooks/useAppHotkeys.ts +4 -0
  47. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  48. package/src/hooks/useDomEditCommits.ts +3 -0
  49. package/src/hooks/useDomEditSession.test.tsx +1 -0
  50. package/src/hooks/useDomEditSession.ts +7 -4
  51. package/src/hooks/useDomEditWiring.ts +40 -40
  52. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  53. package/src/hooks/useDomGeometryCommits.ts +26 -20
  54. package/src/hooks/useGestureCommit.test.tsx +29 -0
  55. package/src/hooks/useGestureCommit.ts +23 -0
  56. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  57. package/src/hooks/useInlineTextEdit.ts +11 -2
  58. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  59. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  60. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  61. package/src/hooks/useTimelineEditing.ts +2 -7
  62. package/src/player/components/Timeline.tsx +22 -23
  63. package/src/player/components/TimelineClip.test.tsx +52 -0
  64. package/src/player/components/TimelineClip.tsx +13 -2
  65. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  66. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  67. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  68. package/src/player/components/timelineTheme.test.ts +4 -4
  69. package/src/player/components/timelineTheme.ts +34 -30
  70. package/src/player/components/timelineTrackDisplay.ts +1 -4
  71. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  72. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  73. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  74. package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
  75. package/src/player/store/playerStore.ts +0 -4
  76. package/src/player/store/timelineElement.ts +2 -4
  77. package/src/player/store/timelineResetState.ts +0 -1
  78. package/src/styles/hex-baseline.json +3 -4
  79. package/src/styles/studio.css +7 -7
  80. package/src/styles/theme.css +29 -0
  81. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  82. 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.56",
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/player": "0.8.56",
55
+ "@hyperframes/sdk": "0.8.56",
56
+ "@hyperframes/core": "0.8.56",
57
+ "@hyperframes/studio-server": "0.8.56",
58
+ "@hyperframes/parsers": "0.8.56"
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.56"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
package/src/App.tsx CHANGED
@@ -63,8 +63,16 @@ import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
63
63
  import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
64
64
  import { useActiveComposition } from "./hooks/useActiveComposition";
65
65
  const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
66
+
67
+ export interface StudioAppProps {
68
+ /** Clicks still select and report; the preview cannot move, edit or delete anything. */
69
+ readOnlyPreview?: boolean;
70
+ /** Short text shown on disabled hand-edit controls while `readOnlyPreview` is set. */
71
+ readOnlyPreviewReason?: string;
72
+ }
73
+
66
74
  // fallow-ignore-next-line complexity
67
- export function StudioApp() {
75
+ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: StudioAppProps = {}) {
68
76
  const { projectId, resolving, waitingForServer } = useServerConnection();
69
77
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
70
78
  useStudioSessionStart(projectId, resolving, waitingForServer);
@@ -252,6 +260,7 @@ export function StudioApp() {
252
260
  activeCompPath,
253
261
  forceReloadSdkSession: sdkHandle.forceReload,
254
262
  onToggleRecording: () => handleToggleRecordingRef.current(),
263
+ readOnlyPreview,
255
264
  });
256
265
  const domEditSession = useDomEditSession({
257
266
  projectId,
@@ -291,6 +300,7 @@ export function StudioApp() {
291
300
  publishSdkSession: sdkHandle.publish,
292
301
  forceReloadSdkSession: sdkHandle.forceReload,
293
302
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
303
+ readOnlyPreview,
294
304
  });
295
305
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
296
306
  handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
@@ -346,6 +356,7 @@ export function StudioApp() {
346
356
  previewIframeRef,
347
357
  showToast,
348
358
  isGestureRecordingRef,
359
+ readOnlyPreview,
349
360
  });
350
361
  handleToggleRecordingRef.current = handleToggleRecording;
351
362
  const canvasRectRef = useRef<DOMRect | null>(null);
@@ -465,6 +476,8 @@ export function StudioApp() {
465
476
  )}
466
477
  <ExternalFileConflictBanner coordinator={externalFileChanges} />
467
478
  <EditorShell
479
+ readOnlyPreview={readOnlyPreview}
480
+ readOnlyPreviewReason={readOnlyPreviewReason}
468
481
  panels={
469
482
  <>
470
483
  <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,59 @@ 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
+ onCompositionLoadingChange={setCompositionLoading}
176
+ onCompositionChange={(compPath) => {
177
+ // Sync activeCompPath when the user drills down via the timeline or
178
+ // navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to
179
+ // avoid circular refresh cascades (activeCompPath → stack → onChange).
180
+ if (compPath !== activeCompPath) {
181
+ setActiveCompPath(compPath);
182
+ refreshPreviewDocumentVersion();
183
+ }
184
+ }}
185
+ >
186
+ <EditorShellBody
187
+ projectId={projectId}
188
+ panels={panels}
189
+ captionEditMode={captionEditMode}
190
+ onSelectTimelineElement={handleTimelineElementSelect}
191
+ onPreviewBlockDrop={handlePreviewBlockDrop}
192
+ timelineToolbar={timelineToolbar}
193
+ renderClipContent={renderClipContent}
194
+ onFileDrop={handleTimelineFileDrop}
195
+ onAssetDrop={handleTimelineAssetDrop}
196
+ onBlockDrop={handleTimelineBlockDrop}
197
+ onCompositionDrop={handleTimelineCompositionDrop}
198
+ onDeleteElement={handleTimelineElementDelete}
199
+ onCopyClip={onCopyClip}
200
+ onPasteClip={onPasteClip}
201
+ onDuplicateClip={onDuplicateClip}
202
+ canPasteClip={canPasteClip}
203
+ previewOverlay={
204
+ <PreviewOverlays
205
+ shouldShowMotionPath={shouldShowMotionPath}
206
+ shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
207
+ blockPreview={blockPreview}
208
+ isGestureRecording={isGestureRecording}
209
+ recordingState={recordingState}
210
+ onToggleRecording={onToggleRecording}
211
+ gestureOverlay={gestureOverlay}
212
+ />
213
+ }
214
+ />
215
+ </NLEProvider>
216
+ </TimelineEditProvider>
217
+ </div>
218
+ </PreviewReadOnlyProvider>
210
219
  );
211
220
  }
212
221
 
@@ -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 }));
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
1
+ import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
2
2
  import { type DomEditSelection } from "./domEditing";
3
3
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
@@ -16,7 +16,7 @@ import {
16
16
  resolveShiftClickCandidate,
17
17
  } from "./domEditOverlayGestures";
18
18
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
19
- import { ChildRectOutlines, OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
19
+ import { ChildRectOutlines, OffCanvasIndicators } from "./OffCanvasIndicators";
20
20
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
21
21
  import { useDomEditNudge } from "./useDomEditNudge";
22
22
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
@@ -27,13 +27,13 @@ import { hugRectForElement } from "./domEditOverlayCrop";
27
27
  import { useCropOverlay } from "../../hooks/useCropOverlay";
28
28
  import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
29
29
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
30
- import { useMountEffect } from "../../hooks/useMountEffect";
31
- import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
32
30
  import { CanvasContextMenu } from "./CanvasContextMenu";
33
31
  import { useInlineTextEditing } from "./useInlineTextEditing";
32
+ import { usePreviewReadOnly } from "./previewReadOnlyContext";
34
33
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
35
34
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
36
35
  import { logSelect } from "../../utils/selectDebug";
36
+ import { useOffCanvasIndicators } from "./useOffCanvasIndicators";
37
37
 
38
38
  // Re-exports for external consumers — preserving existing import paths.
39
39
  export {
@@ -137,6 +137,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
137
137
  onDeleteSelection,
138
138
  onApplyZIndex,
139
139
  }: DomEditOverlayProps) {
140
+ const readOnly = usePreviewReadOnly();
140
141
  const overlayRef = useRef<HTMLDivElement | null>(null);
141
142
  const boxRef = useRef<HTMLDivElement | null>(null);
142
143
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
@@ -220,39 +221,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
220
221
  const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
221
222
  const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
222
223
 
223
- // Off-canvas element indicators — dashed outlines for elements positioned
224
- // outside the composition bounds so users can find them.
225
- const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
226
- const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
227
- const offCanvasDirtyRef = useRef(true);
228
- const offCanvasSigRef = useRef("");
229
- const offCanvasObserverRef = useRef<MutationObserver | null>(null);
230
- const offCanvasObservedDocRef = useRef<Document | null>(null);
231
-
232
- // Positions depend on live iframe layout, not selection — the selected-element
233
- // suppression is a render-time filter, so selection/groupSelections stay out
234
- // of the geometry walk.
235
- useMountEffect(() =>
236
- startOffCanvasIndicatorRefresh({
237
- iframeRef,
238
- overlayRef,
239
- compRectRef,
240
- activeCompositionPathRef,
241
- dirtyRef: offCanvasDirtyRef,
242
- sigRef: offCanvasSigRef,
243
- observerRef: offCanvasObserverRef,
244
- observedDocRef: offCanvasObservedDocRef,
245
- elementsRef: offCanvasElementsRef,
246
- setRects: setOffCanvasRects,
247
- }),
248
- );
249
-
250
- // Switching compositions may not swap the iframe document (so the observer's
251
- // doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
252
- // Force a recompute explicitly on comp change.
253
- useEffect(() => {
254
- offCanvasDirtyRef.current = true;
255
- }, [activeCompositionPath]);
224
+ const { offCanvasRects, offCanvasElementsRef } = useOffCanvasIndicators({
225
+ iframeRef,
226
+ overlayRef,
227
+ compRectRef,
228
+ activeCompositionPathRef,
229
+ activeCompositionPath,
230
+ });
256
231
 
257
232
  const gestures = createDomEditOverlayGestureHandlers({
258
233
  overlayRef,
@@ -280,12 +255,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
280
255
  snapGuidesRef,
281
256
  });
282
257
 
258
+ useEffect(() => {
259
+ if (readOnly) gestures.clearPointerState(selectionRef);
260
+ }, [gestures, readOnly, selectionRef]);
261
+
283
262
  // Arrow-key nudge (1px, Shift = 10px) — commits through the same
284
263
  // path-offset callbacks as a drag, one undo entry per key burst.
285
264
  const { flushNudge } = useDomEditNudge({
286
265
  selection,
287
266
  groupSelections,
288
- allowCanvasMovement,
267
+ allowCanvasMovement: allowCanvasMovement && !readOnly,
289
268
  selectionRef,
290
269
  overlayRectRef,
291
270
  groupOverlayItemsRef,
@@ -558,7 +537,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
558
537
  selection={contextMenu.sel}
559
538
  onClose={closeContextMenu}
560
539
  onDelete={
561
- onDeleteSelection
540
+ onDeleteSelection && !readOnly
562
541
  ? (sel) => {
563
542
  closeContextMenu();
564
543
  onDeleteSelection(sel);
@@ -566,7 +545,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
566
545
  : undefined
567
546
  }
568
547
  onApplyZIndex={
569
- onApplyZIndex
548
+ onApplyZIndex && !readOnly
570
549
  ? (patches, action, crossed) => {
571
550
  onApplyZIndex(contextMenu.sel, patches, action, crossed);
572
551
  }