@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
@@ -1,7 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
- import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
4
4
  import { usePlayerStore } from "../player/store/playerStore";
5
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
6
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
6
7
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
7
8
  import type { TimelineElement } from "../player/store/timelineElement";
@@ -19,7 +20,7 @@ const bgmElement: TimelineElement = {
19
20
  /** Every callback dispatchPlainKey can reach, so a test can assert which one
20
21
  * a key resolved to. Unannotated on purpose: the parameter type is not
21
22
  * exported, and structural inference checks it at the call site. */
22
- function callbacks() {
23
+ function callbacks(overrides: Partial<HotkeyCallbacks> = {}) {
23
24
  return {
24
25
  handleTimelineElementDelete: vi.fn(async () => {}),
25
26
  handleTimelineElementsDelete: vi.fn(async () => {}),
@@ -30,10 +31,15 @@ function callbacks() {
30
31
  handleCopy: vi.fn(() => false),
31
32
  handlePaste: vi.fn(async () => {}),
32
33
  handleCut: vi.fn(async () => false),
34
+ handleDuplicate: vi.fn(async () => false),
35
+ onGroupSelection: vi.fn(),
36
+ onUngroupSelection: vi.fn(),
33
37
  onResetKeyframes: vi.fn(() => true),
34
38
  onDeleteSelectedKeyframes: vi.fn(),
35
39
  showToast: vi.fn(),
36
40
  domEditSelectionRef: { current: null },
41
+ readOnlyPreview: false,
42
+ ...overrides,
37
43
  };
38
44
  }
39
45
 
@@ -298,3 +304,65 @@ describe('dispatchPlainKey — "A" returns to select while the razor is armed',
298
304
  expect(e.defaultPrevented).toBe(false);
299
305
  });
300
306
  });
307
+
308
+ describe("hotkeys with the preview read-only", () => {
309
+ beforeEach(() => {
310
+ usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
311
+ });
312
+
313
+ it("does not delete the selected element on Delete", () => {
314
+ const cb = callbacks({ readOnlyPreview: true });
315
+ cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
316
+ dispatchPlainKey(press("Delete"), "delete", cb);
317
+ expect(cb.handleDomEditElementDelete).not.toHaveBeenCalled();
318
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
319
+ });
320
+
321
+ it("does not split on s", () => {
322
+ usePlayerStore.setState({
323
+ currentTime: 3,
324
+ elements: [{ ...bgmElement, hfId: "hf-bgm" }],
325
+ selectedElementId: "bgm",
326
+ });
327
+ const cb = callbacks({ readOnlyPreview: true });
328
+ dispatchPlainKey(press("s"), "s", cb);
329
+ expect(cb.handleTimelineElementSplit).not.toHaveBeenCalled();
330
+ });
331
+
332
+ it.each([
333
+ ["x", "handleCut"],
334
+ ["d", "handleDuplicate"],
335
+ ["v", "handlePaste"],
336
+ ["g", "onGroupSelection"],
337
+ ] as const)("does not run %s", (key, callback) => {
338
+ const cb = callbacks({ readOnlyPreview: true });
339
+ cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
340
+ dispatchModifierKey(chord(key), key, cb);
341
+ expect(cb[callback]).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it("still undoes, because history covers timeline edits", () => {
345
+ const cb = callbacks({ readOnlyPreview: true });
346
+ dispatchModifierKey(chord("z"), "z", cb);
347
+ expect(cb.handleUndo).toHaveBeenCalledTimes(1);
348
+ });
349
+
350
+ it("keeps timeline paste when the mirrored preview selection is not the owner", () => {
351
+ usePlayerStore.setState({ selectedElementId: "bgm" });
352
+ const cb = callbacks({ readOnlyPreview: true });
353
+ cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
354
+ const event = chord("v");
355
+ const timeline = document.createElement("div");
356
+ timeline.dataset.studioTimeline = "true";
357
+ Object.defineProperty(event, "target", { value: timeline });
358
+ dispatchModifierKey(event, "v", cb);
359
+ expect(cb.handlePaste).toHaveBeenCalledTimes(1);
360
+ });
361
+
362
+ it("control: with the flag off Delete removes the selected element", () => {
363
+ const cb = callbacks();
364
+ cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
365
+ dispatchPlainKey(press("Delete"), "delete", cb);
366
+ expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
367
+ });
368
+ });
@@ -93,6 +93,7 @@ function Harness() {
93
93
  handleDuplicate: vi.fn(async () => false),
94
94
  onResetKeyframes: vi.fn(() => false),
95
95
  onDeleteSelectedKeyframes: keyframeDelete,
96
+ readOnlyPreview: false,
96
97
  });
97
98
 
98
99
  return (
@@ -101,6 +101,8 @@ interface UseAppHotkeysParams {
101
101
  onUngroupSelection?: () => void;
102
102
  /** Active composition path — used to decide whether undo/redo must resync the SDK session. */
103
103
  activeCompPath?: string | null;
104
+ /** Clicks still select and report; the preview cannot move, edit or delete anything. */
105
+ readOnlyPreview: boolean;
104
106
  /**
105
107
  * Force-reload the SDK session after undo/redo reverts the active comp file,
106
108
  * bypassing the self-write suppress window. Without this, the suppress window
@@ -135,6 +137,7 @@ export function useAppHotkeys({
135
137
  onUngroupSelection,
136
138
  activeCompPath,
137
139
  forceReloadSdkSession,
140
+ readOnlyPreview,
138
141
  }: UseAppHotkeysParams) {
139
142
  const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
140
143
 
@@ -203,6 +206,7 @@ export function useAppHotkeys({
203
206
  onUngroupSelection,
204
207
  domEditSelectionRef,
205
208
  showToast,
209
+ readOnlyPreview,
206
210
  };
207
211
 
208
212
  // ── Keydown dispatch ──
@@ -230,6 +230,7 @@ function renderDomEditCommits(
230
230
  refreshDomEditSelectionFromPreview: vi.fn(),
231
231
  buildDomSelectionFromTarget: vi.fn(async () => null),
232
232
  onTrySdkPersist: options.onTrySdkPersist,
233
+ readOnlyPreview: false,
233
234
  });
234
235
  return null;
235
236
  }
@@ -89,6 +89,7 @@ export interface UseDomEditCommitsParams {
89
89
  ) => Promise<CutoverResult>;
90
90
  /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
91
91
  onReorderShadow?: (targets: string[]) => void;
92
+ readOnlyPreview: boolean;
92
93
  }
93
94
 
94
95
  export function useDomEditCommits({
@@ -112,6 +113,7 @@ export function useDomEditCommits({
112
113
  onTrySdkPersist,
113
114
  onTrySdkDelete,
114
115
  onReorderShadow,
116
+ readOnlyPreview,
115
117
  }: UseDomEditCommitsParams) {
116
118
  const resolveImportedFontAsset = useCallback(
117
119
  (fontFamilyValue: string): ImportedFontAsset | null => {
@@ -452,6 +454,7 @@ export function useDomEditCommits({
452
454
  previewIframeRef,
453
455
  showToast,
454
456
  commitPositionPatchToHtml,
457
+ readOnlyPreview,
455
458
  });
456
459
 
457
460
  // ── Element lifecycle (delete, z-index reorder) ──
@@ -99,6 +99,7 @@ function createSessionParams(
99
99
  reloadPreview: vi.fn(),
100
100
  setRefreshKey: vi.fn(),
101
101
  handleTimelineElementsDelete: vi.fn(),
102
+ readOnlyPreview: false,
102
103
  ...overrides,
103
104
  };
104
105
  }
@@ -58,6 +58,7 @@ export interface UseDomEditSessionParams extends DomEditTimelineParams {
58
58
  /** The timeline context menu's delete op — a canvas selection that IS a
59
59
  * timeline row hands off here instead of the REST remove-elements path. */
60
60
  handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
61
+ readOnlyPreview: boolean;
61
62
  }
62
63
 
63
64
  export function useDomEditSession({
@@ -98,8 +99,10 @@ export function useDomEditSession({
98
99
  publishSdkSession,
99
100
  forceReloadSdkSession,
100
101
  handleTimelineElementsDelete,
102
+ readOnlyPreview,
101
103
  }: UseDomEditSessionParams) {
102
104
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
105
+ const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
103
106
  void _setRefreshKey;
104
107
  const {
105
108
  domEditSelection,
@@ -125,7 +128,7 @@ export function useDomEditSession({
125
128
  activeCompPath,
126
129
  isMasterView,
127
130
  compIdToSrc,
128
- captionEditMode,
131
+ captionEditMode: previewCaptionEditMode,
129
132
  previewIframeRef,
130
133
  timelineElements,
131
134
  getTimelineSelectionSet,
@@ -247,6 +250,7 @@ export function useDomEditSession({
247
250
  refreshDomEditSelectionFromPreview,
248
251
  buildDomSelectionFromTarget,
249
252
  forceReloadSdkSession,
253
+ readOnlyPreview,
250
254
  onTrySdkPersist: sdkSession
251
255
  ? (selection, operations, originalContent, targetPath, options) => {
252
256
  // Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
@@ -405,7 +409,6 @@ export function useDomEditSession({
405
409
  handleGsapRemoveAllKeyframes,
406
410
  handleResetSelectedElementKeyframes,
407
411
  } = useDomEditWiring({
408
- // fallow-ignore-next-line code-duplication
409
412
  projectId,
410
413
  activeCompPath,
411
414
  domEditSelection,
@@ -414,7 +417,7 @@ export function useDomEditSession({
414
417
  refreshDomEditGroupSelectionsFromPreview,
415
418
  previewIframeRef,
416
419
  previewIframe,
417
- captionEditMode,
420
+ captionEditMode: previewCaptionEditMode,
418
421
  refreshKey,
419
422
  gsapCacheVersion,
420
423
  bumpGsapCache,
@@ -451,7 +454,7 @@ export function useDomEditSession({
451
454
  handleBlockedDomMove,
452
455
  handleDomManualDragStart,
453
456
  } = usePreviewInteraction({
454
- captionEditMode,
457
+ captionEditMode: previewCaptionEditMode,
455
458
  compositionLoading,
456
459
  previewIframeRef,
457
460
  showToast,
@@ -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 };