@pilotiq/tiptap 3.10.4 → 3.10.6

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 (69) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/dist/react/CollabTextRenderer.d.ts.map +1 -1
  8. package/dist/react/CollabTextRenderer.js +4 -4
  9. package/dist/react/CollabTextRenderer.js.map +1 -1
  10. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  11. package/dist/react/MarkdownEditor.js +4 -5
  12. package/dist/react/MarkdownEditor.js.map +1 -1
  13. package/dist/react/TiptapEditor.d.ts.map +1 -1
  14. package/dist/react/TiptapEditor.js +8 -7
  15. package/dist/react/TiptapEditor.js.map +1 -1
  16. package/package.json +6 -3
  17. package/dist/collabShapes.d.ts +0 -22
  18. package/dist/collabShapes.d.ts.map +0 -1
  19. package/dist/collabShapes.js +0 -2
  20. package/dist/collabShapes.js.map +0 -1
  21. package/src/Block.ts +0 -75
  22. package/src/MentionProvider.ts +0 -153
  23. package/src/PlainTextEditor.dom.test.ts +0 -111
  24. package/src/PlainTextEditor.test.ts +0 -158
  25. package/src/PlainTextEditor.ts +0 -229
  26. package/src/RichTextField.test.ts +0 -447
  27. package/src/RichTextField.ts +0 -508
  28. package/src/collabShapes.ts +0 -22
  29. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  30. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  31. package/src/extensions/AiSuggestionExtension.ts +0 -522
  32. package/src/extensions/BlockNodeExtension.ts +0 -134
  33. package/src/extensions/DragHandleExtension.ts +0 -184
  34. package/src/extensions/GridExtension.test.ts +0 -31
  35. package/src/extensions/GridExtension.ts +0 -138
  36. package/src/extensions/MentionExtension.ts +0 -248
  37. package/src/extensions/MergeTagExtension.ts +0 -75
  38. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  39. package/src/extensions/SlashCommandExtension.ts +0 -332
  40. package/src/extensions/TextSizeMarks.ts +0 -73
  41. package/src/index.ts +0 -62
  42. package/src/markdownExtension.ts +0 -19
  43. package/src/markdownStorage.ts +0 -49
  44. package/src/plugin.test.ts +0 -19
  45. package/src/plugin.ts +0 -26
  46. package/src/react/AiSuggestionBanner.tsx +0 -185
  47. package/src/react/BlockNodeView.tsx +0 -99
  48. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  49. package/src/react/BlockSidePanel.test.ts +0 -412
  50. package/src/react/BlockSidePanel.tsx +0 -451
  51. package/src/react/CollabTextRenderer.tsx +0 -230
  52. package/src/react/FloatingToolbar.tsx +0 -304
  53. package/src/react/MarkdownEditor.tsx +0 -606
  54. package/src/react/MentionMenu.tsx +0 -120
  55. package/src/react/Palette.tsx +0 -86
  56. package/src/react/SlashMenu.tsx +0 -129
  57. package/src/react/TableFloatingToolbar.tsx +0 -154
  58. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  59. package/src/react/TiptapEditor.tsx +0 -776
  60. package/src/react/Toolbar.tsx +0 -438
  61. package/src/react/toolbarButtons.tsx +0 -579
  62. package/src/react/useAiInlineDiff.ts +0 -342
  63. package/src/react/useAiSuggestionBridge.ts +0 -223
  64. package/src/register.test.ts +0 -14
  65. package/src/register.ts +0 -42
  66. package/src/render.test.ts +0 -745
  67. package/src/render.ts +0 -480
  68. package/src/surgicalOps.ts +0 -205
  69. package/src/test/setup.ts +0 -64
@@ -1,776 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { useEditor, EditorContent, type Editor } from '@tiptap/react'
3
- import StarterKit from '@tiptap/starter-kit'
4
- import Placeholder from '@tiptap/extension-placeholder'
5
- import Subscript from '@tiptap/extension-subscript'
6
- import Superscript from '@tiptap/extension-superscript'
7
- import TextAlign from '@tiptap/extension-text-align'
8
- import { TextStyle } from '@tiptap/extension-text-style'
9
- import { Color } from '@tiptap/extension-color'
10
- import Highlight from '@tiptap/extension-highlight'
11
- import Image from '@tiptap/extension-image'
12
- import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
13
- import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details'
14
- import { Grid, GridColumn } from '../extensions/GridExtension.js'
15
- import { Popover } from '@base-ui/react/popover'
16
- import type {
17
- FieldRendererProps,
18
- CollabRoom,
19
- CollabExtensionFactory,
20
- } from '@pilotiq/pilotiq/react'
21
- import { useCollabRoom, getCollabExtensions, useCollabSeed, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react'
22
- import type { YDocShape } from '../collabShapes.js'
23
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
24
- import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
25
- import { AiSuggestionBanner } from './AiSuggestionBanner.js'
26
- import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
27
- import type { BlockMeta } from '../Block.js'
28
- import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
29
- import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
30
- import {
31
- SlashCommandExtension,
32
- type SlashState,
33
- } from '../extensions/SlashCommandExtension.js'
34
- import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
35
- import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
36
- import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
37
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
38
- import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js'
39
- import {
40
- MentionExtension,
41
- type MentionState,
42
- } from '../extensions/MentionExtension.js'
43
- import type { MentionProviderMeta } from '../MentionProvider.js'
44
- import { SlashMenu, type SlashKeyHandlerRef } from './SlashMenu.js'
45
- import { MentionMenu, type MentionKeyHandlerRef } from './MentionMenu.js'
46
- import { FloatingToolbar } from './FloatingToolbar.js'
47
- import { TableFloatingToolbar } from './TableFloatingToolbar.js'
48
- import { Toolbar, AttachFilesDialog, useEditorTick } from './Toolbar.js'
49
- import { BlockSidePanel } from './BlockSidePanel.js'
50
-
51
- /**
52
- * The pilotiq field renderer for `RichTextField`. Registered globally via
53
- * `registerTiptap()`; pilotiq's `SchemaRenderer` looks it up by `fieldType:
54
- * 'richtext'` and mounts it inline inside the form.
55
- *
56
- * Wiring (Phase A):
57
- * - StarterKit + Underline + Subscript + Superscript + TextAlign
58
- * - Placeholder
59
- * - BlockNodeExtension (custom-block storage + React NodeView)
60
- * - SlashCommandExtension (`/` opens menu, items derived from `blocks`)
61
- * - DragHandleExtension (hover gutter handle)
62
- *
63
- * Form integration: a hidden `<input type="hidden" name={field}>` carries
64
- * the editor's serialized output. Storage format depends on the field's
65
- * `.storage('json' | 'html')` setting — JSON parses on the server,
66
- * HTML is passed through.
67
- */
68
- export function TiptapEditor(props: FieldRendererProps) {
69
- // useEditor + ProseMirror touch the DOM during construction — render a
70
- // static placeholder during SSR so Vike's first paint doesn't crash.
71
- // Hydration mounts the real editor on the client.
72
- const [mounted, setMounted] = useState(false)
73
- useEffect(() => { setMounted(true) }, [])
74
-
75
- if (!mounted) {
76
- const storage = (props.el['storage'] as RichTextStorage | undefined) ?? 'json'
77
- const initialValue = serializeForHidden(props.defaultValue, storage)
78
- return (
79
- <div className="flex flex-col">
80
- <input type="hidden" name={props.name} value={initialValue} />
81
- <div className="prose prose-sm max-w-none min-h-[180px] rounded-md border border-input bg-transparent px-10 py-3 text-sm text-muted-foreground">
82
- {props.placeholder ?? 'Start writing…'}
83
- </div>
84
- </div>
85
- )
86
- }
87
-
88
- return <CollabAwareTiptap {...props} />
89
- }
90
-
91
- /**
92
- * Bridges pilotiq's open-core `CollabRoomContext` + `CollabExtensionFactory`
93
- * registry into the Tiptap renderer. When `@pilotiq-pro/collab` is wired
94
- * AND a `<RecordCollabRoom>` is mounted up-tree, the room flips non-null;
95
- * keying `ClientEditor` on that toggle remounts the editor cleanly so the
96
- * `Collaboration` extension can install (Tiptap can't swap it at runtime).
97
- *
98
- * No-op shell when collab isn't installed — `room` stays `null`,
99
- * `getCollabExtensions()` returns `null`, and `ClientEditor` runs its
100
- * plain Tiptap path with the same shape as before.
101
- */
102
- function CollabAwareTiptap(props: FieldRendererProps) {
103
- const room = useCollabRoom()
104
- const factory = getCollabExtensions()
105
- // Per-field opt-out — `RichTextField.collab(false)` stamps `meta.collab`
106
- // explicitly false, overriding the panel-wide auto-on default. Useful for
107
- // fields that should stay device-local (private notes, draft scratch
108
- // space, etc.) inside an otherwise collab-on form.
109
- const fieldCollab = props.el['collab'] as boolean | undefined
110
- const collabActive = !!(room && factory) && fieldCollab !== false
111
- return (
112
- <ClientEditor
113
- key={collabActive ? 'collab' : 'local'}
114
- {...props}
115
- room={collabActive ? room : null}
116
- factory={collabActive ? factory : null}
117
- collabActive={collabActive}
118
- />
119
- )
120
- }
121
-
122
- interface ClientEditorProps extends FieldRendererProps {
123
- /** Active record room, or `null` when no `<RecordCollabRoom>` is mounted. */
124
- room: CollabRoom | null
125
- /** Registered collab extension factory, or `null` when no plugin registered. */
126
- factory: CollabExtensionFactory | null
127
- /** Convenience flag — `true` iff both `room` AND `factory` are non-null. */
128
- collabActive: boolean
129
- }
130
-
131
- function ClientEditor(props: ClientEditorProps) {
132
- const { el, name, defaultValue, placeholder, disabled, room, factory, collabActive } = props
133
-
134
- // Collab-stable identifier — top-level fields just use `name`, but
135
- // Repeater / Builder row leaves rebind to a row-id-anchored composite
136
- // (`metadata.<rowId>.body`) so the `Y.XmlFragment` survives reorders.
137
- // AI suggestion routing, hidden FormData input, and the inline-diff
138
- // banner stay on the positional `name` so tool calls referencing the
139
- // field by its FormData name (`metadata.0.body`) still resolve.
140
- //
141
- // Mirrors the `name` + `fragmentKey` split shipped for `MarkdownField`
142
- // in `@pilotiq/pilotiq@0.20.0` + `@pilotiq/tiptap@3.9.0`. Different
143
- // mechanics: `MarkdownInput` had an intermediate host wrapper in
144
- // pilotiq core (because markdown has a textarea fallback path that
145
- // needs the same composite). `RichTextField` has no fallback —
146
- // pilotiq core just dispatches the registered renderer with the
147
- // positional name. So the composite logic lives here, on the only
148
- // editor that needs it.
149
- const rowCoords = useRowCoords()
150
- const collabName = useMemo<string>(() => {
151
- if (!name.includes('.')) return name
152
- if (!rowCoords) return name
153
- const parsed = parseRowFieldPath(name)
154
- if (!parsed) return name
155
- if (parsed.arrayName !== rowCoords.arrayName) return name
156
- if (parsed.index !== rowCoords.rowIndex) return name
157
- return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
158
- }, [name, rowCoords])
159
-
160
- const blocks = (el['blocks'] as BlockMeta[] | undefined) ?? []
161
- const slashEnabled = (el['slashCommand'] as boolean | undefined) ?? true
162
- const toolbarGroups = (el['toolbarGroups'] as ToolbarGroups | null | undefined) ?? null
163
- const floatingEnabled = (el['floatingToolbar'] as boolean | undefined) ?? true
164
- const storage = (el['storage'] as RichTextStorage | undefined) ?? 'json'
165
- const textColors = (el['textColors'] as ColorSwatch[] | undefined) ?? []
166
- const customTextColors = (el['customTextColors'] as boolean | undefined) ?? false
167
- const highlightColors = (el['highlightColors'] as ColorSwatch[] | undefined) ?? []
168
- const resizableImages = (el['resizableImages'] as boolean | undefined) ?? false
169
- const uploadUrl = (el['uploadUrl'] as string | undefined)
170
- const acceptedFileTypes = (el['fileAttachmentsAcceptedFileTypes'] as string[] | undefined)
171
- const maxAttachmentSize = (el['fileAttachmentsMaxSize'] as number | undefined)
172
- const attachmentDir = (el['fileAttachmentsDirectory'] as string | undefined)
173
- const attachmentVis = (el['fileAttachmentsVisibility'] as ('public' | 'private') | undefined)
174
- const mergeTags = (el['mergeTags'] as string[] | undefined) ?? []
175
- const mentionProviders = (el['mentions'] as MentionProviderMeta[] | undefined) ?? []
176
- const mentionsUrl = (el['mentionsUrl'] as string | undefined)
177
-
178
- const initialContent = parseInitialContent(defaultValue)
179
- const [serialized, setSerialized] = useState(() => serializeForHidden(initialContent, storage))
180
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
181
-
182
- // Slash-menu state. `rawState` is what the extension last emitted.
183
- // `dismissed` is the Escape latch — while true, the popover stays hidden
184
- // even if the suggestion plugin keeps firing onUpdate. It clears when the
185
- // suggestion plugin formally exits (cursor leaves the slash range).
186
- const [rawState, setRawState] = useState<SlashState | null>(null)
187
- const [dismissed, setDismissed] = useState(false)
188
- const slashState = dismissed ? null : rawState
189
- const slashKeyRef = useRef<((event: KeyboardEvent) => boolean) | null>(null)
190
-
191
- const handleStateChange = useCallback((s: SlashState | null) => {
192
- if (s === null) setDismissed(false)
193
- setRawState(s)
194
- }, [])
195
-
196
- // Mention popover state — symmetrical to slash, with its own dismiss latch
197
- // so Escape closes the mention popup without affecting slash.
198
- const [rawMentionState, setRawMentionState] = useState<MentionState | null>(null)
199
- const [mentionDismissed, setMentionDismissed] = useState(false)
200
- const mentionState = mentionDismissed ? null : rawMentionState
201
- const mentionKeyRef = useRef<((event: KeyboardEvent) => boolean) | null>(null)
202
-
203
- // Lifted upload-dialog state — the toolbar's `attachFiles` button and the
204
- // slash menu's "Image" entry both flip this flag. Single source of truth
205
- // keeps the dialog mounted in one place (inside `Toolbar`) regardless of
206
- // which trigger fired.
207
- const [attachOpen, setAttachOpen] = useState(false)
208
-
209
- const handleMentionStateChange = useCallback((s: MentionState | null) => {
210
- if (s === null) setMentionDismissed(false)
211
- setRawMentionState(s)
212
- }, [])
213
-
214
- // Custom-block side panel — opens when a block's NodeView fires its
215
- // Edit button. The NodeView lives in a separate React tree and reaches
216
- // us via `BlockNodeExtension.options.onEdit` (set during configure()
217
- // below). Stores `pos` + `blockType` at open-time; `BlockSidePanel`
218
- // tracks the position forward through transactions and writes attrs
219
- // back via setNodeMarkup. Closing nullifies the slot — re-opening
220
- // remounts the panel fresh, including a re-snapshot of `blockData`.
221
- const [selectedBlock, setSelectedBlock] = useState<{ pos: number; blockType: string } | null>(null)
222
- const handleEditBlock = useCallback((pos: number) => {
223
- // We resolve `blockType` here against the current doc so a stale
224
- // pos (e.g. the block was just deleted before the click landed)
225
- // produces a no-op rather than an empty panel.
226
- setSelectedBlock((prev) => {
227
- // Read from the editor lazily — the editor ref isn't stable yet
228
- // on the very first render where this callback is created, so
229
- // defer the lookup to call time.
230
- const ed = editorRef.current
231
- if (!ed) return prev
232
- const node = (ed.state.doc as unknown as { nodeAt: (p: number) => { type: { name: string }; attrs: Record<string, unknown> } | null }).nodeAt(pos)
233
- if (!node || node.type.name !== 'pilotiqBlock') return prev
234
- return { pos, blockType: String(node.attrs['blockType'] ?? '') }
235
- })
236
- }, [])
237
- const closeBlockPanel = useCallback(() => { setSelectedBlock(null) }, [])
238
-
239
- // editorRef gives the onEdit callback access to the editor instance
240
- // without re-creating the callback on every render (which would force
241
- // the extension config to re-evaluate, triggering a full editor reset).
242
- const editorRef = useRef<Editor | null>(null)
243
-
244
- // Resolve the collab-attached extensions once per editor build.
245
- // `Collaboration` is constructed eagerly here (during `useEditor`'s
246
- // first call); the keyed remount above guarantees we never swap it.
247
- const collabExtensions = useMemo(() => {
248
- if (!collabActive || !room || !factory) return [] as unknown[]
249
- return factory({
250
- ydoc: room.ydoc,
251
- provider: room.provider,
252
- fieldName: collabName,
253
- ...(room.user ? { user: room.user } : {}),
254
- })
255
- // Intentionally deps-stable across renders within the same collab
256
- // mount — the keyed wrapper above remounts us when collab toggles.
257
- // eslint-disable-next-line react-hooks/exhaustive-deps
258
- }, [collabActive])
259
-
260
- const editor = useEditor({
261
- editable: !disabled,
262
- extensions: [
263
- // StarterKit 3.22+ ships Link AND Underline; configure through the
264
- // kit rather than re-adding (else "Duplicate extension names" warns).
265
- // `Collaboration` brings its own Yjs-backed history — disable
266
- // StarterKit's local `undoRedo` extension when collab is active
267
- // (renamed from `history` in Tiptap v3.x; passing `history: false`
268
- // silently no-ops and produces a runtime "not compatible with
269
- // @tiptap/extension-undo-redo" warning).
270
- StarterKit.configure({
271
- link: { openOnClick: false, autolink: true },
272
- ...(collabActive ? { undoRedo: false } : {}),
273
- }),
274
- Subscript,
275
- Superscript,
276
- LeadMarkExtension,
277
- SmallMarkExtension,
278
- // textAlign needs to be told which node types it can target. Headings
279
- // + paragraphs are the standard set. Blockquote alignment is handled
280
- // by aligning the inner paragraph.
281
- TextAlign.configure({ types: ['heading', 'paragraph'] }),
282
- // TextStyle is a no-op mark on its own, but Color decorates it with the
283
- // `color` attribute so `.setColor(...)` works. Loading them as a pair
284
- // keeps the extension surface complete.
285
- TextStyle,
286
- Color,
287
- Highlight.configure({ multicolor: true }),
288
- Image.configure({
289
- // Inline images break under prose's `figure` margin reset; the
290
- // editor uses block images by default, matching the read-side
291
- // renderer's `<img>` output.
292
- inline: false,
293
- // Most upload adapters return URLs — base64 inflates the doc and
294
- // re-uploads on every save. Opt back in only if your adapter
295
- // explicitly stores data URLs.
296
- allowBase64: false,
297
- resize: resizableImages
298
- ? { enabled: true, alwaysPreserveAspectRatio: true }
299
- : false,
300
- }),
301
- // Tables — the four nodes ship from one peer (`@tiptap/extension-table`).
302
- // `resizable: true` mounts the built-in column-resize NodeView so users
303
- // can drag column dividers; `lastColumnResizable: false` keeps the
304
- // right-edge handle from creating an unbounded growth target when the
305
- // table sits inside a constrained-width form.
306
- Table.configure({ resizable: true, lastColumnResizable: false }),
307
- TableRow,
308
- TableHeader,
309
- TableCell,
310
- // Collapsible `<details>` blocks. `persist: true` round-trips the
311
- // open/closed state through the document attrs so SSR + reload pick up
312
- // the same state the author left it in. The default summary text on
313
- // insert ("Title") gives the user something to overwrite.
314
- Details.configure({ persist: true, HTMLAttributes: { class: 'details' } }),
315
- DetailsSummary,
316
- DetailsContent,
317
- // Multi-column grid blocks (`grid` + `gridColumn`). Custom node pair —
318
- // Tiptap doesn't ship a first-party grid extension. Schema constrains
319
- // grids to 2 or 3 columns; consumer owns the CSS for `pilotiq-grid` /
320
- // `pilotiq-grid-cols-N`.
321
- Grid,
322
- GridColumn,
323
- Placeholder.configure({ placeholder: placeholder ?? 'Start writing…' }),
324
- // BlockNodeExtension carries the block registry on its options —
325
- // NodeViews mount in a separate React tree and can't see context.
326
- // `onEdit` is the bridge back to the host editor's tree where the
327
- // side panel lives; the NodeView's Edit button calls it with its
328
- // own `getPos()`.
329
- BlockNodeExtension.configure({ blocks, onEdit: handleEditBlock }),
330
- ...(slashEnabled ? [SlashCommandExtension.configure({
331
- blocks,
332
- mergeTags,
333
- onStateChange: handleStateChange,
334
- hasUpload: Boolean(uploadUrl),
335
- onInsertImage: () => setAttachOpen(true),
336
- })] : []),
337
- // MergeTagExtension provides the `mergeTag` node type even when no tags
338
- // are configured — the slash menu is the gate for *inserting* them, but
339
- // the schema needs to know about the node either way (otherwise loading
340
- // an existing doc that contains one throws a parse error).
341
- MergeTagExtension,
342
- ...(mentionProviders.length > 0 ? [MentionExtension.configure({
343
- providers: mentionProviders,
344
- onStateChange: handleMentionStateChange,
345
- ...(mentionsUrl ? { mentionsUrl } : {}),
346
- fieldName: name,
347
- })] : [MentionExtension]),
348
- DragHandleExtension,
349
- // AI suggestions — chip widget for surgical (range-anchored) edits.
350
- AiSuggestionExtension,
351
- // AI inline diff — Tiptap-Pro-style visualization for whole-field
352
- // suggestions via prosemirror-changeset. See AiInlineDiffExtension.
353
- AiInlineDiffExtension,
354
- // Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
355
- // when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
356
- // registered a factory via `registerCollabExtensions`.
357
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
358
- ...(collabExtensions as any[]),
359
- ],
360
- // Collaboration takes ownership of the document — `content` would race
361
- // the Y.XmlFragment sync. Seed instead via the post-`synced` effect
362
- // below so existing DB content lands once and only once. The non-collab
363
- // branch also gates on `isTiptapShapedContent` so leftover content from
364
- // a previous editor (e.g. Lexical's `{ root: {...} }`) doesn't crash
365
- // the schema-strict node parser on first paint.
366
- content: collabActive
367
- ? ''
368
- : (initialContent !== undefined && isTiptapShapedContent(initialContent) ? initialContent : ''),
369
- onUpdate: ({ editor: ed }) => {
370
- // Debounce serialization — every keystroke fires onUpdate.
371
- if (debounceRef.current) clearTimeout(debounceRef.current)
372
- debounceRef.current = setTimeout(() => {
373
- const value = storage === 'html' ? ed.getHTML() : JSON.stringify(ed.getJSON())
374
- setSerialized(value)
375
- }, 250)
376
- },
377
- editorProps: {
378
- attributes: {
379
- // Drop the top border-radius when the toolbar is on so the toolbar
380
- // and editor body read as a single chrome.
381
- class: `prose prose-sm dark:prose-invert max-w-none min-h-[180px] border border-input bg-transparent px-10 py-3 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
382
- toolbarGroups && toolbarGroups.length > 0
383
- ? 'rounded-b-md border-t-0'
384
- : 'rounded-md'
385
- }`,
386
- },
387
- },
388
- })
389
-
390
- // Document-level keyboard handler for the slash menu. Capture phase so we
391
- // run before ProseMirror's `view.dom` keydown listener — that way Enter
392
- // doesn't split the paragraph and Arrows don't move the cursor while
393
- // navigating the menu. Listen at `document` because Base UI's focus manager
394
- // can briefly pull focus into the popup when it mounts.
395
- const open = slashState !== null
396
- useEffect(() => {
397
- if (!open) return
398
- const onKeyDown = (e: KeyboardEvent) => {
399
- if (e.key === 'Escape') {
400
- setDismissed(true)
401
- e.preventDefault()
402
- e.stopPropagation()
403
- return
404
- }
405
- if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
406
- const handled = slashKeyRef.current?.(e) ?? false
407
- if (handled) {
408
- e.preventDefault()
409
- e.stopPropagation()
410
- }
411
- }
412
- }
413
- document.addEventListener('keydown', onKeyDown, true)
414
- return () => document.removeEventListener('keydown', onKeyDown, true)
415
- }, [open])
416
-
417
- // Mirror keyboard handling for the mention popover. Capture-phase listener
418
- // anchored to `document` for the same reason the slash menu uses it —
419
- // Base UI's focus manager can briefly steal focus to its popup.
420
- const mentionOpen = mentionState !== null
421
- useEffect(() => {
422
- if (!mentionOpen) return
423
- const onKeyDown = (e: KeyboardEvent) => {
424
- if (e.key === 'Escape') {
425
- setMentionDismissed(true)
426
- e.preventDefault()
427
- e.stopPropagation()
428
- return
429
- }
430
- if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
431
- const handled = mentionKeyRef.current?.(e) ?? false
432
- if (handled) {
433
- e.preventDefault()
434
- e.stopPropagation()
435
- }
436
- }
437
- }
438
- document.addEventListener('keydown', onKeyDown, true)
439
- return () => document.removeEventListener('keydown', onKeyDown, true)
440
- }, [mentionOpen])
441
-
442
- useEffect(() => () => {
443
- if (debounceRef.current) clearTimeout(debounceRef.current)
444
- }, [])
445
-
446
- // Mirror the editor instance into a ref so callbacks captured during
447
- // `useEditor`'s extension config (notably the BlockNode `onEdit`
448
- // bridge) can reach the live editor without re-creating themselves
449
- // every render. Re-creation would force the editor to rebuild from
450
- // scratch on every keystroke.
451
- useEffect(() => { editorRef.current = editor ?? null }, [editor])
452
-
453
- // Mirror `disabled` onto the live editor at runtime. `useEditor`'s
454
- // `editable: !disabled` only fires at construction time, so a parent
455
- // flipping read-only after mount (e.g. policy denial mid-edit, form
456
- // submitting state) would silently no-op without this effect. Same
457
- // shape MarkdownEditor.tsx and CollabTextRenderer.tsx already use.
458
- useEffect(() => {
459
- if (!editor) return
460
- editor.setEditable(!disabled)
461
- }, [editor, disabled])
462
-
463
- // First-load seed when collab is active. Collaboration starts the
464
- // editor empty regardless of `defaultValue`; once the room's first
465
- // sync resolves, `useCollabSeed` runs the callback inside
466
- // `ydoc.transact(..., 'pilotiq-collab-seed')`. We check whether the
467
- // field's `Y.XmlFragment` was ever written — empty + we have an
468
- // initial value = first session for this record — push the DB
469
- // content into the ydoc exactly once. Non-empty = the room already
470
- // has authoritative state; don't overwrite. Gating on `editor` keeps
471
- // the effect from firing before Tiptap mounts its
472
- // y-prosemirror binding (Tiptap v3 defers editor construction to
473
- // first effect under `immediatelyRender: false`).
474
- //
475
- // Subscribe-after-sync mirror: after the seed branch (or no-op when
476
- // the fragment already has content from a remote peer), serialize the
477
- // editor's current state into the hidden FormData input. The
478
- // debounced `onUpdate` path covers steady-state typing, but in the
479
- // cold-mount case (fresh peer joining a populated doc) y-prosemirror's
480
- // `ySyncPlugin` view hook may run `_forceRerender` before the React
481
- // owner has installed the `update` listener — leaving the hidden
482
- // input empty on submit. Idempotent: when `onUpdate` already
483
- // propagated, this is a no-op `setSerialized(sameValue)`. Same shape
484
- // as `CollabTextRenderer`'s post-sync mirror and
485
- // `@pilotiq-pro/collab`'s `rowArrayBinding.subscribeRows` catch-up.
486
- useCollabSeed(
487
- editor && collabActive ? room : null,
488
- collabName,
489
- (doc) => {
490
- const fragment = (doc as YDocShape).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
- )
512
-
513
- // Cross-package suggestion bridge — sync the host's
514
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
515
- // extension. No-op when no provider is mounted (default no-op context).
516
- //
517
- // Whole-field handling: NO chip widget here. The chip's `textContent`
518
- // renderer would surface raw HTML tags as literal text inside the
519
- // green pill — unparseable on multi-paragraph rewrites. Instead,
520
- // `<AiSuggestionBanner>` mounts below the editor (see render below).
521
- // Producer-supplied range suggestions still ride the inline chip —
522
- // those have a precise anchor worth visualizing in context.
523
- const applyWholeField = (value: string): void => {
524
- if (!editor || editor.isDestroyed) return
525
- editor.commands.setContent(value)
526
- }
527
- useAiSuggestionBridge(editor ?? null, name, {
528
- onApplyWholeField: applyWholeField,
529
- })
530
-
531
- // Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
532
- // HTML → ProseMirror Slice via the schema's DOMParser. Suggested values
533
- // on a RichTextField are typically HTML (or marked-up JSON that the
534
- // schema's DOMParser also handles via its serialized round-trip). For
535
- // JSON suggestions, the schema may reject — falls back to banner-only.
536
- useAiInlineDiff(editor ?? null, name, {
537
- parseSuggestion: (ed, value) => {
538
- try {
539
- const container = document.createElement('div')
540
- container.innerHTML = value
541
- return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
542
- } catch { return null }
543
- },
544
- })
545
- const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
546
-
547
- // Re-render the toolbar when the selection / marks change so active-state
548
- // booleans stay fresh.
549
- const tick = useEditorTick(editor)
550
-
551
- return (
552
- <div className="relative flex flex-col">
553
- <input type="hidden" name={name} value={serialized} />
554
- {editor && toolbarGroups && toolbarGroups.length > 0 && (
555
- <Toolbar
556
- editor={editor}
557
- groups={toolbarGroups}
558
- tick={tick}
559
- textColors={textColors}
560
- customTextColors={customTextColors}
561
- highlightColors={highlightColors}
562
- onAttachOpenChange={setAttachOpen}
563
- />
564
- )}
565
- {/* Single mount for the attach-files dialog — toolbar's `attachFiles`
566
- button and slash menu's "Image" entry both flip `attachOpen`.
567
- Mounted at the editor level (not the toolbar) so it stays available
568
- when the toolbar is hidden via `.toolbar(false)`. */}
569
- {editor && (
570
- <AttachFilesDialog
571
- open={attachOpen}
572
- onOpenChange={setAttachOpen}
573
- editor={editor}
574
- fieldName={name}
575
- {...(uploadUrl !== undefined ? { uploadUrl } : {})}
576
- {...(acceptedFileTypes !== undefined ? { acceptedFileTypes } : {})}
577
- {...(maxAttachmentSize !== undefined ? { maxFileSize: maxAttachmentSize } : {})}
578
- {...(attachmentDir !== undefined ? { directory: attachmentDir } : {})}
579
- {...(attachmentVis !== undefined ? { visibility: attachmentVis } : {})}
580
- />
581
- )}
582
- <EditorContent editor={editor} />
583
- <AiSuggestionBanner
584
- fieldName={name}
585
- onApplyWholeField={applyWholeField}
586
- {...(isDiffActive && editor
587
- ? {
588
- onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
589
- onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
590
- }
591
- : {})}
592
- />
593
- {editor && floatingEnabled && <FloatingToolbar editor={editor} />}
594
- {editor && <TableFloatingToolbar editor={editor} />}
595
- <SlashPopover state={slashState} keyHandlerRef={slashKeyRef} />
596
- <MentionPopover state={mentionState} keyHandlerRef={mentionKeyRef} />
597
- {editor && selectedBlock && (
598
- <BlockSidePanel
599
- key={`${selectedBlock.pos}:${selectedBlock.blockType}`}
600
- editor={editor}
601
- initialPos={selectedBlock.pos}
602
- blockType={selectedBlock.blockType}
603
- blocks={blocks}
604
- onClose={closeBlockPanel}
605
- />
606
- )}
607
- </div>
608
- )
609
- }
610
-
611
- /**
612
- * Cursor-anchored popover for the mention menu. Same Floating-UI / virtual-
613
- * element pattern as the slash popover — a `clientRect` lambda from the
614
- * Suggestion plugin powers a `getBoundingClientRect`-only anchor object.
615
- */
616
- function MentionPopover({
617
- state,
618
- keyHandlerRef,
619
- }: {
620
- state: MentionState | null
621
- keyHandlerRef: MentionKeyHandlerRef
622
- }) {
623
- const open = state !== null
624
-
625
- const anchor = useMemo(() => {
626
- if (!state) return null
627
- return {
628
- getBoundingClientRect: () => state.clientRect() ?? new DOMRect(0, 0, 0, 0),
629
- }
630
- }, [state])
631
-
632
- return (
633
- <Popover.Root open={open} onOpenChange={() => {}}>
634
- <Popover.Portal>
635
- <Popover.Positioner
636
- anchor={anchor}
637
- positionMethod="fixed"
638
- side="bottom"
639
- align="start"
640
- sideOffset={6}
641
- className="isolate z-50"
642
- >
643
- <Popover.Popup
644
- initialFocus={false}
645
- finalFocus={false}
646
- tabIndex={-1}
647
- className="origin-(--transform-origin) rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
648
- >
649
- {state && (
650
- <MentionMenu
651
- trigger={state.trigger}
652
- items={state.items}
653
- command={state.command}
654
- keyHandlerRef={keyHandlerRef}
655
- />
656
- )}
657
- </Popover.Popup>
658
- </Popover.Positioner>
659
- </Popover.Portal>
660
- </Popover.Root>
661
- )
662
- }
663
-
664
- /**
665
- * Renders the slash menu inside a Base UI Popover anchored to the cursor's
666
- * client rect. Floating UI under Base UI handles scroll/resize tracking and
667
- * collision avoidance, so we never have to recompute position ourselves.
668
- */
669
- function SlashPopover({
670
- state,
671
- keyHandlerRef,
672
- }: {
673
- state: SlashState | null
674
- keyHandlerRef: SlashKeyHandlerRef
675
- }) {
676
- const open = state !== null
677
-
678
- // Virtual element built from the suggestion plugin's clientRect lambda.
679
- // The Positioner re-reads `getBoundingClientRect` on every layout tick,
680
- // and `clientRect()` returns viewport-relative coords from PM, so scroll
681
- // tracking is automatic.
682
- const anchor = useMemo(() => {
683
- if (!state) return null
684
- return {
685
- getBoundingClientRect: () => state.clientRect() ?? new DOMRect(0, 0, 0, 0),
686
- }
687
- }, [state])
688
-
689
- return (
690
- <Popover.Root open={open} onOpenChange={() => {}}>
691
- <Popover.Portal>
692
- <Popover.Positioner
693
- anchor={anchor}
694
- // `fixed` makes the popup's bounding rect viewport-relative, so the
695
- // initial render (before Floating UI computes the anchor position)
696
- // doesn't sit at body (0,0) — that would trigger the browser to
697
- // scroll the page when the popup mounts and momentarily becomes
698
- // the focus target.
699
- positionMethod="fixed"
700
- side="bottom"
701
- align="start"
702
- sideOffset={6}
703
- className="isolate z-50"
704
- >
705
- <Popover.Popup
706
- // Keep focus in the editor — keyboard navigation is driven via a
707
- // document-level listener in TiptapEditor, never via DOM focus.
708
- initialFocus={false}
709
- finalFocus={false}
710
- tabIndex={-1}
711
- className="origin-(--transform-origin) rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
712
- >
713
- {state && (
714
- <SlashMenu
715
- items={state.items}
716
- command={state.command}
717
- keyHandlerRef={keyHandlerRef}
718
- />
719
- )}
720
- </Popover.Popup>
721
- </Popover.Positioner>
722
- </Popover.Portal>
723
- </Popover.Root>
724
- )
725
- }
726
-
727
- /**
728
- * Loose shape check — returns `true` only when the value looks like a Tiptap
729
- * (ProseMirror JSON) document: either an HTML string or an object that opens
730
- * with `{ type: 'doc' }` at the top level. Used by the collab seed effect to
731
- * skip leftover content from previous editors (notably Lexical's
732
- * `{ root: {...} }` envelope) without crashing Tiptap's strict node parser.
733
- *
734
- * The conservative posture: if we can't recognise the shape we don't seed.
735
- * Worst case the user sees an empty editor on the first collab session and
736
- * types fresh — better than the editor showing nothing because a parse threw.
737
- */
738
- function isTiptapShapedContent(raw: unknown): boolean {
739
- if (typeof raw === 'string') return true // HTML or raw text — Tiptap parses both.
740
- if (raw === null || typeof raw !== 'object') return false
741
- const obj = raw as { type?: unknown; content?: unknown; root?: unknown }
742
- if (obj.root !== undefined) return false // Lexical state envelope — never Tiptap.
743
- return obj.type === 'doc' // ProseMirror JSON always opens with `type:'doc'`.
744
- }
745
-
746
- function parseInitialContent(raw: unknown): object | string | undefined {
747
- if (raw === undefined || raw === null || raw === '') return undefined
748
- if (typeof raw === 'object') return raw as object
749
- if (typeof raw === 'string') {
750
- const trimmed = raw.trim()
751
- // Looks like JSON — try to parse. Otherwise treat as HTML and pass to
752
- // Tiptap (it accepts an HTML string as `content`).
753
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
754
- try { return JSON.parse(raw) } catch { return raw }
755
- }
756
- return raw
757
- }
758
- return undefined
759
- }
760
-
761
- function serializeForHidden(content: unknown, storage: RichTextStorage): string {
762
- if (content === undefined || content === null) {
763
- return storage === 'html' ? '' : JSON.stringify(null)
764
- }
765
- if (storage === 'html') {
766
- return typeof content === 'string' ? content : ''
767
- }
768
- if (typeof content === 'object') return JSON.stringify(content)
769
- if (typeof content === 'string') {
770
- // Best-effort: a stored JSON string from the server should round-trip.
771
- const trimmed = content.trim()
772
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) return content
773
- return JSON.stringify(null)
774
- }
775
- return JSON.stringify(null)
776
- }