@pilotiq/tiptap 3.10.0 → 3.10.2
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.
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +26 -31
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +58 -34
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.js +52 -38
- package/dist/react/TiptapEditor.js.map +1 -1
- package/dist/react/useAiInlineDiff.d.ts.map +1 -1
- package/dist/react/useAiInlineDiff.js +28 -4
- package/dist/react/useAiInlineDiff.js.map +1 -1
- package/dist/react/useAiSuggestionBridge.d.ts.map +1 -1
- package/dist/react/useAiSuggestionBridge.js +14 -7
- package/dist/react/useAiSuggestionBridge.js.map +1 -1
- package/dist/test/setup.d.ts +2 -0
- package/dist/test/setup.d.ts.map +1 -0
- package/dist/test/setup.js +65 -0
- package/dist/test/setup.js.map +1 -0
- package/package.json +7 -3
- package/src/PlainTextEditor.dom.test.ts +111 -0
- package/src/react/BlockSidePanel.dom.test.tsx +38 -0
- package/src/react/CollabTextRenderer.tsx +29 -30
- package/src/react/MarkdownEditor.tsx +62 -32
- package/src/react/TiptapEditor.dom.test.tsx +112 -0
- package/src/react/TiptapEditor.tsx +59 -40
- package/src/react/useAiInlineDiff.ts +29 -3
- package/src/react/useAiSuggestionBridge.ts +14 -6
- package/src/test/setup.ts +64 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { render, cleanup, waitFor } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
import { TiptapEditor } from './TiptapEditor.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Behavioral coverage for the rich-text field renderer. The matching
|
|
10
|
+
* pure-data tests under `RichTextField.test.ts` cover `toMeta()` / option
|
|
11
|
+
* resolution; this file proves the React renderer actually mounts in our
|
|
12
|
+
* jsdom + RTL environment and produces the FormData wiring downstream
|
|
13
|
+
* consumers depend on.
|
|
14
|
+
*
|
|
15
|
+
* Scope is deliberately narrow — slash menu, floating toolbar, mention
|
|
16
|
+
* popover, side panel, and AI suggestion bridge all need additional
|
|
17
|
+
* fixtures (focus traps, document-level key handlers, context providers)
|
|
18
|
+
* and are covered by the playground + Playwright e2e suite. The asserts
|
|
19
|
+
* here are the ones that would catch a "renderer crashes at mount" or
|
|
20
|
+
* "hidden input wire-name drift" regression cheaply.
|
|
21
|
+
*/
|
|
22
|
+
describe('TiptapEditor (DOM)', () => {
|
|
23
|
+
function renderEditor(opts: {
|
|
24
|
+
name: string
|
|
25
|
+
defaultValue?: unknown
|
|
26
|
+
placeholder?: string
|
|
27
|
+
}) {
|
|
28
|
+
const { name, defaultValue = '', placeholder = 'Write…' } = opts
|
|
29
|
+
return render(
|
|
30
|
+
<TiptapEditor
|
|
31
|
+
el={{ type: 'field', fieldType: 'richtext', name }}
|
|
32
|
+
name={name}
|
|
33
|
+
defaultValue={defaultValue}
|
|
34
|
+
required={false}
|
|
35
|
+
disabled={false}
|
|
36
|
+
placeholder={placeholder}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('mounts the editor on hydration and exposes the hidden FormData input', async () => {
|
|
42
|
+
const { container } = renderEditor({ name: 'bio' })
|
|
43
|
+
try {
|
|
44
|
+
// Initial render is the SSR placeholder gated on `mounted`. After
|
|
45
|
+
// the mount-effect flips, `ClientEditor` mounts → Tiptap defers
|
|
46
|
+
// editor construction to its own effect under `immediatelyRender:
|
|
47
|
+
// false` → the `.ProseMirror` contenteditable lands in the DOM.
|
|
48
|
+
await waitFor(() => {
|
|
49
|
+
assert.ok(
|
|
50
|
+
container.querySelector('.ProseMirror'),
|
|
51
|
+
'ProseMirror contenteditable mounts after hydration',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
const hidden = container.querySelector<HTMLInputElement>(
|
|
55
|
+
'input[type="hidden"][name="bio"]',
|
|
56
|
+
)
|
|
57
|
+
assert.ok(hidden, 'hidden FormData input present alongside the editor')
|
|
58
|
+
} finally {
|
|
59
|
+
cleanup()
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('serializes a JSON `defaultValue` into the hidden input on first paint', async () => {
|
|
64
|
+
// Tiptap doc shape: paragraph with one text node. The renderer's
|
|
65
|
+
// `serializeForHidden` round-trips this verbatim under the default
|
|
66
|
+
// `storage: 'json'` setting, so the hidden input should hold the
|
|
67
|
+
// JSON string at the very first render (before the editor itself
|
|
68
|
+
// has even mounted — the SSR placeholder ships the same serialized
|
|
69
|
+
// value so submit-on-mount works).
|
|
70
|
+
const defaultValue = {
|
|
71
|
+
type: 'doc',
|
|
72
|
+
content: [
|
|
73
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
const { container } = renderEditor({ name: 'body', defaultValue })
|
|
77
|
+
try {
|
|
78
|
+
const hidden = container.querySelector<HTMLInputElement>(
|
|
79
|
+
'input[type="hidden"][name="body"]',
|
|
80
|
+
)
|
|
81
|
+
assert.ok(hidden, 'hidden input present')
|
|
82
|
+
const parsed = JSON.parse(hidden.value)
|
|
83
|
+
assert.equal(parsed.type, 'doc', 'value parses to a doc')
|
|
84
|
+
assert.equal(parsed.content[0].content[0].text, 'hello')
|
|
85
|
+
} finally {
|
|
86
|
+
cleanup()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('uses the field `name` for the hidden input wire-name', async () => {
|
|
91
|
+
// Pilotiq forms post FormData keyed by field name; renaming the wire
|
|
92
|
+
// input here would silently drop the field on submit. The non-default
|
|
93
|
+
// `name` ("article_body" with an underscore) doubles as a regression
|
|
94
|
+
// guard for any future serializer that tries to clean / normalize
|
|
95
|
+
// names — the value the host passes in is the value posted back.
|
|
96
|
+
const { container } = renderEditor({ name: 'article_body' })
|
|
97
|
+
try {
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
assert.ok(
|
|
100
|
+
container.querySelector('.ProseMirror'),
|
|
101
|
+
'editor mounted (post-hydration probe so the test isn\'t racing the SSR branch)',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
const hidden = container.querySelector<HTMLInputElement>(
|
|
105
|
+
'input[type="hidden"][name="article_body"]',
|
|
106
|
+
)
|
|
107
|
+
assert.ok(hidden, 'wire-name matches `name` prop verbatim')
|
|
108
|
+
} finally {
|
|
109
|
+
cleanup()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
CollabRoom,
|
|
19
19
|
CollabExtensionFactory,
|
|
20
20
|
} from '@pilotiq/pilotiq/react'
|
|
21
|
-
import { useCollabRoom, getCollabExtensions,
|
|
21
|
+
import { useCollabRoom, getCollabExtensions, useCollabSeed, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react'
|
|
22
22
|
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
|
|
23
23
|
import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
|
|
24
24
|
import { AiSuggestionBanner } from './AiSuggestionBanner.js'
|
|
@@ -449,47 +449,66 @@ function ClientEditor(props: ClientEditorProps) {
|
|
|
449
449
|
// scratch on every keystroke.
|
|
450
450
|
useEffect(() => { editorRef.current = editor ?? null }, [editor])
|
|
451
451
|
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// exactly once. Non-empty = the room already has authoritative state;
|
|
458
|
-
// don't overwrite.
|
|
452
|
+
// Mirror `disabled` onto the live editor at runtime. `useEditor`'s
|
|
453
|
+
// `editable: !disabled` only fires at construction time, so a parent
|
|
454
|
+
// flipping read-only after mount (e.g. policy denial mid-edit, form
|
|
455
|
+
// submitting state) would silently no-op without this effect. Same
|
|
456
|
+
// shape MarkdownEditor.tsx and CollabTextRenderer.tsx already use.
|
|
459
457
|
useEffect(() => {
|
|
460
|
-
if (!editor
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
464
|
-
const ydoc = room.ydoc as any
|
|
465
|
-
if (!provider || !ydoc) return
|
|
466
|
-
|
|
467
|
-
const trySeed = () => {
|
|
468
|
-
try {
|
|
469
|
-
const fragment = ydoc.getXmlFragment(collabName)
|
|
470
|
-
if (
|
|
471
|
-
fragment &&
|
|
472
|
-
fragment.length === 0 &&
|
|
473
|
-
initialContent !== undefined &&
|
|
474
|
-
initialContent !== null &&
|
|
475
|
-
initialContent !== '' &&
|
|
476
|
-
isTiptapShapedContent(initialContent)
|
|
477
|
-
) {
|
|
478
|
-
// setContent dispatches a Tiptap transaction; the bound
|
|
479
|
-
// y-prosemirror binding (inside Collaboration) mirrors it
|
|
480
|
-
// into the fragment so every peer sees the seeded state.
|
|
481
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
482
|
-
editor.commands.setContent(initialContent as any)
|
|
483
|
-
}
|
|
484
|
-
} catch { /* ignore — seed is best-effort */ }
|
|
485
|
-
}
|
|
458
|
+
if (!editor) return
|
|
459
|
+
editor.setEditable(!disabled)
|
|
460
|
+
}, [editor, disabled])
|
|
486
461
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
462
|
+
// First-load seed when collab is active. Collaboration starts the
|
|
463
|
+
// editor empty regardless of `defaultValue`; once the room's first
|
|
464
|
+
// sync resolves, `useCollabSeed` runs the callback inside
|
|
465
|
+
// `ydoc.transact(..., 'pilotiq-collab-seed')`. We check whether the
|
|
466
|
+
// field's `Y.XmlFragment` was ever written — empty + we have an
|
|
467
|
+
// initial value = first session for this record — push the DB
|
|
468
|
+
// content into the ydoc exactly once. Non-empty = the room already
|
|
469
|
+
// has authoritative state; don't overwrite. Gating on `editor` keeps
|
|
470
|
+
// the effect from firing before Tiptap mounts its
|
|
471
|
+
// y-prosemirror binding (Tiptap v3 defers editor construction to
|
|
472
|
+
// first effect under `immediatelyRender: false`).
|
|
473
|
+
//
|
|
474
|
+
// Subscribe-after-sync mirror: after the seed branch (or no-op when
|
|
475
|
+
// the fragment already has content from a remote peer), serialize the
|
|
476
|
+
// editor's current state into the hidden FormData input. The
|
|
477
|
+
// debounced `onUpdate` path covers steady-state typing, but in the
|
|
478
|
+
// cold-mount case (fresh peer joining a populated doc) y-prosemirror's
|
|
479
|
+
// `ySyncPlugin` view hook may run `_forceRerender` before the React
|
|
480
|
+
// owner has installed the `update` listener — leaving the hidden
|
|
481
|
+
// input empty on submit. Idempotent: when `onUpdate` already
|
|
482
|
+
// propagated, this is a no-op `setSerialized(sameValue)`. Same shape
|
|
483
|
+
// as `CollabTextRenderer`'s post-sync mirror and
|
|
484
|
+
// `@pilotiq-pro/collab`'s `rowArrayBinding.subscribeRows` catch-up.
|
|
485
|
+
useCollabSeed(
|
|
486
|
+
editor && collabActive ? room : null,
|
|
487
|
+
collabName,
|
|
488
|
+
(doc) => {
|
|
489
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
490
|
+
const fragment = (doc as any).getXmlFragment(collabName)
|
|
491
|
+
if (
|
|
492
|
+
fragment &&
|
|
493
|
+
fragment.length === 0 &&
|
|
494
|
+
initialContent !== undefined &&
|
|
495
|
+
initialContent !== null &&
|
|
496
|
+
initialContent !== '' &&
|
|
497
|
+
isTiptapShapedContent(initialContent) &&
|
|
498
|
+
editor
|
|
499
|
+
) {
|
|
500
|
+
// setContent dispatches a Tiptap transaction; the bound
|
|
501
|
+
// y-prosemirror binding (inside Collaboration) mirrors it
|
|
502
|
+
// into the fragment so every peer sees the seeded state.
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
+
editor.commands.setContent(initialContent as any)
|
|
505
|
+
}
|
|
506
|
+
if (editor) {
|
|
507
|
+
const value = storage === 'html' ? editor.getHTML() : JSON.stringify(editor.getJSON())
|
|
508
|
+
setSerialized(value)
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
)
|
|
493
512
|
|
|
494
513
|
// Cross-package suggestion bridge — sync the host's
|
|
495
514
|
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
@@ -37,6 +37,7 @@ import { useEditorState } from '@tiptap/react'
|
|
|
37
37
|
import {
|
|
38
38
|
registerPendingSuggestionApplier,
|
|
39
39
|
usePendingSuggestionsForField,
|
|
40
|
+
useFormId,
|
|
40
41
|
type PendingSuggestion,
|
|
41
42
|
type PendingSuggestionApplier,
|
|
42
43
|
} from '@pilotiq/pilotiq/react'
|
|
@@ -86,6 +87,15 @@ export function useAiInlineDiff(
|
|
|
86
87
|
options: UseAiInlineDiffOptions,
|
|
87
88
|
): void {
|
|
88
89
|
const { list } = usePendingSuggestionsForField(fieldName)
|
|
90
|
+
// Scope the applier registration by the surrounding form's id so
|
|
91
|
+
// multi-form pages route suggestions to the editor instance inside the
|
|
92
|
+
// matching form — without this, two editors on different forms but
|
|
93
|
+
// sharing a field name (e.g. two "summary" RichTextFields, one in the
|
|
94
|
+
// main edit form + one in a Replicate modal) would race on
|
|
95
|
+
// `registerPendingSuggestionApplier(undefined, …)` and the last-mounted
|
|
96
|
+
// editor would steal every approval. Falls back to wildcard scope
|
|
97
|
+
// (`undefined`) when no form is up-tree.
|
|
98
|
+
const formId = useFormId()
|
|
89
99
|
|
|
90
100
|
const parseRef = useRef(options.parseSuggestion)
|
|
91
101
|
useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
|
|
@@ -95,6 +105,22 @@ export function useAiInlineDiff(
|
|
|
95
105
|
// suggestions.
|
|
96
106
|
const startedRef = useRef<Set<string>>(new Set())
|
|
97
107
|
|
|
108
|
+
// Re-evaluate the suggestion queue when the editor's doc shape
|
|
109
|
+
// changes. Specifically guards against the seed race: collab-enabled
|
|
110
|
+
// markdown/richtext editors mount empty and seed their content
|
|
111
|
+
// asynchronously after the Yjs provider syncs. A suggestion arriving
|
|
112
|
+
// during that window (or before the first user keystroke) sees an
|
|
113
|
+
// empty doc, `planReplaceBlock` returns null for any blockIndex >= 1,
|
|
114
|
+
// the effect bails — and never re-runs because `list` hasn't changed
|
|
115
|
+
// and React doesn't track ProseMirror's doc state. Watching
|
|
116
|
+
// `doc.childCount` flips the diff-start effect from "ran once at
|
|
117
|
+
// suggestion-push time" to "re-runs when the doc reaches usable
|
|
118
|
+
// shape," which closes the silent no-preview gap.
|
|
119
|
+
const childCount = useEditorState({
|
|
120
|
+
editor,
|
|
121
|
+
selector: ({ editor: ed }) => ed?.state.doc.childCount ?? 0,
|
|
122
|
+
}) ?? 0
|
|
123
|
+
|
|
98
124
|
// Context → editor: start the diff for each new whole-field /
|
|
99
125
|
// surgical-block suggestion. `meta.surgical` (if present) routes to a
|
|
100
126
|
// precise PM transaction; otherwise we treat the suggested value as a
|
|
@@ -159,7 +185,7 @@ export function useAiInlineDiff(
|
|
|
159
185
|
for (const id of Array.from(startedRef.current)) {
|
|
160
186
|
if (!contextIds.has(id)) startedRef.current.delete(id)
|
|
161
187
|
}
|
|
162
|
-
}, [editor, list])
|
|
188
|
+
}, [editor, list, childCount])
|
|
163
189
|
|
|
164
190
|
// Cross-tree applier — two paths:
|
|
165
191
|
//
|
|
@@ -192,8 +218,8 @@ export function useAiInlineDiff(
|
|
|
192
218
|
return true
|
|
193
219
|
})
|
|
194
220
|
}
|
|
195
|
-
return registerPendingSuggestionApplier(
|
|
196
|
-
}, [editor, fieldName])
|
|
221
|
+
return registerPendingSuggestionApplier(formId, fieldName, applier)
|
|
222
|
+
}, [editor, fieldName, formId])
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
function hasEditorRange(s: PendingSuggestion): boolean {
|
|
@@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/core'
|
|
|
3
3
|
import {
|
|
4
4
|
registerPendingSuggestionApplier,
|
|
5
5
|
usePendingSuggestionsForField,
|
|
6
|
+
useFormId,
|
|
6
7
|
type PendingSuggestion,
|
|
7
8
|
type PendingSuggestionApplier,
|
|
8
9
|
} from '@pilotiq/pilotiq/react'
|
|
@@ -74,6 +75,12 @@ export function useAiSuggestionBridge(
|
|
|
74
75
|
options: UseAiSuggestionBridgeOptions = {},
|
|
75
76
|
): void {
|
|
76
77
|
const { list, dismiss } = usePendingSuggestionsForField(fieldName)
|
|
78
|
+
// Scope the applier under the surrounding form's id — same reasoning
|
|
79
|
+
// as `useAiInlineDiff`: two editors with the same field name across
|
|
80
|
+
// different forms (main edit form vs. a Replicate modal, say) would
|
|
81
|
+
// otherwise race on `registerPendingSuggestionApplier(undefined, …)`
|
|
82
|
+
// and the last-mounted editor would steal every approval.
|
|
83
|
+
const formId = useFormId()
|
|
77
84
|
|
|
78
85
|
// Hold the latest `dismiss` in a ref so the editor-side listener — which
|
|
79
86
|
// installs once per editor — always reaches the up-to-date context API.
|
|
@@ -201,12 +208,13 @@ export function useAiSuggestionBridge(
|
|
|
201
208
|
apply(suggestion.suggestedValue)
|
|
202
209
|
}
|
|
203
210
|
}
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
// formId comes from `useFormId()` on the form-context side — scopes
|
|
212
|
+
// the registration per-form so multi-form pages route approvals to
|
|
213
|
+
// the matching editor. Falls back to wildcard (`undefined`) when no
|
|
214
|
+
// FormRenderer is up-tree (modal action schemas, action-modal forms
|
|
215
|
+
// mounted outside the main page form).
|
|
216
|
+
return registerPendingSuggestionApplier(formId, fieldName, applier)
|
|
217
|
+
}, [editor, fieldName, formId])
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
// Re-export the pending-suggestion type for consumers that import the hook
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6e test setup — boots jsdom into globalThis so React Testing
|
|
3
|
+
* Library + Tiptap render against a real-ish DOM. Loaded via the test
|
|
4
|
+
* script's `--import` flag *before* any test file is imported, so React
|
|
5
|
+
* sees the DOM globals at module-load time (RTL's `cleanup()` and
|
|
6
|
+
* `render()` both expect `document` + `window` to exist as globals).
|
|
7
|
+
*
|
|
8
|
+
* We register a minimal subset of DOM globals: the rest hang off
|
|
9
|
+
* `window`, which is how the browser code under test reaches them. The
|
|
10
|
+
* `assign` helper preserves `globalThis`'s native bindings (e.g. Node's
|
|
11
|
+
* `URL`) when jsdom exports a shadowing constructor that breaks
|
|
12
|
+
* downstream code (Tiptap uses `URL` in extension-link's autolink
|
|
13
|
+
* heuristic).
|
|
14
|
+
*/
|
|
15
|
+
import { JSDOM } from 'jsdom'
|
|
16
|
+
|
|
17
|
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
18
|
+
url: 'http://localhost/',
|
|
19
|
+
pretendToBeVisual: true,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const window = dom.window as unknown as Window & typeof globalThis
|
|
23
|
+
|
|
24
|
+
// Properties that Tiptap, React 19, and RTL touch directly via the
|
|
25
|
+
// global namespace (rather than via the captured `window` reference).
|
|
26
|
+
// Keep this list tight — every override is a place where jsdom and Node
|
|
27
|
+
// can diverge subtly.
|
|
28
|
+
const globals: Record<string, unknown> = {
|
|
29
|
+
window,
|
|
30
|
+
document: window.document,
|
|
31
|
+
navigator: window.navigator,
|
|
32
|
+
HTMLElement: window.HTMLElement,
|
|
33
|
+
HTMLInputElement: window.HTMLInputElement,
|
|
34
|
+
HTMLTextAreaElement: window.HTMLTextAreaElement,
|
|
35
|
+
HTMLAnchorElement: window.HTMLAnchorElement,
|
|
36
|
+
HTMLDivElement: window.HTMLDivElement,
|
|
37
|
+
HTMLSpanElement: window.HTMLSpanElement,
|
|
38
|
+
Element: window.Element,
|
|
39
|
+
Node: window.Node,
|
|
40
|
+
Text: window.Text,
|
|
41
|
+
Event: window.Event,
|
|
42
|
+
MouseEvent: window.MouseEvent,
|
|
43
|
+
KeyboardEvent: window.KeyboardEvent,
|
|
44
|
+
CustomEvent: window.CustomEvent,
|
|
45
|
+
DocumentFragment: window.DocumentFragment,
|
|
46
|
+
Range: window.Range,
|
|
47
|
+
Selection: window.Selection,
|
|
48
|
+
MutationObserver: window.MutationObserver,
|
|
49
|
+
IntersectionObserver: window.IntersectionObserver,
|
|
50
|
+
ResizeObserver: window.ResizeObserver ?? class { observe() {} unobserve() {} disconnect() {} },
|
|
51
|
+
DOMRect: window.DOMRect,
|
|
52
|
+
getComputedStyle: window.getComputedStyle.bind(window),
|
|
53
|
+
requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number,
|
|
54
|
+
cancelAnimationFrame: (id: number) => clearTimeout(id),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const [k, v] of Object.entries(globals)) {
|
|
58
|
+
Object.defineProperty(globalThis, k, { value: v, writable: true, configurable: true })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// React 19 + RTL require `IS_REACT_ACT_ENVIRONMENT` so `act()` warnings
|
|
62
|
+
// don't fire on every render. Without it, Tiptap's mount cascade
|
|
63
|
+
// produces dozens of warnings that swamp real test failures.
|
|
64
|
+
;(globalThis as Record<string, unknown>)['IS_REACT_ACT_ENVIRONMENT'] = true
|