@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
@@ -1,13 +1,24 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, expect, it } from "vitest";
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
4
6
  import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
5
7
  import {
6
8
  gsapSourceFileForSelection,
7
9
  selectElementAnimationsOrRetry,
10
+ useGsapAnimationFetchFallback,
8
11
  } from "./useGsapAnimationFetchFallback";
12
+ import { fetchParsedAnimations } from "./useGsapTweenCache";
9
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
14
 
15
+ vi.mock("./useGsapTweenCache", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("./useGsapTweenCache")>()),
17
+ fetchParsedAnimations: vi.fn(),
18
+ }));
19
+
20
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21
+
11
22
  describe("gsapSourceFileForSelection", () => {
12
23
  it("uses the explicit target source instead of ambient selection state", () => {
13
24
  expect(
@@ -28,8 +39,11 @@ describe("selectElementAnimationsOrRetry", () => {
28
39
  expect(selectElementAnimationsOrRetry(null, target)).toEqual({ kind: "fetch-error" });
29
40
  });
30
41
 
31
- it("signals cold (full retry budget) when the parse is reachable but has zero total animations", () => {
32
- expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({ kind: "cold" });
42
+ it("resolves to [] for a file with no tweens at all: the endpoint parses the file per request", () => {
43
+ expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
44
+ kind: "resolved",
45
+ animations: [],
46
+ });
33
47
  });
34
48
 
35
49
  it("resolves the matching animations from a warm parse", () => {
@@ -58,3 +72,30 @@ describe("selectElementAnimationsOrRetry", () => {
58
72
  expect(outcome).toEqual({ kind: "resolved", animations: [] });
59
73
  });
60
74
  });
75
+
76
+ describe("useGsapAnimationFetchFallback", () => {
77
+ const fetchFor = (projectId: string) => {
78
+ let fetcher: ReturnType<typeof useGsapAnimationFetchFallback> | null = null;
79
+ function Probe() {
80
+ fetcher = useGsapAnimationFetchFallback(projectId);
81
+ return null;
82
+ }
83
+ const root = createRoot(document.createElement("div"));
84
+ act(() => root.render(React.createElement(Probe)));
85
+ act(() => root.unmount());
86
+ const element = document.createElement("div");
87
+ return fetcher!({ id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection);
88
+ };
89
+
90
+ it("answers a GSAP-free file with one parse request, no retries", async () => {
91
+ vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(parsed([]));
92
+ await expect(fetchFor("demo")()).resolves.toEqual([]);
93
+ expect(fetchParsedAnimations).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ it("retries a failed fetch once, then reports no animation", async () => {
97
+ vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(null);
98
+ await expect(fetchFor("demo")()).resolves.toEqual([]);
99
+ expect(fetchParsedAnimations).toHaveBeenCalledTimes(2);
100
+ });
101
+ });
@@ -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";
@@ -12,6 +12,7 @@ import {
12
12
  resolveTimelineAutoScrollLoopAction,
13
13
  } from "./timelineEditing";
14
14
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
15
+ import { useTimelineReadOnlyPress } from "./timelineReadOnly";
15
16
 
16
17
  export const BEAT_BAND_H = 14; // dark band height at top of track
17
18
  const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
@@ -88,7 +89,7 @@ function claimBeatDrag(pointerId?: number): BeatDragActor | null {
88
89
  return actor;
89
90
  }
90
91
 
91
- function cancelBeatDrag(): void {
92
+ export function cancelBeatDrag(): void {
92
93
  claimBeatDrag();
93
94
  }
94
95
 
@@ -377,6 +378,7 @@ export const BeatStrip = memo(function BeatStrip({
377
378
  );
378
379
  const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
379
380
  const projectId = usePlayerStore((state) => state.timelineProjectId);
381
+ const readOnlyPress = useTimelineReadOnlyPress();
380
382
 
381
383
  if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
382
384
  const activeBeatIndex = activeActor
@@ -420,7 +422,7 @@ export const BeatStrip = memo(function BeatStrip({
420
422
  top: 0,
421
423
  width: BEAT_HIT_W,
422
424
  height: BEAT_BAND_H,
423
- cursor: "ew-resize",
425
+ cursor: readOnlyPress ? "default" : "ew-resize",
424
426
  pointerEvents: "auto",
425
427
  touchAction: "none",
426
428
  }}
@@ -429,13 +431,14 @@ export const BeatStrip = memo(function BeatStrip({
429
431
  // selection (which otherwise "selects" the whole panel mid-drag).
430
432
  e.preventDefault();
431
433
  e.stopPropagation();
434
+ if (readOnlyPress) return readOnlyPress();
432
435
  // ⌥ starts no drag: the delete lands on release below, so a
433
436
  // slipped ⌥-drag abandons instead of destroying the beat.
434
437
  if (e.altKey) return;
435
438
  beginBeatDrag(e, t, pps);
436
439
  }}
437
440
  onClick={(e) => {
438
- if (!e.altKey) return;
441
+ if (!e.altKey || readOnlyPress) return;
439
442
  // ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
440
443
  // attempt reads as a double-click and would destroy the beat.
441
444
  e.stopPropagation();
@@ -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