@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.97",
3
+ "version": "0.8.98",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.97",
64
- "@hyperframes/parsers": "0.8.97",
65
- "@hyperframes/player": "0.8.97",
66
- "@hyperframes/shader-transitions": "0.8.97",
67
- "@hyperframes/sdk": "0.8.97",
68
- "@hyperframes/studio-server": "0.8.97"
63
+ "@hyperframes/core": "0.8.98",
64
+ "@hyperframes/parsers": "0.8.98",
65
+ "@hyperframes/player": "0.8.98",
66
+ "@hyperframes/sdk": "0.8.98",
67
+ "@hyperframes/shader-transitions": "0.8.98",
68
+ "@hyperframes/studio-server": "0.8.98"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.97"
84
+ "@hyperframes/producer": "0.8.98"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -93,6 +93,7 @@
93
93
  "build": "vite build && tsup",
94
94
  "typecheck": "tsc --noEmit",
95
95
  "test": "vitest run",
96
+ "test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
96
97
  "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
97
98
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
98
99
  "test:watch": "vitest",
@@ -18,6 +18,7 @@ import type { GestureRecordingState } from "./editor/GestureRecordControl";
18
18
  import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
19
19
  import { StudioAgentTools } from "../webmcp/StudioAgentTools";
20
20
  import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
21
+ import type { TimelineProps } from "../player/components/TimelineTypes";
21
22
  import { Dock } from "./dock/Dock";
22
23
 
23
24
  type RenderClipContent = (
@@ -57,6 +58,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
57
58
  onPasteClip: () => Promise<void>;
58
59
  onDuplicateClip: () => Promise<boolean>;
59
60
  canPasteClip: () => boolean;
61
+ clipMenuItems?: TimelineProps["clipMenuItems"];
60
62
  setCompIdToSrc: (map: Map<string, string>) => void;
61
63
  setCompositionLoading: (loading: boolean) => void;
62
64
  shouldShowMotionPath: boolean;
@@ -105,6 +107,7 @@ export function EditorShell({
105
107
  onPasteClip,
106
108
  onDuplicateClip,
107
109
  canPasteClip,
110
+ clipMenuItems,
108
111
  setCompIdToSrc,
109
112
  setCompositionLoading,
110
113
  shouldShowMotionPath,
@@ -201,6 +204,7 @@ export function EditorShell({
201
204
  onPasteClip={onPasteClip}
202
205
  onDuplicateClip={onDuplicateClip}
203
206
  canPasteClip={canPasteClip}
207
+ clipMenuItems={clipMenuItems}
204
208
  previewOverlay={
205
209
  <PreviewOverlays
206
210
  shouldShowMotionPath={shouldShowMotionPath}
@@ -244,6 +248,7 @@ interface EditorShellBodyProps {
244
248
  onPasteClip: () => Promise<void>;
245
249
  onDuplicateClip: () => Promise<boolean>;
246
250
  canPasteClip: () => boolean;
251
+ clipMenuItems?: TimelineProps["clipMenuItems"];
247
252
  }
248
253
 
249
254
  function EditorShellBody({
@@ -264,6 +269,7 @@ function EditorShellBody({
264
269
  onPasteClip,
265
270
  onDuplicateClip,
266
271
  canPasteClip,
272
+ clipMenuItems,
267
273
  }: EditorShellBodyProps) {
268
274
  const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
269
275
 
@@ -316,6 +322,7 @@ function EditorShellBody({
316
322
  onPasteClip={onPasteClip}
317
323
  onDuplicateClip={onDuplicateClip}
318
324
  canPasteClip={canPasteClip}
325
+ clipMenuItems={clipMenuItems}
319
326
  onSelectTimelineElement={onSelectTimelineElement}
320
327
  timelineFooter={
321
328
  captionEditMode ? (
@@ -161,8 +161,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
161
161
  const groupGestureRef = useRef<GroupGestureState | null>(null);
162
162
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
163
163
  const suppressNextBoxClickRef = useRef(false);
164
- const suppressNextBoxMouseDownRef = useRef(false);
165
- const suppressNextOverlayMouseDownRef = useRef(false);
166
164
  const snapGuidesRef = useRef<SnapGuidesState | null>(null);
167
165
  const rafPausedRef = useRef(false);
168
166
 
@@ -329,15 +327,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
329
327
 
330
328
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
331
329
  if (!allowCanvasMovement) return;
332
- if (suppressNextOverlayMouseDownRef.current) {
333
- logSelect("mousedown-suppressed", { shift: event.shiftKey });
334
- suppressNextOverlayMouseDownRef.current = false;
335
- suppressNextBoxMouseDownRef.current = false;
336
- suppressNextBoxClickRef.current = false;
337
- event.preventDefault();
338
- event.stopPropagation();
339
- return;
340
- }
341
330
  const target = event.target as HTMLElement | null;
342
331
  const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
343
332
  logSelect("mousedown", { shift: event.shiftKey, onBox });
@@ -346,10 +335,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
346
335
  // extend beyond the composition rect into the gray zone, and users need
347
336
  // to select/deselect them by clicking there.
348
337
  onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
349
- if (event.shiftKey) {
350
- suppressNextBoxMouseDownRef.current = true;
351
- suppressNextBoxClickRef.current = true;
352
- }
353
338
  };
354
339
 
355
340
  // fallow-ignore-next-line complexity
@@ -373,8 +358,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
373
358
  if (!candidate) return;
374
359
  event.preventDefault();
375
360
  event.stopPropagation();
376
- suppressNextOverlayMouseDownRef.current = true;
377
- suppressNextBoxMouseDownRef.current = true;
378
361
  suppressNextBoxClickRef.current = true;
379
362
  onSelectionChangeRef.current(candidate, { additive: true });
380
363
  return;
@@ -419,7 +402,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
419
402
  // so those elements were always selectable once the band could begin.
420
403
  event.preventDefault();
421
404
  event.stopPropagation();
422
- suppressNextOverlayMouseDownRef.current = true;
423
405
  marquee.begin(event);
424
406
  return;
425
407
  }
@@ -437,13 +419,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
437
419
  onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
438
420
  };
439
421
 
440
- const suppressBoxMouseDown = (e: React.MouseEvent) => {
441
- if (!suppressNextBoxMouseDownRef.current) return;
442
- suppressNextBoxMouseDownRef.current = false;
443
- e.preventDefault();
444
- e.stopPropagation();
445
- };
446
-
447
422
  // Right-click state + handler: select the element under the pointer (if
448
423
  // needed), then open the menu; closes when the selection moves off-target.
449
424
  const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
@@ -509,7 +484,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
509
484
  allowBodyDrag={bodyDrag}
510
485
  groupCanMove={groupCanMove}
511
486
  gestures={gestures}
512
- onBoxMouseDown={suppressBoxMouseDown}
513
487
  onBoxClick={handleBoxClick}
514
488
  />
515
489
  )}
@@ -528,7 +502,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
528
502
  groupSelectionCount={groupSelections.length}
529
503
  gestures={gestures}
530
504
  onStyleCommit={onStyleCommitRef.current}
531
- onBoxMouseDown={suppressBoxMouseDown}
532
505
  onBoxClick={handleBoxClick}
533
506
  />
534
507
  )}
@@ -56,7 +56,6 @@ describe("DomEditSelectionChrome crop composition", () => {
56
56
  groupSelectionCount={0}
57
57
  gestures={{ startGesture: vi.fn() } as never}
58
58
  onStyleCommit={vi.fn()}
59
- onBoxMouseDown={vi.fn()}
60
59
  onBoxClick={vi.fn()}
61
60
  />,
62
61
  );
@@ -112,7 +111,6 @@ describe("DomEditSelectionChrome crop composition", () => {
112
111
  groupSelectionCount={0}
113
112
  gestures={{ startGesture: vi.fn() } as never}
114
113
  onStyleCommit={vi.fn()}
115
- onBoxMouseDown={vi.fn()}
116
114
  onBoxClick={vi.fn()}
117
115
  />,
118
116
  );
@@ -173,7 +171,6 @@ describe("DomEditSelectionChrome while editing text", () => {
173
171
  groupSelectionCount={0}
174
172
  gestures={{ startGesture: vi.fn() } as never}
175
173
  onStyleCommit={vi.fn()}
176
- onBoxMouseDown={vi.fn()}
177
174
  onBoxClick={vi.fn()}
178
175
  inlineText={{ editing, startFromPress: vi.fn() }}
179
176
  />,
@@ -258,7 +255,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
258
255
  selectionKey="box"
259
256
  groupSelectionCount={0}
260
257
  gestures={gestures as never}
261
- onBoxMouseDown={vi.fn()}
262
258
  onBoxClick={vi.fn()}
263
259
  />,
264
260
  );
@@ -313,7 +309,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
313
309
  allowBodyDrag={false}
314
310
  groupCanMove
315
311
  gestures={gestures as never}
316
- onBoxMouseDown={vi.fn()}
317
312
  onBoxClick={vi.fn()}
318
313
  />,
319
314
  );
@@ -62,7 +62,6 @@ interface DomEditGroupChromeProps {
62
62
  allowBodyDrag: boolean;
63
63
  groupCanMove: boolean;
64
64
  gestures: GestureHandlers;
65
- onBoxMouseDown: (e: React.MouseEvent) => void;
66
65
  onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
67
66
  }
68
67
 
@@ -75,7 +74,6 @@ export function DomEditGroupChrome({
75
74
  allowBodyDrag,
76
75
  groupCanMove,
77
76
  gestures,
78
- onBoxMouseDown,
79
77
  onBoxClick,
80
78
  }: DomEditGroupChromeProps) {
81
79
  const canManipulate = allowCanvasMovement && !usePreviewReadOnly();
@@ -108,7 +106,6 @@ export function DomEditGroupChrome({
108
106
  if (!canManipulate || !allowBodyDrag || (e.shiftKey && !groupCanMove)) return;
109
107
  gestures.startGroupDrag(e);
110
108
  }}
111
- onMouseDown={onBoxMouseDown}
112
109
  onClick={onBoxClick}
113
110
  />
114
111
  </>
@@ -128,7 +125,6 @@ interface DomEditSelectionChromeProps {
128
125
  groupSelectionCount: number;
129
126
  gestures: GestureHandlers;
130
127
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
131
- onBoxMouseDown: (e: React.MouseEvent) => void;
132
128
  onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
133
129
  /** The canvas' text-editing session: what opens one, and whether one is open. */
134
130
  inlineText?: {
@@ -157,7 +153,6 @@ export function DomEditSelectionChrome({
157
153
  groupSelectionCount,
158
154
  gestures,
159
155
  onStyleCommit,
160
- onBoxMouseDown,
161
156
  onBoxClick,
162
157
  inlineText,
163
158
  }: DomEditSelectionChromeProps) {
@@ -225,7 +220,6 @@ export function DomEditSelectionChrome({
225
220
  }
226
221
  if (!e.shiftKey) gestures.startBlockedMove(e, selection);
227
222
  }}
228
- onMouseDown={onBoxMouseDown}
229
223
  onClick={onBoxClick}
230
224
  >
231
225
  {cropOutlineInsetPx && (
@@ -0,0 +1,122 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { makeSelection } from "../../hooks/domSelectionTestHarness";
7
+ import type { DomEditSelection } from "./domEditing";
8
+ import "./domEditOverlayTestMocks";
9
+ import { DomEditOverlay } from "./DomEditOverlay";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+ HTMLElement.prototype.setPointerCapture ??= () => {};
13
+ HTMLElement.prototype.releasePointerCapture ??= () => {};
14
+
15
+ const pointTarget = vi.hoisted(() => ({ current: null as HTMLElement | null }));
16
+ const rect = vi.hoisted(() => ({ current: null as Record<string, number> | null }));
17
+ vi.mock("../../utils/studioPreviewHelpers", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("../../utils/studioPreviewHelpers")>()),
19
+ getPreviewTargetFromPointer: () => pointTarget.current,
20
+ }));
21
+ vi.mock("./useDomEditOverlayRects", () => ({
22
+ useDomEditOverlayRects: () => ({
23
+ overlayRect: rect.current,
24
+ overlayRectRef: rect,
25
+ setOverlayRect: () => undefined,
26
+ hoverRect: null,
27
+ groupOverlayItems: [],
28
+ groupOverlayItemsRef: { current: [] },
29
+ setGroupOverlayItems: () => undefined,
30
+ childRects: [],
31
+ }),
32
+ }));
33
+
34
+ let root: Root;
35
+ const onCanvasMouseDown = vi.fn();
36
+ const onSelectionChange = vi.fn();
37
+
38
+ function mount() {
39
+ const host = document.createElement("div");
40
+ document.body.append(host);
41
+ root = createRoot(host);
42
+ const element = document.createElement("h1");
43
+ document.body.append(element);
44
+ return { host, element, hover: makeSelection("Title", element) };
45
+ }
46
+
47
+ function render(
48
+ hoverSelection: DomEditSelection | null,
49
+ selection: DomEditSelection | null = null,
50
+ ) {
51
+ act(() =>
52
+ root.render(
53
+ <DomEditOverlay
54
+ iframeRef={{ current: document.createElement("iframe") }}
55
+ activeCompositionPath={null}
56
+ selection={selection}
57
+ hoverSelection={hoverSelection}
58
+ onCanvasMouseDown={onCanvasMouseDown}
59
+ onCanvasPointerMove={() => Promise.resolve(null)}
60
+ onCanvasPointerLeave={() => undefined}
61
+ onSelectionChange={onSelectionChange}
62
+ onBlockedMove={() => undefined}
63
+ onPathOffsetCommit={() => undefined}
64
+ onGroupPathOffsetCommit={() => undefined}
65
+ onBoxSizeCommit={() => undefined}
66
+ onRotationCommit={() => undefined}
67
+ onMarqueeSelect={() => undefined}
68
+ />,
69
+ ),
70
+ );
71
+ }
72
+
73
+ // Like Chrome: a default-prevented pointerdown sends no compatibility mousedown.
74
+ function press(target: Element, shiftKey = false) {
75
+ const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, shiftKey };
76
+ const down = new PointerEvent("pointerdown", init);
77
+ act(() => void target.dispatchEvent(down));
78
+ if (!down.defaultPrevented)
79
+ act(() => void target.dispatchEvent(new MouseEvent("mousedown", init)));
80
+ act(() => void target.dispatchEvent(new PointerEvent("pointerup", init)));
81
+ act(() => void target.dispatchEvent(new MouseEvent("click", init)));
82
+ }
83
+
84
+ afterEach(() => {
85
+ act(() => root.unmount());
86
+ onCanvasMouseDown.mockClear();
87
+ onSelectionChange.mockClear();
88
+ pointTarget.current = null;
89
+ rect.current = null;
90
+ document.body.innerHTML = "";
91
+ });
92
+
93
+ it.each([
94
+ ["a press on empty canvas", false],
95
+ ["a shift+click add", true],
96
+ ])("after %s, the next click on an element selects it", (_, shiftFirst) => {
97
+ const { host, element, hover } = mount();
98
+ const overlay = () => host.firstElementChild!;
99
+
100
+ pointTarget.current = shiftFirst ? element : null;
101
+ render(shiftFirst ? hover : null);
102
+ press(overlay(), shiftFirst);
103
+ expect(onCanvasMouseDown).not.toHaveBeenCalled();
104
+
105
+ pointTarget.current = element;
106
+ render(hover);
107
+ press(overlay());
108
+ expect(onCanvasMouseDown).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it("a shift+click on a selected box that cannot move toggles it once", () => {
112
+ const { host, element, hover } = mount();
113
+ hover.capabilities.canApplyManualOffset = false;
114
+ rect.current = { left: 20, top: 30, width: 100, height: 40, editScaleX: 1, editScaleY: 1 };
115
+ pointTarget.current = element;
116
+ render(hover, hover);
117
+
118
+ press(host.querySelector('[data-dom-edit-selection-box="true"]')!, true);
119
+
120
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
121
+ expect(onCanvasMouseDown).not.toHaveBeenCalled();
122
+ });
@@ -2,13 +2,32 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { FontFamilyField } from "./propertyPanelFont";
7
+ import { sortFontOptions } from "./propertyPanelHelpers";
8
+
9
+ vi.mock("./propertyPanelHelpers", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
11
+ return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
12
+ });
7
13
 
8
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
15
 
16
+ // The font APIs are stubbed and every family is off the Google lists, so no test reaches the network.
17
+ beforeEach(() => {
18
+ vi.stubGlobal(
19
+ "fetch",
20
+ vi.fn(async (url: string) => ({
21
+ ok: true,
22
+ json: async () => ({ fonts: url.includes("google") ? ["Roboto Slab"] : ["Arial"] }),
23
+ })),
24
+ );
25
+ });
26
+
10
27
  afterEach(() => {
11
28
  document.body.innerHTML = "";
29
+ document.head.innerHTML = "";
30
+ vi.unstubAllGlobals();
12
31
  });
13
32
 
14
33
  describe("FontFamilyField flat trigger", () => {
@@ -17,14 +36,38 @@ describe("FontFamilyField flat trigger", () => {
17
36
  document.body.append(host);
18
37
  const root = createRoot(host);
19
38
  act(() => {
20
- root.render(
21
- <FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
22
- );
39
+ root.render(<FontFamilyField flat value="Georgia" importedFonts={[]} onCommit={vi.fn()} />);
23
40
  });
24
41
  const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
25
42
  expect(trigger).not.toBeNull();
26
43
  expect(trigger?.className).not.toContain("border-neutral-800");
27
- expect(host.textContent).toContain("JetBrains Mono");
44
+ expect(host.textContent).toContain("Georgia");
28
45
  act(() => root.unmount());
29
46
  });
30
47
  });
48
+
49
+ describe("FontFamilyField font list", () => {
50
+ it("builds the list only while the dropdown is open", async () => {
51
+ vi.mocked(sortFontOptions).mockClear();
52
+ const host = document.createElement("div");
53
+ document.body.append(host);
54
+ const root = createRoot(host);
55
+ const render = (value: string) =>
56
+ root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
57
+ try {
58
+ await act(async () => render("Arial"));
59
+ // The font lists arrive and the value changes while the dropdown is closed, as during a drag.
60
+ await act(async () => render("Georgia"));
61
+ expect(sortFontOptions).not.toHaveBeenCalled();
62
+
63
+ const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
64
+ await act(async () => trigger?.click());
65
+ expect(sortFontOptions).toHaveBeenCalledTimes(1);
66
+ expect(host.textContent).toContain("Roboto Slab");
67
+ expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
68
+ } finally {
69
+ act(() => root.unmount());
70
+ host.remove();
71
+ }
72
+ });
73
+ });
@@ -276,6 +276,7 @@ export function FontFamilyField({
276
276
  );
277
277
 
278
278
  const options = useMemo(() => {
279
+ if (!open) return [];
279
280
  const documentFonts = collectDocumentFontFamilies();
280
281
  const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
281
282
  const taggedLocal = localFonts.map(
@@ -294,7 +295,7 @@ export function FontFamilyField({
294
295
  ...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
295
296
  ]),
296
297
  );
297
- }, [currentFamily, googleFonts, localFonts, projectFontAssets]);
298
+ }, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
298
299
 
299
300
  const filteredOptions = useMemo(() => {
300
301
  const matches = options.filter((o) => fontMatchesQuery(o.family, query));
@@ -193,7 +193,6 @@ describe("a shift press on a selected box starts the drag", () => {
193
193
  selectionKey="box"
194
194
  groupSelectionCount={0}
195
195
  gestures={spies as never}
196
- onBoxMouseDown={vi.fn()}
197
196
  onBoxClick={vi.fn()}
198
197
  />
199
198
  ));
@@ -207,7 +206,6 @@ describe("a shift press on a selected box starts the drag", () => {
207
206
  allowBodyDrag
208
207
  groupCanMove={groupCanMove}
209
208
  gestures={spies as never}
210
- onBoxMouseDown={vi.fn()}
211
209
  onBoxClick={vi.fn()}
212
210
  />
213
211
  ));
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
3
  import type { TimelineElement, TimelineTimeRange } from "../../player";
4
+ import type { TimelineProps } from "../../player/components/TimelineTypes";
4
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
6
  import { AudioMeterStrip } from "./AudioMeterStrip";
6
7
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
@@ -45,6 +46,7 @@ export interface TimelinePaneProps {
45
46
  onPasteClip?: () => Promise<void>;
46
47
  onDuplicateClip?: () => Promise<boolean>;
47
48
  canPasteClip?: () => boolean;
49
+ clipMenuItems?: TimelineProps["clipMenuItems"];
48
50
  }
49
51
 
50
52
  export function TimelinePane({
@@ -64,6 +66,7 @@ export function TimelinePane({
64
66
  onPasteClip,
65
67
  onDuplicateClip,
66
68
  canPasteClip,
69
+ clipMenuItems,
67
70
  }: TimelinePaneProps) {
68
71
  const {
69
72
  seek,
@@ -119,6 +122,7 @@ export function TimelinePane({
119
122
  onPasteClip={onPasteClip}
120
123
  onDuplicateClip={onDuplicateClip}
121
124
  canPasteClip={canPasteClip}
125
+ clipMenuItems={clipMenuItems}
122
126
  />
123
127
  </div>
124
128
  <AudioMeterStrip />
@@ -5,10 +5,8 @@ import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBr
5
5
  import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
6
6
  import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
7
7
  import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
8
- import {
9
- gsapSourceFileForSelection,
10
- selectElementAnimationsOrRetry,
11
- } from "./useGsapAnimationFetchFallback";
8
+ import { getAnimationsForElement } from "./gsapElementMatch";
9
+ import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
12
10
 
13
11
  interface CommitPreflight {
14
12
  offset: GsapEditOutcome;
@@ -23,13 +21,7 @@ function runCommitPreflights(
23
21
  group: boolean,
24
22
  ): CommitPreflight {
25
23
  const target = { id: selection.id ?? null, selector: selection.selector ?? null };
26
- const matched = selectElementAnimationsOrRetry(
27
- { animations: fileAnimations },
28
- target,
29
- selection.element,
30
- );
31
- // A file the server parsed with no tweens at all is a definitive answer here.
32
- const animations = matched.kind === "resolved" ? matched.animations : [];
24
+ const animations = getAnimationsForElement(fileAnimations, target, selection.element);
33
25
  return {
34
26
  offset: dragEditOutcome(selection, animations, iframe, [], group),
35
27
  size: preflightGsapResizeIntercept(selection, animations, iframe),
@@ -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
+ });