@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,451 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
2
- import type { Editor } from '@tiptap/react'
3
- import {
4
- FormFields,
5
- parseFormDataToNested,
6
- clampPanelWidth as clampPanelWidthShared,
7
- useResizableWidth,
8
- } from '@pilotiq/pilotiq/react'
9
- import type { BlockMeta } from '../Block.js'
10
-
11
- const PANEL_WIDTH_STORAGE_KEY = 'pilotiq.tiptap.sidePanel.width'
12
- const PANEL_WIDTH_DEFAULT = 320
13
- const PANEL_WIDTH_MIN = 240
14
- const PANEL_WIDTH_MAX = 600
15
-
16
- // Keys that point at any tabbable / interactive element inside the panel.
17
- // Same intent as Filament's focus-trap helper but kept inline — small one-off.
18
- const FOCUSABLE_SELECTOR = [
19
- 'a[href]',
20
- 'button:not([disabled])',
21
- 'input:not([disabled]):not([type="hidden"])',
22
- 'select:not([disabled])',
23
- 'textarea:not([disabled])',
24
- '[tabindex]:not([tabindex="-1"])',
25
- ].join(',')
26
-
27
- /**
28
- * Floating right-docked side panel for editing a custom block's schema
29
- * fields. Mounted by `TiptapEditor` once the user clicks the Edit button
30
- * on a `pilotiqBlock` NodeView; reads/writes flow through ProseMirror
31
- * directly (no form submit, no roundtrip).
32
- *
33
- * Why a sibling of the NodeView and not the NodeView itself:
34
- * - NodeViews mount in a separate React tree (Tiptap quirk), so they
35
- * can't reach pilotiq's `FormFields` renderer or any provider on
36
- * the host page (Theme, Toaster, etc.). Hosting the panel here in
37
- * the host's tree gives us the full pilotiq field surface for free.
38
- *
39
- * Reads: each field's `defaultValue` is overridden from the block's
40
- * stored `blockData`. Inputs are uncontrolled (outside `FormStateProvider`,
41
- * pilotiq's renderers fall back to `defaultValue` automatically).
42
- *
43
- * Writes: container-level event delegation on the form element. Every
44
- * change snapshots the entire form via `new FormData(formEl)` →
45
- * `parseFormDataToNested` (rebuilds nested arrays/objects from
46
- * dotted-path inputs like `myrep.0.title`) → `coerceBlockValues`
47
- * (per-fieldType JSON parse / boolean / number coerce so nested-shape
48
- * fields round-trip in their canonical wire form). The result is
49
- * dispatched through `state.tr.setNodeMarkup` on the tracked position.
50
- * The position is kept fresh by mapping it through every editor
51
- * transaction so live edits elsewhere in the document don't desync.
52
- *
53
- * V2 (2026-05-04 cont'd): nested-shape fields now round-trip cleanly:
54
- * Repeater (array of subschema rows), Builder (heterogeneous block
55
- * rows), TagsInput / KeyValue / FileUpload (JSON-encoded hidden
56
- * inputs), Markdown (plain textarea), and the standard primitives
57
- * (text / textarea / select / toggle / checkbox / radio / date /
58
- * datetime / number / slider / color / toggleButtons / checkboxList).
59
- */
60
- export interface BlockSidePanelProps {
61
- editor: Editor
62
- /** Position at open time. Tracked + remapped on every transaction. */
63
- initialPos: number
64
- /** Block type at open time — guards against the user clicking Edit on
65
- * one block, then someone else's edit replacing it with a different
66
- * block at the same position. */
67
- blockType: string
68
- blocks: BlockMeta[]
69
- onClose: () => void
70
- }
71
-
72
- export function BlockSidePanel({
73
- editor,
74
- initialPos,
75
- blockType,
76
- blocks,
77
- onClose,
78
- }: BlockSidePanelProps): React.ReactElement | null {
79
- const meta = blocks.find((b) => b.name === blockType)
80
-
81
- // Live-tracked position of the block we're editing. Starts at the
82
- // open-time position; every editor transaction maps it forward so the
83
- // panel keeps writing to the same node even as the user types text
84
- // elsewhere in the document.
85
- const [pos, setPos] = useState<number | null>(initialPos)
86
- const posRef = useRef<number | null>(initialPos)
87
-
88
- // Prefilled values seed the form's `defaultValue`s. We re-read once
89
- // when the panel opens (and on hard re-mount via key prop); ongoing
90
- // edits don't snapshot the doc — the form's uncontrolled inputs hold
91
- // their own state until the user closes the panel.
92
- const initialValuesRef = useRef<Record<string, unknown>>(
93
- pos !== null ? readBlockData(editor, pos) : {},
94
- )
95
-
96
- const asideRef = useRef<HTMLElement | null>(null)
97
- const formRef = useRef<HTMLFormElement | null>(null)
98
-
99
- // Width memory — survives panel close/reopen and full reload via
100
- // localStorage. The shared `useResizableWidth` hook from
101
- // `@pilotiq/pilotiq/react` handles the localStorage round-trip + drag
102
- // pipeline; we just bind it to this panel's per-key bounds.
103
- const { width, onResizeStart } = useResizableWidth({
104
- storageKey: PANEL_WIDTH_STORAGE_KEY,
105
- min: PANEL_WIDTH_MIN,
106
- max: PANEL_WIDTH_MAX,
107
- defaultWidth: PANEL_WIDTH_DEFAULT,
108
- edge: 'left',
109
- })
110
-
111
- // Save focus on mount, focus the first focusable inside the panel,
112
- // restore previous focus on unmount. Mount-only effect — re-mounting
113
- // the panel for a different block (key={pos:blockType}) re-runs this.
114
- useEffect(() => {
115
- const previouslyFocused = (typeof document !== 'undefined'
116
- ? document.activeElement
117
- : null) as HTMLElement | null
118
- const aside = asideRef.current
119
- if (aside) {
120
- const first = aside.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
121
- first?.focus()
122
- }
123
- return () => {
124
- // Try/catch — the previously focused element may have been removed
125
- // (e.g. an editor selection refresh nuked the surrounding NodeView).
126
- try { previouslyFocused?.focus?.() } catch { /* noop */ }
127
- }
128
- }, [])
129
-
130
- // ESC closes; Tab / Shift+Tab cycles within the panel. Bubble-phase
131
- // listener — slash and mention menus' capture-phase ESC handlers fire
132
- // first and stopPropagation, so ESC inside an open slash menu only
133
- // closes the menu. ESC anywhere else (panel inputs, editor) closes
134
- // the panel.
135
- useEffect(() => {
136
- const onKey = (e: KeyboardEvent): void => {
137
- if (e.key === 'Escape') {
138
- onClose()
139
- e.preventDefault()
140
- e.stopPropagation()
141
- return
142
- }
143
- if (e.key !== 'Tab') return
144
- const aside = asideRef.current
145
- if (!aside) return
146
- const active = document.activeElement as Node | null
147
- if (!active || !aside.contains(active)) return
148
- const focusables = Array.from(
149
- aside.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
150
- ).filter((el) => !el.hasAttribute('disabled'))
151
- if (focusables.length === 0) return
152
- const first = focusables[0]!
153
- const last = focusables[focusables.length - 1]!
154
- if (e.shiftKey && active === first) {
155
- e.preventDefault()
156
- last.focus()
157
- } else if (!e.shiftKey && active === last) {
158
- e.preventDefault()
159
- first.focus()
160
- }
161
- }
162
- document.addEventListener('keydown', onKey)
163
- return () => document.removeEventListener('keydown', onKey)
164
- }, [onClose])
165
-
166
- useEffect(() => {
167
- if (pos === null) return
168
- const handler = ({ transaction }: { transaction: { mapping: { map: (p: number) => number } } }): void => {
169
- const current = posRef.current
170
- if (current === null) return
171
- const mapped = transaction.mapping.map(current)
172
- // The block was deleted — close the panel.
173
- const nodeNow = nodeAt(editor, mapped)
174
- if (!nodeNow || nodeNow.type.name !== 'pilotiqBlock' || String(nodeNow.attrs['blockType'] ?? '') !== blockType) {
175
- posRef.current = null
176
- setPos(null)
177
- onClose()
178
- return
179
- }
180
- posRef.current = mapped
181
- setPos(mapped)
182
- }
183
- editor.on('transaction', handler)
184
- return () => { editor.off('transaction', handler) }
185
- }, [editor, blockType, pos, onClose])
186
-
187
- const writeBack = useCallback((nextValues: Record<string, unknown>): void => {
188
- const at = posRef.current
189
- if (at === null) return
190
- // ProseMirror's `setNodeMarkup` lives on the transaction, not the
191
- // ChainedCommands surface — go through `tr` directly. Pass `null`
192
- // for the node-type arg to keep the existing type, just swap attrs.
193
- const view = (editor as unknown as { view: { dispatch: (tr: unknown) => void } }).view
194
- const state = (editor as unknown as { state: { tr: { setNodeMarkup: (p: number, t: null, a: Record<string, unknown>) => unknown } } }).state
195
- const tr = state.tr.setNodeMarkup(at, null, { blockType, blockData: nextValues })
196
- view.dispatch(tr)
197
- }, [editor, blockType])
198
-
199
- const handleChange = useCallback((): void => {
200
- const formEl = formRef.current
201
- if (!formEl || !meta) return
202
- // Snapshot the full form: nested arrays / objects materialize from
203
- // dotted-path names (`items.0.title`), JSON-encoded hidden inputs
204
- // (TagsInput / KeyValue / FileUpload-multi) sit as JSON strings,
205
- // toggle / checkbox hidden inputs sit as `'true' | 'false'`. The
206
- // coerce pass below normalizes those to canonical shapes.
207
- const raw = parseFormDataToNested(new FormData(formEl))
208
- const coerced = coerceBlockValues(raw, meta.schema)
209
- writeBack(coerced)
210
- }, [meta, writeBack])
211
-
212
- if (!meta || pos === null) return null
213
-
214
- return (
215
- <aside
216
- ref={asideRef}
217
- role="dialog"
218
- aria-label={`Edit ${meta.label}`}
219
- style={{ width }}
220
- className="absolute top-0 left-full ml-4 max-h-[calc(100vh-2rem)] overflow-y-auto rounded-lg border bg-background shadow-lg z-30"
221
- >
222
- {/* Left-edge resize handle. Thin strip, hover-highlighted, full
223
- height of the panel; mousedown starts a drag tracked at
224
- document level so leaving the strip doesn't end the drag. */}
225
- <div
226
- role="separator"
227
- aria-orientation="vertical"
228
- aria-label="Resize panel"
229
- onPointerDown={onResizeStart}
230
- className="absolute left-0 top-0 h-full w-1 cursor-ew-resize hover:bg-border/80"
231
- />
232
- <header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background px-3 py-2">
233
- <div className="flex items-center gap-2 min-w-0">
234
- {meta.icon && <span aria-hidden="true">{meta.icon}</span>}
235
- <span className="text-sm font-medium truncate">{meta.label}</span>
236
- </div>
237
- <button
238
- type="button"
239
- onClick={onClose}
240
- aria-label="Close panel"
241
- className="text-muted-foreground hover:text-foreground text-sm"
242
- >
243
- ×
244
- </button>
245
- </header>
246
- <form
247
- ref={formRef}
248
- onInput={handleChange}
249
- onChange={handleChange}
250
- onSubmit={(e) => { e.preventDefault() }}
251
- className="flex flex-col gap-3 px-3 py-3"
252
- >
253
- <FormFields
254
- elements={meta.schema}
255
- values={initialValuesRef.current}
256
- />
257
- </form>
258
- </aside>
259
- )
260
- }
261
-
262
- /**
263
- * Clamp + sanitize a candidate panel width against this panel's bounds
264
- * (`[PANEL_WIDTH_MIN, PANEL_WIDTH_MAX]`, default `PANEL_WIDTH_DEFAULT`).
265
- * Thin wrapper around the shared `clampPanelWidth` helper from
266
- * `@pilotiq/pilotiq/react` — kept exported with the panel-specific
267
- * defaults baked in so existing tests + downstream callers don't have
268
- * to plumb the bounds themselves.
269
- */
270
- export function clampPanelWidth(value: unknown): number {
271
- return clampPanelWidthShared(value, {
272
- min: PANEL_WIDTH_MIN,
273
- max: PANEL_WIDTH_MAX,
274
- defaultWidth: PANEL_WIDTH_DEFAULT,
275
- })
276
- }
277
-
278
- /**
279
- * Per-fieldType coerce of a nested values map (built by
280
- * `parseFormDataToNested`) against the block's schema. Mirrors the
281
- * server-side `coerceFormValues` at a small subset suitable for the
282
- * side panel — we only run on top-level block fields plus the immediate
283
- * children of any Repeater rows / Builder rows.data, which is all the
284
- * V2 surface needs.
285
- *
286
- * Non-coerce passthrough for: text, textarea, select, radio, date,
287
- * dateTime, email, color, toggleButtons, slug, hidden. (Their wire shape
288
- * is already a plain string / array of strings.)
289
- *
290
- * Coerce branches:
291
- * - `toggle` / `checkbox`: 'true' / 'false' string → boolean.
292
- * - `number` / `slider`: parse to Number, null on empty, raw string
293
- * passthrough on NaN (so a half-typed value isn't lost).
294
- * - `tagsInput`: JSON-encoded string → string[].
295
- * - `checkboxList`: JSON-encoded string OR array → string[].
296
- * - `keyValue`: JSON-encoded string → Record<string, unknown>.
297
- * - `fileUpload`: single → URL string passthrough; multiple →
298
- * JSON-encoded string → string[].
299
- * - `repeater`: each row in the array gets recursive coerce against
300
- * the field's `template` (the inner field schema definition).
301
- * - `builder`: each row's `data` gets recursive coerce against the
302
- * block matching `row.type` from `field.blocks[]`. Unknown block
303
- * types pass through verbatim — the renderer shows a placeholder
304
- * and the data round-trips intact across config rollbacks.
305
- *
306
- * Exported for unit tests. Pure — no React, no DOM, no editor.
307
- */
308
- export function coerceBlockValues(
309
- raw: Record<string, unknown>,
310
- schema: ReadonlyArray<Record<string, unknown>>,
311
- ): Record<string, unknown> {
312
- const out: Record<string, unknown> = { ...raw }
313
- for (const field of schema) {
314
- const name = String(field['name'] ?? '')
315
- if (!name) continue
316
- const ft = String(field['fieldType'] ?? 'text')
317
- const value = out[name]
318
- out[name] = coerceField(value, ft, field)
319
- }
320
- return out
321
- }
322
-
323
- function coerceField(
324
- value: unknown,
325
- ft: string,
326
- field: Record<string, unknown>,
327
- ): unknown {
328
- switch (ft) {
329
- case 'toggle':
330
- case 'checkbox':
331
- return value === 'true' || value === true
332
- case 'number':
333
- case 'slider':
334
- return coerceNumber(value)
335
- case 'tagsInput':
336
- return parseJsonArray(value)
337
- case 'checkboxList':
338
- return parseJsonArray(value)
339
- case 'keyValue':
340
- return parseJsonObject(value)
341
- case 'fileUpload': {
342
- const multiple = Boolean(field['multiple'])
343
- if (multiple) return parseJsonArray(value)
344
- return typeof value === 'string' ? value : ''
345
- }
346
- case 'repeater': {
347
- if (!Array.isArray(value)) return []
348
- const template = (field['template'] as ReadonlyArray<Record<string, unknown>> | undefined) ?? []
349
- return value.map((row) => {
350
- if (!row || typeof row !== 'object') return {}
351
- return coerceBlockValues(row as Record<string, unknown>, template)
352
- })
353
- }
354
- case 'builder': {
355
- if (!Array.isArray(value)) return []
356
- const blockMetas = (field['blocks'] as ReadonlyArray<Record<string, unknown>> | undefined) ?? []
357
- return value.map((row) => {
358
- if (!row || typeof row !== 'object') return { type: '', data: {} }
359
- const r = row as Record<string, unknown>
360
- const type = String(r['type'] ?? '')
361
- const data = (r['data'] as Record<string, unknown> | undefined) ?? {}
362
- const block = blockMetas.find((b) => String(b['name'] ?? '') === type)
363
- if (!block) return { type, data }
364
- const tpl = (block['template'] as ReadonlyArray<Record<string, unknown>> | undefined) ?? []
365
- return { type, data: coerceBlockValues(data, tpl) }
366
- })
367
- }
368
- default:
369
- return value === undefined ? '' : value
370
- }
371
- }
372
-
373
- function coerceNumber(value: unknown): unknown {
374
- if (value === '' || value === null || value === undefined) return null
375
- if (typeof value === 'number') return value
376
- const raw = String(value)
377
- if (raw === '') return null
378
- const n = Number(raw)
379
- return Number.isNaN(n) ? raw : n
380
- }
381
-
382
- function parseJsonArray(value: unknown): unknown[] {
383
- if (Array.isArray(value)) return value
384
- if (typeof value !== 'string' || value === '') return []
385
- try {
386
- const parsed = JSON.parse(value)
387
- return Array.isArray(parsed) ? parsed : []
388
- } catch {
389
- return []
390
- }
391
- }
392
-
393
- function parseJsonObject(value: unknown): Record<string, unknown> {
394
- if (value && typeof value === 'object' && !Array.isArray(value)) {
395
- return value as Record<string, unknown>
396
- }
397
- if (typeof value !== 'string' || value === '') return {}
398
- try {
399
- const parsed = JSON.parse(value)
400
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
401
- return parsed as Record<string, unknown>
402
- }
403
- } catch { /* fall through */ }
404
- return {}
405
- }
406
-
407
- /**
408
- * Read the resolved field value for a given input event target. Kept
409
- * for back-compat — V1 used this in the per-event handler. V2 reads
410
- * the entire form via `FormData` instead, but this helper still maps
411
- * cleanly onto a single-input read for testing and is exported.
412
- *
413
- * String passthrough for the common case; explicit coercion for
414
- * booleans and numerics so the round-trip into the node attrs preserves
415
- * shape.
416
- */
417
- export function readBlockFieldValue(
418
- target: { type?: string; value: string; checked?: boolean },
419
- fieldMeta: { fieldType?: unknown },
420
- ): unknown {
421
- const ft = String(fieldMeta.fieldType ?? 'text')
422
- if (ft === 'toggle' || ft === 'checkbox') {
423
- return target.checked === true
424
- }
425
- if (ft === 'number' || ft === 'slider') {
426
- const raw = target.value
427
- if (raw === '') return null
428
- const n = Number(raw)
429
- return Number.isNaN(n) ? raw : n
430
- }
431
- return target.value
432
- }
433
-
434
- interface PMNode {
435
- type: { name: string }
436
- attrs: Record<string, unknown>
437
- }
438
-
439
- function readBlockData(editor: Editor, pos: number): Record<string, unknown> {
440
- const node = nodeAt(editor, pos)
441
- if (!node) return {}
442
- return (node.attrs['blockData'] as Record<string, unknown> | null) ?? {}
443
- }
444
-
445
- function nodeAt(editor: Editor, pos: number): PMNode | null {
446
- try {
447
- return (editor.state.doc as unknown as { nodeAt: (p: number) => PMNode | null }).nodeAt(pos)
448
- } catch {
449
- return null
450
- }
451
- }
@@ -1,230 +0,0 @@
1
- import { useEffect, useMemo, useRef } from 'react'
2
- import { useEditor, EditorContent, type Extension } from '@tiptap/react'
3
- import type { AnyExtension } from '@tiptap/core'
4
- import {
5
- useCollabRoom,
6
- getCollabExtensions,
7
- useCollabSeed,
8
- type CollabTextRendererProps,
9
- } from '@pilotiq/pilotiq/react'
10
- import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js'
11
- import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
12
- import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
13
- import type { YDocShape } from '../collabShapes.js'
14
-
15
- /**
16
- * Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
17
- * / similar single-line / multi-line text fields when collab is on.
18
- *
19
- * Lifts the cursor-bookkeeping burden off the field renderer: y-prosemirror
20
- * anchors selections to `Yjs.RelativePosition` items, so concurrent and
21
- * mid-word remote edits translate the local cursor correctly without any
22
- * heuristic. Replaces the legacy `Y.Text` + `computeDelta` + `preserveCursor`
23
- * path documented in `docs/plans/text-fields-tiptap-backed-collab.md`.
24
- *
25
- * Mount conditions (enforced upstream by `TextLikeInput`):
26
- * - A `<RecordCollabRoom>` is mounted up-tree (`useCollabRoom() !== null`).
27
- * - A collab extension factory was registered (`getCollabExtensions() !== null`).
28
- * - The field hasn't opted out via `.collab(false)`.
29
- * - The field is not masked (`.mask(pattern)`).
30
- * - The field is top-level (not a Repeater / Builder row leaf).
31
- *
32
- * If either the room or the factory disappears at runtime (e.g. the plugin
33
- * was never installed), we still render an editor — it's just a non-collab
34
- * plain Tiptap. That's a regression vs `<input>` ergonomically but never
35
- * crashes; in practice the upstream gate prevents this branch from mounting
36
- * when collab isn't wired.
37
- */
38
- export function CollabTextRenderer({
39
- name,
40
- fragmentKey,
41
- multiline,
42
- defaultValue,
43
- placeholder,
44
- disabled,
45
- onChange,
46
- onBlur,
47
- onSubmit,
48
- className,
49
- editorAttributes,
50
- }: CollabTextRendererProps): React.ReactElement {
51
- const room = useCollabRoom()
52
- const factory = getCollabExtensions()
53
- const collabActive = !!(room && factory)
54
-
55
- // Capture the initial non-empty `defaultValue` on mount + keep it stable.
56
- // The seedFn (below) needs to re-seed the empty fragment with the
57
- // SSR-loaded value even if the host's `defaultValue` prop has been
58
- // clobbered to '' by a sync-triggered empty `onChange` round-trip
59
- // (handleChange → setText('') → setValue('') → ctx.values.title='' →
60
- // FormBody re-renders the field with the new empty value). Closing
61
- // over the live prop in seedFn means seed-on-resync silently fails;
62
- // the ref preserves the seed source. Once the user types into the
63
- // editor the seedFn will no longer fire — the fragment won't be
64
- // empty — so this ref is read-only after the first non-empty seed.
65
- const initialDefaultValueRef = useRef<string>(defaultValue)
66
- if (defaultValue && !initialDefaultValueRef.current) {
67
- initialDefaultValueRef.current = defaultValue
68
- }
69
-
70
- // Collab-stable identifier — `name` on top-level fields, but the
71
- // row-id-anchored composite (`items.<rowId>.title`) on Repeater /
72
- // Builder row leaves so the Y.XmlFragment survives reorders. Routes
73
- // ONLY into the collab branches (factory + first-load seed); AI
74
- // suggestion bridge + onChange + hidden FormData input stay on
75
- // `name` (the positional FormData path) so AI tool calls referencing
76
- // the field by its FormData name still reach the editor.
77
- const collabName = fragmentKey ?? name
78
-
79
- // Built once per editor mount. The factory closes over the room's `ydoc`
80
- // + `provider` and the field name to produce a `Collaboration` (and
81
- // optional `CollaborationCursor`) extension targeting the field's
82
- // `Y.XmlFragment`. Re-running on every render would tear down the editor.
83
- const collabExtensions = useMemo<AnyExtension[]>(() => {
84
- if (!collabActive || !room || !factory) return []
85
- return factory({
86
- ydoc: room.ydoc,
87
- provider: room.provider,
88
- fieldName: collabName,
89
- ...(room.user ? { user: room.user } : {}),
90
- }) as Extension[]
91
- // eslint-disable-next-line react-hooks/exhaustive-deps
92
- }, [collabActive])
93
-
94
- const editor = useEditor(
95
- {
96
- // Tiptap v3 SSR guard. With `immediatelyRender: true` (default)
97
- // `useEditor` touches the DOM during construction; under Vike's
98
- // `onRenderHtml` that throws "SSR has been detected, please set
99
- // `immediatelyRender` explicitly to `false` to avoid hydration
100
- // mismatches." Deferring until the first React effect lets SSR
101
- // produce an empty shell + hydration mount the live editor.
102
- //
103
- // Load-bearing for the AI-attached auto-upgrade path: with rule
104
- // #2, AI fields render the Tiptap surface during SSR (where
105
- // `useCollabRoom()` is null but `aiActions.length > 0` flips the
106
- // host's gate). Without this flag the dev server would crash on
107
- // the first SSR pass of any record-edit page touching AI fields.
108
- immediatelyRender: false,
109
- ...createPlainTextEditor({
110
- multiline,
111
- ...(placeholder !== undefined ? { placeholder } : {}),
112
- editable: !disabled,
113
- // When Collaboration owns the doc, omit `content` so the editor
114
- // doesn't race the y-prosemirror sync. The post-`synced` effect below
115
- // seeds the fragment on first connect when it's still empty. When
116
- // collab is off, seed from defaultValue directly.
117
- content: collabActive ? '' : defaultValue,
118
- // AI suggestions — always-on extension that tracks suggested edits as
119
- // inline strikethrough + Approve/Reject chip widgets. Idle until the
120
- // host calls `editor.commands.addAiSuggestion(...)` via the bridge below.
121
- // Matches the `TiptapEditor` wiring so suggestion mode works uniformly
122
- // across RichTextField / MarkdownField / TextField+TextareaField.
123
- extensions: [...collabExtensions, AiSuggestionExtension],
124
- onUpdate: (text) => onChange(text),
125
- ...(onSubmit ? { onSubmit: () => { onSubmit(); return false } } : {}),
126
- ...(className || editorAttributes
127
- ? {
128
- editorAttributes: {
129
- ...(editorAttributes ?? {}),
130
- ...(className ? { class: className } : {}),
131
- },
132
- }
133
- : {}),
134
- }),
135
- },
136
- // Re-mount when collab toggles. Other props (multiline, name, etc) are
137
- // stable per mount under the upstream gate.
138
- [collabActive],
139
- )
140
-
141
- // Mirror the editor's editable state with the prop. `useEditor` snapshots
142
- // `editable` at first call, so we update it imperatively on changes.
143
- useEffect(() => {
144
- if (!editor) return
145
- editor.setEditable(!disabled)
146
- }, [editor, disabled])
147
-
148
- // Cross-package suggestion bridge — sync the host's
149
- // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
150
- // extension. No-op when no provider is mounted (default no-op context).
151
- //
152
- // Whole-field fallback: chat-driven suggestions (e.g. `update_form_state`)
153
- // arrive without `meta.editorRange`. Plain-text editors opt into a
154
- // synthesized full-doc range so the inline-diff chip (red strikethrough on
155
- // the current value + green chip with the suggested text + ✓/✕ buttons)
156
- // renders BEFORE the user approves. The extension's `applyApprove` is
157
- // text-node-based which fits the plain-text schema exactly. The
158
- // `onApplyWholeField` callback stays as a fallback for cases that don't
159
- // synthesize (e.g. an empty doc — `from === to` skips the chip but the
160
- // applier still needs to swap content).
161
- useAiSuggestionBridge(editor ?? null, name, {
162
- synthesizeWholeFieldRange: (ed) => ({
163
- from: 0,
164
- to: ed.state.doc.content.size,
165
- }),
166
- onApplyWholeField: (value) => {
167
- if (!editor || editor.isDestroyed) return
168
- editor.commands.setContent(plainTextToDoc(value, !!multiline))
169
- },
170
- })
171
-
172
- // First-load seed when collab is active. Collaboration starts the editor
173
- // empty regardless of `defaultValue`; once the room's first sync
174
- // resolves, `useCollabSeed` runs the callback inside `ydoc.transact`.
175
- // Empty fragment + we have an initial value = first session for this
176
- // record — push the SSR-rendered default into the editor once.
177
- //
178
- // Race caveat: two peers simultaneously mounting against a brand-new
179
- // record (both seeing `fragment.length === 0`) can both seed and produce
180
- // duplicated text. Same window as `TiptapEditor`'s rich-text seed path.
181
- // Acceptable for now; can be tightened later via a deterministic
182
- // first-writer election or a server-side seed handoff.
183
- //
184
- // Subscribe-after-sync mirror: after the seed branch (or no-op when the
185
- // fragment already has content from a remote peer), replay the editor's
186
- // current text into `onChange`. The mount-time safety-net effect below
187
- // fires once when the editor instance materializes, but in the cold-mount
188
- // case (fresh peer joining a populated doc) y-prosemirror's `ySyncPlugin`
189
- // view hook may run _forceRerender before the React owner has installed
190
- // the `update` listener that drives `onUpdate` — leaving the hidden
191
- // FormData input empty. Mirroring after `room.synced` resolves closes the
192
- // gap. Idempotent — when `onUpdate` already propagated the value, this is
193
- // a no-op `setText(sameValue)`. Same shape as the catch-up replay in
194
- // `@pilotiq-pro/collab`'s `rowArrayBinding.subscribeRows`.
195
- useCollabSeed(
196
- editor && collabActive ? room : null,
197
- collabName,
198
- (doc) => {
199
- const fragment = (doc as YDocShape).getXmlFragment(collabName)
200
- const seedValue = initialDefaultValueRef.current
201
- if (fragment && fragment.length === 0 && seedValue && editor) {
202
- editor.commands.setContent(plainTextToDoc(seedValue, multiline))
203
- }
204
- if (editor) onChange(plainTextOf(editor))
205
- },
206
- )
207
-
208
- // Bubble the editor's blur event up to the host. Tiptap exposes this via
209
- // `editor.on('blur', ...)`. The simpler `onBlur` prop on `EditorContent`
210
- // fires on the DOM node, but selection inside contenteditable can land on
211
- // child nodes; the Tiptap event is the canonical "editor lost focus".
212
- useEffect(() => {
213
- if (!editor) return
214
- const handler = (): void => onBlur()
215
- editor.on('blur', handler)
216
- return () => { editor.off('blur', handler) }
217
- }, [editor, onBlur])
218
-
219
- // Best-effort getText safety net — onUpdate should fire on every
220
- // y-prosemirror sync, but if a remote update somehow doesn't trigger
221
- // `onUpdate`, the wrapper's hidden input goes stale. Re-emit on every
222
- // editor render tick. No-op when text matches the last emit.
223
- useEffect(() => {
224
- if (!editor) return
225
- onChange(plainTextOf(editor))
226
- // eslint-disable-next-line react-hooks/exhaustive-deps
227
- }, [editor])
228
-
229
- return <EditorContent editor={editor} />
230
- }