@hyperframes/studio 0.8.54 → 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 (90) hide show
  1. package/dist/assets/{hyperframes-player-Cy_AwUa8.js → hyperframes-player-k8LzMtXX.js} +1 -1
  2. package/dist/assets/{index-DPUJoO8n.js → index-BlkJ_Axe.js} +192 -192
  3. package/dist/assets/{index-CTa-6Kso.js → index-CxR7F2YQ.js} +1 -1
  4. package/dist/assets/{index-DkAzKuPZ.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 +3152 -3010
  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/gsapDragCommit.ts +1 -1
  42. package/src/hooks/gsapEditOutcome.test.ts +115 -0
  43. package/src/hooks/gsapEditOutcome.ts +39 -10
  44. package/src/hooks/gsapResizeIntercept.test.ts +42 -3
  45. package/src/hooks/gsapResizeIntercept.ts +12 -2
  46. package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
  47. package/src/hooks/gsapRuntimeBridge.ts +14 -3
  48. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  49. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  50. package/src/hooks/timelineTrackVisibility.ts +7 -8
  51. package/src/hooks/useAppHotkeys.test.ts +71 -3
  52. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  53. package/src/hooks/useAppHotkeys.ts +4 -0
  54. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  55. package/src/hooks/useDomEditCommits.ts +3 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -0
  57. package/src/hooks/useDomEditSession.ts +7 -4
  58. package/src/hooks/useDomEditWiring.ts +40 -40
  59. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  60. package/src/hooks/useDomGeometryCommits.ts +26 -20
  61. package/src/hooks/useGestureCommit.test.tsx +29 -0
  62. package/src/hooks/useGestureCommit.ts +23 -0
  63. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  64. package/src/hooks/useInlineTextEdit.ts +11 -2
  65. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  66. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  67. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  68. package/src/hooks/useTimelineEditing.ts +2 -7
  69. package/src/player/components/Timeline.tsx +22 -23
  70. package/src/player/components/TimelineClip.test.tsx +52 -0
  71. package/src/player/components/TimelineClip.tsx +13 -2
  72. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  73. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  74. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  75. package/src/player/components/timelineTheme.test.ts +4 -4
  76. package/src/player/components/timelineTheme.ts +34 -30
  77. package/src/player/components/timelineTrackDisplay.ts +1 -4
  78. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  79. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  80. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  81. package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
  82. package/src/player/store/playerStore.ts +0 -4
  83. package/src/player/store/timelineElement.ts +2 -4
  84. package/src/player/store/timelineResetState.ts +0 -1
  85. package/src/styles/hex-baseline.json +3 -4
  86. package/src/styles/studio.css +7 -7
  87. package/src/styles/theme.css +29 -0
  88. package/src/utils/studioSaveDiagnostics.ts +12 -0
  89. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  90. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -109,46 +109,46 @@ export interface UseDomEditWiringParams {
109
109
  }
110
110
 
111
111
  // fallow-ignore-next-line complexity
112
- export function useDomEditWiring({
113
- // fallow-ignore-next-line code-duplication
114
- projectId,
115
- activeCompPath,
116
- domEditSelection,
117
- domEditSelectionRef,
118
- domEditGroupSelectionsRef,
119
- refreshDomEditGroupSelectionsFromPreview,
120
- previewIframeRef,
121
- previewIframe,
122
- captionEditMode,
123
- refreshKey,
124
- gsapCacheVersion,
125
- bumpGsapCache,
126
- showToast,
127
- refreshPreviewDocumentVersion,
128
- syncPreviewHotkeys,
129
- applyStudioManualEditsToPreviewRef,
130
- applyDomSelection,
131
- buildDomSelectionFromTarget,
132
- openSourceForSelection,
133
- updateGsapProperty,
134
- updateGsapMeta,
135
- deleteGsapAnimation,
136
- deleteAllForSelector,
137
- addGsapAnimation,
138
- addGsapProperty,
139
- removeGsapProperty,
140
- updateGsapFromProperty,
141
- addGsapFromProperty,
142
- removeGsapFromProperty,
143
- addKeyframe,
144
- addKeyframeBatch,
145
- removeKeyframe,
146
- moveKeyframe,
147
- resizeKeyframedTween,
148
- convertToKeyframes,
149
- removeAllKeyframes,
150
- handleDomManualEditsReset,
151
- }: UseDomEditWiringParams) {
112
+ export function useDomEditWiring(params: UseDomEditWiringParams) {
113
+ const {
114
+ projectId,
115
+ activeCompPath,
116
+ domEditSelection,
117
+ domEditSelectionRef,
118
+ domEditGroupSelectionsRef,
119
+ refreshDomEditGroupSelectionsFromPreview,
120
+ previewIframeRef,
121
+ previewIframe,
122
+ captionEditMode,
123
+ refreshKey,
124
+ gsapCacheVersion,
125
+ bumpGsapCache,
126
+ showToast,
127
+ refreshPreviewDocumentVersion,
128
+ syncPreviewHotkeys,
129
+ applyStudioManualEditsToPreviewRef,
130
+ applyDomSelection,
131
+ buildDomSelectionFromTarget,
132
+ openSourceForSelection,
133
+ updateGsapProperty,
134
+ updateGsapMeta,
135
+ deleteGsapAnimation,
136
+ deleteAllForSelector,
137
+ addGsapAnimation,
138
+ addGsapProperty,
139
+ removeGsapProperty,
140
+ updateGsapFromProperty,
141
+ addGsapFromProperty,
142
+ removeGsapFromProperty,
143
+ addKeyframe,
144
+ addKeyframeBatch,
145
+ removeKeyframe,
146
+ moveKeyframe,
147
+ resizeKeyframedTween,
148
+ convertToKeyframes,
149
+ removeAllKeyframes,
150
+ handleDomManualEditsReset,
151
+ } = params;
152
152
  // ── Click-to-source navigation ──
153
153
 
154
154
  const onClickToSource = useCallback(
@@ -12,10 +12,30 @@ import {
12
12
  readStudioPathOffset,
13
13
  readStudioRotation,
14
14
  } from "../components/editor/manualEdits";
15
- import { useDomGeometryCommits } from "./useDomGeometryCommits";
15
+ import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
16
16
 
17
17
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
18
 
19
+ function mountCommits(
20
+ commitPositionPatchToHtml: UseDomGeometryCommitsParams["commitPositionPatchToHtml"],
21
+ readOnlyPreview = false,
22
+ ) {
23
+ let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
24
+ function Probe() {
25
+ commits = useDomGeometryCommits({
26
+ previewIframeRef: { current: null },
27
+ showToast: vi.fn(),
28
+ commitPositionPatchToHtml,
29
+ readOnlyPreview,
30
+ });
31
+ return null;
32
+ }
33
+ const host = document.createElement("div");
34
+ const root = createRoot(host);
35
+ act(() => root.render(<Probe />));
36
+ return { commits: () => commits!, unmount: () => act(() => root.unmount()) };
37
+ }
38
+
19
39
  describe("useDomGeometryCommits rollback", () => {
20
40
  it("restores every optimistic geometry mutation when persistence rejects", async () => {
21
41
  const element = document.createElement("div");
@@ -30,7 +50,9 @@ describe("useDomGeometryCommits rollback", () => {
30
50
  element,
31
51
  } as unknown as DomEditSelection;
32
52
  const failure = new Error("save failed");
33
- const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
53
+ const commitPositionPatchToHtml = vi
54
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
55
+ .mockRejectedValue(failure);
34
56
  let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
35
57
  const host = document.createElement("div");
36
58
  const root = createRoot(host);
@@ -40,6 +62,7 @@ describe("useDomGeometryCommits rollback", () => {
40
62
  previewIframeRef: { current: null },
41
63
  showToast: vi.fn(),
42
64
  commitPositionPatchToHtml,
65
+ readOnlyPreview: false,
43
66
  });
44
67
  return null;
45
68
  }
@@ -60,3 +83,63 @@ describe("useDomGeometryCommits rollback", () => {
60
83
  act(() => root.unmount());
61
84
  });
62
85
  });
86
+
87
+ describe("useDomGeometryCommits read-only preview", () => {
88
+ const selectionOn = (element: HTMLElement) =>
89
+ ({ id: element.id, selector: `#${element.id}`, element }) as unknown as DomEditSelection;
90
+
91
+ it("refuses a manual offset commit: no write, no history entry", async () => {
92
+ const element = document.createElement("div");
93
+ element.id = "ro-offset";
94
+ document.body.append(element);
95
+ applyStudioPathOffset(element, { x: 1, y: 2 });
96
+ const commitPositionPatchToHtml =
97
+ vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
98
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
99
+ await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 99, y: 99 });
100
+ expect(readStudioPathOffset(element)).toEqual({ x: 1, y: 2 });
101
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
102
+ unmount();
103
+ });
104
+
105
+ it("refuses a manual box-size commit: no write, no history entry", async () => {
106
+ const element = document.createElement("div");
107
+ element.id = "ro-size";
108
+ document.body.append(element);
109
+ applyStudioBoxSize(element, { width: 10, height: 20 });
110
+ const commitPositionPatchToHtml =
111
+ vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
112
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
113
+ await commits().handleDomBoxSizeCommit(selectionOn(element), { width: 999, height: 999 });
114
+ expect(readStudioBoxSize(element)).toEqual({ width: 10, height: 20 });
115
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
116
+ unmount();
117
+ });
118
+
119
+ it("refuses a manual rotation commit: no write, no history entry", async () => {
120
+ const element = document.createElement("div");
121
+ element.id = "ro-rotate";
122
+ document.body.append(element);
123
+ applyStudioRotation(element, { angle: 5 });
124
+ const commitPositionPatchToHtml =
125
+ vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
126
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
127
+ await commits().handleDomRotationCommit(selectionOn(element), { angle: 350 });
128
+ expect(readStudioRotation(element)).toEqual({ angle: 5 });
129
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
130
+ unmount();
131
+ });
132
+
133
+ it("still commits an offset with the flag off", async () => {
134
+ const element = document.createElement("div");
135
+ element.id = "rw-offset";
136
+ document.body.append(element);
137
+ const commitPositionPatchToHtml = vi
138
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
139
+ .mockResolvedValue(undefined);
140
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
141
+ await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 5, y: 6 });
142
+ expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
143
+ unmount();
144
+ });
145
+ });
@@ -28,9 +28,20 @@ import { isElementGsapTargeted } from "./gsapTargetCache";
28
28
  const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
29
29
  "This element is GSAP-animated — dragging via CSS would corrupt keyframes";
30
30
 
31
+ function rejectGsapCssFallback(
32
+ selection: DomEditSelection,
33
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
34
+ showToast: (message: string, tone?: "error" | "info") => void,
35
+ ): Promise<never> | null {
36
+ if (!isElementGsapTargeted(previewIframeRef.current, selection.element)) return null;
37
+ const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
38
+ showToast(error.message, "error");
39
+ return Promise.reject(error);
40
+ }
41
+
31
42
  // ── Hook ──
32
43
 
33
- interface UseDomGeometryCommitsParams {
44
+ export interface UseDomGeometryCommitsParams {
34
45
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
35
46
  showToast: (message: string, tone?: "error" | "info") => void;
36
47
  commitPositionPatchToHtml: (
@@ -38,25 +49,24 @@ interface UseDomGeometryCommitsParams {
38
49
  patches: PatchOperation[],
39
50
  options: { label: string; coalesceKey: string; skipRefresh?: boolean },
40
51
  ) => Promise<void>;
52
+ readOnlyPreview: boolean;
41
53
  }
42
54
 
43
55
  export function useDomGeometryCommits({
44
56
  previewIframeRef,
45
57
  showToast,
46
58
  commitPositionPatchToHtml,
59
+ readOnlyPreview,
47
60
  }: UseDomGeometryCommitsParams) {
48
61
  const handleDomPathOffsetCommit = useCallback(
49
62
  (selection: DomEditSelection, next: { x: number; y: number }) => {
63
+ if (readOnlyPreview) return Promise.resolve();
50
64
  // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
51
65
  // elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
52
66
  // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
53
67
  // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
54
- const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element);
55
- if (gsapTargeted) {
56
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
57
- showToast(error.message, "error");
58
- return Promise.reject(error);
59
- }
68
+ const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
69
+ if (gsapFallback) return gsapFallback;
60
70
  const before = captureStudioPathOffset(selection.element);
61
71
  applyStudioPathOffset(selection.element, next);
62
72
  return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
@@ -67,7 +77,7 @@ export function useDomGeometryCommits({
67
77
  throw error;
68
78
  });
69
79
  },
70
- [commitPositionPatchToHtml, previewIframeRef, showToast],
80
+ [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
71
81
  );
72
82
 
73
83
  const handleDomBoxSizeCommit = useCallback(
@@ -76,11 +86,9 @@ export function useDomGeometryCommits({
76
86
  next: { width: number; height: number },
77
87
  offset?: { x: number; y: number },
78
88
  ) => {
79
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
80
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
81
- showToast(error.message, "error");
82
- return Promise.reject(error);
83
- }
89
+ if (readOnlyPreview) return Promise.resolve();
90
+ const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
91
+ if (gsapFallback) return gsapFallback;
84
92
  const beforeSize = captureStudioBoxSize(selection.element);
85
93
  const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
86
94
  applyStudioBoxSize(selection.element, next);
@@ -104,16 +112,14 @@ export function useDomGeometryCommits({
104
112
  throw error;
105
113
  });
106
114
  },
107
- [commitPositionPatchToHtml, previewIframeRef, showToast],
115
+ [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
108
116
  );
109
117
 
110
118
  const handleDomRotationCommit = useCallback(
111
119
  (selection: DomEditSelection, next: { angle: number }) => {
112
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
113
- const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
114
- showToast(error.message, "error");
115
- return Promise.reject(error);
116
- }
120
+ if (readOnlyPreview) return Promise.resolve();
121
+ const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
122
+ if (gsapFallback) return gsapFallback;
117
123
  const before = captureStudioRotation(selection.element);
118
124
  applyStudioRotation(selection.element, next);
119
125
  return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
@@ -124,7 +130,7 @@ export function useDomGeometryCommits({
124
130
  throw error;
125
131
  });
126
132
  },
127
- [commitPositionPatchToHtml, previewIframeRef, showToast],
133
+ [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
128
134
  );
129
135
 
130
136
  const handleDomManualEditsReset = useCallback(
@@ -107,6 +107,7 @@ describe("useGestureCommit", () => {
107
107
  previewIframeRef: { current: iframe },
108
108
  showToast: vi.fn(),
109
109
  isGestureRecordingRef: { current: false },
110
+ readOnlyPreview: false,
110
111
  });
111
112
  return null;
112
113
  }
@@ -127,4 +128,32 @@ describe("useGestureCommit", () => {
127
128
  expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true }));
128
129
  expect(options[1]).not.toHaveProperty("skipReload");
129
130
  });
131
+
132
+ it("does not start a recording while the preview is read-only", () => {
133
+ const element = document.createElement("div");
134
+ const commitMutation = vi.fn(async () => {});
135
+ const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
136
+ function Probe() {
137
+ captured.hook = useGestureCommit({
138
+ domEditSessionRef: {
139
+ current: {
140
+ domEditSelection: makeSelection(element),
141
+ selectedGsapAnimations: [],
142
+ commitMutation,
143
+ },
144
+ },
145
+ previewIframeRef: { current: document.createElement("iframe") },
146
+ showToast: vi.fn(),
147
+ isGestureRecordingRef: { current: false },
148
+ readOnlyPreview: true,
149
+ });
150
+ return null;
151
+ }
152
+ const root = mountReactHarness(<Probe />);
153
+ cleanup = () => act(() => root.unmount());
154
+
155
+ act(() => captured.hook?.handleToggleRecording());
156
+ expect(gestureRecording.startRecording).not.toHaveBeenCalled();
157
+ expect(commitMutation).not.toHaveBeenCalled();
158
+ });
130
159
  });
@@ -87,6 +87,7 @@ interface UseGestureCommitParams {
87
87
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
88
88
  showToast: (message: string, tone?: "error" | "info") => void;
89
89
  isGestureRecordingRef: React.MutableRefObject<boolean>;
90
+ readOnlyPreview: boolean;
90
91
  }
91
92
 
92
93
  export interface UseGestureCommitResult {
@@ -101,6 +102,7 @@ export function useGestureCommit({
101
102
  previewIframeRef,
102
103
  showToast,
103
104
  isGestureRecordingRef,
105
+ readOnlyPreview,
104
106
  }: UseGestureCommitParams): UseGestureCommitResult {
105
107
  const gestureRecording = useGestureRecording();
106
108
  const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
@@ -115,6 +117,20 @@ export function useGestureCommit({
115
117
  // Unmount: clear auto-stop interval
116
118
  useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
117
119
 
120
+ const cancelRecording = useCallback(() => {
121
+ clearInterval(recordingAutoStopRef.current);
122
+ gestureRecording.stopRecording();
123
+ gestureRecording.clearSamples();
124
+ gestureStateRef.current = "idle";
125
+ isGestureRecordingRef.current = false;
126
+ capturedSelectionRef.current = null;
127
+ setGestureState("idle");
128
+ }, [gestureRecording, isGestureRecordingRef]);
129
+
130
+ useEffect(() => {
131
+ if (readOnlyPreview && gestureStateRef.current === "recording") cancelRecording();
132
+ }, [cancelRecording, readOnlyPreview]);
133
+
118
134
  // fallow-ignore-next-line complexity
119
135
  const stopAndCommitRecording = useCallback(async () => {
120
136
  clearInterval(recordingAutoStopRef.current);
@@ -335,9 +351,14 @@ export function useGestureCommit({
335
351
  // fallow-ignore-next-line complexity
336
352
  const handleToggleRecording = useCallback(() => {
337
353
  if (gestureStateRef.current === "recording") {
354
+ if (readOnlyPreview) {
355
+ cancelRecording();
356
+ return;
357
+ }
338
358
  void stopAndCommitRecording();
339
359
  return;
340
360
  }
361
+ if (readOnlyPreview) return;
341
362
  const sel = domEditSessionRef.current.domEditSelection;
342
363
  if (!sel) {
343
364
  showToast("Select an element first", "error");
@@ -373,9 +394,11 @@ export function useGestureCommit({
373
394
  gestureRecording,
374
395
  showToast,
375
396
  stopAndCommitRecording,
397
+ cancelRecording,
376
398
  previewIframeRef,
377
399
  domEditSessionRef,
378
400
  isGestureRecordingRef,
401
+ readOnlyPreview,
379
402
  ]);
380
403
 
381
404
  return { gestureState, gestureRecording, handleToggleRecording };
@@ -3,6 +3,7 @@
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 { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
6
7
  import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -20,7 +21,7 @@ function heading(text = "Motion Playground"): HTMLElement {
20
21
  return element;
21
22
  }
22
23
 
23
- function mount(onCommit = vi.fn(), onPause = vi.fn()) {
24
+ function mount(onCommit = vi.fn(), onPause = vi.fn(), readOnly = false) {
24
25
  const controls: { current: InlineTextEditControls | null } = { current: null };
25
26
  function Probe() {
26
27
  controls.current = useInlineTextEdit({ onCommit, onPause });
@@ -29,7 +30,13 @@ function mount(onCommit = vi.fn(), onPause = vi.fn()) {
29
30
  const host = document.createElement("div");
30
31
  document.body.append(host);
31
32
  const root = createRoot(host);
32
- act(() => root.render(<Probe />));
33
+ act(() =>
34
+ root.render(
35
+ <PreviewReadOnlyProvider readOnly={readOnly}>
36
+ <Probe />
37
+ </PreviewReadOnlyProvider>,
38
+ ),
39
+ );
33
40
  return { controls: () => controls.current!, root, onCommit, onPause };
34
41
  }
35
42
 
@@ -552,4 +559,18 @@ describe("useInlineTextEdit with styled runs", () => {
552
559
  expect(insertText).toHaveBeenCalledWith("insertText", false, "plain words");
553
560
  act(() => root.unmount());
554
561
  });
562
+
563
+ it("refuses to open an edit while the preview is read-only", () => {
564
+ const element = heading();
565
+ const { controls, root, onCommit, onPause } = mount(vi.fn(), vi.fn(), true);
566
+ let opened = true;
567
+ act(() => {
568
+ opened = controls().start(element);
569
+ });
570
+ expect(opened).toBe(false);
571
+ expect(element.hasAttribute("contenteditable")).toBe(false);
572
+ expect(onPause).not.toHaveBeenCalled();
573
+ expect(onCommit).not.toHaveBeenCalled();
574
+ act(() => root.unmount());
575
+ });
555
576
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
+ import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
3
4
 
4
5
  /**
5
6
  * Editing an element's text where it sits, in the composition itself.
@@ -89,6 +90,7 @@ export function useInlineTextEdit({
89
90
  /** Stop playback, so the element is not animating under the caret. */
90
91
  onPause?: () => void;
91
92
  }): InlineTextEditControls {
93
+ const readOnly = usePreviewReadOnly();
92
94
  const [session, setSession] = useState<InlineTextEditSession | null>(null);
93
95
  // The teardown reads this rather than the state, so an exit path that runs
94
96
  // before React re-renders still sees the element it has to clean up.
@@ -123,7 +125,7 @@ export function useInlineTextEdit({
123
125
 
124
126
  const start = useCallback(
125
127
  (element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
126
- if (openRef.current) return false;
128
+ if (openRef.current || readOnly) return false;
127
129
 
128
130
  const open = {
129
131
  element,
@@ -156,7 +158,7 @@ export function useInlineTextEdit({
156
158
  framesRef.current = raf ?? null;
157
159
  return true;
158
160
  },
159
- [onPause],
161
+ [onPause, readOnly],
160
162
  );
161
163
 
162
164
  const commit = useCallback(() => {
@@ -179,6 +181,13 @@ export function useInlineTextEdit({
179
181
  teardown();
180
182
  }, [teardown]);
181
183
 
184
+ // Read-only can be enabled while an edit is already open. Close that
185
+ // session as a cancellation so the preview cannot keep committing through
186
+ // the now-disabled editing surface.
187
+ useEffect(() => {
188
+ if (readOnly && openRef.current) cancel();
189
+ }, [cancel, readOnly]);
190
+
182
191
  // The keys belong to the element, not to the document: the element lives in
183
192
  // the preview's own document, so a listener on Studio's would never see them.
184
193
  useEffect(() => {
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
103
103
  expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
104
104
  });
105
105
 
106
- it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
106
+ it("measures the insert row against every manifest timeline row", async () => {
107
107
  const clip = (id: string, track: number): TimelineElement => ({
108
108
  id,
109
109
  key: id,
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
123
123
  }),
124
124
  "</main>",
125
125
  ].join("\n");
126
- usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
127
126
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
128
127
  const getDrop = renderDropHook(source, writeProjectFile, [
129
128
  clip("a", 0),
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
136
135
  });
137
136
 
138
137
  const [, written] = writeProjectFile.mock.calls[0] as [string, string];
139
- expect(written).toContain('id="b" data-start="0" data-track-index="2"');
140
- expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
138
+ expect(written).toContain('id="b" data-start="0" data-track-index="3"');
139
+ expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
141
140
  expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
142
141
  });
143
142
 
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
25
25
  import { extendRootDurationInSource } from "../utils/rootDuration";
26
26
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
27
27
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
28
- import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
- import { usePlayerStore } from "../player/store/playerStore";
30
28
 
31
29
  /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
32
30
  function fileDropPlacement(
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
127
125
  const { source: sourceWithRoom, track } = resolveDropTrack({
128
126
  source: originalContent,
129
127
  // insertRow counts the rows the timeline shows, so plan against those.
130
- elements: selectTimelineRowElements(
131
- relevantElements,
132
- usePlayerStore.getState().topLevelIds,
133
- ),
128
+ elements: relevantElements,
134
129
  placement,
135
130
  dropped: {
136
131
  id: newId,
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
1561
1561
  unmount();
1562
1562
  });
1563
1563
 
1564
- it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
1565
- // Regression: canEdit must resolve against the expanded rows
1566
- // (timelineTrackVisibility.ts's own invariant), not the raw
1567
- // timelineElements prop — proven by making the two disagree below.
1564
+ it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
1565
+ // The visibility hook reads the single store-owned row source. The editing
1566
+ // guard must receive that same source so a blocked row cannot fall through
1567
+ // to the write path.
1568
1568
  const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1569
1569
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1570
1570
  usePlayerStore.getState().setElements([clip]);
1571
1571
  const showToast = vi.fn();
1572
1572
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1573
+ const canEdit = vi.fn((element: TimelineElement) =>
1574
+ element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
1575
+ );
1573
1576
  let hook: ReturnType<typeof useTimelineEditing> | null = null;
1574
1577
  function Harness() {
1575
1578
  hook = useTimelineEditing({
1576
1579
  projectId: "p1",
1577
1580
  activeCompPath: "index.html",
1578
- timelineElements: [], // deliberately does not contain `clip`
1581
+ timelineElements: [clip],
1579
1582
  showToast,
1580
1583
  writeProjectFile,
1581
1584
  recordEdit: vi.fn(),
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
1583
1586
  previewIframeRef: { current: iframe },
1584
1587
  pendingTimelineEditPathRef: { current: new Set<string>() },
1585
1588
  uploadProjectFiles: vi.fn(),
1586
- canEdit: (element) =>
1587
- element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
1589
+ canEdit,
1588
1590
  });
1589
1591
  return null;
1590
1592
  }
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
1596
1598
  await flushAsyncWork();
1597
1599
  });
1598
1600
 
1601
+ expect(canEdit).toHaveBeenCalledWith(clip);
1599
1602
  expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1600
1603
  expect(writeProjectFile).not.toHaveBeenCalled();
1601
1604
  unmount();
@@ -25,7 +25,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
25
25
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
26
26
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
27
27
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
28
- import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
28
  import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
30
29
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
31
30
  import {
@@ -77,10 +76,6 @@ export function useTimelineEditing({
77
76
  const checkEditable = useTimelineEditGate(canEdit, showToast);
78
77
  const checkEditableRef = useRef(checkEditable);
79
78
  checkEditableRef.current = checkEditable;
80
- // Same expanded-row source the hide handlers themselves resolve against
81
- // (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
82
- // only exists here, not in the raw store list canEdit would otherwise miss.
83
- const timelineRowElements = useTimelineRowElements();
84
79
  const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
85
80
  // Refuses (no call, no write, no history entry) when any target is
86
81
  // blocked; otherwise runs fn as before. Cached by fn identity — like
@@ -514,14 +509,14 @@ export function useTimelineEditing({
514
509
  handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
515
510
  handleToggleTrackHidden: track(
516
511
  guard(
517
- (trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
512
+ (trackIndex) => timelineElements.filter((el) => el.track === trackIndex),
518
513
  handleToggleTrackHidden,
519
514
  ),
520
515
  ),
521
516
  handleToggleElementHidden: track(
522
517
  guard((elementKey) => {
523
518
  const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
524
- return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
519
+ return timelineElements.filter((el) => keys.has(el.key ?? el.id));
525
520
  }, handleToggleElementHidden),
526
521
  ),
527
522
  handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),