@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
@@ -2,6 +2,27 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
2
2
 
3
3
  export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
4
4
 
5
+ /**
6
+ * Which of the nine situations produced a block. The user-facing `reason` stays
7
+ * coarse — three messages — but "source-uneditable" alone covers nine distinct
8
+ * causes, and `edit_blocked` telemetry could not tell them apart. That matters
9
+ * because the copy ("This animation is computed at runtime") is only literally
10
+ * true for `provenance-runtime-dynamic`; the others are parser or source-match
11
+ * limits, where the animation may well be plain authored source.
12
+ *
13
+ * Telemetry only. Nothing branches on it.
14
+ */
15
+ export type GsapEditBlockDetail =
16
+ | "provenance-runtime-dynamic"
17
+ | "unresolved-keyframes"
18
+ | "unresolved-selector"
19
+ | "geometry-unresolved-source"
20
+ | "live-position-no-source-tween"
21
+ | "no-position-tween"
22
+ | "live-rotation-no-source-tween"
23
+ | "live-resize-no-source-tween"
24
+ | "zero-duration-tween";
25
+
5
26
  export type GsapEditOutcome =
6
27
  | {
7
28
  status: "persisted";
@@ -22,7 +43,7 @@ export type GsapEditOutcome =
22
43
  */
23
44
  ownsDragOffset?: boolean;
24
45
  }
25
- | { status: "blocked"; reason: GsapEditBlockReason };
46
+ | { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
26
47
 
27
48
  const COPY: Record<GsapEditBlockReason, string> = {
28
49
  "no-selector": "This layer needs a stable selector before Studio can save the edit.",
@@ -32,25 +53,32 @@ const COPY: Record<GsapEditBlockReason, string> = {
32
53
  };
33
54
 
34
55
  export class GsapEditBlockedError extends Error {
35
- constructor(readonly reason: GsapEditBlockReason) {
56
+ constructor(
57
+ readonly reason: GsapEditBlockReason,
58
+ readonly detail?: GsapEditBlockDetail,
59
+ ) {
36
60
  super(COPY[reason]);
37
61
  this.name = "GsapEditBlockedError";
38
62
  }
39
63
  }
40
64
 
41
65
  export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
42
- if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
66
+ if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
43
67
  }
44
68
 
45
69
  function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
46
70
  const editability = editabilityForProvenance(animation.provenance);
47
71
  if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
48
- if (
49
- editability === "source" ||
50
- animation.hasUnresolvedKeyframes ||
51
- animation.hasUnresolvedSelector
52
- ) {
53
- throw new GsapEditBlockedError("source-uneditable");
72
+ // Same message for all three, but they are different problems: only the first
73
+ // is genuinely a runtime-computed value.
74
+ if (editability === "source") {
75
+ throw new GsapEditBlockedError("source-uneditable", "provenance-runtime-dynamic");
76
+ }
77
+ if (animation.hasUnresolvedKeyframes) {
78
+ throw new GsapEditBlockedError("source-uneditable", "unresolved-keyframes");
79
+ }
80
+ if (animation.hasUnresolvedSelector) {
81
+ throw new GsapEditBlockedError("source-uneditable", "unresolved-selector");
54
82
  }
55
83
  }
56
84
 
@@ -84,7 +112,8 @@ export function directEditOutcomeForProperties(
84
112
  }
85
113
  return { status: "persisted" };
86
114
  } catch (error) {
87
- if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
115
+ if (isGsapEditBlockedError(error))
116
+ return { status: "blocked", reason: error.reason, detail: error.detail };
88
117
  throw error;
89
118
  }
90
119
  }
@@ -180,11 +180,15 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
180
180
  );
181
181
  });
182
182
 
183
- it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
183
+ function makeBoxSelection(): { el: HTMLElement; selection: DomEditSelection } {
184
184
  const el = document.createElement("div");
185
185
  el.id = "box";
186
186
  document.body.append(el);
187
- const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
187
+ return { el, selection: { id: "box", selector: "#box", element: el } as DomEditSelection };
188
+ }
189
+
190
+ it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
191
+ const { el, selection } = makeBoxSelection();
188
192
  const liveSizeTween = {
189
193
  targets: () => [el],
190
194
  vars: { width: 300, duration: 1 },
@@ -199,7 +203,42 @@ it("blocks when runtime size motion exists but the authored tween cannot be reso
199
203
 
200
204
  await expect(
201
205
  tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
202
- ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
206
+ ).resolves.toEqual({
207
+ status: "blocked",
208
+ reason: "source-uneditable",
209
+ detail: "live-resize-no-source-tween",
210
+ });
211
+ expect(commitMutation).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it("blocks a tween with no positive duration instead of committing into it", async () => {
215
+ const { selection } = makeBoxSelection();
216
+ // A zero-duration `from` is not an instant hold (those are `set`/`to`), so it
217
+ // reaches the duration check: a resize has no timeline position to land on.
218
+ const zeroDurationTween = {
219
+ id: "#box-size",
220
+ targetSelector: "#box",
221
+ propertyGroup: "size",
222
+ method: "from",
223
+ position: 0,
224
+ duration: 0,
225
+ properties: { width: 150, height: 150 },
226
+ } as unknown as GsapAnimation;
227
+ const commitMutation = vi.fn();
228
+
229
+ await expect(
230
+ tryGsapResizeIntercept(
231
+ selection,
232
+ { width: 344, height: 344 },
233
+ [zeroDurationTween],
234
+ null,
235
+ commitMutation,
236
+ ),
237
+ ).resolves.toEqual({
238
+ status: "blocked",
239
+ reason: "source-uneditable",
240
+ detail: "zero-duration-tween",
241
+ });
203
242
  expect(commitMutation).not.toHaveBeenCalled();
204
243
  });
205
244
 
@@ -137,7 +137,12 @@ export async function tryGsapResizeIntercept(
137
137
  ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
138
138
  : false;
139
139
  if (!anim && hasLiveResizeTween) {
140
- return { status: "blocked", reason: "source-uneditable" };
140
+ // Third twin of the position/rotation cases: a live tween with no source match.
141
+ return {
142
+ status: "blocked",
143
+ reason: "source-uneditable",
144
+ detail: "live-resize-no-source-tween",
145
+ };
141
146
  }
142
147
  logResize("intercept-enter", {
143
148
  hasScaleGroup,
@@ -194,7 +199,12 @@ export async function tryGsapResizeIntercept(
194
199
  }
195
200
 
196
201
  const tweenDuration = resolveTweenDuration(anim);
197
- if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
202
+ if (tweenDuration <= 0) {
203
+ // The tween exists in source but has no positive duration, so there is no
204
+ // timeline position at which a resize could land — a different cause from
205
+ // the live-tween-without-a-source-match cases above.
206
+ return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
207
+ }
198
208
 
199
209
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
200
210
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -94,7 +94,11 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
94
94
  vi.fn().mockResolvedValue([]),
95
95
  );
96
96
 
97
- expect(result).toEqual({ status: "blocked", reason: "source-uneditable" });
97
+ expect(result).toEqual({
98
+ status: "blocked",
99
+ reason: "source-uneditable",
100
+ detail: "live-position-no-source-tween",
101
+ });
98
102
  expect(commitMutation).not.toHaveBeenCalled();
99
103
  });
100
104
 
@@ -354,7 +358,11 @@ describe("tryGsapRotationIntercept — instant holds", () => {
354
358
  fakeIframe("puck-b", [liveRotation]),
355
359
  commitMutation,
356
360
  ),
357
- ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
361
+ ).resolves.toEqual({
362
+ status: "blocked",
363
+ reason: "source-uneditable",
364
+ detail: "live-rotation-no-source-tween",
365
+ });
358
366
  expect(commitMutation).not.toHaveBeenCalled();
359
367
  });
360
368
 
@@ -156,7 +156,13 @@ async function preflightGsapDragIntercept(
156
156
  const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
157
157
 
158
158
  if (hasLivePosition && !posAnim) {
159
- return { status: "blocked", reason: "source-uneditable" };
159
+ // GSAP is visibly moving this element but the parser found no position
160
+ // tween for it — a source-match gap, not necessarily computed source.
161
+ return {
162
+ status: "blocked",
163
+ reason: "source-uneditable",
164
+ detail: "live-position-no-source-tween",
165
+ };
160
166
  }
161
167
  if (!posAnim && !writeTargetSelector(selection)) {
162
168
  return { status: "blocked", reason: "no-selector" };
@@ -268,7 +274,7 @@ export async function tryGsapDragIntercept(
268
274
  }
269
275
 
270
276
  if (!posAnim) {
271
- return { status: "blocked", reason: "source-uneditable" };
277
+ return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
272
278
  }
273
279
 
274
280
  // Verify the anim ID is still valid in the current file. The React-state
@@ -353,7 +359,12 @@ export async function tryGsapRotationIntercept(
353
359
  ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
354
360
  : false;
355
361
  if (!anim && hasLiveRotationTween) {
356
- return { status: "blocked", reason: "source-uneditable" };
362
+ // Rotation twin of the position case above: live tween, no source match.
363
+ return {
364
+ status: "blocked",
365
+ reason: "source-uneditable",
366
+ detail: "live-rotation-no-source-tween",
367
+ };
357
368
  }
358
369
 
359
370
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -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 ──
@@ -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,