@hyperframes/studio 0.8.97 → 0.8.99

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 (75) hide show
  1. package/dist/assets/{hyperframes-player-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
  3. package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.d.ts +16 -4
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +758 -607
  8. package/dist/index.js.map +1 -1
  9. package/package.json +10 -9
  10. package/src/components/EditorShell.tsx +7 -0
  11. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  12. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  13. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  14. package/src/components/editor/DomEditOverlay.tsx +0 -27
  15. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  16. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  17. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  18. package/src/components/editor/cropResize.test.ts +129 -0
  19. package/src/components/editor/cropResize.ts +143 -0
  20. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  21. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  22. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  23. package/src/components/editor/elementPositionOffset.ts +5 -4
  24. package/src/components/editor/manualEdits.test.ts +22 -8
  25. package/src/components/editor/manualEditsDom.ts +27 -11
  26. package/src/components/editor/manualEditsParsing.ts +0 -4
  27. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  30. package/src/components/editor/propertyPanelFont.tsx +2 -1
  31. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  32. package/src/components/nle/TimelinePane.tsx +4 -0
  33. package/src/hooks/domSelectionTestHarness.ts +8 -0
  34. package/src/hooks/draggedGsapPosition.ts +4 -2
  35. package/src/hooks/gsapDragCommit.ts +6 -6
  36. package/src/hooks/gsapEditOutcome.ts +6 -0
  37. package/src/hooks/gsapResizeIntercept.ts +5 -5
  38. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  39. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  40. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  41. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  42. package/src/hooks/timelineTimingSync.ts +40 -4
  43. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  44. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  45. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  46. package/src/hooks/useCropOverlay.ts +8 -2
  47. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  48. package/src/hooks/useDomEditCommits.ts +1 -0
  49. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  50. package/src/hooks/useDomEditSession.ts +2 -0
  51. package/src/hooks/useDomGeometryCommit.ts +1 -0
  52. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  53. package/src/hooks/useDomGeometryCommits.ts +5 -0
  54. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  55. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  56. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  57. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  58. package/src/hooks/useGsapAwareEditing.ts +37 -23
  59. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  60. package/src/hooks/useGsapTweenCache.ts +0 -24
  61. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  62. package/src/hooks/useTimelineEditing.ts +13 -13
  63. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  64. package/src/index.ts +4 -1
  65. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  66. package/src/player/components/ClipContextMenu.tsx +52 -10
  67. package/src/player/components/TimelineOverlays.test.ts +30 -7
  68. package/src/player/components/TimelineOverlays.tsx +7 -1
  69. package/src/player/components/TimelineProvider.tsx +1 -0
  70. package/src/player/components/TimelineTypes.ts +10 -0
  71. package/src/player/components/menuKeyboardNav.ts +12 -5
  72. package/src/player/components/useTimelineProviderState.tsx +2 -0
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  74. package/src/utils/rounding.ts +5 -0
  75. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -0,0 +1,182 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, StrictMode, useRef, useState } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import { ClipContextMenu } from "./ClipContextMenu";
7
+ import type { TimelineClipMenuItem } from "./TimelineTypes";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ const element = {
16
+ id: "title",
17
+ tag: "div",
18
+ start: 0,
19
+ duration: 2,
20
+ track: 0,
21
+ } as unknown as TimelineElement;
22
+
23
+ function renderMenu(hostItems: readonly TimelineClipMenuItem[], onClose = vi.fn()) {
24
+ const host = document.createElement("div");
25
+ document.body.appendChild(host);
26
+ const root = createRoot(host);
27
+ act(() =>
28
+ root.render(
29
+ <ClipContextMenu
30
+ x={10}
31
+ y={10}
32
+ element={element}
33
+ currentTime={1}
34
+ onClose={onClose}
35
+ onSplit={vi.fn()}
36
+ onDelete={vi.fn()}
37
+ onCopy={() => true}
38
+ hostItems={hostItems}
39
+ />,
40
+ ),
41
+ );
42
+ const items = () =>
43
+ Array.from(document.body.querySelectorAll<HTMLButtonElement>("[role=menuitem]"));
44
+ return { items, unmount: () => act(() => root.unmount()) };
45
+ }
46
+
47
+ describe("ClipContextMenu host items", () => {
48
+ it("lists the host's items first, with their icon and shortcut, and closes on a pick", () => {
49
+ const onSelect = vi.fn();
50
+ const onClose = vi.fn();
51
+ const { items, unmount } = renderMenu(
52
+ [{ id: "ask", label: "Ask", icon: <svg data-testid="ask-icon" />, shortcut: "A", onSelect }],
53
+ onClose,
54
+ );
55
+ expect(items().map((item) => item.textContent)).toEqual(["AskA", "Copy⌘C", "Delete⌫"]);
56
+ expect(items()[0]!.querySelector("[data-testid=ask-icon]")).not.toBeNull();
57
+ act(() => items()[0]!.click());
58
+ expect(onSelect).toHaveBeenCalledOnce();
59
+ expect(onClose).toHaveBeenCalledOnce();
60
+ unmount();
61
+ });
62
+
63
+ it("counts host rows and their divider when keeping the menu inside the window", () => {
64
+ const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
65
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
66
+ try {
67
+ const host = document.createElement("div");
68
+ document.body.appendChild(host);
69
+ const root = createRoot(host);
70
+ const ask = { label: "Ask", onSelect: vi.fn() };
71
+ act(() =>
72
+ root.render(
73
+ <ClipContextMenu
74
+ x={10}
75
+ y={550}
76
+ element={element}
77
+ currentTime={1}
78
+ onClose={vi.fn()}
79
+ onSplit={vi.fn()}
80
+ onDelete={vi.fn()}
81
+ onCopy={() => true}
82
+ hostItems={[
83
+ { id: "a", ...ask },
84
+ { id: "b", ...ask },
85
+ ]}
86
+ />,
87
+ ),
88
+ );
89
+ // Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window.
90
+ expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("446px");
91
+ act(() => root.unmount());
92
+ } finally {
93
+ if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
94
+ }
95
+ });
96
+
97
+ it("keeps a disabled host item inert", () => {
98
+ const onSelect = vi.fn();
99
+ const { items, unmount } = renderMenu([{ id: "ask", label: "Ask", disabled: true, onSelect }]);
100
+ expect(items()[0]!.disabled).toBe(true);
101
+ act(() => items()[0]!.click());
102
+ expect(onSelect).not.toHaveBeenCalled();
103
+ unmount();
104
+ });
105
+
106
+ it("shows only Studio's items when the host adds none", () => {
107
+ const { items, unmount } = renderMenu([]);
108
+ expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
109
+ expect(document.body.querySelectorAll(".border-t")).toHaveLength(1);
110
+ unmount();
111
+ });
112
+ });
113
+
114
+ /** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
115
+ function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
116
+ const [open, setOpen] = useState(false);
117
+ const [asked, setAsked] = useState(false);
118
+ const existing = useRef<HTMLInputElement>(null);
119
+ const ask = () => (focus === "existing" ? existing.current!.focus() : setAsked(true));
120
+ return (
121
+ <>
122
+ <button type="button" data-testid="clip" onClick={() => setOpen(true)} />
123
+ <input ref={existing} data-testid="existing" />
124
+ {asked && <input data-testid="mounted" autoFocus />}
125
+ {open && (
126
+ <ClipContextMenu
127
+ x={10}
128
+ y={10}
129
+ element={element}
130
+ currentTime={1}
131
+ onClose={() => setOpen(false)}
132
+ onSplit={vi.fn()}
133
+ onDelete={vi.fn()}
134
+ hostItems={[{ id: "ask", label: "Ask", onSelect: ask }]}
135
+ />
136
+ )}
137
+ </>
138
+ );
139
+ }
140
+
141
+ function openFrom(focus: "existing" | "mounted") {
142
+ const host = document.createElement("div");
143
+ document.body.appendChild(host);
144
+ const root = createRoot(host);
145
+ // StrictMode runs the menu's effect cleanup once while it is still mounted, as Studio's dev build does.
146
+ act(() =>
147
+ root.render(
148
+ <StrictMode>
149
+ <FocusHost focus={focus} />
150
+ </StrictMode>,
151
+ ),
152
+ );
153
+ const clip = document.body.querySelector<HTMLButtonElement>("[data-testid=clip]")!;
154
+ clip.focus();
155
+ act(() => clip.click());
156
+ const menuItem = () => document.body.querySelector<HTMLButtonElement>("[role=menuitem]");
157
+ expect(document.activeElement).toBe(menuItem());
158
+ return { menuItem, unmount: () => act(() => root.unmount()) };
159
+ }
160
+
161
+ const focusedId = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
162
+
163
+ describe("ClipContextMenu focus on close", () => {
164
+ it.each(["existing", "mounted"] as const)(
165
+ "leaves the focus where a host item put it (%s field)",
166
+ (focus) => {
167
+ const { menuItem, unmount } = openFrom(focus);
168
+ act(() => menuItem()!.click());
169
+ expect(menuItem()).toBeNull();
170
+ expect(focusedId()).toBe(focus);
171
+ unmount();
172
+ },
173
+ );
174
+
175
+ it("gives the focus back to the clip when the menu closes without moving it", () => {
176
+ const { menuItem, unmount } = openFrom("existing");
177
+ act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
178
+ expect(menuItem()).toBeNull();
179
+ expect(focusedId()).toBe("clip");
180
+ unmount();
181
+ });
182
+ });
@@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
6
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
7
+ import type { TimelineClipMenuItem } from "./TimelineTypes";
7
8
 
8
9
  interface ClipContextMenuProps {
9
10
  x: number;
@@ -17,6 +18,51 @@ interface ClipContextMenuProps {
17
18
  onPaste?: () => Promise<void>;
18
19
  onDuplicate?: () => Promise<boolean>;
19
20
  canPaste?: boolean;
21
+ hostItems?: readonly TimelineClipMenuItem[] | undefined;
22
+ }
23
+
24
+ // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
25
+ const itemClass = (enabled: boolean) =>
26
+ `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
27
+ enabled
28
+ ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
29
+ : " text-neutral-600 cursor-not-allowed"
30
+ }`;
31
+
32
+ /** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
33
+ function HostItems({
34
+ items,
35
+ onClose,
36
+ }: {
37
+ items: readonly TimelineClipMenuItem[];
38
+ onClose: () => void;
39
+ }) {
40
+ return (
41
+ <>
42
+ {items.map((item) => (
43
+ <button
44
+ key={item.id}
45
+ type="button"
46
+ role="menuitem"
47
+ className={itemClass(!item.disabled)}
48
+ disabled={item.disabled}
49
+ onClick={() => {
50
+ onClose();
51
+ item.onSelect();
52
+ }}
53
+ >
54
+ <span className="flex items-center gap-2">
55
+ {item.icon}
56
+ {item.label}
57
+ </span>
58
+ {item.shortcut && (
59
+ <span className="text-neutral-500 text-[10px] ml-3">{item.shortcut}</span>
60
+ )}
61
+ </button>
62
+ ))}
63
+ <div className="my-1 border-t border-neutral-700/60" />
64
+ </>
65
+ );
20
66
  }
21
67
 
22
68
  // A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
@@ -33,6 +79,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
33
79
  onPaste,
34
80
  onDuplicate,
35
81
  canPaste,
82
+ hostItems = [],
36
83
  }: ClipContextMenuProps) {
37
84
  const menuRef = useContextMenuDismiss(onClose);
38
85
  useMenuKeyboardNav(menuRef);
@@ -57,22 +104,16 @@ export const ClipContextMenu = memo(function ClipContextMenu({
57
104
  : "Split (move playhead inside clip)";
58
105
 
59
106
  const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
60
- const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
61
- const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
107
+ const hostRows = hostItems.length;
108
+ const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
109
+ const dividerCount =
110
+ (hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
62
111
  const menuWidth = 200;
63
112
  const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
64
113
  const overflowY = y + menuHeight - window.innerHeight;
65
114
  const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
66
115
  const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
67
116
 
68
- // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
69
- const itemClass = (enabled: boolean) =>
70
- `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
71
- enabled
72
- ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
73
- : " text-neutral-600 cursor-not-allowed"
74
- }`;
75
-
76
117
  return createPortal(
77
118
  <div
78
119
  ref={menuRef}
@@ -81,6 +122,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
81
122
  className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
82
123
  style={{ left: adjustedX, top: adjustedY }}
83
124
  >
125
+ {hostRows > 0 && <HostItems items={hostItems} onClose={onClose} />}
84
126
  {splitLabel && (
85
127
  <>
86
128
  <button
@@ -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
  }
@@ -111,6 +111,7 @@ export interface TimelineOverlaysState {
111
111
  onPasteClip?: () => Promise<void>;
112
112
  onDuplicateClip?: () => Promise<boolean>;
113
113
  canPasteClip?: () => boolean;
114
+ clipMenuItems?: TimelineProps["clipMenuItems"];
114
115
  gapContextMenu: TrackGapContextMenuState | null;
115
116
  onDismissGapContextMenu: () => void;
116
117
  onCloseTrackGap: () => void;
@@ -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,6 +42,7 @@ 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;
@@ -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
  };
@@ -71,6 +71,7 @@ export function useTimelineProviderState({
71
71
  onPasteClip,
72
72
  onDuplicateClip,
73
73
  canPasteClip,
74
+ clipMenuItems,
74
75
  theme: themeOverrides,
75
76
  showAudioEffects = true,
76
77
  showKeyframes = true,
@@ -379,6 +380,7 @@ export function useTimelineProviderState({
379
380
  onPasteClip,
380
381
  onDuplicateClip,
381
382
  canPasteClip,
383
+ clipMenuItems,
382
384
  onSelectElement,
383
385
  selectedElementId,
384
386
  setRangeSelectionRef,
@@ -2,7 +2,13 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
5
+ import {
6
+ elementScroll,
7
+ observeElementOffset,
8
+ observeElementRect,
9
+ Virtualizer,
10
+ } from "@tanstack/react-virtual";
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
6
12
  import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
7
13
  import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
8
14
  import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
@@ -203,4 +209,27 @@ describe("useTimelineVirtualRows", () => {
203
209
  expect(rows.some((row) => row.index === 500)).toBe(false);
204
210
  act(() => root.unmount());
205
211
  });
212
+
213
+ // A scroll-end timer left running fires into the torn-down test window ("window is not defined").
214
+ it("gets no virtualizer change after unmount from a scroll just before it", async () => {
215
+ const scroll = createScrollElement(0);
216
+ const onChange = vi.fn();
217
+ const virtualizer = new Virtualizer<HTMLDivElement, Element>({
218
+ count: 100,
219
+ getScrollElement: () => scroll,
220
+ estimateSize: () => 48,
221
+ scrollToFn: elementScroll,
222
+ observeElementRect,
223
+ observeElementOffset,
224
+ onChange,
225
+ });
226
+ const unmount = virtualizer._didMount();
227
+ virtualizer._willUpdate();
228
+ scroll.dispatchEvent(new Event("scroll"));
229
+ unmount();
230
+ onChange.mockClear();
231
+
232
+ await new Promise((settle) => setTimeout(settle, 200));
233
+ expect(onChange).not.toHaveBeenCalled();
234
+ });
206
235
  });
@@ -3,6 +3,11 @@ export function roundTo3(val: number): number {
3
3
  return Math.round(val * 1000) / 1000;
4
4
  }
5
5
 
6
+ /** Whole CSS px: GSAP parses a -50% translate against the integer offsetWidth/Height. */
7
+ export function roundToLayoutPx(val: number): number {
8
+ return Math.round(val);
9
+ }
10
+
6
11
  /** Round to 2 decimal places (centisecond precision for timeline values). */
7
12
  export function roundToCenti(val: number): number {
8
13
  return Math.round(val * 100) / 100;
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  export type CutoverResult =
13
13
  | { status: "declined"; reason: string }
14
- | { status: "committed"; version: string }
14
+ | { status: "committed"; version: string; before: string; after: string }
15
15
  | { status: "failed"; error: Error };
16
16
 
17
17
  export interface SdkSessionPublication {
@@ -143,7 +143,9 @@ export function failedCutover(
143
143
  }
144
144
 
145
145
  /** Only an explicit decline may enter the legacy mutation backend. */
146
- export function cutoverCommittedOrThrow(result: CutoverResult): boolean {
146
+ export function cutoverCommittedOrThrow(
147
+ result: CutoverResult,
148
+ ): result is Extract<CutoverResult, { status: "committed" }> {
147
149
  if (result.status === "failed") throw result.error;
148
150
  return result.status === "committed";
149
151
  }
@@ -300,7 +302,12 @@ async function commitCandidateEdit(
300
302
  });
301
303
  }
302
304
  if (refreshTarget) refreshCommittedEdit(edit.after, deps, options);
303
- return { status: "committed", version: hashContent(edit.after) };
305
+ return {
306
+ status: "committed",
307
+ version: hashContent(edit.after),
308
+ before: originalContent,
309
+ after: edit.after,
310
+ };
304
311
  }
305
312
 
306
313
  export async function persistSdkCandidateMutation(