@hyperframes/studio 0.8.97 → 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 (32) hide show
  1. package/dist/assets/{hyperframes-player-Biu5_W0l.js → hyperframes-player-C6muFtfk.js} +1 -1
  2. package/dist/assets/{index-B5Z-1RW9.js → index-B6vU39NM.js} +1 -1
  3. package/dist/assets/{index-CWuozokW.js → index-BxnoLgxG.js} +1 -1
  4. package/dist/assets/{index-BdV5T-QI.js → index-itc1XziP.js} +174 -174
  5. package/dist/index.d.ts +16 -4
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +216 -234
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -8
  10. package/src/components/EditorShell.tsx +7 -0
  11. package/src/components/editor/DomEditOverlay.tsx +0 -27
  12. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  13. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  14. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  15. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  16. package/src/components/editor/propertyPanelFont.tsx +2 -1
  17. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  18. package/src/components/nle/TimelinePane.tsx +4 -0
  19. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  20. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  21. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  22. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  23. package/src/hooks/useGsapTweenCache.ts +0 -24
  24. package/src/index.ts +4 -1
  25. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  26. package/src/player/components/ClipContextMenu.tsx +52 -10
  27. package/src/player/components/TimelineOverlays.test.ts +30 -7
  28. package/src/player/components/TimelineOverlays.tsx +7 -1
  29. package/src/player/components/TimelineProvider.tsx +1 -0
  30. package/src/player/components/TimelineTypes.ts +10 -0
  31. package/src/player/components/menuKeyboardNav.ts +12 -5
  32. package/src/player/components/useTimelineProviderState.tsx +2 -0
@@ -3,13 +3,8 @@ import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
5
5
 
6
- // A cold parse is the initial-load race: the endpoint is reachable but its parse
7
- // isn't warm yet (zero total animations). It's worth waiting out (~600ms).
8
- const COLD_PARSE_RETRIES = 5;
9
- const COLD_PARSE_DELAY_MS = 120;
10
6
  // A hard fetch error (404/403/network/JSON failure → `fetchParsedAnimations`
11
- // returns null) is NOT a parse-warming race, so it shouldn't burn the full
12
- // cold-parse budget. One short retry covers a transient blip; beyond that the
7
+ // returns null) gets one short retry for a transient blip; beyond that the
13
8
  // endpoint genuinely isn't serving this file, so fall through to "no animation".
14
9
  const FETCH_ERROR_RETRIES = 1;
15
10
  const FETCH_ERROR_DELAY_MS = 120;
@@ -18,17 +13,14 @@ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
18
13
 
19
14
  /**
20
15
  * Outcome of resolving an element's animations from a single parse result.
21
- * - `resolved`: a definitive answer (matched animations, or `[]` when the parse
22
- * is warm but this element has no animation — create a new one, don't retry).
16
+ * - `resolved`: a definitive answer. The endpoint parses the file on disk per request,
17
+ * so no match, or a file with no tweens at all, means the element has no animation.
23
18
  * - `fetch-error`: `fetchParsedAnimations` returned null (HTTP/network/JSON
24
- * failure) — retry briefly, not for the full cold-parse budget.
25
- * - `cold`: the parse came back reachable but with zero total animations — the
26
- * initial-load warming race, worth the full cold-parse retry budget.
19
+ * failure) — retry briefly.
27
20
  */
28
21
  export type ElementAnimationsOutcome =
29
22
  | { kind: "resolved"; animations: GsapAnimation[] }
30
- | { kind: "fetch-error" }
31
- | { kind: "cold" };
23
+ | { kind: "fetch-error" };
32
24
 
33
25
  export interface GsapAnimationFetchOptions {
34
26
  /** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
@@ -41,26 +33,19 @@ export function gsapSourceFileForSelection(selection: DomEditSelection): string
41
33
  return selection.sourceFile || "index.html";
42
34
  }
43
35
 
44
- /**
45
- * Classify a parse result for one element. Differentiates a hard fetch failure
46
- * (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
47
- * so the caller can apply the right retry budget to each.
48
- */
36
+ /** Classify a parse result for one element: a hard fetch failure (`parsed === null`) or its animations. */
49
37
  export function selectElementAnimationsOrRetry(
50
38
  parsed: Pick<ParsedGsap, "animations"> | null,
51
39
  target: { id: string | null; selector: string | null },
52
40
  element?: Element | null,
53
41
  ): ElementAnimationsOutcome {
54
42
  if (!parsed) return { kind: "fetch-error" };
55
- if (parsed.animations.length === 0) return { kind: "cold" };
56
43
  return {
57
44
  kind: "resolved",
58
45
  animations: getAnimationsForElement(parsed.animations, target, element),
59
46
  };
60
47
  }
61
48
 
62
- // Retry policy deliberately distinguishes cold parses from hard fetch errors.
63
- // fallow-ignore-next-line complexity
64
49
  async function fetchElementAnimationsWithRetry(
65
50
  projectId: string,
66
51
  gsapSourceFile: string,
@@ -69,25 +54,16 @@ async function fetchElementAnimationsWithRetry(
69
54
  failOnFetchError: boolean,
70
55
  fresh: boolean,
71
56
  ): Promise<GsapAnimation[]> {
72
- let coldAttempts = 0;
73
- let errorAttempts = 0;
74
- for (;;) {
57
+ for (let errorAttempts = 0; ; errorAttempts++) {
75
58
  const parsed = await fetchParsedAnimations(projectId, gsapSourceFile, { fresh });
76
59
  fresh = false;
77
60
  const outcome = selectElementAnimationsOrRetry(parsed, target, element);
78
61
  if (outcome.kind === "resolved") return outcome.animations;
79
- if (outcome.kind === "fetch-error") {
80
- if (errorAttempts >= FETCH_ERROR_RETRIES) {
81
- if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
82
- return [];
83
- }
84
- errorAttempts++;
85
- await delay(FETCH_ERROR_DELAY_MS);
86
- continue;
62
+ if (errorAttempts >= FETCH_ERROR_RETRIES) {
63
+ if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
64
+ return [];
87
65
  }
88
- if (coldAttempts >= COLD_PARSE_RETRIES) return [];
89
- coldAttempts++;
90
- await delay(COLD_PARSE_DELAY_MS);
66
+ await delay(FETCH_ERROR_DELAY_MS);
91
67
  }
92
68
  }
93
69
 
@@ -16,9 +16,10 @@ const dotTween = {
16
16
  } as GsapAnimation;
17
17
  vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({
18
18
  ...(await importOriginal<typeof import("./keyframeCacheAstLoad")>()),
19
- fetchParsedAnimations: async () => ({ animations: [dotTween] }),
19
+ fetchParsedAnimations: vi.fn(async () => ({ animations: [dotTween] })),
20
20
  }));
21
21
  const { useGsapAnimationsForElement } = await import("./useGsapTweenCache");
22
+ const { fetchParsedAnimations } = await import("./keyframeCacheAstLoad");
22
23
 
23
24
  it("attributes a class tween by the selected element, not an earlier same-id copy in a sub-composition", async () => {
24
25
  document.body.innerHTML =
@@ -37,3 +38,24 @@ it("attributes a class tween by the selected element, not an earlier same-id cop
37
38
  expect(animations.map((animation) => animation.id)).toEqual(["dots"]);
38
39
  act(() => root.unmount());
39
40
  });
41
+
42
+ it("reads a file with no tweens once: the parse endpoint answers from the file on disk", async () => {
43
+ vi.useFakeTimers();
44
+ vi.mocked(fetchParsedAnimations)
45
+ .mockClear()
46
+ .mockResolvedValue({ animations: [] } as never);
47
+ const selection = { id: "card", sourceFile: "index.html" } as DomEditSelection;
48
+ function Harness() {
49
+ useGsapAnimationsForElement("p", "index.html", selection, 0);
50
+ return null;
51
+ }
52
+ const root = mountReactHarness(<Harness />);
53
+ try {
54
+ await act(async () => {});
55
+ await act(async () => vi.advanceTimersByTime(2000));
56
+ expect(fetchParsedAnimations).toHaveBeenCalledTimes(1);
57
+ } finally {
58
+ act(() => root.unmount());
59
+ vi.useRealTimers();
60
+ }
61
+ });
@@ -41,7 +41,6 @@ export function useGsapAnimationsForElement(
41
41
  const [multipleTimelines, setMultipleTimelines] = useState(false);
42
42
  const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
43
43
  const lastFetchKeyRef = useRef("");
44
- const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45
44
  // Re-run the per-element cache populate when sub-comp DOM children appear, so a
46
45
  // sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
47
46
  const domClipChildrenKey = usePlayerStore((s) =>
@@ -54,11 +53,6 @@ export function useGsapAnimationsForElement(
54
53
  if (fetchKey === lastFetchKeyRef.current) return;
55
54
  lastFetchKeyRef.current = fetchKey;
56
55
 
57
- if (retryTimerRef.current) {
58
- clearTimeout(retryTimerRef.current);
59
- retryTimerRef.current = null;
60
- }
61
-
62
56
  if (!projectId) {
63
57
  setAllAnimations([]);
64
58
  setMultipleTimelines(false);
@@ -80,28 +74,10 @@ export function useGsapAnimationsForElement(
80
74
  setAllAnimations(parsed.animations);
81
75
  setMultipleTimelines(parsed.multipleTimelines === true);
82
76
  setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
83
-
84
- // Retry once if initial fetch returned 0 animations — handles
85
- // cold-load race where the sourceFile isn't resolved yet.
86
- if (parsed.animations.length === 0 && targetKey) {
87
- retryTimerRef.current = setTimeout(() => {
88
- if (cancelled) return;
89
- fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
90
- if (cancelled) return;
91
- if (retryParsed && retryParsed.animations.length > 0) {
92
- setAllAnimations(retryParsed.animations);
93
- }
94
- });
95
- }, 800);
96
- }
97
77
  });
98
78
 
99
79
  return () => {
100
80
  cancelled = true;
101
- if (retryTimerRef.current) {
102
- clearTimeout(retryTimerRef.current);
103
- retryTimerRef.current = null;
104
- }
105
81
  };
106
82
  }, [projectId, sourceFile, version, target?.id, target?.selector]);
107
83
 
package/src/index.ts CHANGED
@@ -62,7 +62,10 @@ export type { ImageThumbnailProps } from "./player/components/ImageThumbnail";
62
62
  export { useRenderClipContent } from "./hooks/useRenderClipContent";
63
63
  export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent";
64
64
  export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler";
65
- export type { TimelineClipRenderContext } from "./player/components/TimelineTypes";
65
+ export type {
66
+ TimelineClipMenuItem,
67
+ TimelineClipRenderContext,
68
+ } from "./player/components/TimelineTypes";
66
69
 
67
70
  // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
68
71
  export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
@@ -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,