@hyperframes/studio 0.8.96 → 0.8.98

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 (89) hide show
  1. package/dist/assets/{hyperframes-player-ClfxdXeC.js → hyperframes-player-C6muFtfk.js} +1 -1
  2. package/dist/assets/{index-D8ztHPd3.js → index-B6vU39NM.js} +1 -1
  3. package/dist/assets/{index-BNQLP5_v.js → index-BxnoLgxG.js} +1 -1
  4. package/dist/assets/{index-CSHEHeOo.js → index-itc1XziP.js} +198 -192
  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 +23 -6
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +2153 -1969
  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 +9 -8
  22. package/src/components/EditorShell.tsx +7 -0
  23. package/src/components/TimelineToolPicker.tsx +19 -15
  24. package/src/components/TimelineToolbar.test.tsx +62 -0
  25. package/src/components/TimelineToolbar.tsx +3 -1
  26. package/src/components/editor/DomEditOverlay.tsx +0 -27
  27. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  28. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  29. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  30. package/src/components/editor/domEditing.test.ts +9 -1
  31. package/src/components/editor/domEditingDom.ts +2 -1
  32. package/src/components/editor/fontAssets.test.ts +69 -0
  33. package/src/components/editor/fontAssets.ts +11 -2
  34. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  35. package/src/components/editor/propertyPanelFont.tsx +2 -1
  36. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  37. package/src/components/nle/TimelinePane.tsx +4 -0
  38. package/src/components/ui/Menu.test.tsx +21 -0
  39. package/src/components/ui/Popover.tsx +21 -1
  40. package/src/hooks/domSelectionTimelineMirror.ts +5 -4
  41. package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
  42. package/src/hooks/useAppHotkeys.test.ts +7 -5
  43. package/src/hooks/useClipboard.ts +25 -7
  44. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  45. package/src/hooks/useDomSelection.test.ts +56 -32
  46. package/src/hooks/useDomSelection.ts +4 -3
  47. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  48. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  49. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  50. package/src/hooks/useGsapTweenCache.ts +0 -24
  51. package/src/index.ts +4 -1
  52. package/src/player/components/BeatStrip.tsx +6 -3
  53. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  54. package/src/player/components/ClipContextMenu.tsx +52 -10
  55. package/src/player/components/Timeline.readOnly.test.tsx +670 -0
  56. package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
  57. package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
  58. package/src/player/components/TimelineClipDiamonds.tsx +7 -2
  59. package/src/player/components/TimelineFxButton.tsx +3 -0
  60. package/src/player/components/TimelineLanes.tsx +3 -2
  61. package/src/player/components/TimelineOverlays.test.ts +30 -7
  62. package/src/player/components/TimelineOverlays.tsx +7 -1
  63. package/src/player/components/TimelineProvider.tsx +24 -7
  64. package/src/player/components/TimelineTypes.ts +12 -0
  65. package/src/player/components/menuKeyboardNav.ts +12 -5
  66. package/src/player/components/studioShortcuts.ts +1 -1
  67. package/src/player/components/timelineClipGestureHandlers.ts +3 -1
  68. package/src/player/components/timelineDragDrop.ts +11 -1
  69. package/src/player/components/timelineEditCapabilities.ts +1 -0
  70. package/src/player/components/timelineMountTestEnv.ts +24 -0
  71. package/src/player/components/timelineReadOnly.ts +64 -0
  72. package/src/player/components/useAutomationLaneGestures.ts +13 -2
  73. package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
  74. package/src/player/components/useTimelineProviderState.tsx +44 -14
  75. package/src/player/hooks/useShadowPreviewReload.ts +2 -2
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  78. package/src/player/lib/timelineDOM.test.ts +25 -0
  79. package/src/player/lib/timelineDOM.ts +4 -2
  80. package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
  81. package/src/player/store/playerStore.ts +4 -4
  82. package/src/utils/clipboardLook.test.ts +130 -0
  83. package/src/utils/clipboardLook.ts +82 -0
  84. package/src/utils/clipboardPayload.ts +8 -2
  85. package/src/utils/studioFontHelpers.ts +6 -3
  86. package/dist/chunk-PQFEGZWM.js.map +0 -1
  87. package/dist/chunk-QPSOTK6M.js.map +0 -1
  88. package/dist/chunk-QZ6HNNFG.js.map +0 -1
  89. /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;
@@ -71,11 +71,12 @@ describe("resolveTimelineContextElement", () => {
71
71
  });
72
72
  });
73
73
 
74
- function renderKeyframeOverlay(options: {
74
+ function renderOverlay(options: {
75
75
  capturedElement: TimelineElement;
76
76
  currentElement: TimelineElement;
77
77
  setKfContextMenu?: ReturnType<typeof vi.fn>;
78
78
  onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
79
+ overlays?: Partial<TimelineOverlaysState>;
79
80
  }) {
80
81
  const container = document.createElement("div");
81
82
  document.body.appendChild(container);
@@ -117,6 +118,7 @@ function renderKeyframeOverlay(options: {
117
118
  onCloseTrackGap: vi.fn(),
118
119
  onCloseAllTrackGaps: vi.fn(),
119
120
  onHoverGapAction: vi.fn(),
121
+ ...options.overlays,
120
122
  } satisfies TimelineOverlaysState;
121
123
  const contextValue = {
122
124
  state: {
@@ -151,22 +153,24 @@ function renderKeyframeOverlay(options: {
151
153
  );
152
154
  }
153
155
 
156
+ const render = () =>
157
+ root.render(
158
+ createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
159
+ );
154
160
  act(() => {
155
161
  usePlayerStore.setState({
156
162
  selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
157
163
  timelineSessionEpoch: 2,
158
164
  });
159
- root.render(
160
- createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
161
- );
165
+ render();
162
166
  });
163
- return { setKfContextMenu, onDeleteAllKeyframes };
167
+ return { setKfContextMenu, onDeleteAllKeyframes, rerender: () => act(render) };
164
168
  }
165
169
 
166
170
  describe("TimelineOverlays context lifecycle", () => {
167
171
  it("dismisses a keyframe menu when its selected target becomes stale", () => {
168
172
  const setKfContextMenu = vi.fn();
169
- renderKeyframeOverlay({
173
+ renderOverlay({
170
174
  capturedElement: captured,
171
175
  currentElement: captured,
172
176
  setKfContextMenu,
@@ -180,7 +184,7 @@ describe("TimelineOverlays context lifecycle", () => {
180
184
  it("dispatches a menu action with the current model element", () => {
181
185
  const current = { ...captured, start: 4, track: 7 };
182
186
  const onDeleteAllKeyframes = vi.fn();
183
- renderKeyframeOverlay({
187
+ renderOverlay({
184
188
  capturedElement: captured,
185
189
  currentElement: current,
186
190
  onDeleteAllKeyframes,
@@ -193,4 +197,23 @@ describe("TimelineOverlays context lifecycle", () => {
193
197
 
194
198
  expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
195
199
  });
200
+
201
+ it("builds the host's clip menu items once for the current model element and lists them first", () => {
202
+ const current = { ...captured, start: 4, track: 7 };
203
+ const clipMenuItems = vi.fn(() => [{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
204
+ const { rerender } = renderOverlay({
205
+ capturedElement: captured,
206
+ currentElement: current,
207
+ overlays: {
208
+ kfContextMenu: null,
209
+ clipContextMenu: { x: 10, y: 10, sessionEpoch: 2, element: captured },
210
+ clipMenuItems,
211
+ },
212
+ });
213
+
214
+ rerender();
215
+
216
+ expect(clipMenuItems).toHaveBeenCalledExactlyOnceWith(current);
217
+ expect(document.body.querySelector('[role="menuitem"]')?.textContent).toBe("Ask");
218
+ });
196
219
  });
@@ -1,4 +1,4 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, useMemo } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import { EditPopover } from "./EditModal";
4
4
  import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
@@ -152,6 +152,11 @@ export function TimelineClipMenuOverlay() {
152
152
  useEffect(() => {
153
153
  if (clipContextMenu && !element) setClipContextMenu(null);
154
154
  }, [element, clipContextMenu, setClipContextMenu]);
155
+ const { clipMenuItems } = overlay;
156
+ const hostItems = useMemo(
157
+ () => (element ? clipMenuItems?.(element) : undefined),
158
+ [clipMenuItems, element],
159
+ );
155
160
  if (!clipContextMenu || !element) return null;
156
161
  const readCurrentElement = () =>
157
162
  resolveTimelineContextElement({
@@ -183,6 +188,7 @@ export function TimelineClipMenuOverlay() {
183
188
  onPaste={overlay.onPasteClip}
184
189
  onDuplicate={overlay.onDuplicateClip}
185
190
  canPaste={overlay.canPasteClip?.() ?? false}
191
+ hostItems={hostItems}
186
192
  />
187
193
  );
188
194
  }
@@ -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,
@@ -107,6 +111,7 @@ export interface TimelineOverlaysState {
107
111
  onPasteClip?: () => Promise<void>;
108
112
  onDuplicateClip?: () => Promise<boolean>;
109
113
  canPasteClip?: () => boolean;
114
+ clipMenuItems?: TimelineProps["clipMenuItems"];
110
115
  gapContextMenu: TrackGapContextMenuState | null;
111
116
  onDismissGapContextMenu: () => void;
112
117
  onCloseTrackGap: () => void;
@@ -180,16 +185,28 @@ export interface TimelineContextValue {
180
185
 
181
186
  const TimelineContext = createContext<TimelineContextValue | null>(null);
182
187
 
183
- export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
188
+ const NO_EDITS: TimelineEditCallbacks = {};
189
+
190
+ export function TimelineProvider({
191
+ children,
192
+ readOnly = false,
193
+ onReadOnlyPress,
194
+ ...props
195
+ }: TimelineProps & { children: ReactNode }) {
184
196
  const editContext = useTimelineEditContextValue();
185
- if (!editContext) {
186
- return (
187
- <TimelineEditProvider value={props}>
197
+ const onReadOnlyPressRef = useRef(onReadOnlyPress);
198
+ useLayoutEffect(() => {
199
+ onReadOnlyPressRef.current = onReadOnlyPress;
200
+ });
201
+ const refuse = useCallback(() => onReadOnlyPressRef.current?.(), []);
202
+ const readOnlyPress = readOnly ? refuse : null;
203
+ return (
204
+ <TimelineReadOnlyContext.Provider value={readOnlyPress}>
205
+ <TimelineEditProvider value={readOnly ? NO_EDITS : (editContext ?? props)}>
188
206
  <TimelineProviderState {...props}>{children}</TimelineProviderState>
189
207
  </TimelineEditProvider>
190
- );
191
- }
192
- return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
208
+ </TimelineReadOnlyContext.Provider>
209
+ );
193
210
  }
194
211
 
195
212
  function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
@@ -12,6 +12,15 @@ export interface TimelineClipRenderContext {
12
12
  rich: boolean;
13
13
  }
14
14
 
15
+ export interface TimelineClipMenuItem {
16
+ id: string;
17
+ label: string;
18
+ icon?: ReactNode;
19
+ shortcut?: string;
20
+ disabled?: boolean;
21
+ onSelect: () => void;
22
+ }
23
+
15
24
  export interface TimelineProps
16
25
  extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
17
26
  /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
@@ -33,8 +42,11 @@ export interface TimelineProps
33
42
  onPasteClip?: () => Promise<void>;
34
43
  onDuplicateClip?: () => Promise<boolean>;
35
44
  canPasteClip?: () => boolean;
45
+ clipMenuItems?: (element: TimelineElement) => readonly TimelineClipMenuItem[];
36
46
  theme?: Partial<TimelineTheme>;
37
47
  showAudioEffects?: boolean;
38
48
  showKeyframes?: boolean;
39
49
  trackPadding?: TimelineTrackPadding;
50
+ readOnly?: boolean;
51
+ onReadOnlyPress?: () => void;
40
52
  }
@@ -2,10 +2,10 @@ import { useEffect, type RefObject } from "react";
2
2
 
3
3
  /**
4
4
  * APG menu keyboard basics for the timeline context menus: focuses the first
5
- * menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and restores
6
- * focus to the previously focused element when the menu unmounts. Pair with
7
- * `role="menu"` on the container and `role="menuitem"` on the buttons
8
- * (dismiss/Escape handling stays in useContextMenuDismiss).
5
+ * menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and gives
6
+ * focus back to the element that held it at open, on unmount, unless an item moved
7
+ * it elsewhere. Pair with `role="menu"` on the container and `role="menuitem"`
8
+ * on the buttons (dismiss/Escape handling stays in useContextMenuDismiss).
9
9
  */
10
10
  export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
11
11
  useEffect(() => {
@@ -39,7 +39,14 @@ export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): v
39
39
 
40
40
  return () => {
41
41
  menu.removeEventListener("keydown", onKeyDown);
42
- if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) {
42
+ const active = document.activeElement;
43
+ const focusLeftWithMenu =
44
+ active === null || active === document.body || menu.contains(active);
45
+ if (
46
+ focusLeftWithMenu &&
47
+ previouslyFocused instanceof HTMLElement &&
48
+ document.contains(previouslyFocused)
49
+ ) {
43
50
  previouslyFocused.focus();
44
51
  }
45
52
  };
@@ -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;