@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
@@ -167,6 +167,15 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
167
167
  const commitSessionRef = useRef(commitSession);
168
168
  commitSessionRef.current = commitSession;
169
169
 
170
+ useEffect(() => {
171
+ if (params.allowCanvasMovement || !sessionRef.current) return;
172
+ const session = sessionRef.current;
173
+ sessionRef.current = null;
174
+ if (session.timer) clearTimeout(session.timer);
175
+ restoreManualOffsetDragMembers(session.members);
176
+ endManualOffsetDragMembers(session.members);
177
+ }, [params.allowCanvasMovement]);
178
+
170
179
  // Build drag members for the current target set — the same member snapshot a
171
180
  // pointer drag starts from (startGesture / startGroupDrag), so the nudge
172
181
  // commit converts offsets → GSAP x/y with identical math.
@@ -532,6 +532,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
532
532
  restoreGestureOverlayRect(g);
533
533
  }
534
534
  if (g?.mode === "rotation" && sel) {
535
+ applyRotationDraftViaGsap(
536
+ sel.element,
537
+ g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
538
+ );
535
539
  restoreStudioRotation(sel.element, g.initialRotation);
536
540
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
537
541
  }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import type { DomEditCompositionRect } from "./useDomEditCompositionRect";
4
+ import { type OffCanvasRect } from "./OffCanvasIndicators";
5
+ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
6
+
7
+ export function useOffCanvasIndicators({
8
+ iframeRef,
9
+ overlayRef,
10
+ compRectRef,
11
+ activeCompositionPathRef,
12
+ activeCompositionPath,
13
+ }: {
14
+ iframeRef: RefObject<HTMLIFrameElement | null>;
15
+ overlayRef: RefObject<HTMLDivElement | null>;
16
+ compRectRef: React.MutableRefObject<DomEditCompositionRect>;
17
+ activeCompositionPathRef: React.MutableRefObject<string | null>;
18
+ activeCompositionPath: string | null;
19
+ }): {
20
+ offCanvasRects: OffCanvasRect[];
21
+ offCanvasElementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
22
+ } {
23
+ const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
24
+ const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
25
+ const offCanvasDirtyRef = useRef(true);
26
+ const offCanvasSigRef = useRef("");
27
+ const offCanvasObserverRef = useRef<MutationObserver | null>(null);
28
+ const offCanvasObservedDocRef = useRef<Document | null>(null);
29
+
30
+ useMountEffect(() =>
31
+ startOffCanvasIndicatorRefresh({
32
+ iframeRef,
33
+ overlayRef,
34
+ compRectRef,
35
+ activeCompositionPathRef,
36
+ dirtyRef: offCanvasDirtyRef,
37
+ sigRef: offCanvasSigRef,
38
+ observerRef: offCanvasObserverRef,
39
+ observedDocRef: offCanvasObservedDocRef,
40
+ elementsRef: offCanvasElementsRef,
41
+ setRects: setOffCanvasRects,
42
+ }),
43
+ );
44
+
45
+ useEffect(() => {
46
+ offCanvasDirtyRef.current = true;
47
+ }, [activeCompositionPath]);
48
+
49
+ return { offCanvasRects, offCanvasElementsRef };
50
+ }
@@ -3,21 +3,13 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { getPreviewState, iframeRef } from "./PreviewOverlaysTestMocks";
6
7
  import { PreviewOverlays } from "./PreviewOverlays";
7
8
 
8
- (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+ const previewState = getPreviewState();
9
10
 
10
- const previewState = vi.hoisted(() => ({ captionEditMode: false }));
11
- const iframeRef = { current: null as HTMLIFrameElement | null };
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
13
- vi.mock("../../contexts/StudioContext", () => ({
14
- useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
15
- useStudioPlaybackContext: () => ({
16
- captionEditMode: previewState.captionEditMode,
17
- compositionLoading: false,
18
- isPlaying: false,
19
- }),
20
- }));
21
13
  vi.mock("../../contexts/DomEditContext", () => ({
22
14
  useDomEditSelectionContext: () => ({
23
15
  domEditHoverSelection: null,
@@ -41,30 +33,7 @@ vi.mock("../../contexts/DomEditContext", () => ({
41
33
  handleDomZIndexReorderCommit: vi.fn(),
42
34
  }),
43
35
  }));
44
- vi.mock("../../captions/store", () => {
45
- const state = {
46
- model: null,
47
- dismissed: false,
48
- syncError: null,
49
- clearSelection: vi.fn(),
50
- setDismissed: vi.fn(),
51
- setEditMode: vi.fn(),
52
- setSyncError: vi.fn(),
53
- };
54
- return {
55
- useCaptionStore: Object.assign(
56
- (selector: (value: typeof state) => unknown) => selector(state),
57
- { getState: () => state },
58
- ),
59
- };
60
- });
61
- vi.mock("../../hooks/useCompositionDimensions", () => ({
62
- useCompositionDimensions: () => null,
63
- }));
64
- vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
65
- vi.mock("./useCanvasZOrderTimelineMirror", () => ({
66
- useCanvasZOrderTimelineMirror: () => vi.fn(),
67
- }));
36
+
68
37
  vi.mock("../editor/TopologyLens", async () => {
69
38
  const { createElement } = await import("react");
70
39
  return {
@@ -5,6 +5,7 @@ import { DomEditOverlay } from "../editor/DomEditOverlay";
5
5
  import { TopologyLens } from "../editor/TopologyLens";
6
6
  import { MotionPathOverlay } from "../editor/MotionPathOverlay";
7
7
  import { SnapToolbar } from "../editor/SnapToolbar";
8
+ import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
8
9
  import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
9
10
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
10
11
  import {
@@ -140,6 +141,8 @@ export function PreviewOverlays({
140
141
  const { activeCompPath, previewIframeRef } = useStudioShellContext();
141
142
  const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
142
143
  const compositionDimensions = useCompositionDimensions(previewIframeRef);
144
+ const readOnly = usePreviewReadOnly();
145
+ const previewCaptionEditMode = captionEditMode && !readOnly;
143
146
 
144
147
  // Caption edit mode is entered automatically when captions are detected;
145
148
  // these give the author an explicit way OUT (and back in). Without them the
@@ -216,7 +219,7 @@ export function PreviewOverlays({
216
219
  );
217
220
  }
218
221
 
219
- if (captionEditMode) {
222
+ if (previewCaptionEditMode) {
220
223
  return (
221
224
  <>
222
225
  <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
@@ -267,7 +270,9 @@ export function PreviewOverlays({
267
270
  iframeRef={previewIframeRef}
268
271
  activeCompositionPath={activeCompPath}
269
272
  hoverSelection={
270
- !captionEditMode && !compositionLoading && !isPlaying ? domEditHoverSelection : null
273
+ !previewCaptionEditMode && !compositionLoading && !isPlaying
274
+ ? domEditHoverSelection
275
+ : null
271
276
  }
272
277
  selection={shouldShowSelectedDomBounds ? domEditSelection : null}
273
278
  groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
@@ -327,12 +332,14 @@ export function PreviewOverlays({
327
332
  onMarqueeSelect={applyMarqueeSelection}
328
333
  />
329
334
  <SnapToolbar onSnapChange={setSnapPrefs} />
330
- <MotionPathOverlay
331
- iframeRef={previewIframeRef}
332
- selection={shouldShowMotionPath ? domEditSelection : null}
333
- compositionSize={compositionDimensions}
334
- isPlaying={isPlaying}
335
- />
335
+ {!readOnly && (
336
+ <MotionPathOverlay
337
+ iframeRef={previewIframeRef}
338
+ selection={shouldShowMotionPath ? domEditSelection : null}
339
+ compositionSize={compositionDimensions}
340
+ isPlaying={isPlaying}
341
+ />
342
+ )}
336
343
  {gestureOverlay}
337
344
  {captionModelPresent && captionDismissed && (
338
345
  <button
@@ -0,0 +1,40 @@
1
+ import { vi } from "vitest";
2
+
3
+ const previewState = vi.hoisted(() => ({ captionEditMode: false }));
4
+ export function getPreviewState() {
5
+ return previewState;
6
+ }
7
+ export const iframeRef = { current: null as HTMLIFrameElement | null };
8
+
9
+ vi.mock("../../contexts/StudioContext", () => ({
10
+ useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
11
+ useStudioPlaybackContext: () => ({
12
+ captionEditMode: previewState.captionEditMode,
13
+ compositionLoading: false,
14
+ isPlaying: false,
15
+ }),
16
+ }));
17
+ vi.mock("../../captions/store", () => {
18
+ const state = {
19
+ model: null,
20
+ dismissed: false,
21
+ syncError: null,
22
+ clearSelection: vi.fn(),
23
+ setDismissed: vi.fn(),
24
+ setEditMode: vi.fn(),
25
+ setSyncError: vi.fn(),
26
+ };
27
+ return {
28
+ useCaptionStore: Object.assign(
29
+ (selector: (value: typeof state) => unknown) => selector(state),
30
+ { getState: () => state },
31
+ ),
32
+ };
33
+ });
34
+ vi.mock("../../hooks/useCompositionDimensions", () => ({
35
+ useCompositionDimensions: () => null,
36
+ }));
37
+ vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
38
+ vi.mock("./useCanvasZOrderTimelineMirror", () => ({
39
+ useCanvasZOrderTimelineMirror: () => vi.fn(),
40
+ }));
@@ -255,7 +255,11 @@ export function TimelinePane({
255
255
  );
256
256
 
257
257
  return (
258
- <div className="relative flex h-full flex-col" aria-disabled={timelineDisabled || undefined}>
258
+ <div
259
+ className="relative flex h-full flex-col"
260
+ data-studio-timeline="true"
261
+ aria-disabled={timelineDisabled || undefined}
262
+ >
259
263
  <div
260
264
  className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
261
265
  onDoubleClick={(e) => {
@@ -0,0 +1,128 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { getPreviewState, iframeRef } from "./PreviewOverlaysTestMocks";
7
+ import { PreviewOverlays } from "./PreviewOverlays";
8
+ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
9
+ import { PreviewReadOnlyProvider } from "../editor/previewReadOnlyContext";
10
+ import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
11
+
12
+ const previewState = getPreviewState();
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ vi.mock("../../contexts/DomEditContext", () => ({
17
+ useDomEditSelectionContext: () => ({
18
+ domEditHoverSelection: null,
19
+ domEditSelection: null,
20
+ domEditGroupSelections: [],
21
+ }),
22
+ useDomEditActionsContext: () => ({}),
23
+ }));
24
+ vi.mock("../editor/TopologyLens", () => ({ TopologyLens: () => null }));
25
+ vi.mock("../../captions/components/CaptionOverlay", () => ({
26
+ CaptionOverlay: () => <i data-testid="caption-overlay" />,
27
+ }));
28
+ vi.mock("../editor/DomEditOverlay", () => ({
29
+ DomEditOverlay: () => <i data-testid="dom-edit-overlay" />,
30
+ }));
31
+ vi.mock("../editor/MotionPathOverlay", () => ({
32
+ MotionPathOverlay: () => <i data-testid="motion-path" />,
33
+ }));
34
+ vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
35
+
36
+ let root: Root;
37
+ let host: HTMLDivElement;
38
+
39
+ function mount(node: React.ReactNode, readOnly = false): void {
40
+ host = document.createElement("div");
41
+ iframeRef.current = document.createElement("iframe");
42
+ document.body.append(host, iframeRef.current);
43
+ root = createRoot(host);
44
+ act(() =>
45
+ root.render(<PreviewReadOnlyProvider readOnly={readOnly}>{node}</PreviewReadOnlyProvider>),
46
+ );
47
+ }
48
+
49
+ afterEach(() => {
50
+ act(() => root.unmount());
51
+ previewState.captionEditMode = false;
52
+ document.body.replaceChildren();
53
+ });
54
+
55
+ const overlays = () => (
56
+ <PreviewOverlays shouldShowMotionPath={true} shouldShowSelectedDomBounds={true} />
57
+ );
58
+ const has = (id: string) => host.querySelector(`[data-testid="${id}"]`) !== null;
59
+
60
+ describe("PreviewOverlays with the preview read-only", () => {
61
+ it("control: with the flag off the motion path can be edited", () => {
62
+ mount(overlays());
63
+ expect(has("motion-path")).toBe(true);
64
+ });
65
+
66
+ it("does not mount the motion path editor", () => {
67
+ mount(overlays(), true);
68
+ expect(has("motion-path")).toBe(false);
69
+ expect(has("dom-edit-overlay")).toBe(true);
70
+ });
71
+
72
+ it("control: with the flag off caption mode mounts the caption editor", () => {
73
+ previewState.captionEditMode = true;
74
+ mount(overlays());
75
+ expect(has("caption-overlay")).toBe(true);
76
+ });
77
+
78
+ it("does not mount the caption editor, and selection stays on the canvas overlay", () => {
79
+ previewState.captionEditMode = true;
80
+ mount(overlays(), true);
81
+ expect(has("caption-overlay")).toBe(false);
82
+ expect(has("dom-edit-overlay")).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("usePreviewBlockDrop with the preview read-only", () => {
87
+ function dropOnPreview(
88
+ onBlockDrop: (name: string, pos: { left: number; top: number }) => void,
89
+ readOnly = false,
90
+ ) {
91
+ const stage = document.createElement("div");
92
+ stage.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect;
93
+ let handlers: ReturnType<typeof usePreviewBlockDrop> | null = null;
94
+ function Probe() {
95
+ handlers = usePreviewBlockDrop({
96
+ stageRef: { current: stage },
97
+ compositionSize: { width: 1000, height: 1000 },
98
+ onBlockDrop,
99
+ });
100
+ return null;
101
+ }
102
+ mount(<Probe />, readOnly);
103
+ const event = {
104
+ clientX: 50,
105
+ clientY: 50,
106
+ preventDefault: vi.fn(),
107
+ dataTransfer: {
108
+ types: [TIMELINE_BLOCK_MIME],
109
+ getData: () => JSON.stringify({ name: "lower-third" }),
110
+ },
111
+ };
112
+ act(() => handlers!.handleDrop(event as never));
113
+ return event;
114
+ }
115
+
116
+ it("control: with the flag off a dropped block is placed", () => {
117
+ const onBlockDrop = vi.fn();
118
+ dropOnPreview(onBlockDrop);
119
+ expect(onBlockDrop).toHaveBeenCalledWith("lower-third", { left: 500, top: 500 });
120
+ });
121
+
122
+ it("does not place a dropped block", () => {
123
+ const onBlockDrop = vi.fn();
124
+ const event = dropOnPreview(onBlockDrop, true);
125
+ expect(onBlockDrop).not.toHaveBeenCalled();
126
+ expect(event.preventDefault).not.toHaveBeenCalled();
127
+ });
128
+ });
@@ -24,7 +24,7 @@ import {
24
24
  afterEach(() => {
25
25
  document.body.innerHTML = "";
26
26
  // Wrapped in act: mounted harnesses subscribe to the store via
27
- // useTimelineRowElements, so the reset re-renders them.
27
+ // the store timeline elements, so the reset re-renders them.
28
28
  act(() => usePlayerStore.getState().setElements([]));
29
29
  });
30
30
 
@@ -1,6 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { usePlayerStore } from "../../player";
3
- import { useTimelineRowElements } from "../../player/hooks/useTimelineRowElements";
4
3
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
5
4
  import {
6
5
  displayTrackOrder,
@@ -41,10 +40,8 @@ export interface MirrorZOrderInput {
41
40
  * commitZMirrorLaneMove) — the shared key is unique per gesture
42
41
  * (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped.
43
42
  *
44
- * Element source: `useTimelineRowElements()` — the same expanded display
45
- * set the Timeline renders and the resolver expects (post-normalizeToZones
46
- * lanes, expanded sub-comp children on their synthetic rows). No new expansion
47
- * is built here.
43
+ * Element source: the store's timeline elements, the same set the Timeline
44
+ * renders and the resolver expects. No alternate row expansion is built here.
48
45
  *
49
46
  * The mirror persists through the SAME machinery as a timeline lane drag
50
47
  * (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded
@@ -141,7 +138,7 @@ function useMirrorLaneMoveCommit(): (
141
138
  coalesceKey: string,
142
139
  resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove,
143
140
  ) => Promise<boolean> {
144
- const elements = useTimelineRowElements();
141
+ const elements = usePlayerStore((state) => state.elements);
145
142
  const elementsRef = useRef(elements);
146
143
  elementsRef.current = elements;
147
144
  const { onMoveElements } = useTimelineEditContextOptional();
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useRef, useState, type RefObject } from "react";
2
2
  import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+ import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
3
4
 
4
5
  interface UsePreviewBlockDropOptions {
5
6
  portrait?: boolean;
@@ -67,8 +68,9 @@ export function usePreviewBlockDrop({
67
68
  portrait,
68
69
  compositionSize,
69
70
  stageRef,
70
- onBlockDrop,
71
+ onBlockDrop: onBlockDropProp,
71
72
  }: UsePreviewBlockDropOptions) {
73
+ const onBlockDrop = usePreviewReadOnly() ? undefined : onBlockDropProp;
72
74
  const [isDragOver, setIsDragOver] = useState(false);
73
75
  // dragenter/dragleave fire for every internal element boundary; a depth
74
76
  // counter keeps the drop indicator steady instead of flickering.
@@ -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;
@@ -135,7 +135,7 @@ export async function materializeIfDynamic(
135
135
  void iframe;
136
136
  void commitMutation;
137
137
  void selection;
138
- throw new GsapEditBlockedError("source-uneditable");
138
+ throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
139
139
  }
140
140
 
141
141
  // ── Drag → GSAP position math ──────────────────────────────────────────────
@@ -0,0 +1,115 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildStudioSaveFailureProperties } from "../utils/studioSaveDiagnostics";
4
+ import {
5
+ GsapEditBlockedError,
6
+ assertGsapEditPersisted,
7
+ directEditOutcomeForProperties,
8
+ } from "./gsapEditOutcome";
9
+
10
+ function blockDetailFor(error: unknown): unknown {
11
+ return buildStudioSaveFailureProperties({ source: "gsap_commit", error }).block_detail;
12
+ }
13
+
14
+ /** Minimal animation that writes `x`, so the editability check actually runs. */
15
+ function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
16
+ return {
17
+ id: "a1",
18
+ targetSelector: "#box",
19
+ method: "to",
20
+ position: 0,
21
+ properties: { x: 100 },
22
+ ...overrides,
23
+ } as GsapAnimation;
24
+ }
25
+
26
+ const POSITION = new Set(["x"]);
27
+
28
+ describe("a blocked GSAP edit names which of the nine causes it hit", () => {
29
+ // The three causes inside assertGsapAnimationDirectlyEditable share one
30
+ // message, so only the detail distinguishes them. Asserted through the
31
+ // exported wrapper because that is the path a real edit takes — and the
32
+ // wrapper used to rebuild the outcome from `reason` alone, dropping it.
33
+ it("reports a runtime-dynamic provenance", () => {
34
+ const outcome = directEditOutcomeForProperties(
35
+ [animation({ provenance: { kind: "runtime-dynamic" } })],
36
+ POSITION,
37
+ );
38
+ expect(outcome).toEqual({
39
+ status: "blocked",
40
+ reason: "source-uneditable",
41
+ detail: "provenance-runtime-dynamic",
42
+ });
43
+ });
44
+
45
+ it("reports unresolved keyframes separately from provenance", () => {
46
+ const outcome = directEditOutcomeForProperties(
47
+ [animation({ hasUnresolvedKeyframes: true })],
48
+ POSITION,
49
+ );
50
+ expect(outcome).toEqual({
51
+ status: "blocked",
52
+ reason: "source-uneditable",
53
+ detail: "unresolved-keyframes",
54
+ });
55
+ });
56
+
57
+ it("reports an unresolved selector separately from provenance", () => {
58
+ const outcome = directEditOutcomeForProperties(
59
+ [animation({ hasUnresolvedSelector: true })],
60
+ POSITION,
61
+ );
62
+ expect(outcome).toEqual({
63
+ status: "blocked",
64
+ reason: "source-uneditable",
65
+ detail: "unresolved-selector",
66
+ });
67
+ });
68
+
69
+ it("still routes a helper or loop to unroll, which has its own message", () => {
70
+ const outcome = directEditOutcomeForProperties(
71
+ [animation({ provenance: { kind: "loop" } })],
72
+ POSITION,
73
+ );
74
+ expect(outcome).toEqual({ status: "blocked", reason: "unroll-required", detail: undefined });
75
+ });
76
+
77
+ it("leaves a literal tween editable", () => {
78
+ expect(directEditOutcomeForProperties([animation()], POSITION)).toEqual({
79
+ status: "persisted",
80
+ });
81
+ });
82
+
83
+ it("carries the detail through a returned outcome, not just a thrown one", () => {
84
+ // gsapRuntimeBridge returns blocked outcomes rather than throwing.
85
+ expect(() =>
86
+ assertGsapEditPersisted({
87
+ status: "blocked",
88
+ reason: "source-uneditable",
89
+ detail: "no-position-tween",
90
+ }),
91
+ ).toThrow(
92
+ expect.objectContaining({ name: "GsapEditBlockedError", detail: "no-position-tween" }),
93
+ );
94
+ });
95
+
96
+ it("surfaces the detail on the telemetry payload", () => {
97
+ expect(
98
+ blockDetailFor(
99
+ new GsapEditBlockedError("source-uneditable", "live-rotation-no-source-tween"),
100
+ ),
101
+ ).toBe("live-rotation-no-source-tween");
102
+ });
103
+
104
+ it("omits the detail for a block that has no specific cause", () => {
105
+ expect(blockDetailFor(new GsapEditBlockedError("no-selector"))).toBeUndefined();
106
+ });
107
+
108
+ it("ignores a detail-shaped field on an error that is not a block", () => {
109
+ // Guards the name check: any error can carry a `detail`, and reporting a
110
+ // save failure's detail as a block cause would be a silent mislabel.
111
+ const impostor = Object.assign(new Error("network down"), { detail: "unresolved-selector" });
112
+ expect(blockDetailFor(impostor)).toBeUndefined();
113
+ expect(blockDetailFor("not an error")).toBeUndefined();
114
+ });
115
+ });