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