@hyperframes/studio 0.7.72 → 0.7.74

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-Bjf2HPzR.js → hyperframes-player-wiJqS2i-.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-Ct_pxETK.js → index-BXepgTJb.js} +1 -1
  4. package/dist/assets/index-CBDJuOGW.js +428 -0
  5. package/dist/assets/{index-Ch1hbJ3e.js → index-DGVNG1dd.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +4203 -2281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  11. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  12. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  13. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  14. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  15. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  17. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  18. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  19. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  20. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  21. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  22. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  23. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +168 -3
  24. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +184 -243
  25. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  26. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  27. package/src/components/editor/useColorGradingController.test.ts +133 -17
  28. package/src/components/editor/useColorGradingController.ts +11 -2
  29. package/src/components/editor/useColorGradingPreviews.ts +54 -11
  30. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  31. package/src/components/editor/useColorGradingScopes.ts +62 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  33. package/src/icons/SystemIcons.tsx +4 -0
  34. package/dist/assets/index-BgZMle9I.css +0 -1
  35. package/dist/assets/index-DCyWLHnx.js +0 -428
@@ -0,0 +1,448 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ getHfColorGradingCapabilities,
4
+ normalizeHfColorGrading,
5
+ type NormalizedHfColorGradingSecondary,
6
+ } from "@hyperframes/core/color-grading";
7
+ import { Eyedropper, Plus, Trash } from "../../icons/SystemIcons";
8
+ import { FlatSlider } from "./propertyPanelFlatPrimitives";
9
+ import { FlatToggle } from "./propertyPanelFlatToggle";
10
+ import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
11
+ import {
12
+ buildColorGradingSecondaryMatte,
13
+ readColorGradingFramePixels,
14
+ sampleColorGradingSecondary,
15
+ } from "./colorGradingFrameAnalysis";
16
+
17
+ type Secondaries = readonly NormalizedHfColorGradingSecondary[];
18
+
19
+ const SECONDARY_CAPABILITIES = getHfColorGradingCapabilities().secondaries;
20
+ const PERCENT_SCALE = 100;
21
+ const RANGE_GAP = 0.01;
22
+ const HUE_CENTER_MAX = SECONDARY_CAPABILITIES.hue.center.maxExclusive - RANGE_GAP;
23
+
24
+ function wrapHueCenter(value: number): number {
25
+ const { min, maxExclusive } = SECONDARY_CAPABILITIES.hue.center;
26
+ const span = maxExclusive - min;
27
+ return ((((value - min) % span) + span) % span) + min;
28
+ }
29
+
30
+ const CORRECTION_CONTROLS = [
31
+ ["hueShift", "Hue shift", 1, "°"],
32
+ ["saturation", "Saturation", PERCENT_SCALE, "%"],
33
+ ["luma", "Luma", PERCENT_SCALE, "%"],
34
+ ["temperature", "Warmth", PERCENT_SCALE, "%"],
35
+ ["tint", "Tint", PERCENT_SCALE, "%"],
36
+ ] as const;
37
+
38
+ function defaultSecondary(): NormalizedHfColorGradingSecondary {
39
+ const secondary = normalizeHfColorGrading({
40
+ secondaries: [{ key: {}, correction: {} }],
41
+ })?.secondaries?.[0];
42
+ if (!secondary) throw new Error("Missing default color grading secondary");
43
+ return secondary;
44
+ }
45
+
46
+ const DEFAULT_SECONDARY = defaultSecondary();
47
+
48
+ function sampleCapturedFrame(
49
+ pixels: Uint8ClampedArray,
50
+ frame: ColorGradingCapturedFrame,
51
+ target: HTMLElement,
52
+ clientX?: number,
53
+ clientY?: number,
54
+ ) {
55
+ const rect = target.getBoundingClientRect();
56
+ const x = (clientX === undefined ? 0.5 : (clientX - rect.left) / rect.width) * frame.width;
57
+ const y = (clientY === undefined ? 0.5 : (clientY - rect.top) / rect.height) * frame.height;
58
+ return sampleColorGradingSecondary(pixels, frame.width, frame.height, x, y);
59
+ }
60
+
61
+ function percent(value: number): string {
62
+ return `${Math.round(value * 100)}%`;
63
+ }
64
+
65
+ export function PropertyPanelColorSecondary({
66
+ secondaries,
67
+ captureFrame,
68
+ onCommit,
69
+ }: {
70
+ secondaries: Secondaries;
71
+ captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
72
+ onCommit: (secondaries: Secondaries) => void;
73
+ }) {
74
+ const [selectedIndex, setSelectedIndex] = useState(0);
75
+ const [sampleFrame, setSampleFrame] = useState<ColorGradingCapturedFrame | null>(null);
76
+ const [samplePixels, setSamplePixels] = useState<Uint8ClampedArray | null>(null);
77
+ const [showMatte, setShowMatte] = useState(false);
78
+ const [captureError, setCaptureError] = useState<string | null>(null);
79
+ const [sampling, setSampling] = useState(false);
80
+ const matteCanvasRef = useRef<HTMLCanvasElement>(null);
81
+ const activeIndex = Math.min(selectedIndex, Math.max(0, secondaries.length - 1));
82
+ const selected = secondaries[activeIndex];
83
+
84
+ useEffect(() => {
85
+ const canvas = matteCanvasRef.current;
86
+ if (!showMatte || !canvas || !sampleFrame || !samplePixels || !selected) return;
87
+ const context = canvas.getContext("2d");
88
+ if (!context) return;
89
+ const image = context.createImageData(sampleFrame.width, sampleFrame.height);
90
+ image.data.set(
91
+ buildColorGradingSecondaryMatte(
92
+ samplePixels,
93
+ sampleFrame.width,
94
+ sampleFrame.height,
95
+ selected.key,
96
+ ),
97
+ );
98
+ context.putImageData(image, 0, 0);
99
+ }, [sampleFrame, samplePixels, selected, showMatte]);
100
+
101
+ const replaceSelected = (next: NormalizedHfColorGradingSecondary) => {
102
+ if (!selected) return;
103
+ onCommit(secondaries.map((secondary, index) => (index === activeIndex ? next : secondary)));
104
+ };
105
+ const addSecondary = () => {
106
+ if (secondaries.length >= SECONDARY_CAPABILITIES.max) return;
107
+ onCommit([...secondaries, DEFAULT_SECONDARY]);
108
+ setSelectedIndex(secondaries.length);
109
+ };
110
+ const removeSelected = () => {
111
+ if (!selected) return;
112
+ const next = secondaries.filter((_, index) => index !== activeIndex);
113
+ onCommit(next);
114
+ setSelectedIndex(Math.max(0, Math.min(activeIndex, next.length - 1)));
115
+ setSampleFrame(null);
116
+ setSamplePixels(null);
117
+ setCaptureError(null);
118
+ };
119
+
120
+ return (
121
+ <div className="space-y-1.5" data-flat-grade-secondary="true">
122
+ <div className="flex items-center justify-between gap-2">
123
+ <span className="flex items-center gap-1">
124
+ {secondaries.map((_, index) => (
125
+ <button
126
+ key={index}
127
+ type="button"
128
+ aria-pressed={activeIndex === index}
129
+ onClick={() => {
130
+ setSelectedIndex(index);
131
+ setSampleFrame(null);
132
+ setSamplePixels(null);
133
+ setCaptureError(null);
134
+ }}
135
+ className={`h-6 min-w-6 border-b-2 px-1 font-mono text-[10px] ${
136
+ activeIndex === index
137
+ ? "border-panel-accent text-panel-text-0"
138
+ : "border-transparent text-panel-text-4 hover:text-panel-text-2"
139
+ }`}
140
+ >
141
+ {index + 1}
142
+ </button>
143
+ ))}
144
+ </span>
145
+ <span className="flex items-center gap-2">
146
+ <button
147
+ type="button"
148
+ aria-label="Add secondary color selection"
149
+ title="Add secondary color selection"
150
+ disabled={secondaries.length >= SECONDARY_CAPABILITIES.max}
151
+ onClick={addSecondary}
152
+ className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
153
+ >
154
+ <Plus size={12} />
155
+ </button>
156
+ <button
157
+ type="button"
158
+ aria-label="Remove selected secondary"
159
+ title="Remove selected secondary"
160
+ disabled={!selected}
161
+ onClick={removeSelected}
162
+ className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
163
+ >
164
+ <Trash size={12} />
165
+ </button>
166
+ </span>
167
+ </div>
168
+
169
+ {!selected ? (
170
+ <button
171
+ type="button"
172
+ title="Add color selection"
173
+ onClick={addSecondary}
174
+ className="w-full border border-dashed border-panel-border-input px-2 py-2 text-[10px] text-panel-text-4 hover:border-panel-accent/50 hover:text-panel-text-2"
175
+ >
176
+ Add a color selection
177
+ </button>
178
+ ) : (
179
+ <>
180
+ <FlatToggle
181
+ label="Selection enabled"
182
+ checked={selected.enabled}
183
+ onChange={(enabled) => replaceSelected({ ...selected, enabled })}
184
+ />
185
+ <button
186
+ type="button"
187
+ disabled={sampling}
188
+ onClick={async () => {
189
+ setSampling(true);
190
+ setCaptureError(null);
191
+ try {
192
+ const frame = await captureFrame();
193
+ if (!frame) throw new Error("Frame capture unavailable");
194
+ const pixels = await readColorGradingFramePixels(frame);
195
+ if (!pixels) throw new Error("Frame pixels unavailable");
196
+ setSampleFrame(frame);
197
+ setSamplePixels(pixels);
198
+ setShowMatte(false);
199
+ } catch {
200
+ setSampleFrame(null);
201
+ setSamplePixels(null);
202
+ setCaptureError("Preview frame unavailable. Try again after the media loads.");
203
+ } finally {
204
+ setSampling(false);
205
+ }
206
+ }}
207
+ className="flex min-h-7 items-center gap-1.5 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:opacity-50"
208
+ >
209
+ <Eyedropper size={12} />
210
+ {sampling ? "Capturing frame" : "Sample color from frame"}
211
+ </button>
212
+ {captureError && (
213
+ <p role="alert" className="text-[9px] leading-4 text-red-300">
214
+ {captureError}
215
+ </p>
216
+ )}
217
+ {sampleFrame && samplePixels && (
218
+ <div className="space-y-1">
219
+ <div className="flex gap-2 text-[9px]">
220
+ <button
221
+ type="button"
222
+ aria-pressed={!showMatte}
223
+ onClick={() => setShowMatte(false)}
224
+ className={!showMatte ? "text-panel-accent" : "text-panel-text-4"}
225
+ >
226
+ Source
227
+ </button>
228
+ <button
229
+ type="button"
230
+ aria-pressed={showMatte}
231
+ onClick={() => setShowMatte(true)}
232
+ className={showMatte ? "text-panel-accent" : "text-panel-text-4"}
233
+ >
234
+ Selection matte
235
+ </button>
236
+ </div>
237
+ {showMatte ? (
238
+ <canvas
239
+ ref={matteCanvasRef}
240
+ width={sampleFrame.width}
241
+ height={sampleFrame.height}
242
+ role="img"
243
+ aria-label="Selected color matte"
244
+ className="block h-auto w-full border border-panel-hairline bg-black"
245
+ />
246
+ ) : (
247
+ <button
248
+ type="button"
249
+ className="block w-full overflow-hidden border border-panel-hairline bg-black"
250
+ title="Click a color to initialize this selection"
251
+ aria-label="Sample color from captured frame"
252
+ onClick={(event) => {
253
+ const pointer: [] | [number, number] =
254
+ event.detail === 0 ? [] : [event.clientX, event.clientY];
255
+ const key = sampleCapturedFrame(
256
+ samplePixels,
257
+ sampleFrame,
258
+ event.currentTarget,
259
+ ...pointer,
260
+ );
261
+ if (!key) return;
262
+ replaceSelected({ ...selected, key });
263
+ setShowMatte(true);
264
+ }}
265
+ >
266
+ <img
267
+ src={sampleFrame.dataUrl}
268
+ alt=""
269
+ draggable={false}
270
+ className="block h-auto w-full cursor-crosshair object-contain"
271
+ />
272
+ </button>
273
+ )}
274
+ </div>
275
+ )}
276
+
277
+ <div className="text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
278
+ Qualifier
279
+ </div>
280
+ <FlatSlider
281
+ label="Hue"
282
+ value={selected.key.hue.center}
283
+ min={SECONDARY_CAPABILITIES.hue.center.min}
284
+ max={HUE_CENTER_MAX}
285
+ step={0.1}
286
+ tier="explicitCustom"
287
+ displayValue={`${Math.round(selected.key.hue.center)}°`}
288
+ onCommit={(center) =>
289
+ replaceSelected({
290
+ ...selected,
291
+ key: {
292
+ ...selected.key,
293
+ hue: { ...selected.key.hue, center: wrapHueCenter(center) },
294
+ },
295
+ })
296
+ }
297
+ />
298
+ <FlatSlider
299
+ label="Hue range"
300
+ value={selected.key.hue.range}
301
+ min={SECONDARY_CAPABILITIES.hue.range.min}
302
+ max={SECONDARY_CAPABILITIES.hue.range.max}
303
+ tier="explicitCustom"
304
+ displayValue={`${Math.round(selected.key.hue.range)}°`}
305
+ onCommit={(range) =>
306
+ replaceSelected({
307
+ ...selected,
308
+ key: {
309
+ ...selected.key,
310
+ hue: {
311
+ ...selected.key.hue,
312
+ range,
313
+ softness: Math.min(
314
+ selected.key.hue.softness,
315
+ SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - range,
316
+ ),
317
+ },
318
+ },
319
+ })
320
+ }
321
+ />
322
+ <FlatSlider
323
+ label="Hue softness"
324
+ value={selected.key.hue.softness}
325
+ min={SECONDARY_CAPABILITIES.hue.softness.min}
326
+ max={Math.min(
327
+ SECONDARY_CAPABILITIES.hue.softness.max,
328
+ SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - selected.key.hue.range,
329
+ )}
330
+ tier="explicitCustom"
331
+ displayValue={`${Math.round(selected.key.hue.softness)}°`}
332
+ onCommit={(softness) =>
333
+ replaceSelected({
334
+ ...selected,
335
+ key: { ...selected.key, hue: { ...selected.key.hue, softness } },
336
+ })
337
+ }
338
+ />
339
+ {(["saturation", "luma"] as const).flatMap((key) => {
340
+ const label = key === "saturation" ? "Saturation" : "Luma";
341
+ const range = selected.key[key];
342
+ const capability = SECONDARY_CAPABILITIES[key];
343
+ return [
344
+ <FlatSlider
345
+ key={`${key}-min`}
346
+ label={`${label} min`}
347
+ value={range.min * PERCENT_SCALE}
348
+ min={capability.min.min * PERCENT_SCALE}
349
+ max={capability.min.max * PERCENT_SCALE}
350
+ tier="explicitCustom"
351
+ displayValue={percent(range.min)}
352
+ onCommit={(value) =>
353
+ replaceSelected({
354
+ ...selected,
355
+ key: {
356
+ ...selected.key,
357
+ [key]: {
358
+ ...range,
359
+ min: Math.max(
360
+ capability.min.min,
361
+ Math.min(value / PERCENT_SCALE, range.max - RANGE_GAP),
362
+ ),
363
+ },
364
+ },
365
+ })
366
+ }
367
+ />,
368
+ <FlatSlider
369
+ key={`${key}-max`}
370
+ label={`${label} max`}
371
+ value={range.max * PERCENT_SCALE}
372
+ min={capability.max.min * PERCENT_SCALE}
373
+ max={capability.max.max * PERCENT_SCALE}
374
+ tier="explicitCustom"
375
+ displayValue={percent(range.max)}
376
+ onCommit={(value) =>
377
+ replaceSelected({
378
+ ...selected,
379
+ key: {
380
+ ...selected.key,
381
+ [key]: {
382
+ ...range,
383
+ max: Math.min(
384
+ capability.max.max,
385
+ Math.max(value / PERCENT_SCALE, range.min + RANGE_GAP),
386
+ ),
387
+ },
388
+ },
389
+ })
390
+ }
391
+ />,
392
+ <FlatSlider
393
+ key={`${key}-softness`}
394
+ label={`${label} softness`}
395
+ value={range.softness * PERCENT_SCALE}
396
+ min={capability.softness.min * PERCENT_SCALE}
397
+ max={capability.softness.max * PERCENT_SCALE}
398
+ tier="explicitCustom"
399
+ displayValue={percent(range.softness)}
400
+ onCommit={(value) =>
401
+ replaceSelected({
402
+ ...selected,
403
+ key: {
404
+ ...selected.key,
405
+ [key]: { ...range, softness: value / PERCENT_SCALE },
406
+ },
407
+ })
408
+ }
409
+ />,
410
+ ];
411
+ })}
412
+
413
+ <div className="pt-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
414
+ Correction
415
+ </div>
416
+ {CORRECTION_CONTROLS.map(([key, label, scale, suffix]) => {
417
+ const limit = SECONDARY_CAPABILITIES.correction[key];
418
+ const value = selected.correction[key] * scale;
419
+ return (
420
+ <FlatSlider
421
+ key={key}
422
+ label={label}
423
+ value={value}
424
+ min={limit.min * scale}
425
+ max={limit.max * scale}
426
+ centerTick
427
+ tier={Math.abs(value) > 0.0001 ? "explicitCustom" : "default"}
428
+ displayValue={`${Math.round(value)}${suffix}`}
429
+ onCommit={(next) =>
430
+ replaceSelected({
431
+ ...selected,
432
+ correction: { ...selected.correction, [key]: next / scale },
433
+ })
434
+ }
435
+ onReset={() =>
436
+ replaceSelected({
437
+ ...selected,
438
+ correction: { ...selected.correction, [key]: 0 },
439
+ })
440
+ }
441
+ />
442
+ );
443
+ })}
444
+ </>
445
+ )}
446
+ </div>
447
+ );
448
+ }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { NormalizedHfColorGradingWheels } from "@hyperframes/core/color-grading";
7
+ import { ColorWheels } from "./propertyPanelColorWheels";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const NEUTRAL: NormalizedHfColorGradingWheels = {
12
+ shadows: { hue: 0, amount: 0, level: 0 },
13
+ midtones: { hue: 0, amount: 0, level: 0 },
14
+ highlights: { hue: 0, amount: 0, level: 0 },
15
+ };
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ function renderWheels() {
22
+ const onPreview = vi.fn();
23
+ const onCommit = vi.fn();
24
+ const host = document.body.appendChild(document.createElement("div"));
25
+ const root = createRoot(host);
26
+ act(() => root.render(<ColorWheels value={NEUTRAL} onPreview={onPreview} onCommit={onCommit} />));
27
+ const surface = host.querySelector<HTMLDivElement>(
28
+ '[data-color-wheel="shadows"] [data-color-wheel-surface="true"]',
29
+ );
30
+ if (!surface) throw new Error("Expected the shadows wheel");
31
+ Object.defineProperty(surface, "getBoundingClientRect", {
32
+ value: () => ({ left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 }),
33
+ });
34
+ return { root, surface, onPreview, onCommit };
35
+ }
36
+
37
+ function pointerAt(surface: HTMLElement, x: number, y: number, pointerId = 1) {
38
+ act(() => {
39
+ surface.dispatchEvent(
40
+ new PointerEvent("pointerdown", {
41
+ bubbles: true,
42
+ pointerId,
43
+ clientX: x,
44
+ clientY: y,
45
+ }),
46
+ );
47
+ surface.dispatchEvent(
48
+ new PointerEvent("pointerup", {
49
+ bubbles: true,
50
+ pointerId,
51
+ clientX: x,
52
+ clientY: y,
53
+ }),
54
+ );
55
+ });
56
+ }
57
+
58
+ describe("ColorWheels", () => {
59
+ it("maps cardinal pointer colors to the documented counter-clockwise hue angles", () => {
60
+ const cases = [
61
+ [100, 50, 0],
62
+ [50, 0, 90],
63
+ [0, 50, 180],
64
+ [50, 100, 270],
65
+ ] as const;
66
+
67
+ for (const [x, y, expectedHue] of cases) {
68
+ const { root, surface, onCommit } = renderWheels();
69
+ pointerAt(surface, x, y);
70
+ expect(onCommit.mock.calls[0]?.[0]?.shadows).toMatchObject({
71
+ hue: expectedHue,
72
+ amount: 1,
73
+ });
74
+ act(() => root.unmount());
75
+ }
76
+ });
77
+
78
+ it("renders a color sequence that follows the same hue direction as the pointer", () => {
79
+ const { root, surface } = renderWheels();
80
+ expect(surface.style.background).toContain("#f33, #f3f, #33f, #3ff, #3f3, #ff3, #f33");
81
+ act(() => root.unmount());
82
+ });
83
+
84
+ it("previews during drag and commits only once on release", () => {
85
+ const { root, surface, onPreview, onCommit } = renderWheels();
86
+ act(() => {
87
+ surface.dispatchEvent(
88
+ new PointerEvent("pointerdown", {
89
+ bubbles: true,
90
+ pointerId: 7,
91
+ clientX: 100,
92
+ clientY: 50,
93
+ }),
94
+ );
95
+ surface.dispatchEvent(
96
+ new PointerEvent("pointermove", {
97
+ bubbles: true,
98
+ pointerId: 7,
99
+ clientX: 50,
100
+ clientY: 0,
101
+ }),
102
+ );
103
+ });
104
+ expect(onPreview.mock.calls.at(-1)?.[0]?.shadows.hue).toBe(90);
105
+ expect(onCommit).not.toHaveBeenCalled();
106
+ act(() => {
107
+ surface.dispatchEvent(
108
+ new PointerEvent("pointerup", {
109
+ bubbles: true,
110
+ pointerId: 7,
111
+ clientX: 50,
112
+ clientY: 0,
113
+ }),
114
+ );
115
+ });
116
+ expect(onCommit).toHaveBeenCalledOnce();
117
+ act(() => root.unmount());
118
+ });
119
+ });