@hyperframes/studio 0.8.55 → 0.8.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -54,6 +54,11 @@ export interface HotkeyCallbacks {
54
54
  onUngroupSelection?: () => void;
55
55
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
56
56
  showToast: (message: string, tone?: "error" | "info") => void;
57
+ readOnlyPreview: boolean;
58
+ }
59
+
60
+ function timelineOwnsKey(event: KeyboardEvent): boolean {
61
+ return event.target instanceof Element && event.target.closest("[data-studio-timeline]") !== null;
57
62
  }
58
63
 
59
64
  /** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
@@ -95,6 +100,7 @@ export function dispatchModifierKey(
95
100
 
96
101
  if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
97
102
  event.preventDefault();
103
+ if (cb.readOnlyPreview) return true;
98
104
  if (event.shiftKey) cb.onUngroupSelection?.();
99
105
  else cb.onGroupSelection?.();
100
106
  return true;
@@ -113,6 +119,12 @@ export function dispatchModifierKey(
113
119
  }
114
120
  return true;
115
121
  }
122
+ const previewOwnsMutation =
123
+ cb.readOnlyPreview && cb.domEditSelectionRef.current !== null && !timelineOwnsKey(event);
124
+ if (previewOwnsMutation && ["v", "x", "d"].includes(key)) {
125
+ event.preventDefault();
126
+ return true;
127
+ }
116
128
  if (key === "v") {
117
129
  event.preventDefault();
118
130
  trackStudioEvent("keyboard_shortcut", { action: "paste" });
@@ -158,6 +170,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
158
170
  // Reserve bare `s` for Split even when the current selection cannot split,
159
171
  // so secondary listeners do not reinterpret the same key as Snap toggle.
160
172
  event.preventDefault();
173
+ if (cb.readOnlyPreview) return;
161
174
  const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
162
175
  if (selectedElementId) {
163
176
  const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
@@ -240,8 +253,10 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
240
253
  // the timeline left other selected elements behind. Timeline stays as the
241
254
  // fallback for rows with no canvas node (audio, an inactive comp).
242
255
  const domSel = cb.domEditSelectionRef.current;
243
- if (domSel) {
256
+ const timelineOwnsDelete = timelineOwnsKey(event);
257
+ if (domSel && !timelineOwnsDelete) {
244
258
  event.preventDefault();
259
+ if (cb.readOnlyPreview) return;
245
260
  // The whole marquee group, not just the primary the ref holds.
246
261
  void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
247
262
  return;
@@ -196,6 +196,7 @@ function renderGestureCommit(
196
196
  previewIframeRef: { current: document.createElement("iframe") },
197
197
  showToast: vi.fn(),
198
198
  isGestureRecordingRef: { current: false },
199
+ readOnlyPreview: false,
199
200
  });
200
201
  toggle = handleToggleRecording;
201
202
  return null;
@@ -8,7 +8,6 @@
8
8
 
9
9
  import { useCallback } from "react";
10
10
  import { usePlayerStore, type TimelineElement } from "../player";
11
- import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
12
11
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
13
12
  import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
14
13
  import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
@@ -286,7 +285,7 @@ export function useAudioGroupCarveAssignment({
286
285
  groupId: string,
287
286
  groupLabel?: string,
288
287
  ) => Promise<void> {
289
- const expandedElements = useTimelineRowElements();
288
+ const timelineElements = usePlayerStore((state) => state.elements);
290
289
  return useCallback(
291
290
  async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
292
291
  if (isRecordingRef?.current) {
@@ -299,7 +298,7 @@ export function useAudioGroupCarveAssignment({
299
298
  // timeline's group-pointer button) name clips the way the document does,
300
299
  // because that is the only space `resolveAudioGroups` reads back.
301
300
  const wanted = new Set(clipIds);
302
- const elements = expandedElements.filter((item) => {
301
+ const elements = timelineElements.filter((item) => {
303
302
  const domId = runtimeAudioId(item);
304
303
  return domId !== null && wanted.has(domId);
305
304
  });
@@ -340,7 +339,7 @@ export function useAudioGroupCarveAssignment({
340
339
  },
341
340
  [
342
341
  activeCompPath,
343
- expandedElements,
342
+ timelineElements,
344
343
  previewIframeRef,
345
344
  writeProjectFile,
346
345
  recordEdit,
@@ -1,7 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../player";
3
3
  import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
4
- import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
5
4
  import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
6
5
  import {
7
6
  timelineTrackOrder,
@@ -295,7 +294,7 @@ export function useTimelineTrackVisibilityEditing({
295
294
  // virtual sub-comp children carry their own (display.track + idx) track numbers,
296
295
  // so filtering the raw store list by a virtual track number would hide the wrong
297
296
  // outer-scene sibling sharing that index.
298
- const expandedElements = useTimelineRowElements();
297
+ const timelineElements = usePlayerStore((state) => state.elements);
299
298
  return useCallback(
300
299
  async (track: number, hidden: boolean, displayNumber?: number | null) => {
301
300
  if (isRecordingRef?.current) {
@@ -308,7 +307,7 @@ export function useTimelineTrackVisibilityEditing({
308
307
  await toggleTimelineTrackHidden({
309
308
  projectId: pid,
310
309
  activeCompPath,
311
- timelineElements: expandedElements,
310
+ timelineElements,
312
311
  track,
313
312
  hidden,
314
313
  displayNumber,
@@ -327,7 +326,7 @@ export function useTimelineTrackVisibilityEditing({
327
326
  },
328
327
  [
329
328
  activeCompPath,
330
- expandedElements,
329
+ timelineElements,
331
330
  previewIframeRef,
332
331
  writeProjectFile,
333
332
  recordEdit,
@@ -354,7 +353,7 @@ export function useTimelineElementVisibilityEditing({
354
353
  elementKey: string | readonly string[],
355
354
  hidden: boolean,
356
355
  ) => Promise<void> {
357
- const expandedElements = useTimelineRowElements();
356
+ const timelineElements = usePlayerStore((state) => state.elements);
358
357
  return useCallback(
359
358
  async (elementKey: string | readonly string[], hidden: boolean) => {
360
359
  if (isRecordingRef?.current) {
@@ -364,7 +363,7 @@ export function useTimelineElementVisibilityEditing({
364
363
  const pid = projectIdRef.current;
365
364
  if (!pid) return;
366
365
  const keys = typeof elementKey === "string" ? [elementKey] : elementKey;
367
- if (!expandedElements.some((item) => keys.includes(item.key ?? item.id))) {
366
+ if (!timelineElements.some((item) => keys.includes(item.key ?? item.id))) {
368
367
  showToast("This element is inside a sub-composition and has no timeline row to hide.");
369
368
  return;
370
369
  }
@@ -372,7 +371,7 @@ export function useTimelineElementVisibilityEditing({
372
371
  await toggleTimelineElementHidden({
373
372
  projectId: pid,
374
373
  activeCompPath,
375
- timelineElements: expandedElements,
374
+ timelineElements,
376
375
  elementKey,
377
376
  hidden,
378
377
  previewIframe: previewIframeRef.current,
@@ -390,7 +389,7 @@ export function useTimelineElementVisibilityEditing({
390
389
  },
391
390
  [
392
391
  activeCompPath,
393
- expandedElements,
392
+ timelineElements,
394
393
  previewIframeRef,
395
394
  writeProjectFile,
396
395
  recordEdit,
@@ -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 ──
@@ -3,6 +3,7 @@ import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { expect, it } from "vitest";
5
5
  import { useCompositionDimensions } from "./useCompositionDimensions";
6
+ import { usePreviewIframeStore } from "../player/store/previewIframeStore";
6
7
 
7
8
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
9
 
@@ -45,3 +46,40 @@ it("accepts only the current preview frame and rejects foreign or missing sender
45
46
  foreign.remove();
46
47
  }
47
48
  });
49
+
50
+ it("re-reads the size from the promoted iframe's document", () => {
51
+ const host = document.createElement("div");
52
+ const a = document.createElement("iframe");
53
+ const b = document.createElement("iframe");
54
+ document.body.append(host, a, b);
55
+ const ref = { current: a };
56
+ const root = createRoot(host);
57
+ const setRoot = (frame: HTMLIFrameElement, width: number, height: number) => {
58
+ const el = frame.contentDocument!.createElement("div");
59
+ el.setAttribute("data-composition-id", "main");
60
+ el.setAttribute("data-width", String(width));
61
+ el.setAttribute("data-height", String(height));
62
+ frame.contentDocument!.body.append(el);
63
+ };
64
+ setRoot(a, 1920, 1080);
65
+ setRoot(b, 1080, 1920);
66
+ function Harness() {
67
+ return JSON.stringify(useCompositionDimensions(ref));
68
+ }
69
+ try {
70
+ act(() => usePreviewIframeStore.getState().setIframe(a));
71
+ act(() => root.render(<Harness />));
72
+ expect(host.textContent).toBe('{"width":1920,"height":1080}');
73
+ act(() => {
74
+ ref.current = b;
75
+ usePreviewIframeStore.getState().setIframe(b);
76
+ });
77
+ expect(host.textContent).toBe('{"width":1080,"height":1920}');
78
+ } finally {
79
+ act(() => root.unmount());
80
+ usePreviewIframeStore.setState({ iframe: null });
81
+ host.remove();
82
+ a.remove();
83
+ b.remove();
84
+ }
85
+ });
@@ -1,4 +1,6 @@
1
- import { useState, type RefObject } from "react";
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import { useLivePreviewIframe } from "../player/store/previewIframeStore";
3
+ import { readPreviewCompositionSize } from "../utils/previewCompositionSize";
2
4
  import { useMountEffect } from "./useMountEffect";
3
5
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
6
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -34,6 +36,20 @@ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement
34
36
  null,
35
37
  );
36
38
 
39
+ const livePreviewIframe = useLivePreviewIframe();
40
+
41
+ // The runtime posts stage-size once per document, so a promoted reload's message was sent
42
+ // before this listener could accept it. The size is authored on the document: read it there.
43
+ useEffect(() => {
44
+ const dimensions = readPreviewCompositionSize(livePreviewIframe);
45
+ if (!dimensions) return;
46
+ setCompositionDimensions((prev) =>
47
+ prev && prev.width === dimensions.width && prev.height === dimensions.height
48
+ ? prev
49
+ : dimensions,
50
+ );
51
+ }, [livePreviewIframe]);
52
+
37
53
  useMountEffect(() => {
38
54
  const handleMessage = (e: MessageEvent) => {
39
55
  if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveOptimisticAttributeValue } from "./useDomEditAttributeCommits";
3
+
4
+ describe("resolveOptimisticAttributeValue", () => {
5
+ it("removes an HTML boolean attribute when the value is the literal string false", () => {
6
+ expect(resolveOptimisticAttributeValue("loop", "false")).toBeNull();
7
+ expect(resolveOptimisticAttributeValue("muted", "false")).toBeNull();
8
+ });
9
+
10
+ it("keeps the literal string false for a non-boolean attribute, matching what persist() writes", () => {
11
+ expect(resolveOptimisticAttributeValue("data-example", "false")).toBe("false");
12
+ });
13
+
14
+ it("always removes on null regardless of attribute", () => {
15
+ expect(resolveOptimisticAttributeValue("loop", null)).toBeNull();
16
+ expect(resolveOptimisticAttributeValue("data-example", null)).toBeNull();
17
+ });
18
+
19
+ it("passes any other value through unchanged", () => {
20
+ expect(resolveOptimisticAttributeValue("data-example", "true")).toBe("true");
21
+ expect(resolveOptimisticAttributeValue("loop", "true")).toBe("true");
22
+ });
23
+ });
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import type { PatchOperation } from "../utils/sourcePatcher";
2
+ import { HTML_BOOLEAN_ATTRIBUTES, type PatchOperation } from "../utils/sourcePatcher";
3
3
  import {
4
4
  findElementForSelection,
5
5
  getDomEditTargetKey,
@@ -55,6 +55,14 @@ function resolveFullAttrName(attr: string, prefixData: boolean | undefined): str
55
55
  return prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
56
56
  }
57
57
 
58
+ // Matches sourcePatcher's own boolean handling: "false" means "remove" only
59
+ // for HTML_BOOLEAN_ATTRIBUTES (loop, muted, ...), so the live preview node
60
+ // ends up holding what persist() actually writes to disk.
61
+ export function resolveOptimisticAttributeValue(attr: string, value: string | null): string | null {
62
+ if (value === null) return null;
63
+ return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
64
+ }
65
+
58
66
  function setOrRemovePreviewAttribute(
59
67
  el: HTMLElement,
60
68
  fullAttr: string,
@@ -393,8 +401,11 @@ export function useDomEditAttributeCommits({
393
401
  },
394
402
  apply: () => {
395
403
  if (!editedElement) return;
396
- const nextValue = value === null || value === "false" ? null : value;
397
- setOrRemovePreviewAttribute(editedElement, attr, nextValue);
404
+ setOrRemovePreviewAttribute(
405
+ editedElement,
406
+ attr,
407
+ resolveOptimisticAttributeValue(attr, value),
408
+ );
398
409
  },
399
410
  persist: () =>
400
411
  persistDomEditOperations(domEditSelection, [op], {
@@ -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(