@pilotiq/tiptap 3.3.3 → 3.5.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.
@@ -1,36 +1,29 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { registerPendingSuggestionApplier, usePendingSuggestionsForField, } from '@pilotiq/pilotiq/react';
3
3
  import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js';
4
- /**
5
- * Two-way sync between the cross-package `<PendingSuggestionsContext>`
6
- * queue and this editor's `AiSuggestionExtension` state.
7
- *
8
- * - **Context → editor**: every entry whose `meta.editorRange = { from, to }`
9
- * is present and whose `suggestedValue` is a string gets pushed into the
10
- * editor as an inline-diff hunk via `addAiSuggestion`. Entries leaving the
11
- * queue are removed from the editor via `rejectAiSuggestion` (no doc edit).
12
- *
13
- * - **Editor → context**: when a chip's Approve / Reject button removes a
14
- * hunk from the editor's plugin state, the matching id is dismissed from
15
- * the queue (`dismiss(id)`) so other surfaces (e.g. the chat-sidebar pill,
16
- * a future FieldShell overlay) clear in lock-step. The doc mutation
17
- * itself happens inside the editor — context is just a notification.
18
- *
19
- * Cycle protection: the hook tracks which ids it has personally pushed to
20
- * the editor (`pushed`). The Context→editor pass never re-pushes an id that's
21
- * already there, and the Editor→context pass only dismisses ids that this
22
- * hook had previously pushed (so an id added directly by host code via
23
- * `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
24
- * a context that never knew about it).
25
- */
26
- export function useAiSuggestionBridge(editor, fieldName) {
4
+ export function useAiSuggestionBridge(editor, fieldName, options = {}) {
27
5
  const { list, dismiss } = usePendingSuggestionsForField(fieldName);
28
6
  // Hold the latest `dismiss` in a ref so the editor-side listener — which
29
7
  // installs once per editor — always reaches the up-to-date context API.
30
8
  const dismissRef = useRef(dismiss);
31
9
  useEffect(() => { dismissRef.current = dismiss; }, [dismiss]);
10
+ // Same ref pattern for the whole-field applier — captured here so the
11
+ // applier closure registered below stays stable across re-renders without
12
+ // re-registering on every option change.
13
+ const onApplyWholeFieldRef = useRef(options.onApplyWholeField);
14
+ useEffect(() => { onApplyWholeFieldRef.current = options.onApplyWholeField; }, [options.onApplyWholeField]);
15
+ const synthesizeRangeRef = useRef(options.synthesizeWholeFieldRange);
16
+ useEffect(() => { synthesizeRangeRef.current = options.synthesizeWholeFieldRange; }, [options.synthesizeWholeFieldRange]);
32
17
  // Set of ids this hook pushed; used by both directions for cycle control.
33
18
  const pushedRef = useRef(new Set());
19
+ // Subset of `pushedRef` whose range was synthesized (no producer-supplied
20
+ // `meta.editorRange`). Approving these must NOT route through the chip's
21
+ // plain-text replace — that would clobber HTML / markdown formatting on
22
+ // rich editors. Instead the applier delegates to `onApplyWholeField`,
23
+ // which sets content via the renderer's content-shape-aware command
24
+ // (`setContent(plainTextToDoc(...))` / `setContent(markdownSrc)` /
25
+ // `setContent(html)`), then clears the chip without a doc edit.
26
+ const synthesizedRef = useRef(new Set());
34
27
  // Context → editor.
35
28
  useEffect(() => {
36
29
  if (!editor)
@@ -40,9 +33,25 @@ export function useAiSuggestionBridge(editor, fieldName) {
40
33
  if (pushedRef.current.has(s.id))
41
34
  continue;
42
35
  const meta = (s.meta ?? {});
43
- const range = meta['editorRange'];
44
- if (!range || typeof range.from !== 'number' || typeof range.to !== 'number')
45
- continue;
36
+ const rawRange = meta['editorRange'];
37
+ let range;
38
+ let isSynthesized = false;
39
+ if (rawRange && typeof rawRange.from === 'number' && typeof rawRange.to === 'number') {
40
+ range = { from: rawRange.from, to: rawRange.to };
41
+ }
42
+ else {
43
+ // Producer didn't supply a range — let the renderer synthesize one
44
+ // so the inline-diff chip widget can still render. Renderers that
45
+ // can't safely round-trip a plain-text replace (richtext/markdown
46
+ // losing formatting on approve) STILL benefit by synthesizing for
47
+ // visualization — the applier below routes Approve through
48
+ // `onApplyWholeField` for synthesized ids instead of the chip's
49
+ // text-node replace.
50
+ range = synthesizeRangeRef.current?.(editor, s);
51
+ if (!range)
52
+ continue;
53
+ isSynthesized = true;
54
+ }
46
55
  const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : '';
47
56
  editor.commands.addAiSuggestion({
48
57
  id: s.id,
@@ -52,6 +61,8 @@ export function useAiSuggestionBridge(editor, fieldName) {
52
61
  ...(s.source ? { source: s.source } : {}),
53
62
  });
54
63
  pushedRef.current.add(s.id);
64
+ if (isSynthesized)
65
+ synthesizedRef.current.add(s.id);
55
66
  }
56
67
  for (const id of Array.from(pushedRef.current)) {
57
68
  if (contextIds.has(id))
@@ -60,6 +71,7 @@ export function useAiSuggestionBridge(editor, fieldName) {
60
71
  // mutating the doc (rejectAiSuggestion drops state only).
61
72
  editor.commands.rejectAiSuggestion(id);
62
73
  pushedRef.current.delete(id);
74
+ synthesizedRef.current.delete(id);
63
75
  }
64
76
  }, [editor, list]);
65
77
  // Editor → context.
@@ -92,13 +104,32 @@ export function useAiSuggestionBridge(editor, fieldName) {
92
104
  if (!editor)
93
105
  return;
94
106
  const applier = (suggestion) => {
95
- // Bail when the suggestion isn't one of ours (no editor range or
96
- // bridge-pushed entry). Pro provider falls back to plain dismiss.
97
- if (!pushedRef.current.has(suggestion.id))
107
+ const apply = onApplyWholeFieldRef.current;
108
+ const hasSynthesized = synthesizedRef.current.has(suggestion.id);
109
+ const hasPushed = pushedRef.current.has(suggestion.id);
110
+ // Synthesized whole-field range — the chip rendered for visualization,
111
+ // but routing Approve through the editor's `approveAiSuggestion` would
112
+ // do a plain-text replace and clobber HTML / markdown formatting.
113
+ // Delegate to the renderer-supplied applier (content-shape-aware)
114
+ // and clear the chip state without a doc edit.
115
+ if (hasSynthesized && apply && typeof suggestion.suggestedValue === 'string') {
116
+ apply(suggestion.suggestedValue);
117
+ editor.commands.rejectAiSuggestion(suggestion.id);
98
118
  return;
99
- editor.chain().focus().approveAiSuggestion(suggestion.id).run();
100
- // The transaction listener above sees the editor state drop the id
101
- // and calls `dismiss(id)` on its own no manual mirror needed.
119
+ }
120
+ // Producer-supplied editor range surgical edit. Forward Approve to
121
+ // the editor command; the transaction listener above mirrors the
122
+ // dismiss back into context.
123
+ if (hasPushed) {
124
+ editor.chain().focus().approveAiSuggestion(suggestion.id).run();
125
+ return;
126
+ }
127
+ // Whole-field path WITHOUT visualization — producer skipped the range
128
+ // and the renderer didn't synthesize. Same applier as above, no chip
129
+ // to clear. Context's `approve()` dismisses the queue entry.
130
+ if (apply && typeof suggestion.suggestedValue === 'string') {
131
+ apply(suggestion.suggestedValue);
132
+ }
102
133
  };
103
134
  // Editor renderers don't currently have access to a `formId` here;
104
135
  // pass `undefined` so the wildcard form scope resolves. Phase 8.5+
@@ -1 +1 @@
1
- {"version":3,"file":"useAiSuggestionBridge.js","sourceRoot":"","sources":["../../src/react/useAiSuggestionBridge.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useAiSuggestionBridge.js","sourceRoot":"","sources":["../../src/react/useAiSuggestionBridge.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.3.3",
3
+ "version": "3.5.0",
4
4
  "description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -81,12 +81,12 @@
81
81
  "react": "^19",
82
82
  "react-dom": "^19",
83
83
  "typescript": "^5",
84
- "@pilotiq/pilotiq": "^0.18.0"
84
+ "@pilotiq/pilotiq": "^0.19.0"
85
85
  },
86
86
  "author": "Suleiman Shahbari",
87
87
  "scripts": {
88
88
  "build": "tsc -p tsconfig.build.json && pnpm run bundle:markdown",
89
- "bundle:markdown": "esbuild src/markdownExtension.ts --bundle --format=esm --outfile=dist/markdownExtension.js --external:@tiptap/* --sourcemap",
89
+ "bundle:markdown": "esbuild src/markdownExtension.ts --bundle --format=esm --outfile=dist/markdownExtension.js --external:@tiptap/* && rm -f dist/markdownExtension.js.map",
90
90
  "dev": "tsc -p tsconfig.build.json --watch",
91
91
  "typecheck": "tsc --noEmit",
92
92
  "lint": "eslint src",
@@ -170,6 +170,62 @@ export const AiSuggestionExtension = Extension.create<AiSuggestionExtensionOptio
170
170
  }
171
171
  },
172
172
 
173
+ onCreate() {
174
+ // Inject minimal default styles for the chip + strikethrough on first
175
+ // mount so consumers see the visualization without wiring CSS. Idempotent
176
+ // via the `data-pilotiq-ai-suggestion-styles` sentinel; consumers who
177
+ // want full control just add their own `<style>` with the same class
178
+ // names (last wins — the cascade picks user overrides over our defaults
179
+ // since the user stylesheet appears AFTER our injected one in `<head>`
180
+ // when imported via Vite/Webpack, OR via higher specificity).
181
+ if (typeof document === 'undefined') return
182
+ const SENTINEL = 'data-pilotiq-ai-suggestion-styles'
183
+ if (document.head.querySelector(`style[${SENTINEL}]`)) return
184
+ const prefix = this.options.classPrefix
185
+ const style = document.createElement('style')
186
+ style.setAttribute(SENTINEL, '')
187
+ // Colors picked to look right on light + dark surfaces without theme
188
+ // overrides (60% alpha on background-color, 100% on text). Tuned to
189
+ // match the inline-diff convention used by the Tiptap Pro AI Agent.
190
+ style.textContent = `
191
+ .${prefix}-original {
192
+ text-decoration: line-through;
193
+ text-decoration-color: rgba(220, 38, 38, 0.7);
194
+ background-color: rgba(254, 226, 226, 0.6);
195
+ color: rgb(153, 27, 27);
196
+ }
197
+ .${prefix}-chip {
198
+ display: inline-flex;
199
+ align-items: center;
200
+ gap: 0.25rem;
201
+ margin-left: 0.25rem;
202
+ padding: 0 0.25rem;
203
+ border-radius: 0.25rem;
204
+ background-color: rgba(220, 252, 231, 0.7);
205
+ color: rgb(22, 101, 52);
206
+ font-size: 0.875em;
207
+ line-height: 1.4;
208
+ }
209
+ .${prefix}-replacement {
210
+ padding: 0 0.125rem;
211
+ }
212
+ .${prefix}-accept,
213
+ .${prefix}-reject {
214
+ appearance: none;
215
+ background: transparent;
216
+ border: 0;
217
+ padding: 0 0.25rem;
218
+ cursor: pointer;
219
+ font-size: 0.875em;
220
+ line-height: 1;
221
+ color: inherit;
222
+ }
223
+ .${prefix}-accept:hover { color: rgb(21, 128, 61); }
224
+ .${prefix}-reject:hover { color: rgb(185, 28, 28); }
225
+ `
226
+ document.head.appendChild(style)
227
+ },
228
+
173
229
  addCommands() {
174
230
  return {
175
231
  addAiSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
@@ -8,6 +8,8 @@ import {
8
8
  type CollabTextRendererProps,
9
9
  } from '@pilotiq/pilotiq/react'
10
10
  import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js'
11
+ import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
12
+ import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
11
13
 
12
14
  /**
13
15
  * Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
@@ -64,27 +66,47 @@ export function CollabTextRenderer({
64
66
  }, [collabActive])
65
67
 
66
68
  const editor = useEditor(
67
- createPlainTextEditor({
68
- multiline,
69
- ...(placeholder !== undefined ? { placeholder } : {}),
70
- editable: !disabled,
71
- // When Collaboration owns the doc, omit `content` so the editor
72
- // doesn't race the y-prosemirror sync. The post-`synced` effect below
73
- // seeds the fragment on first connect when it's still empty. When
74
- // collab is off, seed from defaultValue directly.
75
- content: collabActive ? '' : defaultValue,
76
- extensions: collabExtensions,
77
- onUpdate: (text) => onChange(text),
78
- ...(onSubmit ? { onSubmit: () => { onSubmit(); return false } } : {}),
79
- ...(className || editorAttributes
80
- ? {
81
- editorAttributes: {
82
- ...(editorAttributes ?? {}),
83
- ...(className ? { class: className } : {}),
84
- },
85
- }
86
- : {}),
87
- }),
69
+ {
70
+ // Tiptap v3 SSR guard. With `immediatelyRender: true` (default)
71
+ // `useEditor` touches the DOM during construction; under Vike's
72
+ // `onRenderHtml` that throws "SSR has been detected, please set
73
+ // `immediatelyRender` explicitly to `false` to avoid hydration
74
+ // mismatches." Deferring until the first React effect lets SSR
75
+ // produce an empty shell + hydration mount the live editor.
76
+ //
77
+ // Load-bearing for the AI-attached auto-upgrade path: with rule
78
+ // #2, AI fields render the Tiptap surface during SSR (where
79
+ // `useCollabRoom()` is null but `aiActions.length > 0` flips the
80
+ // host's gate). Without this flag the dev server would crash on
81
+ // the first SSR pass of any record-edit page touching AI fields.
82
+ immediatelyRender: false,
83
+ ...createPlainTextEditor({
84
+ multiline,
85
+ ...(placeholder !== undefined ? { placeholder } : {}),
86
+ editable: !disabled,
87
+ // When Collaboration owns the doc, omit `content` so the editor
88
+ // doesn't race the y-prosemirror sync. The post-`synced` effect below
89
+ // seeds the fragment on first connect when it's still empty. When
90
+ // collab is off, seed from defaultValue directly.
91
+ content: collabActive ? '' : defaultValue,
92
+ // AI suggestions — always-on extension that tracks suggested edits as
93
+ // inline strikethrough + Approve/Reject chip widgets. Idle until the
94
+ // host calls `editor.commands.addAiSuggestion(...)` via the bridge below.
95
+ // Matches the `TiptapEditor` wiring so suggestion mode works uniformly
96
+ // across RichTextField / MarkdownField / TextField+TextareaField.
97
+ extensions: [...collabExtensions, AiSuggestionExtension],
98
+ onUpdate: (text) => onChange(text),
99
+ ...(onSubmit ? { onSubmit: () => { onSubmit(); return false } } : {}),
100
+ ...(className || editorAttributes
101
+ ? {
102
+ editorAttributes: {
103
+ ...(editorAttributes ?? {}),
104
+ ...(className ? { class: className } : {}),
105
+ },
106
+ }
107
+ : {}),
108
+ }),
109
+ },
88
110
  // Re-mount when collab toggles. Other props (multiline, name, etc) are
89
111
  // stable per mount under the upstream gate.
90
112
  [collabActive],
@@ -97,6 +119,30 @@ export function CollabTextRenderer({
97
119
  editor.setEditable(!disabled)
98
120
  }, [editor, disabled])
99
121
 
122
+ // Cross-package suggestion bridge — sync the host's
123
+ // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
124
+ // extension. No-op when no provider is mounted (default no-op context).
125
+ //
126
+ // Whole-field fallback: chat-driven suggestions (e.g. `update_form_state`)
127
+ // arrive without `meta.editorRange`. Plain-text editors opt into a
128
+ // synthesized full-doc range so the inline-diff chip (red strikethrough on
129
+ // the current value + green chip with the suggested text + ✓/✕ buttons)
130
+ // renders BEFORE the user approves. The extension's `applyApprove` is
131
+ // text-node-based which fits the plain-text schema exactly. The
132
+ // `onApplyWholeField` callback stays as a fallback for cases that don't
133
+ // synthesize (e.g. an empty doc — `from === to` skips the chip but the
134
+ // applier still needs to swap content).
135
+ useAiSuggestionBridge(editor ?? null, name, {
136
+ synthesizeWholeFieldRange: (ed) => ({
137
+ from: 0,
138
+ to: ed.state.doc.content.size,
139
+ }),
140
+ onApplyWholeField: (value) => {
141
+ if (!editor || editor.isDestroyed) return
142
+ editor.commands.setContent(plainTextToDoc(value, !!multiline))
143
+ },
144
+ })
145
+
100
146
  // First-load seed when collab is active. Collaboration starts the editor
101
147
  // empty regardless of `defaultValue`; once the provider syncs the room
102
148
  // state from the server we check whether the field's `Y.XmlFragment`
@@ -16,6 +16,8 @@ import {
16
16
  onProviderSynced,
17
17
  type MarkdownEditorProps,
18
18
  } from '@pilotiq/pilotiq/react'
19
+ import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
20
+ import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
19
21
 
20
22
  // Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
21
23
  // package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
@@ -212,6 +214,12 @@ export function MarkdownEditor({
212
214
  }),
213
215
  Image.configure({ inline: false, allowBase64: false }),
214
216
  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.
222
+ AiSuggestionExtension,
215
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
224
  ...(collabExtensions as any[]),
217
225
  ],
@@ -236,6 +244,28 @@ export function MarkdownEditor({
236
244
  editor.setEditable(!disabled && tab === 'editor')
237
245
  }, [editor, disabled, tab])
238
246
 
247
+ // Cross-package suggestion bridge — sync the host's
248
+ // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
249
+ // extension. No-op when no provider is mounted (default no-op context).
250
+ //
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
+ 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)
266
+ },
267
+ })
268
+
239
269
  // First-load seed for collab. Collaboration starts the editor empty
240
270
  // regardless of `content`; once the provider syncs from the server we
241
271
  // check whether the field's `Y.XmlFragment` was ever written. Empty +
@@ -463,7 +463,23 @@ function ClientEditor(props: ClientEditorProps) {
463
463
  // Cross-package suggestion bridge — sync the host's
464
464
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
465
465
  // extension. No-op when no provider is mounted (default no-op context).
466
- useAiSuggestionBridge(editor ?? null, name)
466
+ //
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.
473
+ 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)
481
+ },
482
+ })
467
483
 
468
484
  // Re-render the toolbar when the selection / marks change so active-state
469
485
  // booleans stay fresh.
@@ -29,8 +29,50 @@ import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js'
29
29
  * hook had previously pushed (so an id added directly by host code via
30
30
  * `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
31
31
  * a context that never knew about it).
32
+ *
33
+ * **Whole-field fallback** (chat-driven suggestions). Producers like
34
+ * `@pilotiq-pro/ai`'s `update_form_state` tool push suggestions that target
35
+ * the whole field — no `meta.editorRange`, just `suggestedValue` as a string.
36
+ * Without `editorRange` the bridge can't render the inline-diff chip widget
37
+ * (it has nowhere to anchor), so the host renderer passes an
38
+ * `onApplyWholeField(value)` callback. When the chat-sidebar Approve fires
39
+ * for a non-bridge-pushed id, the registered applier calls this callback
40
+ * instead of no-op'ing — letting each renderer apply the suggestion the
41
+ * right way for its shape (plain text → `plainTextToDoc`, markdown → set
42
+ * markdown source, richtext → setContent with HTML/JSON). The host is also
43
+ * responsible for the Approve UI — FieldShell hides its legacy overlay
44
+ * whenever a Tiptap renderer is mounted (richtext / markdown / collab text).
32
45
  */
33
- export function useAiSuggestionBridge(editor: Editor | null, fieldName: string): void {
46
+ export interface UseAiSuggestionBridgeOptions {
47
+ /**
48
+ * Apply a whole-field suggestion that lacks `meta.editorRange`. Each
49
+ * Tiptap renderer passes its own implementation (different content
50
+ * shapes — plain text, markdown source, HTML/JSON). Omit for editors
51
+ * that should ignore whole-field suggestions entirely.
52
+ */
53
+ onApplyWholeField?: (suggestedValue: string) => void
54
+
55
+ /**
56
+ * Synthesize a `{ from, to }` range for whole-field suggestions so the
57
+ * inline-diff chip widget can render BEFORE the user approves. The
58
+ * extension's `applyApprove` inserts a plain text node spanning the
59
+ * synthesized range — safe only for editors whose schema accepts a
60
+ * text-node replacement covering the whole doc (CollabTextRenderer's
61
+ * plain-text schema fits; richtext / markdown lose formatting if
62
+ * approved that way). Return `undefined` to skip synthesis and fall
63
+ * through to the legacy `onApplyWholeField` callback (silent swap).
64
+ */
65
+ synthesizeWholeFieldRange?: (
66
+ editor: Editor,
67
+ suggestion: PendingSuggestion,
68
+ ) => { from: number; to: number } | undefined
69
+ }
70
+
71
+ export function useAiSuggestionBridge(
72
+ editor: Editor | null,
73
+ fieldName: string,
74
+ options: UseAiSuggestionBridgeOptions = {},
75
+ ): void {
34
76
  const { list, dismiss } = usePendingSuggestionsForField(fieldName)
35
77
 
36
78
  // Hold the latest `dismiss` in a ref so the editor-side listener — which
@@ -38,8 +80,24 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
38
80
  const dismissRef = useRef(dismiss)
39
81
  useEffect(() => { dismissRef.current = dismiss }, [dismiss])
40
82
 
83
+ // Same ref pattern for the whole-field applier — captured here so the
84
+ // applier closure registered below stays stable across re-renders without
85
+ // re-registering on every option change.
86
+ const onApplyWholeFieldRef = useRef(options.onApplyWholeField)
87
+ useEffect(() => { onApplyWholeFieldRef.current = options.onApplyWholeField }, [options.onApplyWholeField])
88
+ const synthesizeRangeRef = useRef(options.synthesizeWholeFieldRange)
89
+ useEffect(() => { synthesizeRangeRef.current = options.synthesizeWholeFieldRange }, [options.synthesizeWholeFieldRange])
90
+
41
91
  // Set of ids this hook pushed; used by both directions for cycle control.
42
92
  const pushedRef = useRef<Set<string>>(new Set())
93
+ // Subset of `pushedRef` whose range was synthesized (no producer-supplied
94
+ // `meta.editorRange`). Approving these must NOT route through the chip's
95
+ // plain-text replace — that would clobber HTML / markdown formatting on
96
+ // rich editors. Instead the applier delegates to `onApplyWholeField`,
97
+ // which sets content via the renderer's content-shape-aware command
98
+ // (`setContent(plainTextToDoc(...))` / `setContent(markdownSrc)` /
99
+ // `setContent(html)`), then clears the chip without a doc edit.
100
+ const synthesizedRef = useRef<Set<string>>(new Set())
43
101
 
44
102
  // Context → editor.
45
103
  useEffect(() => {
@@ -49,8 +107,23 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
49
107
  for (const s of list) {
50
108
  if (pushedRef.current.has(s.id)) continue
51
109
  const meta = (s.meta ?? {}) as Record<string, unknown>
52
- const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
53
- if (!range || typeof range.from !== 'number' || typeof range.to !== 'number') continue
110
+ const rawRange = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
111
+ let range: { from: number; to: number } | undefined
112
+ let isSynthesized = false
113
+ if (rawRange && typeof rawRange.from === 'number' && typeof rawRange.to === 'number') {
114
+ range = { from: rawRange.from, to: rawRange.to }
115
+ } else {
116
+ // Producer didn't supply a range — let the renderer synthesize one
117
+ // so the inline-diff chip widget can still render. Renderers that
118
+ // can't safely round-trip a plain-text replace (richtext/markdown
119
+ // losing formatting on approve) STILL benefit by synthesizing for
120
+ // visualization — the applier below routes Approve through
121
+ // `onApplyWholeField` for synthesized ids instead of the chip's
122
+ // text-node replace.
123
+ range = synthesizeRangeRef.current?.(editor, s)
124
+ if (!range) continue
125
+ isSynthesized = true
126
+ }
54
127
  const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : ''
55
128
  editor.commands.addAiSuggestion({
56
129
  id: s.id,
@@ -60,6 +133,7 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
60
133
  ...(s.source ? { source: s.source } : {}),
61
134
  })
62
135
  pushedRef.current.add(s.id)
136
+ if (isSynthesized) synthesizedRef.current.add(s.id)
63
137
  }
64
138
 
65
139
  for (const id of Array.from(pushedRef.current)) {
@@ -68,6 +142,7 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
68
142
  // mutating the doc (rejectAiSuggestion drops state only).
69
143
  editor.commands.rejectAiSuggestion(id)
70
144
  pushedRef.current.delete(id)
145
+ synthesizedRef.current.delete(id)
71
146
  }
72
147
  }, [editor, list])
73
148
 
@@ -98,12 +173,33 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
98
173
  useEffect(() => {
99
174
  if (!editor) return
100
175
  const applier: PendingSuggestionApplier = (suggestion) => {
101
- // Bail when the suggestion isn't one of ours (no editor range or
102
- // bridge-pushed entry). Pro provider falls back to plain dismiss.
103
- if (!pushedRef.current.has(suggestion.id)) return
104
- editor.chain().focus().approveAiSuggestion(suggestion.id).run()
105
- // The transaction listener above sees the editor state drop the id
106
- // and calls `dismiss(id)` on its own no manual mirror needed.
176
+ const apply = onApplyWholeFieldRef.current
177
+ const hasSynthesized = synthesizedRef.current.has(suggestion.id)
178
+ const hasPushed = pushedRef.current.has(suggestion.id)
179
+
180
+ // Synthesized whole-field range the chip rendered for visualization,
181
+ // but routing Approve through the editor's `approveAiSuggestion` would
182
+ // do a plain-text replace and clobber HTML / markdown formatting.
183
+ // Delegate to the renderer-supplied applier (content-shape-aware)
184
+ // and clear the chip state without a doc edit.
185
+ if (hasSynthesized && apply && typeof suggestion.suggestedValue === 'string') {
186
+ apply(suggestion.suggestedValue)
187
+ editor.commands.rejectAiSuggestion(suggestion.id)
188
+ return
189
+ }
190
+ // Producer-supplied editor range — surgical edit. Forward Approve to
191
+ // the editor command; the transaction listener above mirrors the
192
+ // dismiss back into context.
193
+ if (hasPushed) {
194
+ editor.chain().focus().approveAiSuggestion(suggestion.id).run()
195
+ return
196
+ }
197
+ // Whole-field path WITHOUT visualization — producer skipped the range
198
+ // and the renderer didn't synthesize. Same applier as above, no chip
199
+ // to clear. Context's `approve()` dismisses the queue entry.
200
+ if (apply && typeof suggestion.suggestedValue === 'string') {
201
+ apply(suggestion.suggestedValue)
202
+ }
107
203
  }
108
204
  // Editor renderers don't currently have access to a `formId` here;
109
205
  // pass `undefined` so the wildcard form scope resolves. Phase 8.5+