@pilotiq/tiptap 3.4.0 → 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.
- package/dist/extensions/AiSuggestionExtension.d.ts.map +1 -1
- package/dist/extensions/AiSuggestionExtension.js +57 -0
- package/dist/extensions/AiSuggestionExtension.js.map +1 -1
- package/dist/markdownExtension.js +0 -1
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +62 -27
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +18 -1
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.js +18 -1
- package/dist/react/TiptapEditor.js.map +1 -1
- package/dist/react/useAiSuggestionBridge.d.ts +37 -1
- package/dist/react/useAiSuggestionBridge.d.ts.map +1 -1
- package/dist/react/useAiSuggestionBridge.js +63 -32
- package/dist/react/useAiSuggestionBridge.js.map +1 -1
- package/package.json +3 -3
- package/src/extensions/AiSuggestionExtension.ts +56 -0
- package/src/react/CollabTextRenderer.tsx +61 -27
- package/src/react/MarkdownEditor.tsx +18 -1
- package/src/react/TiptapEditor.tsx +17 -1
- package/src/react/useAiSuggestionBridge.ts +105 -9
- package/dist/markdownExtension.js.map +0 -7
|
@@ -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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAEzC,OAAO,EACL,gCAAgC,EAChC,6BAA6B,GAG9B,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAA;
|
|
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
|
+
"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.
|
|
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/*
|
|
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 }) => {
|
|
@@ -66,32 +66,47 @@ export function CollabTextRenderer({
|
|
|
66
66
|
}, [collabActive])
|
|
67
67
|
|
|
68
68
|
const editor = useEditor(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
// AI
|
|
79
|
-
//
|
|
80
|
-
// host
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
},
|
|
95
110
|
// Re-mount when collab toggles. Other props (multiline, name, etc) are
|
|
96
111
|
// stable per mount under the upstream gate.
|
|
97
112
|
[collabActive],
|
|
@@ -107,7 +122,26 @@ export function CollabTextRenderer({
|
|
|
107
122
|
// Cross-package suggestion bridge — sync the host's
|
|
108
123
|
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
109
124
|
// extension. No-op when no provider is mounted (default no-op context).
|
|
110
|
-
|
|
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
|
+
})
|
|
111
145
|
|
|
112
146
|
// First-load seed when collab is active. Collaboration starts the editor
|
|
113
147
|
// empty regardless of `defaultValue`; once the provider syncs the room
|
|
@@ -247,7 +247,24 @@ export function MarkdownEditor({
|
|
|
247
247
|
// Cross-package suggestion bridge — sync the host's
|
|
248
248
|
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
249
249
|
// extension. No-op when no provider is mounted (default no-op context).
|
|
250
|
-
|
|
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
|
+
})
|
|
251
268
|
|
|
252
269
|
// First-load seed for collab. Collaboration starts the editor empty
|
|
253
270
|
// regardless of `content`; once the provider syncs from the server we
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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+
|