@hyperframes/studio 0.7.105 → 0.7.107

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-Jx07OQgw.js → hyperframes-player-BLrfwq5o.js} +1 -1
  2. package/dist/assets/{index-CryZ0PGi.js → index-BA9yhzfR.js} +1 -1
  3. package/dist/assets/{index-Dqi2GmOp.js → index-BSBk5srs.js} +1 -1
  4. package/dist/assets/{index-DuxKGsHZ.js → index-dF-CmLZu.js} +218 -218
  5. package/dist/assets/index-zQ4JFwwB.css +1 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5335 -3954
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/editor/DomEditOverlay.tsx +36 -18
  19. package/src/components/editor/DomEditSelectionChrome.test.tsx +119 -31
  20. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  21. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  22. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  23. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  24. package/src/components/editor/domEditInlineText.test.ts +86 -0
  25. package/src/components/editor/domEditInlineText.ts +99 -0
  26. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  27. package/src/components/editor/domEditOverlayCrop.test.ts +136 -2
  28. package/src/components/editor/domEditOverlayCrop.ts +99 -15
  29. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  30. package/src/components/editor/domEditOverlayGeometry.ts +13 -10
  31. package/src/components/editor/domEditOverlayTransform.ts +88 -0
  32. package/src/components/editor/domEditing.ts +1 -0
  33. package/src/components/editor/domEditingLayers.ts +7 -6
  34. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  35. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  36. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  37. package/src/components/editor/useDomEditNudge.ts +2 -2
  38. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  39. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  40. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  41. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  42. package/src/components/feedback/feedbackTrigger.ts +269 -0
  43. package/src/components/feedback/projectProvenance.test.ts +120 -0
  44. package/src/components/feedback/projectProvenance.ts +83 -0
  45. package/src/components/renders/useRenderQueue.ts +62 -4
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  47. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  48. package/src/contexts/DomEditContext.tsx +4 -0
  49. package/src/hooks/domEditPersistFailure.ts +0 -7
  50. package/src/hooks/useAppHotkeys.ts +4 -4
  51. package/src/hooks/useDomEditCommits.test.tsx +103 -21
  52. package/src/hooks/useDomEditCommits.ts +2 -0
  53. package/src/hooks/useDomEditSession.ts +2 -0
  54. package/src/hooks/useDomEditTextCommits.ts +110 -11
  55. package/src/hooks/useFileTree.ts +8 -3
  56. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  57. package/src/hooks/useInlineTextEdit.ts +320 -0
  58. package/src/player/lib/playbackShortcuts.ts +5 -4
  59. package/src/telemetry/breadcrumbs.test.ts +68 -0
  60. package/src/telemetry/breadcrumbs.ts +70 -0
  61. package/src/telemetry/client.ts +5 -0
  62. package/src/telemetry/events.ts +84 -3
  63. package/src/utils/sourcePatcher.ts +5 -1
  64. package/src/utils/studioHelpers.ts +2 -5
  65. package/src/utils/timelineDiscovery.ts +3 -24
  66. package/src/utils/typingTarget.test.ts +54 -0
  67. package/src/utils/typingTarget.ts +43 -0
  68. package/dist/assets/index-tBPidglp.css +0 -1
  69. package/dist/chunk-OBAG3GWK.js.map +0 -1
  70. package/src/components/StudioFeedbackBar.tsx +0 -217
  71. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,555 @@
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 { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ /** A heading in the document, standing in for one in the preview. */
16
+ function heading(text = "Motion Playground"): HTMLElement {
17
+ const element = document.createElement("h1");
18
+ element.textContent = text;
19
+ document.body.append(element);
20
+ return element;
21
+ }
22
+
23
+ function mount(onCommit = vi.fn(), onPause = vi.fn()) {
24
+ const controls: { current: InlineTextEditControls | null } = { current: null };
25
+ function Probe() {
26
+ controls.current = useInlineTextEdit({ onCommit, onPause });
27
+ return null;
28
+ }
29
+ const host = document.createElement("div");
30
+ document.body.append(host);
31
+ const root = createRoot(host);
32
+ act(() => root.render(<Probe />));
33
+ return { controls: () => controls.current!, root, onCommit, onPause };
34
+ }
35
+
36
+ describe("useInlineTextEdit", () => {
37
+ // Selecting the whole text would mean the next keystroke destroys it, which
38
+ // is a bad thing to do to someone who double-clicked to fix a typo.
39
+ it("leaves the caret after the last character, with nothing selected", async () => {
40
+ const element = heading("Motion Playground");
41
+ const { controls, root } = mount();
42
+
43
+ act(() => {
44
+ controls().start(element);
45
+ });
46
+ await act(async () => {
47
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
48
+ });
49
+
50
+ const selection = document.getSelection()!;
51
+ expect(selection.toString()).toBe("");
52
+ expect(selection.isCollapsed).toBe(true);
53
+ expect(selection.anchorOffset).toBe(element.textContent!.length);
54
+ act(() => root.unmount());
55
+ });
56
+
57
+ /**
58
+ * Removing contenteditable and blurring does not drop the browser's own
59
+ * highlight, so a word picked with a double press stayed painted grey after
60
+ * the click that closed the edit — text that reads as selected in an element
61
+ * that is no longer being edited.
62
+ */
63
+ it("drops the highlight when the edit closes", () => {
64
+ const element = heading("Hello world, style me");
65
+ const { controls, root } = mount();
66
+
67
+ act(() => {
68
+ controls().start(element);
69
+ });
70
+ // Select "world", the way a double press inside the text does.
71
+ const text = element.firstChild!;
72
+ const range = document.createRange();
73
+ range.setStart(text, 6);
74
+ range.setEnd(text, 11);
75
+ const selection = document.getSelection()!;
76
+ selection.removeAllRanges();
77
+ selection.addRange(range);
78
+ expect(selection.toString()).toBe("world");
79
+
80
+ act(() => controls().commit());
81
+
82
+ expect(selection.toString()).toBe("");
83
+ act(() => root.unmount());
84
+ });
85
+
86
+ it("leaves a selection outside the edited element alone", () => {
87
+ const element = heading("Hello world, style me");
88
+ const outsider = heading("somewhere else entirely");
89
+ const { controls, root } = mount();
90
+
91
+ act(() => {
92
+ controls().start(element);
93
+ });
94
+ const range = document.createRange();
95
+ range.selectNodeContents(outsider);
96
+ const selection = document.getSelection()!;
97
+ selection.removeAllRanges();
98
+ selection.addRange(range);
99
+
100
+ act(() => controls().commit());
101
+
102
+ expect(selection.toString()).toBe("somewhere else entirely");
103
+ act(() => root.unmount());
104
+ });
105
+
106
+ it("drops a selection dragged across the edited element's boundary", () => {
107
+ const element = heading("Hello world");
108
+ const outsider = heading("somewhere else");
109
+ const { controls, root } = mount();
110
+
111
+ act(() => {
112
+ controls().start(element);
113
+ });
114
+ const range = document.createRange();
115
+ range.setStart(element.firstChild!, 6);
116
+ range.setEnd(outsider.firstChild!, 4);
117
+ const selection = document.getSelection()!;
118
+ selection.removeAllRanges();
119
+ selection.addRange(range);
120
+
121
+ act(() => controls().commit());
122
+
123
+ expect(selection.toString()).toBe("");
124
+ act(() => root.unmount());
125
+ });
126
+
127
+ it("makes the element editable, and focuses it once the press has finished", async () => {
128
+ const element = heading();
129
+ const { controls, root, onPause } = mount();
130
+
131
+ act(() => {
132
+ controls().start(element);
133
+ });
134
+
135
+ // Not `plaintext-only`: that would make it impossible to give three
136
+ // characters a colour, which is the point of editing in the composition.
137
+ expect(element.getAttribute("contenteditable")).toBe("true");
138
+ expect(controls().session?.element).toBe(element);
139
+ // The frame being edited is the one the user chose to edit on.
140
+ expect(onPause).toHaveBeenCalledTimes(1);
141
+
142
+ // Focus lands on the next frame, after the press that opened this and the
143
+ // click that follows it have both been and gone.
144
+ await act(async () => {
145
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
146
+ });
147
+ expect(document.activeElement).toBe(element);
148
+ act(() => root.unmount());
149
+ });
150
+
151
+ it("cancels a pending focus frame when the editor unmounts", () => {
152
+ const element = heading();
153
+ vi.spyOn(window, "requestAnimationFrame").mockReturnValue(42);
154
+ const cancelFrame = vi.spyOn(window, "cancelAnimationFrame");
155
+ const { controls, root } = mount();
156
+
157
+ act(() => {
158
+ controls().start(element);
159
+ });
160
+ act(() => root.unmount());
161
+
162
+ expect(cancelFrame).toHaveBeenCalledWith(42);
163
+ expect(element.hasAttribute("contenteditable")).toBe(false);
164
+ });
165
+
166
+ it("hands the current text over exactly once when it commits", () => {
167
+ const element = heading();
168
+ const { controls, root, onCommit } = mount();
169
+
170
+ act(() => {
171
+ controls().start(element);
172
+ });
173
+ element.textContent = "Motion Playground Live";
174
+ act(() => controls().commit());
175
+
176
+ expect(onCommit.mock.calls).toEqual([
177
+ [{ element, html: "Motion Playground Live", previousHtml: "Motion Playground" }],
178
+ ]);
179
+ act(() => root.unmount());
180
+ });
181
+
182
+ // Cancelling must leave the preview exactly as it found it: the element was
183
+ // being mutated live, and nothing was persisted.
184
+ it("puts the original text back on cancel, and commits nothing", () => {
185
+ const element = heading("Motion Playground");
186
+ const { controls, root, onCommit } = mount();
187
+
188
+ act(() => {
189
+ controls().start(element);
190
+ });
191
+ element.textContent = "half-typed";
192
+ act(() => controls().cancel());
193
+
194
+ expect(element.textContent).toBe("Motion Playground");
195
+ expect(onCommit).not.toHaveBeenCalled();
196
+ act(() => root.unmount());
197
+ });
198
+
199
+ it.each([
200
+ ["commit", (c: InlineTextEditControls) => c.commit()],
201
+ ["cancel", (c: InlineTextEditControls) => c.cancel()],
202
+ ])("stops being editable after %s", (_name, close) => {
203
+ const element = heading();
204
+ const { controls, root } = mount();
205
+
206
+ act(() => {
207
+ controls().start(element);
208
+ });
209
+ act(() => close(controls()));
210
+
211
+ expect(element.hasAttribute("contenteditable")).toBe(false);
212
+ expect(controls().session).toBeNull();
213
+ act(() => root.unmount());
214
+ });
215
+
216
+ // A session that failed to close would leave the canvas unable to select.
217
+ it("tears down once when it is closed twice", () => {
218
+ const element = heading();
219
+ const { controls, root, onCommit } = mount();
220
+
221
+ act(() => {
222
+ controls().start(element);
223
+ });
224
+ act(() => controls().commit());
225
+ act(() => controls().commit());
226
+
227
+ expect(onCommit).toHaveBeenCalledTimes(1);
228
+ act(() => root.unmount());
229
+ });
230
+
231
+ it("refuses to open a second session over an open one", () => {
232
+ const first = heading("first");
233
+ const second = heading("second");
234
+ const { controls, root } = mount();
235
+
236
+ let opened = false;
237
+ act(() => {
238
+ controls().start(first);
239
+ opened = controls().start(second);
240
+ });
241
+
242
+ expect(opened).toBe(false);
243
+ expect(controls().session?.element).toBe(first);
244
+ expect(second.hasAttribute("contenteditable")).toBe(false);
245
+ act(() => root.unmount());
246
+ });
247
+
248
+ // The composition reloads while an edit is open often enough to matter.
249
+ it("closes without throwing when the element has left the document", () => {
250
+ const element = heading();
251
+ const { controls, root } = mount();
252
+
253
+ act(() => {
254
+ controls().start(element);
255
+ });
256
+ element.remove();
257
+
258
+ expect(() => act(() => controls().cancel())).not.toThrow();
259
+ expect(controls().session).toBeNull();
260
+ act(() => root.unmount());
261
+ });
262
+
263
+ describe("the keys that end it", () => {
264
+ function press(element: HTMLElement, key: string, shiftKey = false) {
265
+ act(() => {
266
+ element.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
267
+ });
268
+ }
269
+
270
+ it("commits on Enter", () => {
271
+ const element = heading();
272
+ const { controls, root, onCommit } = mount();
273
+ act(() => {
274
+ controls().start(element);
275
+ });
276
+
277
+ element.textContent = "Renamed";
278
+ press(element, "Enter");
279
+
280
+ expect(onCommit.mock.calls).toEqual([
281
+ [{ element, html: "Renamed", previousHtml: "Motion Playground" }],
282
+ ]);
283
+ expect(controls().session).toBeNull();
284
+ act(() => root.unmount());
285
+ });
286
+
287
+ // A multi-line element still needs a way to get a line break.
288
+ it("leaves Shift+Enter alone", () => {
289
+ const element = heading();
290
+ const { controls, root, onCommit } = mount();
291
+ act(() => {
292
+ controls().start(element);
293
+ });
294
+
295
+ press(element, "Enter", true);
296
+
297
+ expect(onCommit).not.toHaveBeenCalled();
298
+ expect(controls().session).not.toBeNull();
299
+ act(() => root.unmount());
300
+ });
301
+
302
+ it("cancels on Escape", () => {
303
+ const element = heading("Original");
304
+ const { controls, root, onCommit } = mount();
305
+ act(() => {
306
+ controls().start(element);
307
+ });
308
+
309
+ element.textContent = "half-typed";
310
+ press(element, "Escape");
311
+
312
+ expect(element.textContent).toBe("Original");
313
+ expect(onCommit).not.toHaveBeenCalled();
314
+ act(() => root.unmount());
315
+ });
316
+
317
+ it("commits when the element loses focus", () => {
318
+ const element = heading();
319
+ const { controls, root, onCommit } = mount();
320
+ act(() => {
321
+ controls().start(element);
322
+ });
323
+
324
+ element.textContent = "Clicked away";
325
+ act(() => element.dispatchEvent(new FocusEvent("blur")));
326
+
327
+ expect(onCommit.mock.calls).toEqual([
328
+ [{ element, html: "Clicked away", previousHtml: "Motion Playground" }],
329
+ ]);
330
+ act(() => root.unmount());
331
+ });
332
+
333
+ it("stops listening once the session is over", () => {
334
+ const element = heading();
335
+ const { controls, root, onCommit } = mount();
336
+ act(() => {
337
+ controls().start(element);
338
+ });
339
+ act(() => controls().commit());
340
+
341
+ press(element, "Enter");
342
+
343
+ expect(onCommit).toHaveBeenCalledTimes(1);
344
+ act(() => root.unmount());
345
+ });
346
+ });
347
+
348
+ // The mark that says the caret is in the text, not that the element is
349
+ // selected. It has to live in the same document as the caret to read so.
350
+ it("outlines the element while it is being edited, and puts it back after", () => {
351
+ const element = heading();
352
+ element.style.outline = "1px dotted red";
353
+ element.style.outlineOffset = "6px";
354
+ const { controls, root } = mount();
355
+
356
+ act(() => {
357
+ controls().start(element);
358
+ });
359
+ // Serialisation order is the browser's; what matters is that it is the
360
+ // accent, solid, and thicker than whatever it replaced.
361
+ expect(element.style.outline).toContain("#3CE6AC");
362
+ expect(element.style.outline).toContain("2px");
363
+
364
+ act(() => controls().cancel());
365
+ expect(element.style.outline).toContain("dotted");
366
+ expect(element.style.outline).toContain("red");
367
+ expect(element.style.getPropertyValue("outline-offset")).toBe("6px");
368
+ act(() => root.unmount());
369
+ });
370
+
371
+ // Opening on a point is what makes this feel like text rather than a dialog.
372
+ it("opens the caret where the press landed when it can resolve one", async () => {
373
+ const element = heading("Motion Playground");
374
+ const range = document.createRange();
375
+ range.setStart(element.firstChild!, 6);
376
+ range.collapse(true);
377
+ const doc = document as Document & { caretRangeFromPoint?: unknown };
378
+ const original = doc.caretRangeFromPoint;
379
+ doc.caretRangeFromPoint = () => range;
380
+
381
+ const { controls, root } = mount();
382
+ act(() => {
383
+ controls().start(element, { x: 120, y: 40 });
384
+ });
385
+ await act(async () => {
386
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
387
+ });
388
+
389
+ expect(document.getSelection()?.anchorOffset).toBe(6);
390
+ doc.caretRangeFromPoint = original;
391
+ act(() => root.unmount());
392
+ });
393
+
394
+ // A point that resolves outside the element (a rotated glyph, a gap) must
395
+ // not put the caret in someone else's text.
396
+ it("falls back to the end when the point lands outside the element", async () => {
397
+ const element = heading("Motion Playground");
398
+ const stranger = heading("Somewhere else");
399
+ const strayRange = document.createRange();
400
+ strayRange.setStart(stranger.firstChild!, 3);
401
+ strayRange.collapse(true);
402
+ const doc = document as Document & { caretRangeFromPoint?: unknown };
403
+ const original = doc.caretRangeFromPoint;
404
+ doc.caretRangeFromPoint = () => strayRange;
405
+
406
+ const { controls, root } = mount();
407
+ act(() => {
408
+ controls().start(element, { x: 9999, y: 9999 });
409
+ });
410
+ await act(async () => {
411
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
412
+ });
413
+
414
+ const selection = document.getSelection()!;
415
+ expect(selection.anchorNode?.parentElement).toBe(element);
416
+ expect(selection.anchorOffset).toBe(element.textContent!.length);
417
+ doc.caretRangeFromPoint = original;
418
+ act(() => root.unmount());
419
+ });
420
+
421
+ // Double click takes the word and triple click takes the lot, in this element
422
+ // exactly as in any other text field, because nothing here interferes with
423
+ // either. Claiming the double click for select-all cost the word selection.
424
+ it("leaves double and triple click to the browser", async () => {
425
+ const element = heading("Motion Playground");
426
+ const { controls, root } = mount();
427
+ act(() => {
428
+ controls().start(element);
429
+ });
430
+ await act(async () => {
431
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
432
+ });
433
+ const before = document.getSelection()?.toString();
434
+
435
+ act(() => element.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })));
436
+
437
+ // No handler ran, so the selection is untouched: the real browser would
438
+ // have set it to the word under the pointer before this ever fired.
439
+ expect(document.getSelection()?.toString()).toBe(before);
440
+ act(() => root.unmount());
441
+ });
442
+ });
443
+
444
+ // Styling a run of characters is what the element is edited in place for, and
445
+ // it only counts once the markup survives the trip out of the element.
446
+ describe("useInlineTextEdit with styled runs", () => {
447
+ it("hands over the markup, not just the words", () => {
448
+ const element = heading("hello");
449
+ const { controls, onCommit, root } = mount();
450
+
451
+ act(() => {
452
+ controls().start(element);
453
+ });
454
+ element.innerHTML = 'hell<span style="color: red">o</span>';
455
+ act(() => controls().commit());
456
+
457
+ expect(onCommit).toHaveBeenCalledWith({
458
+ element,
459
+ html: 'hell<span style="color: red">o</span>',
460
+ previousHtml: "hello",
461
+ });
462
+ act(() => root.unmount());
463
+ });
464
+
465
+ it("cleans what it hands over, so the preview shows what will be saved", () => {
466
+ const element = heading("hello");
467
+ const { controls, onCommit, root } = mount();
468
+
469
+ act(() => {
470
+ controls().start(element);
471
+ });
472
+ element.innerHTML = '<span onclick="steal()" style="position: fixed">hi</span>';
473
+ act(() => controls().commit());
474
+
475
+ expect(onCommit).toHaveBeenCalledWith({
476
+ element,
477
+ html: "<span>hi</span>",
478
+ previousHtml: "hello",
479
+ });
480
+ // Cleaned in the element too, not only on the way out.
481
+ expect(element.innerHTML).toBe("<span>hi</span>");
482
+ act(() => root.unmount());
483
+ });
484
+
485
+ it("puts the styling back on cancel, not just the letters", () => {
486
+ const element = heading();
487
+ element.innerHTML = '<span style="color: red">before</span>';
488
+ const { controls, onCommit, root } = mount();
489
+
490
+ act(() => {
491
+ controls().start(element);
492
+ });
493
+ element.innerHTML = "after";
494
+ act(() => controls().cancel());
495
+
496
+ expect(element.innerHTML).toBe('<span style="color: red">before</span>');
497
+ expect(onCommit).not.toHaveBeenCalled();
498
+ act(() => root.unmount());
499
+ });
500
+
501
+ it("keeps the element markup-free when nothing was styled", () => {
502
+ const element = heading("plain words");
503
+ const { controls, onCommit, root } = mount();
504
+
505
+ act(() => {
506
+ controls().start(element);
507
+ });
508
+ act(() => controls().commit());
509
+
510
+ expect(onCommit).toHaveBeenCalledWith({
511
+ element,
512
+ html: "plain words",
513
+ previousHtml: "plain words",
514
+ });
515
+ act(() => root.unmount());
516
+ });
517
+
518
+ const plainTextTransfers: Array<{
519
+ label: string;
520
+ eventType: "paste" | "drop";
521
+ transferProperty: "clipboardData" | "dataTransfer";
522
+ }> = [
523
+ { label: "pastes", eventType: "paste", transferProperty: "clipboardData" },
524
+ { label: "drops", eventType: "drop", transferProperty: "dataTransfer" },
525
+ ];
526
+
527
+ it.each(plainTextTransfers)("$label the words without accepting markup", (testCase) => {
528
+ const element = heading("hi");
529
+ const { controls, root } = mount();
530
+ act(() => {
531
+ controls().start(element);
532
+ });
533
+ const insertText = vi.fn();
534
+ Object.defineProperty(document, "execCommand", { configurable: true, value: insertText });
535
+ const transfer = {
536
+ getData: (type: string) =>
537
+ type === "text/plain" ? "plain words" : '<img src="x" onerror="steal()">',
538
+ };
539
+ const event = new Event(testCase.eventType, { bubbles: true, cancelable: true });
540
+ Object.defineProperty(event, testCase.transferProperty, { value: transfer });
541
+
542
+ act(() => {
543
+ if (testCase.eventType === "drop") {
544
+ const dragover = new Event("dragover", { bubbles: true, cancelable: true });
545
+ element.dispatchEvent(dragover);
546
+ expect(dragover.defaultPrevented).toBe(true);
547
+ }
548
+ element.dispatchEvent(event);
549
+ });
550
+
551
+ expect(event.defaultPrevented).toBe(true);
552
+ expect(insertText).toHaveBeenCalledWith("insertText", false, "plain words");
553
+ act(() => root.unmount());
554
+ });
555
+ });