@hyperframes/studio 0.8.55 → 0.8.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/assets/{hyperframes-player-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
  2. package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
  3. package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
  4. package/dist/assets/{index-BEIRytvV.js → index-DFlILHJe.js} +1 -1
  5. package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
  6. package/dist/index.d.ts +16 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3111 -2998
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +14 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +59 -50
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/editor/DomEditOverlay.tsx +19 -40
  18. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  19. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  20. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  21. package/src/components/editor/PropertyPanel.tsx +4 -3
  22. package/src/components/editor/SnapToolbar.test.tsx +1 -0
  23. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  25. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  26. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  27. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  29. package/src/components/editor/useDomEditNudge.ts +9 -0
  30. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  31. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  32. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  33. package/src/components/nle/PreviewOverlays.tsx +15 -8
  34. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  35. package/src/components/nle/TimelinePane.tsx +5 -1
  36. package/src/components/nle/previewReadOnly.test.tsx +128 -0
  37. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  38. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  39. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  40. package/src/hooks/appHotkeysDispatch.ts +16 -1
  41. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  42. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  43. package/src/hooks/timelineTrackVisibility.ts +7 -8
  44. package/src/hooks/useAppHotkeys.test.ts +71 -3
  45. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  46. package/src/hooks/useAppHotkeys.ts +4 -0
  47. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  48. package/src/hooks/useDomEditCommits.ts +3 -0
  49. package/src/hooks/useDomEditSession.test.tsx +1 -0
  50. package/src/hooks/useDomEditSession.ts +7 -4
  51. package/src/hooks/useDomEditWiring.ts +40 -40
  52. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  53. package/src/hooks/useDomGeometryCommits.ts +26 -20
  54. package/src/hooks/useGestureCommit.test.tsx +29 -0
  55. package/src/hooks/useGestureCommit.ts +23 -0
  56. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  57. package/src/hooks/useInlineTextEdit.ts +11 -2
  58. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  59. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  60. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  61. package/src/hooks/useTimelineEditing.ts +2 -7
  62. package/src/player/components/Timeline.tsx +22 -23
  63. package/src/player/components/TimelineClip.test.tsx +52 -0
  64. package/src/player/components/TimelineClip.tsx +13 -2
  65. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  66. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  67. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  68. package/src/player/components/timelineTheme.test.ts +4 -4
  69. package/src/player/components/timelineTheme.ts +34 -30
  70. package/src/player/components/timelineTrackDisplay.ts +1 -4
  71. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  72. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  73. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  74. package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
  75. package/src/player/store/playerStore.ts +0 -4
  76. package/src/player/store/timelineElement.ts +2 -4
  77. package/src/player/store/timelineResetState.ts +0 -1
  78. package/src/styles/hex-baseline.json +3 -4
  79. package/src/styles/studio.css +7 -7
  80. package/src/styles/theme.css +29 -0
  81. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  82. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -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;
@@ -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,