@pilotiq/tiptap 3.0.0 → 3.1.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 +114 -0
- package/dist/extensions/AiSuggestionExtension.d.ts.map +1 -0
- package/dist/extensions/AiSuggestionExtension.js +302 -0
- package/dist/extensions/AiSuggestionExtension.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +10 -0
- package/dist/react/TiptapEditor.js.map +1 -1
- package/dist/react/useAiSuggestionBridge.d.ts +27 -0
- package/dist/react/useAiSuggestionBridge.d.ts.map +1 -0
- package/dist/react/useAiSuggestionBridge.js +110 -0
- package/dist/react/useAiSuggestionBridge.js.map +1 -0
- package/package.json +3 -3
- package/src/extensions/AiSuggestionExtension.test.ts +141 -0
- package/src/extensions/AiSuggestionExtension.ts +415 -0
- package/src/index.ts +13 -0
- package/src/react/TiptapEditor.tsx +11 -0
- package/src/react/useAiSuggestionBridge.ts +119 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { Extension, type Editor } from '@tiptap/core'
|
|
2
|
+
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
|
3
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One AI-suggested replacement of `[from, to)` with `replacement`.
|
|
7
|
+
*
|
|
8
|
+
* `from === to` represents a pure insertion at that position. v1 carries
|
|
9
|
+
* plain-text replacement only — marks and structure round-trip through the
|
|
10
|
+
* editor as-is when the suggestion is approved (the original range's marks
|
|
11
|
+
* are preserved on the inserted text node by ProseMirror).
|
|
12
|
+
*/
|
|
13
|
+
export interface AiSuggestion {
|
|
14
|
+
/** Stable id; consumer-provided. Re-adding with the same id replaces the prior entry. */
|
|
15
|
+
id: string
|
|
16
|
+
/** Inclusive document position the original range starts at. */
|
|
17
|
+
from: number
|
|
18
|
+
/** Exclusive document position the original range ends at. */
|
|
19
|
+
to: number
|
|
20
|
+
/** Plain-text replacement. Empty string = pure deletion. */
|
|
21
|
+
replacement: string
|
|
22
|
+
/** Optional attribution surfaced on the chip widget. */
|
|
23
|
+
source?: {
|
|
24
|
+
agentSlug?: string
|
|
25
|
+
agentLabel?: string
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AiSuggestionExtensionOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Class prefix for both decoration spans and chip widgets. The package
|
|
32
|
+
* stays CSS-free — consumers ship the matching styles. Default
|
|
33
|
+
* `'pilotiq-ai-suggestion'` produces classes:
|
|
34
|
+
* - `pilotiq-ai-suggestion-original` (strikethrough on the original range)
|
|
35
|
+
* - `pilotiq-ai-suggestion-chip` (root of the inline widget)
|
|
36
|
+
* - `pilotiq-ai-suggestion-replacement` (the suggested-text preview span)
|
|
37
|
+
* - `pilotiq-ai-suggestion-accept` (Approve button)
|
|
38
|
+
* - `pilotiq-ai-suggestion-reject` (Reject button)
|
|
39
|
+
*/
|
|
40
|
+
classPrefix: string
|
|
41
|
+
/**
|
|
42
|
+
* Fired whenever the suggestion list changes — after `add*`, `approve*`,
|
|
43
|
+
* `reject*`, `clear*`, or after a doc edit collapses a range. Lets the host
|
|
44
|
+
* mirror state into a React context (e.g. `PendingSuggestionsApi`).
|
|
45
|
+
*/
|
|
46
|
+
onChange?: (suggestions: AiSuggestion[]) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
declare module '@tiptap/core' {
|
|
50
|
+
interface Commands<ReturnType> {
|
|
51
|
+
aiSuggestion: {
|
|
52
|
+
/** Add or replace a suggestion (matched by id). */
|
|
53
|
+
addAiSuggestion: (suggestion: AiSuggestion) => ReturnType
|
|
54
|
+
/** Add or replace many suggestions in one transaction. */
|
|
55
|
+
addAiSuggestions: (suggestions: AiSuggestion[]) => ReturnType
|
|
56
|
+
/** Apply the replacement to the doc and drop the suggestion. */
|
|
57
|
+
approveAiSuggestion: (id: string) => ReturnType
|
|
58
|
+
/** Drop the suggestion without touching the doc. */
|
|
59
|
+
rejectAiSuggestion: (id: string) => ReturnType
|
|
60
|
+
/** Apply every replacement in highest-`from`-first order. */
|
|
61
|
+
approveAllAiSuggestions: () => ReturnType
|
|
62
|
+
/** Drop every suggestion. */
|
|
63
|
+
rejectAllAiSuggestions: () => ReturnType
|
|
64
|
+
/** Alias for `rejectAllAiSuggestions`. */
|
|
65
|
+
clearAiSuggestions: () => ReturnType
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PluginState {
|
|
71
|
+
suggestions: readonly AiSuggestion[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface SetMeta {
|
|
75
|
+
type: 'set'
|
|
76
|
+
next: readonly AiSuggestion[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const aiSuggestionPluginKey = new PluginKey<PluginState>('pilotiqAiSuggestion')
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Append or replace by id. Pure — exported for tests and so the same dedupe
|
|
83
|
+
* shape can drive consumer-side mirror state.
|
|
84
|
+
*/
|
|
85
|
+
export function upsertSuggestion(
|
|
86
|
+
current: readonly AiSuggestion[],
|
|
87
|
+
next: AiSuggestion,
|
|
88
|
+
): AiSuggestion[] {
|
|
89
|
+
const idx = current.findIndex(s => s.id === next.id)
|
|
90
|
+
if (idx === -1) return [...current, next]
|
|
91
|
+
const copy = current.slice()
|
|
92
|
+
copy[idx] = next
|
|
93
|
+
return copy
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Append or replace many — semantically equivalent to a fold over `upsertSuggestion`. */
|
|
97
|
+
export function upsertSuggestions(
|
|
98
|
+
current: readonly AiSuggestion[],
|
|
99
|
+
nexts: readonly AiSuggestion[],
|
|
100
|
+
): AiSuggestion[] {
|
|
101
|
+
let acc: AiSuggestion[] = current.slice()
|
|
102
|
+
for (const n of nexts) acc = upsertSuggestion(acc, n)
|
|
103
|
+
return acc
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Remove by id. */
|
|
107
|
+
export function removeSuggestion(
|
|
108
|
+
current: readonly AiSuggestion[],
|
|
109
|
+
id: string,
|
|
110
|
+
): AiSuggestion[] {
|
|
111
|
+
return current.filter(s => s.id !== id)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remap survivors through a PM mapping; drop ranges that collapsed past
|
|
116
|
+
* each other (`to < from` after remap). Pure — exported for tests.
|
|
117
|
+
*/
|
|
118
|
+
export function remapSuggestions(
|
|
119
|
+
suggestions: readonly AiSuggestion[],
|
|
120
|
+
map: (pos: number, side: -1 | 1) => number,
|
|
121
|
+
): AiSuggestion[] {
|
|
122
|
+
const out: AiSuggestion[] = []
|
|
123
|
+
for (const s of suggestions) {
|
|
124
|
+
const from = map(s.from, -1)
|
|
125
|
+
const to = map(s.to, 1)
|
|
126
|
+
if (to < from) continue
|
|
127
|
+
out.push({ ...s, from, to })
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Order suggestions for `approveAll` so the highest-`from` runs first;
|
|
134
|
+
* earlier-in-doc replacements then can't shift positions of later ones.
|
|
135
|
+
* Pure — exported for tests.
|
|
136
|
+
*/
|
|
137
|
+
export function sortForApproveAll(
|
|
138
|
+
suggestions: readonly AiSuggestion[],
|
|
139
|
+
): AiSuggestion[] {
|
|
140
|
+
return suggestions.slice().sort((a, b) => b.from - a.from)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Editor extension that tracks AI-suggested edits as inline decorations with
|
|
145
|
+
* per-hunk Approve/Reject chips. The package is CSS-free — consumers wire
|
|
146
|
+
* styles against the documented class names.
|
|
147
|
+
*
|
|
148
|
+
* Usage:
|
|
149
|
+
* ```ts
|
|
150
|
+
* editor.commands.addAiSuggestion({
|
|
151
|
+
* id: 'seo-1',
|
|
152
|
+
* from: 12,
|
|
153
|
+
* to: 18,
|
|
154
|
+
* replacement: 'better',
|
|
155
|
+
* source: { agentLabel: 'SEO' },
|
|
156
|
+
* })
|
|
157
|
+
* // …user clicks ✓ on the chip, or:
|
|
158
|
+
* editor.commands.approveAiSuggestion('seo-1')
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* Mounted by default inside `TiptapEditor`; consumer code reaches it through
|
|
162
|
+
* the editor's command surface.
|
|
163
|
+
*/
|
|
164
|
+
export const AiSuggestionExtension = Extension.create<AiSuggestionExtensionOptions>({
|
|
165
|
+
name: 'pilotiqAiSuggestion',
|
|
166
|
+
|
|
167
|
+
addOptions() {
|
|
168
|
+
return {
|
|
169
|
+
classPrefix: 'pilotiq-ai-suggestion',
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
addCommands() {
|
|
174
|
+
return {
|
|
175
|
+
addAiSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
|
|
176
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
177
|
+
const next = upsertSuggestion(current, suggestion)
|
|
178
|
+
if (dispatch) {
|
|
179
|
+
tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
|
|
180
|
+
dispatch(tr)
|
|
181
|
+
}
|
|
182
|
+
return true
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
addAiSuggestions: (suggestions) => ({ tr, state, dispatch }) => {
|
|
186
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
187
|
+
const next = upsertSuggestions(current, suggestions)
|
|
188
|
+
if (dispatch) {
|
|
189
|
+
tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
|
|
190
|
+
dispatch(tr)
|
|
191
|
+
}
|
|
192
|
+
return true
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
approveAiSuggestion: (id) => ({ tr, state, dispatch }) => {
|
|
196
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
197
|
+
const target = current.find(s => s.id === id)
|
|
198
|
+
if (!target) return false
|
|
199
|
+
if (dispatch) {
|
|
200
|
+
applyApprove(tr, state, target)
|
|
201
|
+
tr.setMeta(aiSuggestionPluginKey, {
|
|
202
|
+
type: 'set',
|
|
203
|
+
next: removeSuggestion(current, id),
|
|
204
|
+
} satisfies SetMeta)
|
|
205
|
+
dispatch(tr)
|
|
206
|
+
}
|
|
207
|
+
return true
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
rejectAiSuggestion: (id) => ({ tr, state, dispatch }) => {
|
|
211
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
212
|
+
const target = current.find(s => s.id === id)
|
|
213
|
+
if (!target) return false
|
|
214
|
+
if (dispatch) {
|
|
215
|
+
tr.setMeta(aiSuggestionPluginKey, {
|
|
216
|
+
type: 'set',
|
|
217
|
+
next: removeSuggestion(current, id),
|
|
218
|
+
} satisfies SetMeta)
|
|
219
|
+
dispatch(tr)
|
|
220
|
+
}
|
|
221
|
+
return true
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
approveAllAiSuggestions: () => ({ tr, state, dispatch }) => {
|
|
225
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
226
|
+
if (current.length === 0) return false
|
|
227
|
+
if (dispatch) {
|
|
228
|
+
for (const s of sortForApproveAll(current)) applyApprove(tr, state, s)
|
|
229
|
+
tr.setMeta(aiSuggestionPluginKey, {
|
|
230
|
+
type: 'set',
|
|
231
|
+
next: [],
|
|
232
|
+
} satisfies SetMeta)
|
|
233
|
+
dispatch(tr)
|
|
234
|
+
}
|
|
235
|
+
return true
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
rejectAllAiSuggestions: () => ({ tr, state, dispatch }) => {
|
|
239
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
240
|
+
if (current.length === 0) return false
|
|
241
|
+
if (dispatch) {
|
|
242
|
+
tr.setMeta(aiSuggestionPluginKey, {
|
|
243
|
+
type: 'set',
|
|
244
|
+
next: [],
|
|
245
|
+
} satisfies SetMeta)
|
|
246
|
+
dispatch(tr)
|
|
247
|
+
}
|
|
248
|
+
return true
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
clearAiSuggestions: () => ({ tr, state, dispatch }) => {
|
|
252
|
+
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
253
|
+
if (current.length === 0) return false
|
|
254
|
+
if (dispatch) {
|
|
255
|
+
tr.setMeta(aiSuggestionPluginKey, {
|
|
256
|
+
type: 'set',
|
|
257
|
+
next: [],
|
|
258
|
+
} satisfies SetMeta)
|
|
259
|
+
dispatch(tr)
|
|
260
|
+
}
|
|
261
|
+
return true
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
addProseMirrorPlugins() {
|
|
267
|
+
const ext = this
|
|
268
|
+
return [
|
|
269
|
+
new Plugin<PluginState>({
|
|
270
|
+
key: aiSuggestionPluginKey,
|
|
271
|
+
state: {
|
|
272
|
+
init: (): PluginState => ({ suggestions: [] }),
|
|
273
|
+
apply(tr, prev): PluginState {
|
|
274
|
+
const meta = tr.getMeta(aiSuggestionPluginKey) as SetMeta | undefined
|
|
275
|
+
const base = meta?.type === 'set' ? meta.next : prev.suggestions
|
|
276
|
+
if (!tr.docChanged) return { suggestions: base }
|
|
277
|
+
return {
|
|
278
|
+
suggestions: remapSuggestions(base, (pos, side) =>
|
|
279
|
+
tr.mapping.map(pos, side)),
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
props: {
|
|
284
|
+
decorations(state) {
|
|
285
|
+
const ps = aiSuggestionPluginKey.getState(state)
|
|
286
|
+
if (!ps || ps.suggestions.length === 0) return DecorationSet.empty
|
|
287
|
+
return buildDecorations(state, ps.suggestions, ext.options.classPrefix, ext.editor)
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
view(view) {
|
|
291
|
+
let last = aiSuggestionPluginKey.getState(view.state)?.suggestions
|
|
292
|
+
return {
|
|
293
|
+
update(updated) {
|
|
294
|
+
const next = aiSuggestionPluginKey.getState(updated.state)?.suggestions
|
|
295
|
+
if (next === last) return
|
|
296
|
+
last = next
|
|
297
|
+
const cb = ext.options.onChange
|
|
298
|
+
if (cb) cb(next ? [...next] : [])
|
|
299
|
+
},
|
|
300
|
+
destroy() {},
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
]
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
function applyApprove(tr: Transaction, state: EditorState, target: AiSuggestion): void {
|
|
309
|
+
const docSize = state.doc.content.size
|
|
310
|
+
const from = clampPos(target.from, docSize)
|
|
311
|
+
const to = clampPos(target.to, docSize)
|
|
312
|
+
if (from > to) return
|
|
313
|
+
if (target.replacement.length > 0) {
|
|
314
|
+
tr.replaceWith(from, to, state.schema.text(target.replacement))
|
|
315
|
+
} else if (from < to) {
|
|
316
|
+
tr.delete(from, to)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildDecorations(
|
|
321
|
+
state: EditorState,
|
|
322
|
+
suggestions: readonly AiSuggestion[],
|
|
323
|
+
prefix: string,
|
|
324
|
+
editor: Editor,
|
|
325
|
+
): DecorationSet {
|
|
326
|
+
const docSize = state.doc.content.size
|
|
327
|
+
const decos: Decoration[] = []
|
|
328
|
+
|
|
329
|
+
for (const s of suggestions) {
|
|
330
|
+
const from = clampPos(s.from, docSize)
|
|
331
|
+
const to = clampPos(s.to, docSize)
|
|
332
|
+
if (from > to) continue
|
|
333
|
+
|
|
334
|
+
if (from < to) {
|
|
335
|
+
decos.push(
|
|
336
|
+
Decoration.inline(from, to, {
|
|
337
|
+
class: `${prefix}-original`,
|
|
338
|
+
'data-pilotiq-ai-suggestion-id': s.id,
|
|
339
|
+
}),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
decos.push(
|
|
344
|
+
Decoration.widget(to, () => buildChip(s, prefix, editor), {
|
|
345
|
+
side: 1,
|
|
346
|
+
ignoreSelection: true,
|
|
347
|
+
key: `pilotiq-ai-suggestion:${s.id}`,
|
|
348
|
+
}),
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return DecorationSet.create(state.doc, decos)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Bound `pos` into `[0, max]`; non-finite or negative input collapses to 0. */
|
|
356
|
+
export function clampPos(pos: number, max: number): number {
|
|
357
|
+
if (!Number.isFinite(pos)) return 0
|
|
358
|
+
if (pos < 0) return 0
|
|
359
|
+
if (pos > max) return max
|
|
360
|
+
return Math.trunc(pos)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function buildChip(s: AiSuggestion, prefix: string, editor: Editor): HTMLElement {
|
|
364
|
+
const root = document.createElement('span')
|
|
365
|
+
root.className = `${prefix}-chip`
|
|
366
|
+
root.setAttribute('data-pilotiq-ai-suggestion-id', s.id)
|
|
367
|
+
root.contentEditable = 'false'
|
|
368
|
+
|
|
369
|
+
if (s.replacement.length > 0) {
|
|
370
|
+
const insert = document.createElement('span')
|
|
371
|
+
insert.className = `${prefix}-replacement`
|
|
372
|
+
insert.textContent = s.replacement
|
|
373
|
+
root.appendChild(insert)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (s.source?.agentLabel) {
|
|
377
|
+
root.setAttribute('data-pilotiq-ai-suggestion-source', s.source.agentLabel)
|
|
378
|
+
}
|
|
379
|
+
if (s.source?.agentSlug) {
|
|
380
|
+
root.setAttribute('data-pilotiq-ai-suggestion-source-slug', s.source.agentSlug)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
root.appendChild(buildButton(prefix, 'accept', '✓', 'Accept suggestion', () => {
|
|
384
|
+
editor.chain().focus().approveAiSuggestion(s.id).run()
|
|
385
|
+
}))
|
|
386
|
+
root.appendChild(buildButton(prefix, 'reject', '✕', 'Reject suggestion', () => {
|
|
387
|
+
editor.chain().focus().rejectAiSuggestion(s.id).run()
|
|
388
|
+
}))
|
|
389
|
+
|
|
390
|
+
return root
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildButton(
|
|
394
|
+
prefix: string,
|
|
395
|
+
variant: 'accept' | 'reject',
|
|
396
|
+
glyph: string,
|
|
397
|
+
title: string,
|
|
398
|
+
onClick: () => void,
|
|
399
|
+
): HTMLButtonElement {
|
|
400
|
+
const btn = document.createElement('button')
|
|
401
|
+
btn.type = 'button'
|
|
402
|
+
btn.className = `${prefix}-${variant}`
|
|
403
|
+
btn.title = title
|
|
404
|
+
btn.textContent = glyph
|
|
405
|
+
// Don't steal the editor selection on press — the click handler runs on
|
|
406
|
+
// mouseup, but mousedown is what flips focus to the button. Cancelling it
|
|
407
|
+
// keeps the cursor in the editor so `editor.chain().focus()` lands cleanly.
|
|
408
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault())
|
|
409
|
+
btn.addEventListener('click', (e) => {
|
|
410
|
+
e.preventDefault()
|
|
411
|
+
e.stopPropagation()
|
|
412
|
+
onClick()
|
|
413
|
+
})
|
|
414
|
+
return btn
|
|
415
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,19 @@ export {
|
|
|
19
19
|
export { registerTiptap } from './register.js'
|
|
20
20
|
export { tiptap } from './plugin.js'
|
|
21
21
|
export { TiptapEditor } from './react/TiptapEditor.js'
|
|
22
|
+
export {
|
|
23
|
+
AiSuggestionExtension,
|
|
24
|
+
aiSuggestionPluginKey,
|
|
25
|
+
upsertSuggestion,
|
|
26
|
+
upsertSuggestions,
|
|
27
|
+
removeSuggestion,
|
|
28
|
+
remapSuggestions,
|
|
29
|
+
sortForApproveAll,
|
|
30
|
+
clampPos,
|
|
31
|
+
type AiSuggestion,
|
|
32
|
+
type AiSuggestionExtensionOptions,
|
|
33
|
+
} from './extensions/AiSuggestionExtension.js'
|
|
34
|
+
export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js'
|
|
22
35
|
export {
|
|
23
36
|
renderRichTextToHtml,
|
|
24
37
|
isRichTextValue,
|
|
@@ -14,6 +14,7 @@ import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-detai
|
|
|
14
14
|
import { Grid, GridColumn } from '../extensions/GridExtension.js'
|
|
15
15
|
import { Popover } from '@base-ui/react/popover'
|
|
16
16
|
import type { FieldRendererProps } from '@pilotiq/pilotiq/react'
|
|
17
|
+
import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
|
|
17
18
|
import type { BlockMeta } from '../Block.js'
|
|
18
19
|
import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
|
|
19
20
|
import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
|
|
25
26
|
import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
|
|
26
27
|
import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
|
|
28
|
+
import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
|
|
27
29
|
import {
|
|
28
30
|
MentionExtension,
|
|
29
31
|
type MentionState,
|
|
@@ -246,6 +248,10 @@ function ClientEditor(props: FieldRendererProps) {
|
|
|
246
248
|
fieldName: name,
|
|
247
249
|
})] : [MentionExtension]),
|
|
248
250
|
DragHandleExtension,
|
|
251
|
+
// AI suggestions — always-on extension that tracks suggested edits as
|
|
252
|
+
// inline strikethrough + Approve/Reject chip widgets. Idle until the
|
|
253
|
+
// host calls `editor.commands.addAiSuggestion(...)`.
|
|
254
|
+
AiSuggestionExtension,
|
|
249
255
|
],
|
|
250
256
|
content: initialContent ?? '',
|
|
251
257
|
onUpdate: ({ editor: ed }) => {
|
|
@@ -332,6 +338,11 @@ function ClientEditor(props: FieldRendererProps) {
|
|
|
332
338
|
// scratch on every keystroke.
|
|
333
339
|
useEffect(() => { editorRef.current = editor ?? null }, [editor])
|
|
334
340
|
|
|
341
|
+
// Cross-package suggestion bridge — sync the host's
|
|
342
|
+
// `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
|
|
343
|
+
// extension. No-op when no provider is mounted (default no-op context).
|
|
344
|
+
useAiSuggestionBridge(editor ?? null, name)
|
|
345
|
+
|
|
335
346
|
// Re-render the toolbar when the selection / marks change so active-state
|
|
336
347
|
// booleans stay fresh.
|
|
337
348
|
const tick = useEditorTick(editor)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import {
|
|
4
|
+
registerPendingSuggestionApplier,
|
|
5
|
+
usePendingSuggestionsForField,
|
|
6
|
+
type PendingSuggestion,
|
|
7
|
+
type PendingSuggestionApplier,
|
|
8
|
+
} from '@pilotiq/pilotiq/react'
|
|
9
|
+
import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two-way sync between the cross-package `<PendingSuggestionsContext>`
|
|
13
|
+
* queue and this editor's `AiSuggestionExtension` state.
|
|
14
|
+
*
|
|
15
|
+
* - **Context → editor**: every entry whose `meta.editorRange = { from, to }`
|
|
16
|
+
* is present and whose `suggestedValue` is a string gets pushed into the
|
|
17
|
+
* editor as an inline-diff hunk via `addAiSuggestion`. Entries leaving the
|
|
18
|
+
* queue are removed from the editor via `rejectAiSuggestion` (no doc edit).
|
|
19
|
+
*
|
|
20
|
+
* - **Editor → context**: when a chip's Approve / Reject button removes a
|
|
21
|
+
* hunk from the editor's plugin state, the matching id is dismissed from
|
|
22
|
+
* the queue (`dismiss(id)`) so other surfaces (e.g. the chat-sidebar pill,
|
|
23
|
+
* a future FieldShell overlay) clear in lock-step. The doc mutation
|
|
24
|
+
* itself happens inside the editor — context is just a notification.
|
|
25
|
+
*
|
|
26
|
+
* Cycle protection: the hook tracks which ids it has personally pushed to
|
|
27
|
+
* the editor (`pushed`). The Context→editor pass never re-pushes an id that's
|
|
28
|
+
* already there, and the Editor→context pass only dismisses ids that this
|
|
29
|
+
* hook had previously pushed (so an id added directly by host code via
|
|
30
|
+
* `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
|
|
31
|
+
* a context that never knew about it).
|
|
32
|
+
*/
|
|
33
|
+
export function useAiSuggestionBridge(editor: Editor | null, fieldName: string): void {
|
|
34
|
+
const { list, dismiss } = usePendingSuggestionsForField(fieldName)
|
|
35
|
+
|
|
36
|
+
// Hold the latest `dismiss` in a ref so the editor-side listener — which
|
|
37
|
+
// installs once per editor — always reaches the up-to-date context API.
|
|
38
|
+
const dismissRef = useRef(dismiss)
|
|
39
|
+
useEffect(() => { dismissRef.current = dismiss }, [dismiss])
|
|
40
|
+
|
|
41
|
+
// Set of ids this hook pushed; used by both directions for cycle control.
|
|
42
|
+
const pushedRef = useRef<Set<string>>(new Set())
|
|
43
|
+
|
|
44
|
+
// Context → editor.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!editor) return
|
|
47
|
+
const contextIds = new Set(list.map(s => s.id))
|
|
48
|
+
|
|
49
|
+
for (const s of list) {
|
|
50
|
+
if (pushedRef.current.has(s.id)) continue
|
|
51
|
+
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
|
|
54
|
+
const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : ''
|
|
55
|
+
editor.commands.addAiSuggestion({
|
|
56
|
+
id: s.id,
|
|
57
|
+
from: range.from,
|
|
58
|
+
to: range.to,
|
|
59
|
+
replacement,
|
|
60
|
+
...(s.source ? { source: s.source } : {}),
|
|
61
|
+
})
|
|
62
|
+
pushedRef.current.add(s.id)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const id of Array.from(pushedRef.current)) {
|
|
66
|
+
if (contextIds.has(id)) continue
|
|
67
|
+
// Context dropped the suggestion — remove from editor without
|
|
68
|
+
// mutating the doc (rejectAiSuggestion drops state only).
|
|
69
|
+
editor.commands.rejectAiSuggestion(id)
|
|
70
|
+
pushedRef.current.delete(id)
|
|
71
|
+
}
|
|
72
|
+
}, [editor, list])
|
|
73
|
+
|
|
74
|
+
// Editor → context.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!editor) return
|
|
77
|
+
const handler = () => {
|
|
78
|
+
const ps = aiSuggestionPluginKey.getState(editor.state)
|
|
79
|
+
if (!ps) return
|
|
80
|
+
const editorIds = new Set(ps.suggestions.map((s: { id: string }) => s.id))
|
|
81
|
+
for (const id of Array.from(pushedRef.current)) {
|
|
82
|
+
if (editorIds.has(id)) continue
|
|
83
|
+
// Chip removed the suggestion (Approve mutated the doc, Reject did
|
|
84
|
+
// not — either way it's gone from editor state). Mirror to context.
|
|
85
|
+
pushedRef.current.delete(id)
|
|
86
|
+
dismissRef.current(id)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
editor.on('transaction', handler)
|
|
90
|
+
return () => { editor.off('transaction', handler) }
|
|
91
|
+
}, [editor])
|
|
92
|
+
|
|
93
|
+
// Cross-tree applier (Phase 8.5). When an aggregate consumer (e.g. a
|
|
94
|
+
// chat-sidebar pending-pill) calls `pendingSuggestions.approve(id)`,
|
|
95
|
+
// the pro provider looks up the applier registered for this
|
|
96
|
+
// `(formId, fieldName)` and invokes it. We translate that into the
|
|
97
|
+
// editor's own approve command — same path the inline chip click takes.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!editor) return
|
|
100
|
+
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.
|
|
107
|
+
}
|
|
108
|
+
// Editor renderers don't currently have access to a `formId` here;
|
|
109
|
+
// pass `undefined` so the wildcard form scope resolves. Phase 8.5+
|
|
110
|
+
// can thread `formId` via the bridge call site if a future multi-
|
|
111
|
+
// form richtext consumer needs it.
|
|
112
|
+
return registerPendingSuggestionApplier(undefined, fieldName, applier)
|
|
113
|
+
}, [editor, fieldName])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Re-export the pending-suggestion type for consumers that import the hook
|
|
117
|
+
// from this module directly — saves them a separate `@pilotiq/pilotiq/react`
|
|
118
|
+
// import when wiring an external producer.
|
|
119
|
+
export type { PendingSuggestion }
|