@pilotiq/tiptap 3.5.0 → 3.6.0

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.
@@ -4,6 +4,7 @@ import type { AnyExtension } from '@tiptap/core'
4
4
  import StarterKit from '@tiptap/starter-kit'
5
5
  import Placeholder from '@tiptap/extension-placeholder'
6
6
  import Image from '@tiptap/extension-image'
7
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
7
8
  // The `tiptap-markdown` chain (incl. CJS-only `markdown-it-task-lists`) is
8
9
  // pre-bundled into `dist/markdownExtension.js` at @pilotiq/tiptap build
9
10
  // time; importing the wrapper instead of `tiptap-markdown` directly
@@ -17,7 +18,10 @@ import {
17
18
  type MarkdownEditorProps,
18
19
  } from '@pilotiq/pilotiq/react'
19
20
  import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
21
+ import { AiInlineDiffExtension, aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
20
22
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
23
+ import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
24
+ import { AiSuggestionBanner } from './AiSuggestionBanner.js'
21
25
 
22
26
  // Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
23
27
  // package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
@@ -214,12 +218,14 @@ export function MarkdownEditor({
214
218
  }),
215
219
  Image.configure({ inline: false, allowBase64: false }),
216
220
  Placeholder.configure({ placeholder: placeholder ?? 'Write in markdown…' }),
217
- // AI suggestions — always-on extension that tracks suggested edits as
218
- // inline strikethrough + Approve/Reject chip widgets. Idle until the
219
- // host calls `editor.commands.addAiSuggestion(...)` via the bridge below.
220
- // Matches the `TiptapEditor` wiring so suggestion mode works uniformly
221
- // across RichTextField / MarkdownField / TextField+TextareaField.
221
+ // AI suggestions — chip widget for surgical (range-anchored) edits.
222
222
  AiSuggestionExtension,
223
+ // AI inline diff — Tiptap-Pro-style visualization for whole-field
224
+ // suggestions (prosemirror-changeset under the hood). Decorations
225
+ // show green-background inserts inline + red-strikethrough widgets
226
+ // for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
227
+ // Reject via the extension's commands.
228
+ AiInlineDiffExtension,
223
229
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
230
  ...(collabExtensions as any[]),
225
231
  ],
@@ -248,23 +254,46 @@ export function MarkdownEditor({
248
254
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
249
255
  // extension. No-op when no provider is mounted (default no-op context).
250
256
  //
251
- // Whole-field handling for chat-driven suggestions (e.g.
252
- // `update_form_state`). The chip widget renders inline for visualization
253
- // (synthesized range over the whole doc), but Approve routes through
254
- // `onApplyWholeField` so the new markdown source parses correctly via
255
- // the Markdown extension the chip's plain-text replace would lose
256
- // headings, lists, formatting.
257
+ // Whole-field handling: NO chip widget here. The chip's `textContent`
258
+ // renderer surfaces raw markdown (`## Heading\n- item`) as literal text
259
+ // inside the green pill visually unparseable for multi-paragraph
260
+ // rewrites. Instead, `<AiSuggestionBanner>` mounts above the editor
261
+ // (see render below). Producer-supplied range suggestions still ride
262
+ // the inline chip path — those have a precise anchor worth showing
263
+ // in context.
264
+ const applyWholeField = (value: string): void => {
265
+ if (!editor || editor.isDestroyed) return
266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
+ ;(editor.commands as any).setContent(value)
268
+ }
257
269
  useAiSuggestionBridge(editor ?? null, name, {
258
- synthesizeWholeFieldRange: (ed) => ({
259
- from: 0,
260
- to: ed.state.doc.content.size,
261
- }),
262
- onApplyWholeField: (value) => {
263
- if (!editor || editor.isDestroyed) return
264
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
- ;(editor.commands as any).setContent(value)
270
+ onApplyWholeField: applyWholeField,
271
+ })
272
+
273
+ // Inline diff for whole-field suggestions — replaces the editor doc with
274
+ // the proposed markdown so the user sees the structural diff (inserted
275
+ // headings / list items / etc.) before approving. Pipeline:
276
+ // 1. tiptap-markdown's parser turns the source into HTML
277
+ // (`editor.storage.markdown.parser.parse(value)` returns a string).
278
+ // 2. ProseMirror's `DOMParser.fromSchema(schema).parseSlice(...)` turns
279
+ // that HTML into a Slice against THIS editor's schema — same path
280
+ // the editor's own clipboard-paste uses, so the slice is guaranteed
281
+ // schema-valid.
282
+ useAiInlineDiff(editor ?? null, name, {
283
+ parseSuggestion: (ed, value) => {
284
+ try {
285
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
+ const parser = (ed.storage as any).markdown?.parser
287
+ if (!parser || typeof parser.parse !== 'function') return null
288
+ const html = parser.parse(value)
289
+ if (typeof html !== 'string') return null
290
+ const container = document.createElement('div')
291
+ container.innerHTML = html
292
+ return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
293
+ } catch { return null }
266
294
  },
267
295
  })
296
+ const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
268
297
 
269
298
  // First-load seed for collab. Collaboration starts the editor empty
270
299
  // regardless of `content`; once the provider syncs from the server we
@@ -434,6 +463,16 @@ export function MarkdownEditor({
434
463
 
435
464
  return (
436
465
  <div className="flex flex-col rounded-md border bg-background">
466
+ <AiSuggestionBanner
467
+ fieldName={name}
468
+ onApplyWholeField={applyWholeField}
469
+ {...(isDiffActive && editor
470
+ ? {
471
+ onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
472
+ onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
473
+ }
474
+ : {})}
475
+ />
437
476
  {canAttach && (
438
477
  <input
439
478
  ref={fileInputRef}
@@ -20,6 +20,9 @@ import type {
20
20
  } from '@pilotiq/pilotiq/react'
21
21
  import { useCollabRoom, getCollabExtensions, onProviderSynced } from '@pilotiq/pilotiq/react'
22
22
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
23
+ import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
24
+ import { AiSuggestionBanner } from './AiSuggestionBanner.js'
25
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
23
26
  import type { BlockMeta } from '../Block.js'
24
27
  import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
25
28
  import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
@@ -31,6 +34,7 @@ import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
31
34
  import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
32
35
  import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
33
36
  import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
37
+ import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js'
34
38
  import {
35
39
  MentionExtension,
36
40
  type MentionState,
@@ -315,10 +319,11 @@ function ClientEditor(props: ClientEditorProps) {
315
319
  fieldName: name,
316
320
  })] : [MentionExtension]),
317
321
  DragHandleExtension,
318
- // AI suggestions — always-on extension that tracks suggested edits as
319
- // inline strikethrough + Approve/Reject chip widgets. Idle until the
320
- // host calls `editor.commands.addAiSuggestion(...)`.
322
+ // AI suggestions — chip widget for surgical (range-anchored) edits.
321
323
  AiSuggestionExtension,
324
+ // AI inline diff — Tiptap-Pro-style visualization for whole-field
325
+ // suggestions via prosemirror-changeset. See AiInlineDiffExtension.
326
+ AiInlineDiffExtension,
322
327
  // Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
323
328
  // when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
324
329
  // registered a factory via `registerCollabExtensions`.
@@ -464,22 +469,35 @@ function ClientEditor(props: ClientEditorProps) {
464
469
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
465
470
  // extension. No-op when no provider is mounted (default no-op context).
466
471
  //
467
- // Whole-field handling: rare for RichTextField (chat-driven flows usually
468
- // emit range-anchored suggestions for richtext), but `update_form_state`
469
- // can still arrive with `set_value` on a rich field. The chip widget
470
- // renders over the whole doc for visualization; Approve routes through
471
- // `onApplyWholeField` so `setContent` parses the new HTML / JSON
472
- // correctly the chip's plain-text replace would lose all formatting.
472
+ // Whole-field handling: NO chip widget here. The chip's `textContent`
473
+ // renderer would surface raw HTML tags as literal text inside the
474
+ // green pill unparseable on multi-paragraph rewrites. Instead,
475
+ // `<AiSuggestionBanner>` mounts above the editor (see render below).
476
+ // Producer-supplied range suggestions still ride the inline chip
477
+ // those have a precise anchor worth visualizing in context.
478
+ const applyWholeField = (value: string): void => {
479
+ if (!editor || editor.isDestroyed) return
480
+ editor.commands.setContent(value)
481
+ }
473
482
  useAiSuggestionBridge(editor ?? null, name, {
474
- synthesizeWholeFieldRange: (ed) => ({
475
- from: 0,
476
- to: ed.state.doc.content.size,
477
- }),
478
- onApplyWholeField: (value) => {
479
- if (!editor || editor.isDestroyed) return
480
- editor.commands.setContent(value)
483
+ onApplyWholeField: applyWholeField,
484
+ })
485
+
486
+ // Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
487
+ // HTML ProseMirror Slice via the schema's DOMParser. Suggested values
488
+ // on a RichTextField are typically HTML (or marked-up JSON that the
489
+ // schema's DOMParser also handles via its serialized round-trip). For
490
+ // JSON suggestions, the schema may reject — falls back to banner-only.
491
+ useAiInlineDiff(editor ?? null, name, {
492
+ parseSuggestion: (ed, value) => {
493
+ try {
494
+ const container = document.createElement('div')
495
+ container.innerHTML = value
496
+ return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
497
+ } catch { return null }
481
498
  },
482
499
  })
500
+ const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
483
501
 
484
502
  // Re-render the toolbar when the selection / marks change so active-state
485
503
  // booleans stay fresh.
@@ -488,6 +506,16 @@ function ClientEditor(props: ClientEditorProps) {
488
506
  return (
489
507
  <div className="relative flex flex-col">
490
508
  <input type="hidden" name={name} value={serialized} />
509
+ <AiSuggestionBanner
510
+ fieldName={name}
511
+ onApplyWholeField={applyWholeField}
512
+ {...(isDiffActive && editor
513
+ ? {
514
+ onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
515
+ onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
516
+ }
517
+ : {})}
518
+ />
491
519
  {editor && toolbarGroups && toolbarGroups.length > 0 && (
492
520
  <Toolbar
493
521
  editor={editor}
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Bridge between the host's `<PendingSuggestionsContext>` queue and the
3
+ * editor's `AiInlineDiffExtension`. When a whole-field suggestion arrives
4
+ * for the field, the hook:
5
+ *
6
+ * 1. Parses the suggested value into a ProseMirror `Slice` via the
7
+ * renderer-supplied parser. Each Tiptap surface owns its own
8
+ * content shape — markdown source for `MarkdownEditor`, HTML / JSON
9
+ * for `TiptapEditor`, plain text for `CollabTextRenderer`.
10
+ * 2. Calls `editor.commands.startAiInlineDiff(id, slice)` — the
11
+ * extension snapshots the current doc as the baseline, replaces
12
+ * the doc content with the proposed slice, and starts a
13
+ * `prosemirror-changeset` tracking the diff.
14
+ * 3. Registers an applier on the cross-tree pending-suggestion
15
+ * registry so the host's `<AiSuggestionBanner>` Accept button (and
16
+ * any other surface calling `pendingSuggestions.approve(id)`) runs
17
+ * `acceptAiInlineDiff()` instead of the legacy `onApplyWholeField`
18
+ * callback. The current doc IS the accepted state — no extra
19
+ * content swap needed.
20
+ *
21
+ * Reject handling: not registered on the applier (the registry only
22
+ * tracks Approve). Renderers wire Reject through the banner's
23
+ * `onRejectWithEditor` prop, which calls `rejectAiInlineDiff()` to revert
24
+ * the doc to the baseline before dismissing the suggestion.
25
+ *
26
+ * Defensive: only one inline diff active at a time per editor. If a new
27
+ * synthesized suggestion arrives while one is still pending review, the
28
+ * hook drops it (the producer should have waited). This matches
29
+ * `AiSuggestionExtension`'s chip path which also allows only one
30
+ * suggestion at a time per id.
31
+ */
32
+
33
+ import { useEffect, useRef } from 'react'
34
+ import type { Editor } from '@tiptap/core'
35
+ import type { Slice } from '@tiptap/pm/model'
36
+ import {
37
+ registerPendingSuggestionApplier,
38
+ usePendingSuggestionsForField,
39
+ type PendingSuggestion,
40
+ type PendingSuggestionApplier,
41
+ } from '@pilotiq/pilotiq/react'
42
+ import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
43
+
44
+ export interface UseAiInlineDiffOptions {
45
+ /**
46
+ * Parse the suggested string value into a ProseMirror Slice that's
47
+ * compatible with this editor's schema. Returns `null` to skip (e.g.
48
+ * malformed content, unsupported markup) — the suggestion stays in
49
+ * the queue but no diff renders, and the host's fallback path (banner
50
+ * with `onApplyWholeField`) takes over.
51
+ *
52
+ * Renderers implement this per content shape:
53
+ * - plain text → wrap each line in a `paragraph` node
54
+ * - markdown → run through the Markdown extension's parseMarkdown
55
+ * - HTML → DOMParser + ProseMirror's DOMParser.parse
56
+ */
57
+ parseSuggestion: (editor: Editor, value: string) => Slice | null
58
+ }
59
+
60
+ /**
61
+ * Returns whether a diff is currently active in the editor. Hosts use
62
+ * this to gate the banner's UI between the legacy `onApplyWholeField`
63
+ * mode and the diff-aware mode (Reject routes through
64
+ * `rejectAiInlineDiff` to revert the doc).
65
+ */
66
+ export function useIsAiInlineDiffActive(editor: Editor | null): boolean {
67
+ // Re-render on every editor transaction so the hook tracks state
68
+ // changes (start / accept / reject). useEditorState would be the
69
+ // idiomatic way; we read directly here to keep the dep surface tiny.
70
+ const [, force] = useReducerForceUpdate()
71
+ useEffect(() => {
72
+ if (!editor) return
73
+ const handler = () => force()
74
+ editor.on('transaction', handler)
75
+ return () => { editor.off('transaction', handler) }
76
+ }, [editor, force])
77
+ if (!editor) return false
78
+ return aiInlineDiffPluginKey.getState(editor.state) !== null
79
+ }
80
+
81
+ export function useAiInlineDiff(
82
+ editor: Editor | null,
83
+ fieldName: string,
84
+ options: UseAiInlineDiffOptions,
85
+ ): void {
86
+ const { list } = usePendingSuggestionsForField(fieldName)
87
+
88
+ const parseRef = useRef(options.parseSuggestion)
89
+ useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
90
+
91
+ // Track which ids we've handed off to the editor's diff extension
92
+ // so we don't re-start the diff every render or for already-active
93
+ // suggestions.
94
+ const startedRef = useRef<Set<string>>(new Set())
95
+
96
+ // Context → editor: start the diff for each new whole-field suggestion.
97
+ useEffect(() => {
98
+ if (!editor) return
99
+ const wholeField = list.filter(s => !hasEditorRange(s))
100
+ for (const s of wholeField) {
101
+ if (startedRef.current.has(s.id)) continue
102
+ if (typeof s.suggestedValue !== 'string') continue
103
+ // Bail when a different diff is already showing — one at a time.
104
+ // Producer should serialize calls; if not, the second suggestion
105
+ // sits in the queue until the first is approved/rejected.
106
+ if (aiInlineDiffPluginKey.getState(editor.state) !== null) continue
107
+ const slice = parseRef.current(editor, s.suggestedValue)
108
+ if (!slice) continue
109
+ editor.commands.startAiInlineDiff(s.id, slice)
110
+ startedRef.current.add(s.id)
111
+ }
112
+ // Cleanup: when a suggestion leaves the context AND we previously
113
+ // started a diff for it, the editor should drop the diff state too.
114
+ // Approve dismisses via context → here we drop from startedRef.
115
+ const contextIds = new Set(list.map(s => s.id))
116
+ for (const id of Array.from(startedRef.current)) {
117
+ if (!contextIds.has(id)) startedRef.current.delete(id)
118
+ }
119
+ }, [editor, list])
120
+
121
+ // Cross-tree applier — when the banner / chat-sidebar pill calls
122
+ // `pendingSuggestions.approve(id)` for one of our tracked suggestions,
123
+ // accept the diff. Editor is the source of truth for the new doc.
124
+ useEffect(() => {
125
+ if (!editor) return
126
+ const applier: PendingSuggestionApplier = (suggestion) => {
127
+ if (!startedRef.current.has(suggestion.id)) return
128
+ editor.commands.acceptAiInlineDiff()
129
+ }
130
+ return registerPendingSuggestionApplier(undefined, fieldName, applier)
131
+ }, [editor, fieldName])
132
+ }
133
+
134
+ function hasEditorRange(s: PendingSuggestion): boolean {
135
+ const meta = (s.meta ?? {}) as Record<string, unknown>
136
+ const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
137
+ return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
138
+ }
139
+
140
+ // useReducer + dispatch is the smallest-API force-update primitive React
141
+ // ships. Hoisted into a helper so the call site stays one line.
142
+ import { useReducer } from 'react'
143
+ function useReducerForceUpdate(): [number, () => void] {
144
+ const [n, inc] = useReducer((x: number) => (x + 1) | 0, 0)
145
+ return [n, () => inc()]
146
+ }