@hyperframes/studio 0.8.95 → 0.8.97

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 (79) hide show
  1. package/dist/assets/{hyperframes-player-DJJKab4_.js → hyperframes-player-Biu5_W0l.js} +1 -1
  2. package/dist/assets/{index-DE1dn3QB.js → index-B5Z-1RW9.js} +1 -1
  3. package/dist/assets/{index-Cc4by8r7.js → index-BdV5T-QI.js} +200 -194
  4. package/dist/assets/{index-C1dXTZvs.js → index-CWuozokW.js} +1 -1
  5. package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
  6. package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
  7. package/dist/chunk-6GAO75IU.js.map +1 -0
  8. package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
  9. package/dist/chunk-MTAVNAGW.js.map +1 -0
  10. package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
  11. package/dist/chunk-U3EE4ACX.js.map +1 -0
  12. package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
  13. package/dist/index.d.ts +8 -2
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +2036 -1809
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/dist/ui.d.ts +3 -1
  20. package/dist/ui.js +1 -1
  21. package/package.json +8 -8
  22. package/src/components/TimelineToolPicker.tsx +19 -15
  23. package/src/components/TimelineToolbar.test.tsx +62 -0
  24. package/src/components/TimelineToolbar.tsx +3 -1
  25. package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -10
  27. package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
  28. package/src/components/editor/domEditOverlayGestures.ts +16 -0
  29. package/src/components/editor/domEditing.test.ts +9 -1
  30. package/src/components/editor/domEditingDom.ts +2 -1
  31. package/src/components/editor/fontAssets.test.ts +69 -0
  32. package/src/components/editor/fontAssets.ts +11 -2
  33. package/src/components/editor/groupDragMove.ts +13 -6
  34. package/src/components/editor/manualOffsetDrag.test.ts +56 -0
  35. package/src/components/editor/manualOffsetDrag.ts +16 -9
  36. package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
  37. package/src/components/editor/snapEngine.ts +3 -2
  38. package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
  39. package/src/components/ui/Menu.test.tsx +21 -0
  40. package/src/components/ui/Popover.tsx +21 -1
  41. package/src/hooks/domSelectionTimelineMirror.ts +5 -4
  42. package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
  43. package/src/hooks/useAppHotkeys.test.ts +7 -5
  44. package/src/hooks/useClipboard.ts +25 -7
  45. package/src/hooks/useDomSelection.test.ts +56 -32
  46. package/src/hooks/useDomSelection.ts +4 -3
  47. package/src/player/components/BeatStrip.tsx +6 -3
  48. package/src/player/components/Timeline.readOnly.test.tsx +670 -0
  49. package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
  50. package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
  51. package/src/player/components/TimelineClipDiamonds.tsx +7 -2
  52. package/src/player/components/TimelineFxButton.tsx +3 -0
  53. package/src/player/components/TimelineLanes.tsx +3 -2
  54. package/src/player/components/TimelineProvider.tsx +23 -7
  55. package/src/player/components/TimelineTypes.ts +2 -0
  56. package/src/player/components/studioShortcuts.ts +1 -1
  57. package/src/player/components/timelineClipGestureHandlers.ts +3 -1
  58. package/src/player/components/timelineDragDrop.ts +11 -1
  59. package/src/player/components/timelineEditCapabilities.ts +1 -0
  60. package/src/player/components/timelineMountTestEnv.ts +24 -0
  61. package/src/player/components/timelineReadOnly.ts +64 -0
  62. package/src/player/components/useAutomationLaneGestures.ts +13 -2
  63. package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
  64. package/src/player/components/useTimelineProviderState.tsx +42 -14
  65. package/src/player/hooks/useShadowPreviewReload.ts +2 -2
  66. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
  67. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  68. package/src/player/lib/timelineDOM.test.ts +25 -0
  69. package/src/player/lib/timelineDOM.ts +4 -2
  70. package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
  71. package/src/player/store/playerStore.ts +4 -4
  72. package/src/utils/clipboardLook.test.ts +130 -0
  73. package/src/utils/clipboardLook.ts +82 -0
  74. package/src/utils/clipboardPayload.ts +8 -2
  75. package/src/utils/studioFontHelpers.ts +6 -3
  76. package/dist/chunk-PQFEGZWM.js.map +0 -1
  77. package/dist/chunk-QPSOTK6M.js.map +0 -1
  78. package/dist/chunk-QZ6HNNFG.js.map +0 -1
  79. /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
@@ -3,6 +3,7 @@ import { act } from "react";
3
3
  import { describe, expect, it, vi, type Mock } from "vitest";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { TimelineAutomationLane } from "./TimelineAutomationLane";
6
+ import { TimelineReadOnlyContext } from "./timelineReadOnly";
6
7
  import { PAD_X } from "./automationLaneGeometry";
7
8
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
8
9
  import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
@@ -1965,3 +1966,25 @@ describe("TimelineAutomationLane — a read-only lane offers nothing to grab", (
1965
1966
  expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
1966
1967
  });
1967
1968
  });
1969
+
1970
+ describe("TimelineAutomationLane inside a read-only timeline", () => {
1971
+ it("reports a point drag and a double-click once each, and writes nothing", () => {
1972
+ const onReadOnlyPress = vi.fn();
1973
+ const props = laneProps({ automation: ramp, readOnly: true });
1974
+ const { container } = render(
1975
+ <TimelineReadOnlyContext.Provider value={onReadOnlyPress}>
1976
+ <TimelineAutomationLane {...props} />
1977
+ </TimelineReadOnlyContext.Provider>,
1978
+ );
1979
+ const svg = container.querySelector("svg")!;
1980
+ stubBox(svg, BOX);
1981
+ fire(svg, "pointerdown", at(0, 1));
1982
+ fire(svg, "pointermove", at(1, 0.6));
1983
+ fire(svg, "pointermove", at(2, 0.4));
1984
+ fire(svg, "pointerup", at(2, 0.4));
1985
+ expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
1986
+ fire(svg, "dblclick", at(2, 0.4));
1987
+ expect(onReadOnlyPress).toHaveBeenCalledTimes(2);
1988
+ expect(props.onCommit).not.toHaveBeenCalled();
1989
+ });
1990
+ });
@@ -11,6 +11,7 @@ import {
11
11
  type TimelineKeyframeRetimeHandle,
12
12
  } from "./useTimelineKeyframeHandlers";
13
13
  import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
14
+ import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly";
14
15
  import {
15
16
  DIAMOND_RATIO,
16
17
  keyframeTimeLabel,
@@ -92,6 +93,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
92
93
  // This lane only arms it and renders the preview it publishes.
93
94
  const rootRef = useRef<HTMLDivElement>(null);
94
95
  const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
96
+ const readOnlyPress = useTimelineReadOnlyPress();
95
97
  // Retime destinations already dispatched but not yet in the keyframe cache, so
96
98
  // a rapid second drag composes from where the first move left the keyframe
97
99
  // instead of the stale rendered value.
@@ -227,7 +229,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
227
229
  : null;
228
230
  const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)";
229
231
  const baseOpacity = isSelected ? 0.4 : 0.25;
230
- const canDrag = isSelected && !!onMoveKeyframe;
232
+ const canDrag = isSelected && !!onMoveKeyframe && !readOnlyPress;
231
233
 
232
234
  return (
233
235
  <div
@@ -285,7 +287,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
285
287
  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
286
288
  if (e.button !== 0) return;
287
289
  e.stopPropagation();
288
- if (!canDrag) return;
290
+ if (!canDrag) {
291
+ if (isSelected) reportPressOnTravel(e, readOnlyPress);
292
+ return;
293
+ }
289
294
  retimeHandleRef.current = beginTimelineKeyframeRetime({
290
295
  event: e,
291
296
  elementId,
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { useEffect, useRef, useState } from "react";
13
13
  import { createPortal } from "react-dom";
14
+ import { useTimelineReadOnlyPress } from "./timelineReadOnly";
14
15
  import {
15
16
  enabledAudioFxNodes,
16
17
  parseAudioFxChain,
@@ -197,8 +198,10 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
197
198
  const transport = useAuditionTransport();
198
199
  /** Whether THIS audition lifted a mute, so only it puts one back. */
199
200
  const borrowedMute = useRef(false);
201
+ const readOnlyPress = useTimelineReadOnlyPress();
200
202
 
201
203
  const openAt = () => {
204
+ if (readOnlyPress) return readOnlyPress();
202
205
  setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
203
206
  setOpen(true);
204
207
  };
@@ -17,7 +17,7 @@ import {
17
17
  } from "./useTimelineTrackLayout";
18
18
  import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
19
19
  import { clipTimingStart } from "../../hooks/gsapShared";
20
- import { getTimelineEditCapabilities } from "./timelineEditing";
20
+ import { useTimelineClipCapabilities } from "./timelineReadOnly";
21
21
  import { CLIP_Y, TRACK_H } from "./timelineLayout";
22
22
  import { usePlayerStore } from "../store/playerStore";
23
23
  import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
@@ -107,6 +107,7 @@ export function TimelineLanes({
107
107
  const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
108
108
  useTimelineGroupDisclosure();
109
109
  const automationLanes = useAutomationLanes();
110
+ const getClipCapabilities = useTimelineClipCapabilities();
110
111
  const transitionSeamsByTrack = useMemo(
111
112
  () =>
112
113
  deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
@@ -368,7 +369,7 @@ export function TimelineLanes({
368
369
  // diamonds on their own bar instead.
369
370
  const isTrackKeyframeClip = elementKey === keyframeClipKey;
370
371
  const showsLanes = isTrackKeyframeClip && rowExpanded;
371
- const capabilities = getTimelineEditCapabilities(el);
372
+ const capabilities = getClipCapabilities(el);
372
373
  const isSelected =
373
374
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
374
375
  const isComposition = !!el.compositionSrc;
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  createContext,
3
+ useCallback,
3
4
  useContext,
5
+ useLayoutEffect,
6
+ useRef,
4
7
  type ComponentProps,
5
8
  type CSSProperties,
6
9
  type MouseEvent,
@@ -26,6 +29,7 @@ import type { TimelineTheme } from "./timelineTheme";
26
29
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
27
30
  import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
28
31
  import { useTimelineProviderState } from "./useTimelineProviderState";
32
+ import { TimelineReadOnlyContext } from "./timelineReadOnly";
29
33
  import {
30
34
  TimelineEditProvider,
31
35
  useTimelineEditContextValue,
@@ -180,16 +184,28 @@ export interface TimelineContextValue {
180
184
 
181
185
  const TimelineContext = createContext<TimelineContextValue | null>(null);
182
186
 
183
- export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
187
+ const NO_EDITS: TimelineEditCallbacks = {};
188
+
189
+ export function TimelineProvider({
190
+ children,
191
+ readOnly = false,
192
+ onReadOnlyPress,
193
+ ...props
194
+ }: TimelineProps & { children: ReactNode }) {
184
195
  const editContext = useTimelineEditContextValue();
185
- if (!editContext) {
186
- return (
187
- <TimelineEditProvider value={props}>
196
+ const onReadOnlyPressRef = useRef(onReadOnlyPress);
197
+ useLayoutEffect(() => {
198
+ onReadOnlyPressRef.current = onReadOnlyPress;
199
+ });
200
+ const refuse = useCallback(() => onReadOnlyPressRef.current?.(), []);
201
+ const readOnlyPress = readOnly ? refuse : null;
202
+ return (
203
+ <TimelineReadOnlyContext.Provider value={readOnlyPress}>
204
+ <TimelineEditProvider value={readOnly ? NO_EDITS : (editContext ?? props)}>
188
205
  <TimelineProviderState {...props}>{children}</TimelineProviderState>
189
206
  </TimelineEditProvider>
190
- );
191
- }
192
- return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
207
+ </TimelineReadOnlyContext.Provider>
208
+ );
193
209
  }
194
210
 
195
211
  function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
@@ -37,4 +37,6 @@ export interface TimelineProps
37
37
  showAudioEffects?: boolean;
38
38
  showKeyframes?: boolean;
39
39
  trackPadding?: TimelineTrackPadding;
40
+ readOnly?: boolean;
41
+ onReadOnlyPress?: () => void;
40
42
  }
@@ -53,7 +53,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
53
53
  { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
54
54
  { key: "⇧Click", label: "Razor tool: split all tracks" },
55
55
  { key: "[", label: "Select clips starting before the playhead" },
56
- { key: "]", label: "Select clips starting at or after the playhead" },
56
+ { key: "]", label: "Select clips running at or after the playhead" },
57
57
  { key: "⌘G", label: "Group elements" },
58
58
  { key: "⌘⇧G", label: "Ungroup" },
59
59
  { key: "Del", label: "Delete selected element (no keyframe selected)" },
@@ -58,10 +58,12 @@ type PointerDownAction =
58
58
  */
59
59
  function isIntentBlocked(
60
60
  intent: BlockedTimelineEditIntent | null,
61
+ capabilities: TimelineEditCapabilities,
61
62
  onResizeElement: ClipGestureDeps["onResizeElement"],
62
63
  onMoveElement: ClipGestureDeps["onMoveElement"],
63
64
  ): intent is BlockedTimelineEditIntent {
64
65
  if (!intent) return false;
66
+ if (capabilities.readOnly) return true;
65
67
  return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
66
68
  }
67
69
 
@@ -88,7 +90,7 @@ function resolvePointerDownAction(
88
90
  handleWidth: CLIP_HANDLE_W,
89
91
  capabilities,
90
92
  });
91
- if (isIntentBlocked(intent, onResizeElement, onMoveElement)) {
93
+ if (isIntentBlocked(intent, capabilities, onResizeElement, onMoveElement)) {
92
94
  return { kind: "block", intent, rect };
93
95
  }
94
96
 
@@ -23,6 +23,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
23
23
  rowGeometryRef: RefObject<TimelineRowGeometry>;
24
24
  contentOrigin: number;
25
25
  sessionEpoch: number;
26
+ readOnlyPress?: (() => void) | null;
26
27
  }
27
28
 
28
29
  /**
@@ -132,6 +133,7 @@ export function useTimelineAssetDrop({
132
133
  onBlockDrop,
133
134
  onCompositionDrop,
134
135
  sessionEpoch,
136
+ readOnlyPress,
135
137
  }: UseTimelineAssetDropOptions) {
136
138
  const [isDragOver, setIsDragOver] = useState(false);
137
139
  const [dropPreview, setDropPreview] = useState<TimelineDropPlacement | null>(null);
@@ -140,6 +142,7 @@ export function useTimelineAssetDrop({
140
142
  );
141
143
  const autoScrollRafRef = useRef(0);
142
144
  const activeDropEpochRef = useRef<number | null>(null);
145
+ const refusedDragRef = useRef(false);
143
146
 
144
147
  const stopAutoScroll = useCallback(() => {
145
148
  dragPointerRef.current = null;
@@ -210,6 +213,12 @@ export function useTimelineAssetDrop({
210
213
  const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
211
214
  const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
212
215
  if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
216
+ if (readOnlyPress) {
217
+ e.dataTransfer.dropEffect = "none";
218
+ if (!refusedDragRef.current) readOnlyPress();
219
+ refusedDragRef.current = true;
220
+ return;
221
+ }
213
222
  e.preventDefault();
214
223
  e.dataTransfer.dropEffect = "copy";
215
224
  activeDropEpochRef.current = sessionEpoch;
@@ -222,11 +231,12 @@ export function useTimelineAssetDrop({
222
231
  );
223
232
  syncAutoScroll(e.clientX, e.clientY);
224
233
  },
225
- [resolveDropPlacement, sessionEpoch, syncAutoScroll],
234
+ [readOnlyPress, resolveDropPlacement, sessionEpoch, syncAutoScroll],
226
235
  );
227
236
 
228
237
  const clearDropPreview = useCallback(() => {
229
238
  activeDropEpochRef.current = null;
239
+ refusedDragRef.current = false;
230
240
  stopAutoScroll();
231
241
  setIsDragOver(false);
232
242
  setDropPreview(null);
@@ -2,6 +2,7 @@ export interface TimelineEditCapabilities {
2
2
  canMove: boolean;
3
3
  canTrimStart: boolean;
4
4
  canTrimEnd: boolean;
5
+ readOnly?: boolean;
5
6
  }
6
7
 
7
8
  function isDeterministicTimelineWindow(input: {
@@ -27,3 +27,27 @@ export function installTimelineMountEnv(): void {
27
27
  document.body.innerHTML = "";
28
28
  });
29
29
  }
30
+
31
+ export const KEYFRAMED_CARD = new Map([
32
+ [
33
+ "card",
34
+ [
35
+ {
36
+ id: "card-position",
37
+ targetSelector: "#card",
38
+ method: "to" as const,
39
+ position: 0,
40
+ duration: 2,
41
+ properties: {},
42
+ propertyGroup: "position" as const,
43
+ keyframes: {
44
+ format: "percentage" as const,
45
+ keyframes: [
46
+ { percentage: 0, properties: { x: 0 } },
47
+ { percentage: 50, properties: { x: 100 } },
48
+ ],
49
+ },
50
+ },
51
+ ],
52
+ ],
53
+ ]);
@@ -0,0 +1,64 @@
1
+ import { createContext, useContext, useEffect, useEffectEvent } from "react";
2
+ import {
3
+ getTimelineEditCapabilities,
4
+ type TimelineEditCapabilities,
5
+ } from "./timelineEditCapabilities";
6
+
7
+ export const TimelineReadOnlyContext = createContext<(() => void) | null>(null);
8
+
9
+ /** The refusal to report while the timeline is read-only; null while it is editable. */
10
+ export function useTimelineReadOnlyPress(): (() => void) | null {
11
+ return useContext(TimelineReadOnlyContext);
12
+ }
13
+
14
+ const READ_ONLY_CLIP: TimelineEditCapabilities = {
15
+ canMove: false,
16
+ canTrimStart: false,
17
+ canTrimEnd: false,
18
+ readOnly: true,
19
+ };
20
+
21
+ /** Read-only locks every clip; its presses then take the blocked-edit path to the refusal. */
22
+ export function useTimelineClipCapabilities(): typeof getTimelineEditCapabilities {
23
+ return useTimelineReadOnlyPress() ? () => READ_ONLY_CLIP : getTimelineEditCapabilities;
24
+ }
25
+
26
+ /** Read-only swaps each callback for the refusal: the press is reported instead of acted on. */
27
+ export function refuseWhenReadOnly<T extends object>(
28
+ readOnlyPress: (() => void) | null,
29
+ callbacks: T,
30
+ ): T {
31
+ if (!readOnlyPress) return callbacks;
32
+ return Object.fromEntries(Object.keys(callbacks).map((key) => [key, readOnlyPress])) as T;
33
+ }
34
+
35
+ /** Once the switch into read-only lands, drop every edit already in flight without committing it. */
36
+ export function useAbandonEditsOnReadOnly(readOnly: boolean, abandon: () => void): void {
37
+ const abandonNow = useEffectEvent(abandon);
38
+ useEffect(() => {
39
+ if (readOnly) abandonNow();
40
+ }, [readOnly]);
41
+ }
42
+
43
+ /** Reports once if the press that started at `start` travels far enough to have been a drag. */
44
+ export function reportPressOnTravel(
45
+ start: { clientX: number; clientY: number },
46
+ refuse: (() => void) | null,
47
+ threshold = 4,
48
+ ): void {
49
+ if (!refuse) return;
50
+ const stop = () => {
51
+ window.removeEventListener("pointermove", onMove, true);
52
+ window.removeEventListener("pointerup", stop, true);
53
+ window.removeEventListener("pointercancel", stop, true);
54
+ };
55
+ const onMove = (event: PointerEvent) => {
56
+ if (Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) < threshold)
57
+ return;
58
+ stop();
59
+ refuse();
60
+ };
61
+ window.addEventListener("pointermove", onMove, true);
62
+ window.addEventListener("pointerup", stop, true);
63
+ window.addEventListener("pointercancel", stop, true);
64
+ }
@@ -26,6 +26,7 @@ import {
26
26
  type ShiftAxis,
27
27
  } from "./automationLaneDragMath";
28
28
  import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
29
+ import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly";
29
30
 
30
31
  /** How far a press may travel and still count as a click rather than a drag. */
31
32
  const CLICK_SLOP_PX = 3;
@@ -109,6 +110,7 @@ export function useAutomationLaneGestures({
109
110
  duration,
110
111
  rangeSelection,
111
112
  }: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
113
+ const timelineReadOnlyPress = useTimelineReadOnlyPress();
112
114
  const [dragIndex, setDragIndex] = useState<number | null>(null);
113
115
  const [curveIndex, setCurveIndex] = useState<number | null>(null);
114
116
  const [hint, setHint] = useState<string | null>(null);
@@ -267,6 +269,13 @@ export function useAutomationLaneGestures({
267
269
  [hitIndex, segmentDrag, segmentIndex],
268
270
  );
269
271
 
272
+ const reportLaneEditPress = useCallback(
273
+ (e: ReactPointerEvent<SVGSVGElement>) => {
274
+ if (gestureAt(e)) reportPressOnTravel(e, timelineReadOnlyPress);
275
+ },
276
+ [gestureAt, timelineReadOnlyPress],
277
+ );
278
+
270
279
  const onPointerDown = useCallback(
271
280
  (e: ReactPointerEvent<SVGSVGElement>): void => {
272
281
  if (e.button !== 0) return;
@@ -281,6 +290,7 @@ export function useAutomationLaneGestures({
281
290
  // so the field sat open until Enter however far away the next click landed.
282
291
  if (editing) commitEdit();
283
292
  if (readOnly) {
293
+ reportLaneEditPress(e);
284
294
  // The lane sits below the clip bar, so the timeline's selection handler
285
295
  // never sees this press; selecting here is the only way in. The press then
286
296
  // goes on to arm a range drag rather than being spent on the selection: a
@@ -323,6 +333,7 @@ export function useAutomationLaneGestures({
323
333
  gestureAt,
324
334
  lane,
325
335
  readOnly,
336
+ reportLaneEditPress,
326
337
  onSelect,
327
338
  rangeDrag,
328
339
  editing,
@@ -526,7 +537,7 @@ export function useAutomationLaneGestures({
526
537
 
527
538
  const onDoubleClick = useCallback(
528
539
  (e: ReactPointerEvent<SVGSVGElement>): void => {
529
- if (readOnly) return;
540
+ if (readOnly) return timelineReadOnlyPress?.();
530
541
  e.stopPropagation();
531
542
  e.preventDefault();
532
543
  const onPoint = hitIndex(e.clientX, e.clientY);
@@ -550,7 +561,7 @@ export function useAutomationLaneGestures({
550
561
  const { t, v } = pointAt(e.clientX, e.clientY);
551
562
  commitPoints(mergeInsertPoint(lane.points, t, v), true);
552
563
  },
553
- [lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
564
+ [lane, pointAt, commitPoints, readOnly, timelineReadOnlyPress, hitIndex, segmentIndex],
554
565
  );
555
566
 
556
567
  return {
@@ -93,6 +93,7 @@ interface TimelineKeyframeRetimeCoordinator {
93
93
  * is a viewport-wide question, not a per-keyframe one.
94
94
  */
95
95
  latest: PendingTimelineKeyframeRetime | null;
96
+ cancelActive: () => void;
96
97
  }
97
98
 
98
99
  type TimelineRetimePointerEvent = Pick<
@@ -115,6 +116,7 @@ function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordin
115
116
  preview: null,
116
117
  previewListeners: new Set(),
117
118
  latest: null,
119
+ cancelActive: () => {},
118
120
  };
119
121
  keyframeRetimeCoordinators.set(owner, coordinator);
120
122
  return coordinator;
@@ -186,6 +188,10 @@ export interface TimelineKeyframeRetimeHandle {
186
188
  * Starts a keyframe retime on the stable timeline viewport. The row/button is
187
189
  * only an entry point: window listeners own the gesture through virtualization.
188
190
  */
191
+ export function cancelTimelineKeyframeRetime(viewport: HTMLElement | null): void {
192
+ if (viewport) keyframeRetimeCoordinators.get(viewport)?.cancelActive();
193
+ }
194
+
189
195
  export function beginTimelineKeyframeRetime(
190
196
  input: TimelineKeyframeRetimeInput,
191
197
  ): TimelineKeyframeRetimeHandle {
@@ -256,6 +262,7 @@ export function beginTimelineKeyframeRetime(
256
262
  teardownListeners: null,
257
263
  };
258
264
  coordinator.actor = actor;
265
+ coordinator.cancelActive = () => cancel(actor);
259
266
 
260
267
  const matchesPointer = (event: TimelineRetimePointerEvent) =>
261
268
  actor.pointerId === null || event.pointerId === actor.pointerId;
@@ -27,7 +27,10 @@ import {
27
27
  useTimelineDisplayLayout,
28
28
  useTimelineTrackLayout,
29
29
  } from "./useTimelineTrackLayout";
30
- import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
30
+ import {
31
+ cancelTimelineKeyframeRetime,
32
+ useTimelineKeyframeHandlers,
33
+ } from "./useTimelineKeyframeHandlers";
31
34
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
32
35
  import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
33
36
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
@@ -40,6 +43,12 @@ import { useTimelineActiveClips } from "./useTimelineActiveClips";
40
43
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
41
44
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
42
45
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
46
+ import {
47
+ refuseWhenReadOnly,
48
+ useAbandonEditsOnReadOnly,
49
+ useTimelineReadOnlyPress,
50
+ } from "./timelineReadOnly";
51
+ import { cancelBeatDrag } from "./BeatStrip";
43
52
  export function useTimelineProviderState({
44
53
  onSeek,
45
54
  onDrillDown,
@@ -92,6 +101,14 @@ export function useTimelineProviderState({
92
101
  });
93
102
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
94
103
  const editContext = useTimelineEditContextOptional();
104
+ const readOnlyPress = useTimelineReadOnlyPress();
105
+ const oneShotEdits = refuseWhenReadOnly(readOnlyPress, {
106
+ onBlockedEditAttempt,
107
+ onRazorSplitAll,
108
+ onToggleTrackHidden: editContext.onToggleTrackHidden,
109
+ onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
110
+ onRazorSplit: editContext.onRazorSplit,
111
+ });
95
112
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
96
113
  useMusicBeatAnalysis();
97
114
  const timelineElements = usePlayerStore((s) => s.elements);
@@ -198,7 +215,7 @@ export function useTimelineProviderState({
198
215
  onMoveElements: pinnedOnMoveElements,
199
216
  onResizeElement: pinnedOnResizeElement,
200
217
  onResizeElements: pinnedOnResizeElements,
201
- onBlockedEditAttempt,
218
+ onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
202
219
  onSeek,
203
220
  setShowPopover,
204
221
  setRangeSelectionRef,
@@ -218,6 +235,7 @@ export function useTimelineProviderState({
218
235
  onBlockDrop: pinnedOnBlockDrop,
219
236
  onCompositionDrop: pinnedOnCompositionDrop,
220
237
  sessionEpoch,
238
+ readOnlyPress,
221
239
  });
222
240
  const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
223
241
  const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } =
@@ -337,7 +355,7 @@ export function useTimelineProviderState({
337
355
  scrollRef,
338
356
  contentOrigin,
339
357
  pixelsPerSecond: pps,
340
- onSplitAll: onRazorSplitAll,
358
+ onSplitAll: oneShotEdits.onRazorSplitAll,
341
359
  });
342
360
  const overlaysProps = useTimelineOverlaysState({
343
361
  elements: timelineElements,
@@ -405,6 +423,14 @@ export function useTimelineProviderState({
405
423
  handlePointerCancel,
406
424
  } = overlaysProps;
407
425
  const { rangeSelection, setRangeSelection } = overlays;
426
+ useAbandonEditsOnReadOnly(readOnlyPress !== null, () => {
427
+ setDraggedClip(null);
428
+ cancelBeatDrag();
429
+ cancelTimelineKeyframeRetime(scrollRef.current);
430
+ setClipContextMenu(null);
431
+ setKfContextMenu(null);
432
+ overlays.onDismissGapContextMenu();
433
+ });
408
434
  const laneGapStrips = useTimelineGapHighlights({
409
435
  gapHighlight,
410
436
  tracks,
@@ -484,22 +510,24 @@ export function useTimelineProviderState({
484
510
  onClickKeyframe,
485
511
  onShiftClickKeyframe,
486
512
  onMoveKeyframe,
487
- onContextMenuKeyframe,
488
- onContextMenuClip,
489
- onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => {
490
- if (draggedClip?.started || resizingClip) return;
491
- setClipContextMenu(null);
492
- openGapMenu({ x: e.clientX, y: e.clientY, track, time });
493
- },
513
+ ...refuseWhenReadOnly(readOnlyPress, {
514
+ onContextMenuKeyframe,
515
+ onContextMenuClip,
516
+ onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => {
517
+ if (draggedClip?.started || resizingClip) return;
518
+ setClipContextMenu(null);
519
+ openGapMenu({ x: e.clientX, y: e.clientY, track, time });
520
+ },
521
+ }),
494
522
  onResizeElement,
495
523
  onMoveElement,
496
524
  beatDragging,
497
525
  draggedElement,
498
526
  snapGuide,
499
527
  multiDragPreview,
500
- onToggleTrackHidden: editContext.onToggleTrackHidden,
501
- onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
502
- onRazorSplit: editContext.onRazorSplit,
528
+ onToggleTrackHidden: oneShotEdits.onToggleTrackHidden,
529
+ onTogglePropertyGroupKeyframe: oneShotEdits.onTogglePropertyGroupKeyframe,
530
+ onRazorSplit: oneShotEdits.onRazorSplit,
503
531
  onRazorSplitAll: editContext.onRazorSplitAll,
504
532
  };
505
533
  const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
@@ -515,7 +543,7 @@ export function useTimelineProviderState({
515
543
  );
516
544
  const timelineMeta = buildTimelineShellMeta({
517
545
  isDragOver: assetDrop.isDragOver,
518
- hasFileDrop: !!onFileDrop,
546
+ hasFileDrop: !!onFileDrop && !readOnlyPress,
519
547
  drop: {
520
548
  onDragOver: assetDrop.handleAssetDragOver,
521
549
  onDragLeave: assetDrop.handleAssetDragLeave,
@@ -118,9 +118,9 @@ export function useShadowPreviewReload({
118
118
  const liveTime = live?.getTime();
119
119
  const playing = usePlayerStore.getState().isPlaying;
120
120
  live?.pause();
121
- // The store takes the new document's timeline only now that it is the one on screen.
122
- pending.commit();
121
+ // The store takes the new document's timeline only now that it is the one on screen, and reads it there.
123
122
  iframeRef.current = shadow;
123
+ pending.commit();
124
124
  shadowIframeRef.current = null;
125
125
  attachIframeShortcutListeners();
126
126
  applyPreviewAudioState();