@hyperframes/studio 0.8.82 → 0.8.84

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 (85) hide show
  1. package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-Dd74fv7B.js} +3 -3
  2. package/dist/assets/{index-DStmfnpZ.js → index-D9dmtDap.js} +1 -1
  3. package/dist/assets/{index-BiLCTunu.js → index-DFcIBFM8.js} +1 -1
  4. package/dist/assets/{index-DSazIt7R.js → index-gjbSm-vt.js} +193 -193
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-7VDQIGOU.js +1871 -0
  10. package/dist/chunk-7VDQIGOU.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +669 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +20512 -23172
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  29. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  30. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  31. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  32. package/src/components/editor/DomEditOverlay.tsx +36 -21
  33. package/src/components/editor/LayersPanel.tsx +1 -1
  34. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  35. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  36. package/src/components/editor/clipAudioClock.test.ts +61 -0
  37. package/src/components/editor/clipAudioClock.ts +78 -0
  38. package/src/components/editor/marqueeCommit.ts +100 -37
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +234 -17
  40. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
  41. package/src/components/editor/useFxCarve.ts +11 -15
  42. package/src/components/editor/useFxCarveNodes.ts +27 -15
  43. package/src/components/editor/useFxLevelling.ts +7 -43
  44. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  45. package/src/components/feedback/projectProvenance.ts +2 -3
  46. package/src/components/nle/PreviewOverlays.tsx +7 -188
  47. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  48. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  49. package/src/components/ui/HyperframesLogo.tsx +120 -0
  50. package/src/domEditExports.test.tsx +69 -0
  51. package/src/editorGesturesExports.test.tsx +236 -0
  52. package/src/hooks/useDomEditCommits.ts +1 -0
  53. package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
  54. package/src/hooks/useDomEditTextCommits.ts +8 -0
  55. package/src/hooks/useElementLifecycleOps.ts +1 -1
  56. package/src/hooks/useInlineTextEdit.test.tsx +39 -0
  57. package/src/hooks/useInlineTextEdit.ts +5 -9
  58. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  59. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  60. package/src/hooks/usePreviewPersistence.ts +1 -1
  61. package/src/index.ts +45 -32
  62. package/src/lightEntries.test.ts +32 -0
  63. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  64. package/src/player/components/TimelineClipFades.tsx +2 -1
  65. package/src/player/components/TimelineTypes.ts +4 -2
  66. package/src/player/components/studioShortcuts.ts +8 -5
  67. package/src/player/components/timelineClipChildren.test.ts +34 -1
  68. package/src/player/components/timelineClipChildren.tsx +7 -4
  69. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  70. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  71. package/src/player/components/timelinePointerRelease.ts +4 -0
  72. package/src/player/components/useTimelineProviderState.tsx +4 -0
  73. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  74. package/src/player/components/useTimelineStackingSync.ts +18 -11
  75. package/src/player/hooks/timelinePlayerSync.test.ts +1 -0
  76. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  77. package/src/player/lib/timelineDOM.test.ts +41 -7
  78. package/src/player/lib/timelineElementHelpers.ts +7 -7
  79. package/src/playerEntry.ts +1 -0
  80. package/src/timelineStackingSyncExport.test.tsx +67 -0
  81. package/src/ui.ts +31 -0
  82. package/src/utils/mediaTypes.test.ts +33 -0
  83. package/src/utils/mediaTypes.ts +12 -5
  84. package/dist/chunk-I7XNAIOF.js.map +0 -1
  85. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -14,6 +14,7 @@ interface MarqueeState {
14
14
  currentY: number;
15
15
  pointerId: number;
16
16
  pastThreshold: boolean;
17
+ target: Element;
17
18
  }
18
19
 
19
20
  const MARQUEE_THRESHOLD_PX = 4;
@@ -80,14 +81,13 @@ function hitsWithin(rect: Rect, candidates: MarqueeHit[]): MarqueeHit[] {
80
81
  return candidates.filter((candidate) => rectsOverlap(rect, candidate.rect));
81
82
  }
82
83
 
83
- async function runMarqueeIntersection(
84
- rect: Rect,
85
- candidates: MarqueeHit[],
84
+ async function resolveDomEditSelections(
85
+ elements: HTMLElement[],
86
86
  activeCompositionPath: string,
87
87
  ): Promise<DomEditSelection[]> {
88
88
  const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
89
89
  const hits: DomEditSelection[] = [];
90
- for (const { element } of hitsWithin(rect, candidates)) {
90
+ for (const element of elements) {
91
91
  const sel = await resolveDomEditSelection(element, {
92
92
  activeCompositionPath,
93
93
  isMasterView,
@@ -98,23 +98,48 @@ async function runMarqueeIntersection(
98
98
  return hits;
99
99
  }
100
100
 
101
- interface MarqueeGesturesDeps {
101
+ export interface MarqueeGesturesDeps<T = DomEditSelection> {
102
102
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
103
103
  overlayRef: React.RefObject<HTMLDivElement | null>;
104
104
  activeCompositionPathRef: React.RefObject<string | null>;
105
- onMarqueeSelectRef: React.RefObject<
106
- ((selections: DomEditSelection[], additive: boolean) => void) | undefined
107
- >;
108
- selectionRef: React.RefObject<DomEditSelection | null>;
109
- gestures: {
105
+ onMarqueeSelectRef: React.RefObject<((selections: T[], additive: boolean) => void) | undefined>;
106
+ /** Turns the elements a drag touched into picks; without it, Studio's edit selections. */
107
+ resolveHits?: (elements: HTMLElement[]) => T[] | Promise<T[]>;
108
+ selectionRef?: React.RefObject<DomEditSelection | null>;
109
+ /** Studio's pointer handling, for the events that are not part of a marquee. */
110
+ gestures?: {
110
111
  onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
111
112
  onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
112
113
  clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
113
114
  };
114
115
  }
115
116
 
117
+ export interface MarqueeGestures {
118
+ marqueeRect: Rect | null;
119
+ candidateRects: Rect[];
120
+ /** Starts a marquee at this press; the host has decided it landed on empty canvas. */
121
+ begin: (event: React.PointerEvent<HTMLElement>) => void;
122
+ /** Drops an active marquee without selecting anything. */
123
+ cancel: () => void;
124
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
125
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
126
+ onPointerCancel: () => void;
127
+ }
128
+
129
+ function releaseCapture(m: MarqueeState): void {
130
+ try {
131
+ m.target.releasePointerCapture(m.pointerId);
132
+ } catch {
133
+ /* already released */
134
+ }
135
+ }
136
+
137
+ export function useMarqueeGestures(deps: MarqueeGesturesDeps): MarqueeGestures;
138
+ export function useMarqueeGestures<T>(
139
+ deps: MarqueeGesturesDeps<T> & Required<Pick<MarqueeGesturesDeps<T>, "resolveHits">>,
140
+ ): MarqueeGestures;
116
141
  // fallow-ignore-next-line complexity
117
- export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
142
+ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGestures {
118
143
  const marqueeRef = useRef<MarqueeState | null>(null);
119
144
  const [marqueeRect, setMarqueeRect] = useState<Rect | null>(null);
120
145
  // Live "candidate" highlight: the elements the marquee currently touches,
@@ -135,12 +160,55 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
135
160
  if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
136
161
  const acp = deps.activeCompositionPathRef.current ?? "index.html";
137
162
  const candidates = candidatesRef.current ?? collectMarqueeCandidates(iframe, overlay, acp);
138
- const hits = await runMarqueeIntersection(rect, candidates, acp);
139
- deps.onMarqueeSelectRef.current(hits, additive);
163
+ const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
164
+ const resolveHits = deps.resolveHits;
165
+ const picks = resolveHits
166
+ ? await resolveHits(elements)
167
+ : ((await resolveDomEditSelections(elements, acp)) as T[]);
168
+ deps.onMarqueeSelectRef.current?.(picks, additive);
140
169
  },
141
- [deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
170
+ [
171
+ deps.iframeRef,
172
+ deps.overlayRef,
173
+ deps.onMarqueeSelectRef,
174
+ deps.activeCompositionPathRef,
175
+ deps.resolveHits,
176
+ ],
142
177
  );
143
178
 
179
+ const reset = useCallback(() => {
180
+ marqueeRef.current = null;
181
+ setMarqueeRect(null);
182
+ setCandidateRects([]);
183
+ candidatesRef.current = null;
184
+ }, []);
185
+
186
+ const begin = useCallback(
187
+ (event: React.PointerEvent<HTMLElement>) => {
188
+ const oRect = deps.overlayRef.current?.getBoundingClientRect();
189
+ if (!oRect) return;
190
+ event.currentTarget.setPointerCapture(event.pointerId);
191
+ const x = event.clientX - oRect.left;
192
+ const y = event.clientY - oRect.top;
193
+ marqueeRef.current = {
194
+ startX: x,
195
+ startY: y,
196
+ currentX: x,
197
+ currentY: y,
198
+ pointerId: event.pointerId,
199
+ pastThreshold: false,
200
+ target: event.currentTarget,
201
+ };
202
+ },
203
+ [deps.overlayRef],
204
+ );
205
+
206
+ const cancel = useCallback(() => {
207
+ if (!marqueeRef.current) return;
208
+ releaseCapture(marqueeRef.current);
209
+ reset();
210
+ }, [reset]);
211
+
144
212
  const onPointerMove = useCallback(
145
213
  (event: React.PointerEvent<HTMLDivElement>) => {
146
214
  const m = marqueeRef.current;
@@ -172,7 +240,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
172
240
  }
173
241
  return;
174
242
  }
175
- deps.gestures.onPointerMove(event);
243
+ deps.gestures?.onPointerMove(event);
176
244
  },
177
245
  [deps.gestures, deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
178
246
  );
@@ -181,12 +249,7 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
181
249
  (event: React.PointerEvent<HTMLDivElement>) => {
182
250
  const m = marqueeRef.current;
183
251
  if (m) {
184
- marqueeRef.current = null;
185
- try {
186
- (event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
187
- } catch {
188
- /* already released */
189
- }
252
+ releaseCapture(m);
190
253
  if (m.pastThreshold) {
191
254
  commitMarquee(
192
255
  {
@@ -200,26 +263,26 @@ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
200
263
  } else {
201
264
  deps.onMarqueeSelectRef.current?.([], false);
202
265
  }
203
- setMarqueeRect(null);
204
- setCandidateRects([]);
205
- candidatesRef.current = null;
266
+ reset();
206
267
  return;
207
268
  }
208
- deps.gestures.onPointerUp(event);
269
+ deps.gestures?.onPointerUp(event);
209
270
  },
210
- [deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
271
+ [deps.gestures, commitMarquee, deps.onMarqueeSelectRef, reset],
211
272
  );
212
273
 
213
274
  const onPointerCancel = useCallback(() => {
214
- if (marqueeRef.current) {
215
- marqueeRef.current = null;
216
- setMarqueeRect(null);
217
- setCandidateRects([]);
218
- candidatesRef.current = null;
219
- return;
220
- }
221
- deps.gestures.clearPointerState(deps.selectionRef);
222
- }, [deps.gestures, deps.selectionRef]);
223
-
224
- return { marqueeRef, marqueeRect, candidateRects, onPointerMove, onPointerUp, onPointerCancel };
275
+ if (marqueeRef.current) return cancel();
276
+ if (deps.selectionRef) deps.gestures?.clearPointerState(deps.selectionRef);
277
+ }, [deps.gestures, deps.selectionRef, cancel]);
278
+
279
+ return {
280
+ marqueeRect,
281
+ candidateRects,
282
+ begin,
283
+ cancel,
284
+ onPointerMove,
285
+ onPointerUp,
286
+ onPointerCancel,
287
+ };
225
288
  }
@@ -13,7 +13,24 @@ import { DomEditOverlay } from "./DomEditOverlay";
13
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
14
 
15
15
  const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
16
- const layout = vi.hoisted(() => ({ group: [] as unknown[] }));
16
+ const layout = vi.hoisted(() => ({
17
+ group: [] as unknown[],
18
+ hover: null as unknown,
19
+ offCanvas: [] as unknown[],
20
+ offCanvasElements: new Map<string, HTMLElement>(),
21
+ }));
22
+
23
+ const actions = vi.hoisted(() => ({ current: null as unknown }));
24
+ vi.mock("../../contexts/DomEditContext", async (importOriginal) => ({
25
+ ...(await importOriginal<typeof import("../../contexts/DomEditContext")>()),
26
+ useDomEditActionsContextOptional: () => actions.current,
27
+ }));
28
+ vi.mock("./useOffCanvasIndicators", () => ({
29
+ useOffCanvasIndicators: () => ({
30
+ offCanvasRects: layout.offCanvas,
31
+ offCanvasElementsRef: { current: layout.offCanvasElements },
32
+ }),
33
+ }));
17
34
 
18
35
  vi.mock("./useDomEditOverlayRects", () => ({
19
36
  useDomEditOverlayRects: () => ({
@@ -22,7 +39,7 @@ vi.mock("./useDomEditOverlayRects", () => ({
22
39
  current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
23
40
  },
24
41
  setOverlayRect: () => undefined,
25
- hoverRect: null,
42
+ hoverRect: layout.hover,
26
43
  groupOverlayItems: layout.group,
27
44
  groupOverlayItemsRef: { current: layout.group },
28
45
  setGroupOverlayItems: () => undefined,
@@ -57,6 +74,8 @@ function fixture(
57
74
  onStyleCommit: vi.fn(),
58
75
  onDeleteSelection: vi.fn(),
59
76
  onApplyZIndex: vi.fn(),
77
+ onMarqueeSelect: vi.fn(),
78
+ onTextEditingChange: vi.fn(),
60
79
  };
61
80
  const selection = makeSelection("Title", textElement("title"));
62
81
  selection.capabilities.canApplyManualRotation = true;
@@ -92,6 +111,33 @@ const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
92
111
  return event;
93
112
  };
94
113
 
114
+ const pressEnter = (overlay: HTMLElement) =>
115
+ act(() => {
116
+ overlay.dispatchEvent(
117
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
118
+ );
119
+ });
120
+
121
+ const clickBox = (overlay: HTMLElement) => {
122
+ const box = overlay.querySelector(BOX)!;
123
+ for (const type of ["pointerdown", "pointerup", "click"]) fire(box, type);
124
+ };
125
+
126
+ const enterOn = (target: Element) =>
127
+ act(() => {
128
+ target.dispatchEvent(
129
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
130
+ );
131
+ });
132
+
133
+ const rightClick = async (overlay: HTMLElement) => {
134
+ await act(async () => {
135
+ overlay.dispatchEvent(
136
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
137
+ );
138
+ });
139
+ };
140
+
95
141
  beforeEach(() => {
96
142
  vi.useFakeTimers();
97
143
  __resetForTests();
@@ -100,8 +146,12 @@ beforeEach(() => {
100
146
 
101
147
  afterEach(() => {
102
148
  act(() => root.unmount());
149
+ actions.current = null;
103
150
  document.body.innerHTML = "";
104
151
  layout.group = [];
152
+ layout.hover = null;
153
+ layout.offCanvas = [];
154
+ layout.offCanvasElements.clear();
105
155
  vi.useRealTimers();
106
156
  });
107
157
 
@@ -196,13 +246,6 @@ describe("DomEditOverlay with the preview read-only", () => {
196
246
  expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
197
247
  });
198
248
 
199
- const pressEnter = (overlay: HTMLElement) =>
200
- act(() => {
201
- overlay.dispatchEvent(
202
- new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
203
- );
204
- });
205
-
206
249
  it("control: with the flag off Enter opens the text for editing", () => {
207
250
  const { selection, overlay } = fixture();
208
251
  pressEnter(overlay);
@@ -215,14 +258,6 @@ describe("DomEditOverlay with the preview read-only", () => {
215
258
  expect(selection.element.hasAttribute("contenteditable")).toBe(false);
216
259
  });
217
260
 
218
- const rightClick = async (overlay: HTMLElement) => {
219
- await act(async () => {
220
- overlay.dispatchEvent(
221
- new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
222
- );
223
- });
224
- };
225
-
226
261
  it("control: with the flag off right-click offers delete and z-order", async () => {
227
262
  const { overlay } = fixture();
228
263
  await rightClick(overlay);
@@ -258,3 +293,185 @@ describe("DomEditOverlay with the preview read-only", () => {
258
293
  expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
259
294
  });
260
295
  });
296
+
297
+ describe("DomEditOverlay with canvasInput host", () => {
298
+ const HOST = { canvasInput: "host" } as const;
299
+
300
+ it("lets presses through its root, and the overlay default keeps them", () => {
301
+ expect(fixture().overlay.className).toContain("pointer-events-auto");
302
+ act(() => root.unmount());
303
+ expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
304
+ });
305
+
306
+ it("draws no hover box", () => {
307
+ layout.hover = RECT;
308
+ const hovered = (props = {}) => {
309
+ const { overlay } = fixture(props);
310
+ const box = overlay.querySelector('[data-dom-edit-hover-box="true"]');
311
+ act(() => root.unmount());
312
+ return box;
313
+ };
314
+ const selection = makeSelection("Hover", textElement("hover"));
315
+ expect(hovered({ hoverSelection: selection })).not.toBeNull();
316
+ expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
317
+ });
318
+
319
+ it("starts no marquee and makes no selection from a press on empty canvas", () => {
320
+ const off = fixture({ selection: null });
321
+ fire(off.overlay, "pointerdown");
322
+ fire(off.overlay, "pointerup");
323
+ expect(off.spies.onMarqueeSelect).toHaveBeenCalledWith([], false);
324
+ act(() => root.unmount());
325
+ const { spies, overlay } = fixture({ ...HOST, selection: null });
326
+ fire(overlay, "pointerdown");
327
+ fire(overlay, "mousedown");
328
+ fire(overlay, "pointerup");
329
+ expect(spies.onMarqueeSelect).not.toHaveBeenCalled();
330
+ expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
331
+ });
332
+
333
+ it("opens no context menu and selects nothing on right-click", async () => {
334
+ const { spies, overlay } = fixture({ ...HOST, selection: null });
335
+ await rightClick(overlay);
336
+ expect(spies.onSelectionChange).not.toHaveBeenCalled();
337
+ act(() => root.unmount());
338
+ await rightClick(fixture(HOST).overlay);
339
+ expect(document.body.textContent).not.toContain("Delete");
340
+ });
341
+
342
+ it("does not re-select on a click of the selection box", () => {
343
+ const off = fixture();
344
+ clickBox(off.overlay);
345
+ expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
346
+ act(() => root.unmount());
347
+ const { spies, overlay } = fixture(HOST);
348
+ clickBox(overlay);
349
+ fire(overlay.querySelector(BOX)!, "click");
350
+ expect(spies.onCanvasMouseDown).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it("offers no off-canvas indicator to press, so nothing re-selects from one", async () => {
354
+ const MARK = '[aria-label="Select off-canvas element stray"]';
355
+ const offCanvas = (props = {}) => {
356
+ layout.offCanvasElements.set("stray", textElement("stray"));
357
+ layout.offCanvas = [{ key: "stray", left: 820, top: 10, width: 120, height: 40 }];
358
+ return fixture(props);
359
+ };
360
+ const off = offCanvas();
361
+ fire(off.overlay.querySelector(MARK)!, "click");
362
+ await vi.waitFor(() => expect(off.spies.onSelectionChange).toHaveBeenCalledTimes(1));
363
+ act(() => root.unmount());
364
+ const { spies, overlay } = offCanvas(HOST);
365
+ expect(overlay.querySelector(MARK)).toBeNull();
366
+ expect(spies.onSelectionChange).not.toHaveBeenCalled();
367
+ });
368
+
369
+ it("reports each selection-box click to the host once, by either route", () => {
370
+ const cases = [
371
+ { props: {}, movable: true, reselects: 1 },
372
+ { props: HOST, movable: true, reselects: 0 },
373
+ { props: HOST, movable: false, reselects: 0 },
374
+ ];
375
+ for (const { props, movable, reselects } of cases) {
376
+ const onSelectionBoxClick = vi.fn();
377
+ const { spies, selection, overlay } = fixture({ ...props, onSelectionBoxClick });
378
+ selection.capabilities.canApplyManualOffset = movable;
379
+ clickBox(overlay);
380
+ expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
381
+ expect(onSelectionBoxClick).toHaveBeenCalledWith(expect.anything(), selection);
382
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(reselects);
383
+ act(() => root.unmount());
384
+ }
385
+ });
386
+
387
+ it("does not report a press off the box, or a click on a group's box", () => {
388
+ const onSelectionBoxClick = vi.fn();
389
+ const off = fixture({ onSelectionBoxClick, onMarqueeSelect: undefined });
390
+ for (const type of ["pointerdown", "mousedown", "pointerup", "click"]) fire(off.overlay, type);
391
+ expect(off.spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
392
+ act(() => root.unmount());
393
+ const members = ["a", "b"].map((id) => {
394
+ const selection = makeSelection(id, textElement(id));
395
+ return { key: id, selection, element: selection.element, rect: RECT };
396
+ });
397
+ members[1].selection.capabilities.canApplyManualOffset = false;
398
+ layout.group = members;
399
+ const { spies, overlay } = fixture({
400
+ onSelectionBoxClick,
401
+ selection: members[0].selection,
402
+ groupSelections: members.map((m) => m.selection),
403
+ });
404
+ clickBox(overlay);
405
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
406
+ expect(onSelectionBoxClick).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it("reports the first box click after a handle resize", () => {
410
+ const onSelectionBoxClick = vi.fn();
411
+ const { selection, overlay } = fixture({ ...HOST, onSelectionBoxClick });
412
+ for (const type of ["pointerdown", "pointerup", "click"]) {
413
+ fire(overlay.querySelector("div.h-4.w-4")!, type);
414
+ }
415
+ selection.capabilities.canApplyManualOffset = false;
416
+ clickBox(overlay);
417
+ expect(onSelectionBoxClick).toHaveBeenCalledTimes(1);
418
+ });
419
+
420
+ it("opens the text on Enter when nothing else has focus, not from a focused field", () => {
421
+ const off = fixture();
422
+ enterOn(document.body);
423
+ expect(off.selection.element.hasAttribute("contenteditable")).toBe(false);
424
+ act(() => root.unmount());
425
+ const { spies, selection } = fixture(HOST);
426
+ const field = document.createElement("input");
427
+ document.body.append(field);
428
+ enterOn(field);
429
+ expect(selection.element.hasAttribute("contenteditable")).toBe(false);
430
+ enterOn(document.body);
431
+ expect(selection.element.hasAttribute("contenteditable")).toBe(true);
432
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
433
+ });
434
+
435
+ it("keeps the handles working: drag, resize dots and rotate", () => {
436
+ const { spies, overlay } = fixture(HOST);
437
+ fire(overlay.querySelector(BOX)!, "pointerdown");
438
+ expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
439
+ fire(overlay.querySelector(BOX)!, "pointerup");
440
+ expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
441
+ const rotate = overlay.querySelector('[aria-label="Rotate selection"]')!;
442
+ fire(rotate, "pointerdown", { clientX: 200, clientY: 250 });
443
+ fire(rotate, "pointermove", { clientX: 300, clientY: 150 });
444
+ fire(rotate, "pointerup", { clientX: 300, clientY: 150 });
445
+ expect(spies.onRotationCommit).toHaveBeenCalledTimes(1);
446
+ });
447
+ });
448
+
449
+ describe("DomEditOverlay onTextEditingChange", () => {
450
+ it("reports true when the caret goes live and false when the edit ends", () => {
451
+ const { spies, selection, overlay } = fixture();
452
+ expect(spies.onTextEditingChange).not.toHaveBeenCalled();
453
+ pressEnter(overlay);
454
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true]]);
455
+ act(() => {
456
+ selection.element.dispatchEvent(
457
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
458
+ );
459
+ });
460
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
461
+ });
462
+
463
+ it("saves the typed words and reports false when the overlay unmounts mid-edit", () => {
464
+ const handleDomRichTextCommit = vi.fn();
465
+ actions.current = { handleDomRichTextCommit };
466
+ const { spies, selection, overlay } = fixture();
467
+ pressEnter(overlay);
468
+ selection.element.textContent = "Typed";
469
+ act(() => root.unmount());
470
+ expect(handleDomRichTextCommit).toHaveBeenCalledWith({
471
+ element: selection.element,
472
+ html: "Typed",
473
+ previousHtml: "Title",
474
+ });
475
+ expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
476
+ });
477
+ });
@@ -989,6 +989,141 @@ describe("AudioFxGroup dynamic carve", () => {
989
989
  expect(onSetAttributeLive.mock.calls.every((c) => c[0] === "data-fx-carve")).toBe(true);
990
990
  expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false);
991
991
  });
992
+
993
+ describe("on each clip's own clock", () => {
994
+ type Point = { t: number; v: number };
995
+ /** `hz` at `amp` over [from, to) of a `seconds`-long file, silence elsewhere. */
996
+ const tone = (seconds: number, [from, to]: [number, number], hz = 1000, amp = 0.7) =>
997
+ Float32Array.from({ length: SAMPLE_RATE * seconds }, (_, i) => {
998
+ const t = i / SAMPLE_RATE;
999
+ return t >= from && t < to ? amp * Math.sin(2 * Math.PI * hz * t) : 0;
1000
+ });
1001
+
1002
+ /** Each file decodes to its own samples, found by the URL that was fetched. */
1003
+ function stubFiles(files: Record<string, Float32Array>): void {
1004
+ vi.stubGlobal(
1005
+ "fetch",
1006
+ vi.fn(async (url: string) => ({ arrayBuffer: async () => url })),
1007
+ );
1008
+ vi.stubGlobal(
1009
+ "OfflineAudioContext",
1010
+ class {
1011
+ decodeAudioData = async (url: string) => {
1012
+ const data = Object.entries(files).find(([name]) => url.endsWith(name))![1];
1013
+ return { sampleRate: SAMPLE_RATE, getChannelData: () => data };
1014
+ };
1015
+ },
1016
+ );
1017
+ }
1018
+
1019
+ const valueAt = (points: Point[], t: number): number => {
1020
+ const i = points.findIndex((p) => p.t >= t);
1021
+ if (i <= 0) return points.at(i)!.v;
1022
+ const [a, b] = [points[i - 1]!, points[i]!];
1023
+ return a.v + ((b.v - a.v) * (t - a.t)) / (b.t - a.t);
1024
+ };
1025
+
1026
+ /** Carves the bed against `vo` and reads back the lane driving the first node of `type`. */
1027
+ async function carveLane(
1028
+ type: string,
1029
+ voice: Record<string, string>,
1030
+ bed: Record<string, string> = {},
1031
+ strength = 0,
1032
+ ) {
1033
+ const { host, onSetAttributeQuiet } = mount({
1034
+ "fx-chain": carvedChain,
1035
+ "fx-carve": settings(true, { strength, sources: [] }),
1036
+ start: "0",
1037
+ ...bed,
1038
+ });
1039
+ const vo = document.getElementById("vo")!;
1040
+ vo.setAttribute("src", "voice.wav");
1041
+ for (const [name, value] of Object.entries(voice)) vo.setAttribute(`data-${name}`, value);
1042
+ document.getElementById("bed")!.setAttribute("src", "bed.wav");
1043
+ await act(async () => {
1044
+ pickSource(host, "vo");
1045
+ });
1046
+ const calls = onSetAttributeQuiet.mock.calls;
1047
+ const nodes = parseWrite(writeTo(calls, "data-fx-chain")!).nodes as {
1048
+ id: string;
1049
+ type: string;
1050
+ params: { gain: number; frequency: number };
1051
+ }[];
1052
+ const node = nodes.find((n) => n.type === type)!;
1053
+ const lanes = parseWrite(writeTo(calls, "data-automation")!).lanes as {
1054
+ target: string;
1055
+ points: Point[];
1056
+ }[];
1057
+ const points = lanes.find((l) => l.target === `fx.${node.id}.gain`)!.points;
1058
+ return {
1059
+ depth: node.params.gain,
1060
+ frequency: node.params.frequency,
1061
+ points,
1062
+ at: (t: number) => valueAt(points, t),
1063
+ };
1064
+ }
1065
+
1066
+ /** Quiet 1 kHz speech at 0-2 s, loud 4 kHz speech at 4-6 s, in an 8 s file. */
1067
+ const lateSpeech = () => {
1068
+ const after = tone(8, [4, 6], 4000, 0.9);
1069
+ return tone(8, [0, 2], 1000, 0.3).map((v, i) => v + after[i]!);
1070
+ };
1071
+
1072
+ it("ignores speech after the bed has stopped playing", async () => {
1073
+ // The bed file runs 8 s, but the clip plays only its first 3 s.
1074
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(8, [0, 8], 200) });
1075
+ const lane = await carveLane("peaking", { start: "0" }, { duration: "3" });
1076
+ expect(lane.frequency).toBe(1000);
1077
+ });
1078
+
1079
+ it("ignores speech after a bed with no duration runs out, when it ducks", async () => {
1080
+ // No data-duration: the bed plays its 3 s file. Strength 0.05 ducks and keeps one band.
1081
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
1082
+ const lane = await carveLane("peaking", { start: "0" }, {}, 0.05);
1083
+ expect(lane.frequency).toBe(1000);
1084
+ });
1085
+
1086
+ it.each([
1087
+ ["no duration", {}],
1088
+ ["a duration longer than its file", { duration: "6" }],
1089
+ ])("ignores speech after a 3 s bed file with %s, without ducking", async (_, bed) => {
1090
+ stubFiles({ "voice.wav": lateSpeech(), "bed.wav": tone(3, [0, 3], 200) });
1091
+ const lane = await carveLane("peaking", { start: "0" }, bed);
1092
+ expect(lane.frequency).toBe(1000);
1093
+ });
1094
+
1095
+ it("cuts where a trimmed voice speaks, not where its file does", async () => {
1096
+ // File speech 2-3.5 s; the clip plays 2-4 s of it, so the bed hears it at 0-1.5 s.
1097
+ stubFiles({ "voice.wav": tone(6, [2, 3.5]), "bed.wav": tone(8, [0, 8], 200) });
1098
+ const lane = await carveLane("peaking", { start: "0", "media-start": "2", duration: "2" });
1099
+ expect({ speaking: lane.at(0.8) <= lane.depth * 0.5, after: lane.at(3) }).toEqual({
1100
+ speaking: true,
1101
+ after: 0,
1102
+ });
1103
+ });
1104
+
1105
+ it("cuts over half the source length for a voice at 2x", async () => {
1106
+ // File speech 1-3 s at 2x: the bed hears it at 0.5-1.5 s.
1107
+ stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [0, 8], 200) });
1108
+ const lane = await carveLane("peaking", { start: "0", "playback-rate": "2", duration: "2" });
1109
+ expect({
1110
+ speaking: lane.at(0.8) <= lane.depth * 0.5 && lane.at(1.3) <= lane.depth * 0.5,
1111
+ after: lane.at(2.5),
1112
+ }).toEqual({ speaking: true, after: 0 });
1113
+ });
1114
+
1115
+ it("ducks against the part of the bed that plays", async () => {
1116
+ // The bed file is silent for 4 s and loud after; the clip starts 4 s in, so it is loud under the voice.
1117
+ stubFiles({ "voice.wav": tone(4, [1, 3]), "bed.wav": tone(8, [4, 8], 200, 0.9) });
1118
+ const lane = await carveLane(
1119
+ "gain",
1120
+ { start: "0" },
1121
+ { "media-start": "4", duration: "4" },
1122
+ 1,
1123
+ );
1124
+ expect(Math.min(...lane.points.map((p) => p.v))).toBeLessThan(0);
1125
+ });
1126
+ });
992
1127
  });
993
1128
 
994
1129
  describe("AudioFxGroup successive edits", () => {