@pilotiq/tiptap 3.10.4 → 3.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +4 -4
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +4 -5
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +8 -7
- package/dist/react/TiptapEditor.js.map +1 -1
- package/package.json +6 -3
- package/dist/collabShapes.d.ts +0 -22
- package/dist/collabShapes.d.ts.map +0 -1
- package/dist/collabShapes.js +0 -2
- package/dist/collabShapes.js.map +0 -1
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/collabShapes.ts +0 -22
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -230
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -606
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -776
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
|
@@ -1,522 +0,0 @@
|
|
|
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
|
-
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
|
-
/* Banner — bottom-of-editor strip for whole-field suggestions on rich
|
|
227
|
-
surfaces (markdown / richtext). Sibling to the chip styles above;
|
|
228
|
-
lives here so both ship via the same extension-mount sentinel.
|
|
229
|
-
Class names live under \`pilotiq-ai-banner-*\` (not \`-suggestion-\`)
|
|
230
|
-
since the banner is a host-mounted React component, not a PM
|
|
231
|
-
decoration. */
|
|
232
|
-
.pilotiq-ai-banner {
|
|
233
|
-
display: flex;
|
|
234
|
-
align-items: center;
|
|
235
|
-
gap: 0.5rem;
|
|
236
|
-
padding: 0.375rem 0.625rem;
|
|
237
|
-
margin-top: 0.375rem;
|
|
238
|
-
border-radius: 0.375rem;
|
|
239
|
-
background-color: rgba(254, 252, 232, 0.9);
|
|
240
|
-
border: 1px solid rgba(234, 179, 8, 0.4);
|
|
241
|
-
color: rgb(113, 63, 18);
|
|
242
|
-
font-size: 0.875rem;
|
|
243
|
-
line-height: 1.4;
|
|
244
|
-
}
|
|
245
|
-
.pilotiq-ai-banner-icon { flex: 0 0 auto; }
|
|
246
|
-
.pilotiq-ai-banner-label { flex: 1 1 auto; }
|
|
247
|
-
.pilotiq-ai-banner-actions {
|
|
248
|
-
display: inline-flex;
|
|
249
|
-
gap: 0.375rem;
|
|
250
|
-
flex: 0 0 auto;
|
|
251
|
-
}
|
|
252
|
-
.pilotiq-ai-banner-reject,
|
|
253
|
-
.pilotiq-ai-banner-accept {
|
|
254
|
-
appearance: none;
|
|
255
|
-
cursor: pointer;
|
|
256
|
-
font-size: 0.8125rem;
|
|
257
|
-
font-weight: 500;
|
|
258
|
-
line-height: 1;
|
|
259
|
-
padding: 0.25rem 0.625rem;
|
|
260
|
-
border-radius: 0.25rem;
|
|
261
|
-
border: 1px solid transparent;
|
|
262
|
-
}
|
|
263
|
-
.pilotiq-ai-banner-reject {
|
|
264
|
-
background-color: transparent;
|
|
265
|
-
color: rgb(120, 53, 15);
|
|
266
|
-
border-color: rgba(180, 83, 9, 0.4);
|
|
267
|
-
}
|
|
268
|
-
.pilotiq-ai-banner-reject:hover {
|
|
269
|
-
background-color: rgba(254, 215, 170, 0.4);
|
|
270
|
-
}
|
|
271
|
-
.pilotiq-ai-banner-accept {
|
|
272
|
-
background-color: rgb(22, 101, 52);
|
|
273
|
-
color: white;
|
|
274
|
-
}
|
|
275
|
-
.pilotiq-ai-banner-accept:hover { background-color: rgb(21, 128, 61); }
|
|
276
|
-
`
|
|
277
|
-
document.head.appendChild(style)
|
|
278
|
-
},
|
|
279
|
-
|
|
280
|
-
addCommands() {
|
|
281
|
-
return {
|
|
282
|
-
addAiSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
|
|
283
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
284
|
-
const next = upsertSuggestion(current, suggestion)
|
|
285
|
-
if (dispatch) {
|
|
286
|
-
tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
|
|
287
|
-
dispatch(tr)
|
|
288
|
-
}
|
|
289
|
-
return true
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
addAiSuggestions: (suggestions) => ({ tr, state, dispatch }) => {
|
|
293
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
294
|
-
const next = upsertSuggestions(current, suggestions)
|
|
295
|
-
if (dispatch) {
|
|
296
|
-
tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
|
|
297
|
-
dispatch(tr)
|
|
298
|
-
}
|
|
299
|
-
return true
|
|
300
|
-
},
|
|
301
|
-
|
|
302
|
-
approveAiSuggestion: (id) => ({ tr, state, dispatch }) => {
|
|
303
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
304
|
-
const target = current.find(s => s.id === id)
|
|
305
|
-
if (!target) return false
|
|
306
|
-
if (dispatch) {
|
|
307
|
-
applyApprove(tr, state, target)
|
|
308
|
-
tr.setMeta(aiSuggestionPluginKey, {
|
|
309
|
-
type: 'set',
|
|
310
|
-
next: removeSuggestion(current, id),
|
|
311
|
-
} satisfies SetMeta)
|
|
312
|
-
dispatch(tr)
|
|
313
|
-
}
|
|
314
|
-
return true
|
|
315
|
-
},
|
|
316
|
-
|
|
317
|
-
rejectAiSuggestion: (id) => ({ tr, state, dispatch }) => {
|
|
318
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
319
|
-
const target = current.find(s => s.id === id)
|
|
320
|
-
if (!target) return false
|
|
321
|
-
if (dispatch) {
|
|
322
|
-
tr.setMeta(aiSuggestionPluginKey, {
|
|
323
|
-
type: 'set',
|
|
324
|
-
next: removeSuggestion(current, id),
|
|
325
|
-
} satisfies SetMeta)
|
|
326
|
-
dispatch(tr)
|
|
327
|
-
}
|
|
328
|
-
return true
|
|
329
|
-
},
|
|
330
|
-
|
|
331
|
-
approveAllAiSuggestions: () => ({ tr, state, dispatch }) => {
|
|
332
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
333
|
-
if (current.length === 0) return false
|
|
334
|
-
if (dispatch) {
|
|
335
|
-
for (const s of sortForApproveAll(current)) applyApprove(tr, state, s)
|
|
336
|
-
tr.setMeta(aiSuggestionPluginKey, {
|
|
337
|
-
type: 'set',
|
|
338
|
-
next: [],
|
|
339
|
-
} satisfies SetMeta)
|
|
340
|
-
dispatch(tr)
|
|
341
|
-
}
|
|
342
|
-
return true
|
|
343
|
-
},
|
|
344
|
-
|
|
345
|
-
rejectAllAiSuggestions: () => ({ tr, state, dispatch }) => {
|
|
346
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
347
|
-
if (current.length === 0) return false
|
|
348
|
-
if (dispatch) {
|
|
349
|
-
tr.setMeta(aiSuggestionPluginKey, {
|
|
350
|
-
type: 'set',
|
|
351
|
-
next: [],
|
|
352
|
-
} satisfies SetMeta)
|
|
353
|
-
dispatch(tr)
|
|
354
|
-
}
|
|
355
|
-
return true
|
|
356
|
-
},
|
|
357
|
-
|
|
358
|
-
clearAiSuggestions: () => ({ tr, state, dispatch }) => {
|
|
359
|
-
const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
|
|
360
|
-
if (current.length === 0) return false
|
|
361
|
-
if (dispatch) {
|
|
362
|
-
tr.setMeta(aiSuggestionPluginKey, {
|
|
363
|
-
type: 'set',
|
|
364
|
-
next: [],
|
|
365
|
-
} satisfies SetMeta)
|
|
366
|
-
dispatch(tr)
|
|
367
|
-
}
|
|
368
|
-
return true
|
|
369
|
-
},
|
|
370
|
-
}
|
|
371
|
-
},
|
|
372
|
-
|
|
373
|
-
addProseMirrorPlugins() {
|
|
374
|
-
const ext = this
|
|
375
|
-
return [
|
|
376
|
-
new Plugin<PluginState>({
|
|
377
|
-
key: aiSuggestionPluginKey,
|
|
378
|
-
state: {
|
|
379
|
-
init: (): PluginState => ({ suggestions: [] }),
|
|
380
|
-
apply(tr, prev): PluginState {
|
|
381
|
-
const meta = tr.getMeta(aiSuggestionPluginKey) as SetMeta | undefined
|
|
382
|
-
const base = meta?.type === 'set' ? meta.next : prev.suggestions
|
|
383
|
-
if (!tr.docChanged) return { suggestions: base }
|
|
384
|
-
return {
|
|
385
|
-
suggestions: remapSuggestions(base, (pos, side) =>
|
|
386
|
-
tr.mapping.map(pos, side)),
|
|
387
|
-
}
|
|
388
|
-
},
|
|
389
|
-
},
|
|
390
|
-
props: {
|
|
391
|
-
decorations(state) {
|
|
392
|
-
const ps = aiSuggestionPluginKey.getState(state)
|
|
393
|
-
if (!ps || ps.suggestions.length === 0) return DecorationSet.empty
|
|
394
|
-
return buildDecorations(state, ps.suggestions, ext.options.classPrefix, ext.editor)
|
|
395
|
-
},
|
|
396
|
-
},
|
|
397
|
-
view(view) {
|
|
398
|
-
let last = aiSuggestionPluginKey.getState(view.state)?.suggestions
|
|
399
|
-
return {
|
|
400
|
-
update(updated) {
|
|
401
|
-
const next = aiSuggestionPluginKey.getState(updated.state)?.suggestions
|
|
402
|
-
if (next === last) return
|
|
403
|
-
last = next
|
|
404
|
-
const cb = ext.options.onChange
|
|
405
|
-
if (cb) cb(next ? [...next] : [])
|
|
406
|
-
},
|
|
407
|
-
destroy() {},
|
|
408
|
-
}
|
|
409
|
-
},
|
|
410
|
-
}),
|
|
411
|
-
]
|
|
412
|
-
},
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
function applyApprove(tr: Transaction, state: EditorState, target: AiSuggestion): void {
|
|
416
|
-
const docSize = state.doc.content.size
|
|
417
|
-
const from = clampPos(target.from, docSize)
|
|
418
|
-
const to = clampPos(target.to, docSize)
|
|
419
|
-
if (from > to) return
|
|
420
|
-
if (target.replacement.length > 0) {
|
|
421
|
-
tr.replaceWith(from, to, state.schema.text(target.replacement))
|
|
422
|
-
} else if (from < to) {
|
|
423
|
-
tr.delete(from, to)
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function buildDecorations(
|
|
428
|
-
state: EditorState,
|
|
429
|
-
suggestions: readonly AiSuggestion[],
|
|
430
|
-
prefix: string,
|
|
431
|
-
editor: Editor,
|
|
432
|
-
): DecorationSet {
|
|
433
|
-
const docSize = state.doc.content.size
|
|
434
|
-
const decos: Decoration[] = []
|
|
435
|
-
|
|
436
|
-
for (const s of suggestions) {
|
|
437
|
-
const from = clampPos(s.from, docSize)
|
|
438
|
-
const to = clampPos(s.to, docSize)
|
|
439
|
-
if (from > to) continue
|
|
440
|
-
|
|
441
|
-
if (from < to) {
|
|
442
|
-
decos.push(
|
|
443
|
-
Decoration.inline(from, to, {
|
|
444
|
-
class: `${prefix}-original`,
|
|
445
|
-
'data-pilotiq-ai-suggestion-id': s.id,
|
|
446
|
-
}),
|
|
447
|
-
)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
decos.push(
|
|
451
|
-
Decoration.widget(to, () => buildChip(s, prefix, editor), {
|
|
452
|
-
side: 1,
|
|
453
|
-
ignoreSelection: true,
|
|
454
|
-
key: `pilotiq-ai-suggestion:${s.id}`,
|
|
455
|
-
}),
|
|
456
|
-
)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return DecorationSet.create(state.doc, decos)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** Bound `pos` into `[0, max]`; non-finite or negative input collapses to 0. */
|
|
463
|
-
export function clampPos(pos: number, max: number): number {
|
|
464
|
-
if (!Number.isFinite(pos)) return 0
|
|
465
|
-
if (pos < 0) return 0
|
|
466
|
-
if (pos > max) return max
|
|
467
|
-
return Math.trunc(pos)
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function buildChip(s: AiSuggestion, prefix: string, editor: Editor): HTMLElement {
|
|
471
|
-
const root = document.createElement('span')
|
|
472
|
-
root.className = `${prefix}-chip`
|
|
473
|
-
root.setAttribute('data-pilotiq-ai-suggestion-id', s.id)
|
|
474
|
-
root.contentEditable = 'false'
|
|
475
|
-
|
|
476
|
-
if (s.replacement.length > 0) {
|
|
477
|
-
const insert = document.createElement('span')
|
|
478
|
-
insert.className = `${prefix}-replacement`
|
|
479
|
-
insert.textContent = s.replacement
|
|
480
|
-
root.appendChild(insert)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (s.source?.agentLabel) {
|
|
484
|
-
root.setAttribute('data-pilotiq-ai-suggestion-source', s.source.agentLabel)
|
|
485
|
-
}
|
|
486
|
-
if (s.source?.agentSlug) {
|
|
487
|
-
root.setAttribute('data-pilotiq-ai-suggestion-source-slug', s.source.agentSlug)
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
root.appendChild(buildButton(prefix, 'accept', '✓', 'Accept suggestion', () => {
|
|
491
|
-
editor.chain().focus().approveAiSuggestion(s.id).run()
|
|
492
|
-
}))
|
|
493
|
-
root.appendChild(buildButton(prefix, 'reject', '✕', 'Reject suggestion', () => {
|
|
494
|
-
editor.chain().focus().rejectAiSuggestion(s.id).run()
|
|
495
|
-
}))
|
|
496
|
-
|
|
497
|
-
return root
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function buildButton(
|
|
501
|
-
prefix: string,
|
|
502
|
-
variant: 'accept' | 'reject',
|
|
503
|
-
glyph: string,
|
|
504
|
-
title: string,
|
|
505
|
-
onClick: () => void,
|
|
506
|
-
): HTMLButtonElement {
|
|
507
|
-
const btn = document.createElement('button')
|
|
508
|
-
btn.type = 'button'
|
|
509
|
-
btn.className = `${prefix}-${variant}`
|
|
510
|
-
btn.title = title
|
|
511
|
-
btn.textContent = glyph
|
|
512
|
-
// Don't steal the editor selection on press — the click handler runs on
|
|
513
|
-
// mouseup, but mousedown is what flips focus to the button. Cancelling it
|
|
514
|
-
// keeps the cursor in the editor so `editor.chain().focus()` lands cleanly.
|
|
515
|
-
btn.addEventListener('mousedown', (e) => e.preventDefault())
|
|
516
|
-
btn.addEventListener('click', (e) => {
|
|
517
|
-
e.preventDefault()
|
|
518
|
-
e.stopPropagation()
|
|
519
|
-
onClick()
|
|
520
|
-
})
|
|
521
|
-
return btn
|
|
522
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { Node, mergeAttributes } from '@tiptap/core'
|
|
2
|
-
import { ReactNodeViewRenderer } from '@tiptap/react'
|
|
3
|
-
import type { BlockMeta } from '../Block.js'
|
|
4
|
-
import { BlockNodeView } from '../react/BlockNodeView.js'
|
|
5
|
-
|
|
6
|
-
declare module '@tiptap/core' {
|
|
7
|
-
interface Commands<ReturnType> {
|
|
8
|
-
customBlock: {
|
|
9
|
-
/** Insert a custom-block node by type, with optional initial data. */
|
|
10
|
-
insertBlock: (blockType: string, blockData?: Record<string, unknown>) => ReturnType
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface BlockNodeOptions {
|
|
16
|
-
/**
|
|
17
|
-
* Block-meta registry. The NodeView reads from this to find the schema
|
|
18
|
-
* for the block-type it's rendering. Stashed on the extension's options
|
|
19
|
-
* because Tiptap's ReactNodeViewRenderer mounts NodeViews in a separate
|
|
20
|
-
* React tree — `useContext` does NOT reach them, so we can't pass
|
|
21
|
-
* registry data via React context.
|
|
22
|
-
*/
|
|
23
|
-
blocks: BlockMeta[]
|
|
24
|
-
/**
|
|
25
|
-
* Bridge from the NodeView's separate React tree back to the editor's
|
|
26
|
-
* own tree, where the side panel lives. Set by `TiptapEditor` so the
|
|
27
|
-
* "Edit" button on each block can request the panel open against this
|
|
28
|
-
* specific node. `undefined` means no host is listening — the NodeView
|
|
29
|
-
* falls back to a no-op (does not render an Edit affordance, or does
|
|
30
|
-
* so disabled, depending on the consumer's chrome).
|
|
31
|
-
*/
|
|
32
|
-
onEdit?: (pos: number) => void
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Single ProseMirror node type that represents every custom block. The
|
|
37
|
-
* concrete block type ("callout", "image", …) lives in `attrs.blockType`,
|
|
38
|
-
* and the per-block data lives in `attrs.blockData`. The React NodeView
|
|
39
|
-
* looks the type up in this extension's `options.blocks` and renders the
|
|
40
|
-
* matching inline form.
|
|
41
|
-
*
|
|
42
|
-
* Storing one node type per block name would scale O(n) extensions. This
|
|
43
|
-
* approach scales O(1).
|
|
44
|
-
*/
|
|
45
|
-
export const BlockNodeExtension = Node.create<BlockNodeOptions>({
|
|
46
|
-
// Avoid `name: 'block'` — ProseMirror's `block` is a schema group name,
|
|
47
|
-
// and naming a node identically to a group can collide subtly with
|
|
48
|
-
// schema content matching (TrailingNode threw "invalid content" on every
|
|
49
|
-
// dispatch with `name: 'block'`).
|
|
50
|
-
name: 'pilotiqBlock',
|
|
51
|
-
group: 'block',
|
|
52
|
-
// Mirrors the canonical Tiptap atom-block pattern (image / horizontalRule):
|
|
53
|
-
// omit `atom`/`selectable`, set `draggable: true`, no explicit `content`.
|
|
54
|
-
// Setting `atom: true` together with `group: 'block'` was making
|
|
55
|
-
// StarterKit's TrailingNode plugin throw "invalid content" on every
|
|
56
|
-
// dispatch — even before any block was inserted.
|
|
57
|
-
draggable: true,
|
|
58
|
-
|
|
59
|
-
addOptions() {
|
|
60
|
-
// `onEdit` intentionally omitted — `exactOptionalPropertyTypes` makes
|
|
61
|
-
// an explicit `undefined` non-assignable to the optional field, and
|
|
62
|
-
// the host wires it via `BlockNodeExtension.configure({ onEdit })`.
|
|
63
|
-
return { blocks: [] }
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
addAttributes() {
|
|
67
|
-
return {
|
|
68
|
-
blockType: {
|
|
69
|
-
default: null,
|
|
70
|
-
parseHTML: (el) => el.getAttribute('data-block-type'),
|
|
71
|
-
renderHTML: (attrs) => {
|
|
72
|
-
if (!attrs['blockType']) return {}
|
|
73
|
-
return { 'data-block-type': attrs['blockType'] }
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
blockData: {
|
|
77
|
-
// Default `null` rather than `{}` — ProseMirror compares attrs by
|
|
78
|
-
// reference for some equality checks, and a fresh `{}` per node
|
|
79
|
-
// create call breaks them subtly.
|
|
80
|
-
default: null,
|
|
81
|
-
parseHTML: (el) => {
|
|
82
|
-
const raw = el.getAttribute('data-block-data')
|
|
83
|
-
if (!raw) return null
|
|
84
|
-
try { return JSON.parse(raw) } catch { return null }
|
|
85
|
-
},
|
|
86
|
-
renderHTML: (attrs) => {
|
|
87
|
-
if (!attrs['blockData']) return {}
|
|
88
|
-
return { 'data-block-data': JSON.stringify(attrs['blockData']) }
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
|
|
94
|
-
parseHTML() {
|
|
95
|
-
return [{ tag: 'div[data-pilotiq-block]' }]
|
|
96
|
-
},
|
|
97
|
-
|
|
98
|
-
renderHTML({ HTMLAttributes }) {
|
|
99
|
-
return ['div', mergeAttributes({ 'data-pilotiq-block': '' }, HTMLAttributes)]
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
addNodeView() {
|
|
103
|
-
return ReactNodeViewRenderer(BlockNodeView)
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
addCommands() {
|
|
107
|
-
return {
|
|
108
|
-
insertBlock: (blockType, blockData = {}) => ({ commands }) =>
|
|
109
|
-
commands.insertContent({
|
|
110
|
-
type: this.name,
|
|
111
|
-
attrs: { blockType, blockData },
|
|
112
|
-
}),
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
// `Mod-e` opens the side panel for the currently NodeSelected block.
|
|
117
|
-
// Returns false when no block is selected so the browser's default
|
|
118
|
-
// (Safari "Use Selection for Find", etc.) still applies in plain text.
|
|
119
|
-
addKeyboardShortcuts() {
|
|
120
|
-
return {
|
|
121
|
-
'Mod-e': () => {
|
|
122
|
-
const onEdit = this.options.onEdit
|
|
123
|
-
if (!onEdit) return false
|
|
124
|
-
const sel = this.editor.state.selection as unknown as {
|
|
125
|
-
node?: { type: { name: string } }
|
|
126
|
-
from: number
|
|
127
|
-
}
|
|
128
|
-
if (sel.node?.type.name !== this.name) return false
|
|
129
|
-
onEdit(sel.from)
|
|
130
|
-
return true
|
|
131
|
-
},
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
})
|