@pilotiq/tiptap 3.10.5 → 3.10.7
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 +751 -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/markdownExtension.js +259 -164
- package/dist/react/BlockNodeView.d.ts +1 -1
- package/dist/react/FloatingToolbar.d.ts +1 -1
- package/dist/react/MentionMenu.d.ts +1 -1
- package/dist/react/Palette.d.ts +1 -1
- package/dist/react/SlashMenu.d.ts +1 -1
- package/dist/react/TableFloatingToolbar.d.ts +1 -1
- package/dist/react/TiptapEditor.d.ts +1 -1
- package/dist/react/Toolbar.d.ts +2 -2
- package/package.json +6 -4
- package/dist/Block.d.ts.map +0 -1
- package/dist/Block.js.map +0 -1
- package/dist/MentionProvider.d.ts.map +0 -1
- package/dist/MentionProvider.js.map +0 -1
- package/dist/PlainTextEditor.d.ts.map +0 -1
- package/dist/PlainTextEditor.js.map +0 -1
- package/dist/RichTextField.d.ts.map +0 -1
- package/dist/RichTextField.js.map +0 -1
- package/dist/extensions/AiInlineDiffExtension.d.ts.map +0 -1
- package/dist/extensions/AiInlineDiffExtension.js.map +0 -1
- package/dist/extensions/AiSuggestionExtension.d.ts.map +0 -1
- package/dist/extensions/AiSuggestionExtension.js.map +0 -1
- package/dist/extensions/BlockNodeExtension.d.ts.map +0 -1
- package/dist/extensions/BlockNodeExtension.js.map +0 -1
- package/dist/extensions/DragHandleExtension.d.ts.map +0 -1
- package/dist/extensions/DragHandleExtension.js.map +0 -1
- package/dist/extensions/GridExtension.d.ts.map +0 -1
- package/dist/extensions/GridExtension.js.map +0 -1
- package/dist/extensions/MentionExtension.d.ts.map +0 -1
- package/dist/extensions/MentionExtension.js.map +0 -1
- package/dist/extensions/MergeTagExtension.d.ts.map +0 -1
- package/dist/extensions/MergeTagExtension.js.map +0 -1
- package/dist/extensions/SlashCommandExtension.d.ts.map +0 -1
- package/dist/extensions/SlashCommandExtension.js.map +0 -1
- package/dist/extensions/TextSizeMarks.d.ts.map +0 -1
- package/dist/extensions/TextSizeMarks.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/markdownExtension.d.ts.map +0 -1
- package/dist/markdownStorage.d.ts.map +0 -1
- package/dist/markdownStorage.js.map +0 -1
- package/dist/plugin.d.ts.map +0 -1
- package/dist/plugin.js.map +0 -1
- package/dist/react/AiSuggestionBanner.d.ts.map +0 -1
- package/dist/react/AiSuggestionBanner.js.map +0 -1
- package/dist/react/BlockNodeView.d.ts.map +0 -1
- package/dist/react/BlockNodeView.js.map +0 -1
- package/dist/react/BlockSidePanel.d.ts.map +0 -1
- package/dist/react/BlockSidePanel.js.map +0 -1
- package/dist/react/CollabTextRenderer.d.ts.map +0 -1
- package/dist/react/CollabTextRenderer.js.map +0 -1
- package/dist/react/FloatingToolbar.d.ts.map +0 -1
- package/dist/react/FloatingToolbar.js.map +0 -1
- package/dist/react/MarkdownEditor.d.ts.map +0 -1
- package/dist/react/MarkdownEditor.js.map +0 -1
- package/dist/react/MentionMenu.d.ts.map +0 -1
- package/dist/react/MentionMenu.js.map +0 -1
- package/dist/react/Palette.d.ts.map +0 -1
- package/dist/react/Palette.js.map +0 -1
- package/dist/react/SlashMenu.d.ts.map +0 -1
- package/dist/react/SlashMenu.js.map +0 -1
- package/dist/react/TableFloatingToolbar.d.ts.map +0 -1
- package/dist/react/TableFloatingToolbar.js.map +0 -1
- package/dist/react/TiptapEditor.d.ts.map +0 -1
- package/dist/react/TiptapEditor.js.map +0 -1
- package/dist/react/Toolbar.d.ts.map +0 -1
- package/dist/react/Toolbar.js.map +0 -1
- package/dist/react/toolbarButtons.d.ts.map +0 -1
- package/dist/react/toolbarButtons.js.map +0 -1
- package/dist/react/useAiInlineDiff.d.ts.map +0 -1
- package/dist/react/useAiInlineDiff.js.map +0 -1
- package/dist/react/useAiSuggestionBridge.d.ts.map +0 -1
- package/dist/react/useAiSuggestionBridge.js.map +0 -1
- package/dist/register.d.ts.map +0 -1
- package/dist/register.js.map +0 -1
- package/dist/render.d.ts.map +0 -1
- package/dist/render.js.map +0 -1
- package/dist/surgicalOps.d.ts.map +0 -1
- package/dist/surgicalOps.js.map +0 -1
- package/dist/test/setup.d.ts.map +0 -1
- package/dist/test/setup.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/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 -228
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -603
- 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 -777
- 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,342 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bridge between the host's `<PendingSuggestionsContext>` queue and the
|
|
3
|
-
* editor's `AiInlineDiffExtension`. When a whole-field suggestion arrives
|
|
4
|
-
* for the field, the hook:
|
|
5
|
-
*
|
|
6
|
-
* 1. Parses the suggested value into a ProseMirror `Slice` via the
|
|
7
|
-
* renderer-supplied parser. Each Tiptap surface owns its own
|
|
8
|
-
* content shape — markdown source for `MarkdownEditor`, HTML / JSON
|
|
9
|
-
* for `TiptapEditor`, plain text for `CollabTextRenderer`.
|
|
10
|
-
* 2. Calls `editor.commands.startAiInlineDiff(id, slice)` — the
|
|
11
|
-
* extension snapshots the current doc as the baseline, replaces
|
|
12
|
-
* the doc content with the proposed slice, and starts a
|
|
13
|
-
* `prosemirror-changeset` tracking the diff.
|
|
14
|
-
* 3. Registers an applier on the cross-tree pending-suggestion
|
|
15
|
-
* registry so the host's `<AiSuggestionBanner>` Accept button (and
|
|
16
|
-
* any other surface calling `pendingSuggestions.approve(id)`) runs
|
|
17
|
-
* `acceptAiInlineDiff()` instead of the legacy `onApplyWholeField`
|
|
18
|
-
* callback. The current doc IS the accepted state — no extra
|
|
19
|
-
* content swap needed.
|
|
20
|
-
*
|
|
21
|
-
* Reject handling: not registered on the applier (the registry only
|
|
22
|
-
* tracks Approve). Renderers wire Reject through the banner's
|
|
23
|
-
* `onRejectWithEditor` prop, which calls `rejectAiInlineDiff()` to revert
|
|
24
|
-
* the doc to the baseline before dismissing the suggestion.
|
|
25
|
-
*
|
|
26
|
-
* Defensive: only one inline diff active at a time per editor. If a new
|
|
27
|
-
* synthesized suggestion arrives while one is still pending review, the
|
|
28
|
-
* hook drops it (the producer should have waited). This matches
|
|
29
|
-
* `AiSuggestionExtension`'s chip path which also allows only one
|
|
30
|
-
* suggestion at a time per id.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { useEffect, useRef } from 'react'
|
|
34
|
-
import type { Editor } from '@tiptap/core'
|
|
35
|
-
import type { Slice } from '@tiptap/pm/model'
|
|
36
|
-
import { useEditorState } from '@tiptap/react'
|
|
37
|
-
import {
|
|
38
|
-
registerPendingSuggestionApplier,
|
|
39
|
-
usePendingSuggestionsForField,
|
|
40
|
-
useFormId,
|
|
41
|
-
type PendingSuggestion,
|
|
42
|
-
type PendingSuggestionApplier,
|
|
43
|
-
} from '@pilotiq/pilotiq/react'
|
|
44
|
-
import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
|
|
45
|
-
import {
|
|
46
|
-
planReplaceBlock,
|
|
47
|
-
planInsertBlockBefore,
|
|
48
|
-
planDeleteBlock,
|
|
49
|
-
planUpdateBlockMark,
|
|
50
|
-
type BlockMarkRange,
|
|
51
|
-
type TransactionModifier,
|
|
52
|
-
} from '../surgicalOps.js'
|
|
53
|
-
|
|
54
|
-
export interface UseAiInlineDiffOptions {
|
|
55
|
-
/**
|
|
56
|
-
* Parse the suggested string value into a ProseMirror Slice that's
|
|
57
|
-
* compatible with this editor's schema. Returns `null` to skip (e.g.
|
|
58
|
-
* malformed content, unsupported markup) — the suggestion stays in
|
|
59
|
-
* the queue but no diff renders, and the host's fallback path (banner
|
|
60
|
-
* with `onApplyWholeField`) takes over.
|
|
61
|
-
*
|
|
62
|
-
* Renderers implement this per content shape:
|
|
63
|
-
* - plain text → wrap each line in a `paragraph` node
|
|
64
|
-
* - markdown → run through the Markdown extension's parseMarkdown
|
|
65
|
-
* - HTML → DOMParser + ProseMirror's DOMParser.parse
|
|
66
|
-
*/
|
|
67
|
-
parseSuggestion: (editor: Editor, value: string) => Slice | null
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Returns whether a diff is currently active in the editor. Hosts use
|
|
72
|
-
* this to gate the banner's UI between the legacy `onApplyWholeField`
|
|
73
|
-
* mode and the diff-aware mode (Reject routes through
|
|
74
|
-
* `rejectAiInlineDiff` to revert the doc).
|
|
75
|
-
*/
|
|
76
|
-
export function useIsAiInlineDiffActive(editor: Editor | null): boolean {
|
|
77
|
-
const active = useEditorState({
|
|
78
|
-
editor,
|
|
79
|
-
selector: ({ editor: ed }) => !!ed && aiInlineDiffPluginKey.getState(ed.state) !== null,
|
|
80
|
-
})
|
|
81
|
-
return active ?? false
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function useAiInlineDiff(
|
|
85
|
-
editor: Editor | null,
|
|
86
|
-
fieldName: string,
|
|
87
|
-
options: UseAiInlineDiffOptions,
|
|
88
|
-
): void {
|
|
89
|
-
const { list } = usePendingSuggestionsForField(fieldName)
|
|
90
|
-
// Scope the applier registration by the surrounding form's id so
|
|
91
|
-
// multi-form pages route suggestions to the editor instance inside the
|
|
92
|
-
// matching form — without this, two editors on different forms but
|
|
93
|
-
// sharing a field name (e.g. two "summary" RichTextFields, one in the
|
|
94
|
-
// main edit form + one in a Replicate modal) would race on
|
|
95
|
-
// `registerPendingSuggestionApplier(undefined, …)` and the last-mounted
|
|
96
|
-
// editor would steal every approval. Falls back to wildcard scope
|
|
97
|
-
// (`undefined`) when no form is up-tree.
|
|
98
|
-
const formId = useFormId()
|
|
99
|
-
|
|
100
|
-
const parseRef = useRef(options.parseSuggestion)
|
|
101
|
-
useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
|
|
102
|
-
|
|
103
|
-
// Track which ids we've handed off to the editor's diff extension
|
|
104
|
-
// so we don't re-start the diff every render or for already-active
|
|
105
|
-
// suggestions.
|
|
106
|
-
const startedRef = useRef<Set<string>>(new Set())
|
|
107
|
-
|
|
108
|
-
// Re-evaluate the suggestion queue when the editor's doc shape
|
|
109
|
-
// changes. Specifically guards against the seed race: collab-enabled
|
|
110
|
-
// markdown/richtext editors mount empty and seed their content
|
|
111
|
-
// asynchronously after the Yjs provider syncs. A suggestion arriving
|
|
112
|
-
// during that window (or before the first user keystroke) sees an
|
|
113
|
-
// empty doc, `planReplaceBlock` returns null for any blockIndex >= 1,
|
|
114
|
-
// the effect bails — and never re-runs because `list` hasn't changed
|
|
115
|
-
// and React doesn't track ProseMirror's doc state. Watching
|
|
116
|
-
// `doc.childCount` flips the diff-start effect from "ran once at
|
|
117
|
-
// suggestion-push time" to "re-runs when the doc reaches usable
|
|
118
|
-
// shape," which closes the silent no-preview gap.
|
|
119
|
-
const childCount = useEditorState({
|
|
120
|
-
editor,
|
|
121
|
-
selector: ({ editor: ed }) => ed?.state.doc.childCount ?? 0,
|
|
122
|
-
}) ?? 0
|
|
123
|
-
|
|
124
|
-
// Context → editor: start the diff for each new whole-field /
|
|
125
|
-
// surgical-block suggestion. `meta.surgical` (if present) routes to a
|
|
126
|
-
// precise PM transaction; otherwise we treat the suggested value as a
|
|
127
|
-
// whole-field replacement. `meta.editorRange` (chip path) is filtered
|
|
128
|
-
// out — handled by AiSuggestionExtension elsewhere.
|
|
129
|
-
useEffect(() => {
|
|
130
|
-
if (!editor) return
|
|
131
|
-
const diffable = list.filter(s => !hasEditorRange(s))
|
|
132
|
-
for (const s of diffable) {
|
|
133
|
-
if (startedRef.current.has(s.id)) continue
|
|
134
|
-
const diffActive = aiInlineDiffPluginKey.getState(editor.state) !== null
|
|
135
|
-
const surgical = readSurgicalMeta(s)
|
|
136
|
-
|
|
137
|
-
// Cross-tool-call surgical stacking. When a diff is already active
|
|
138
|
-
// and a fresh surgical suggestion arrives (typically the model
|
|
139
|
-
// emitted a second `update_form_state` tool call instead of
|
|
140
|
-
// batching ops in one), fold the new modifier into the active
|
|
141
|
-
// diff. We dispatch a plain transaction with no extension meta;
|
|
142
|
-
// the plugin's existing "no explicit meta + tr.docChanged" branch
|
|
143
|
-
// adds the steps to the running changeset, so decorations update
|
|
144
|
-
// to cover both ops' ranges and the banner shows the combined
|
|
145
|
-
// count. Accept commits the union, Reject reverts to the same
|
|
146
|
-
// baseline captured when the FIRST suggestion started the diff —
|
|
147
|
-
// semantically "reject all pending suggested changes", which
|
|
148
|
-
// matches the banner copy.
|
|
149
|
-
//
|
|
150
|
-
// Whole-field suggestions still bail when a diff is active —
|
|
151
|
-
// dropping a fresh slice on top of an active review is too
|
|
152
|
-
// disruptive (it'd swap the entire doc mid-review).
|
|
153
|
-
if (diffActive) {
|
|
154
|
-
if (!surgical) continue
|
|
155
|
-
const modifier = planSurgicalModifier(editor, surgical)
|
|
156
|
-
if (!modifier) continue
|
|
157
|
-
editor.commands.command(({ tr, dispatch }) => {
|
|
158
|
-
modifier(tr)
|
|
159
|
-
if (!tr.docChanged) return false
|
|
160
|
-
if (dispatch) dispatch(tr)
|
|
161
|
-
return true
|
|
162
|
-
})
|
|
163
|
-
startedRef.current.add(s.id)
|
|
164
|
-
continue
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (surgical) {
|
|
168
|
-
const modifier = planSurgicalModifier(editor, surgical)
|
|
169
|
-
if (!modifier) continue
|
|
170
|
-
editor.commands.applySurgicalAiInlineDiff(s.id, modifier)
|
|
171
|
-
startedRef.current.add(s.id)
|
|
172
|
-
continue
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (typeof s.suggestedValue !== 'string') continue
|
|
176
|
-
const slice = parseRef.current(editor, s.suggestedValue)
|
|
177
|
-
if (!slice) continue
|
|
178
|
-
editor.commands.startAiInlineDiff(s.id, slice)
|
|
179
|
-
startedRef.current.add(s.id)
|
|
180
|
-
}
|
|
181
|
-
// Cleanup: when a suggestion leaves the context AND we previously
|
|
182
|
-
// started a diff for it, the editor should drop the diff state too.
|
|
183
|
-
// Approve dismisses via context → here we drop from startedRef.
|
|
184
|
-
const contextIds = new Set(list.map(s => s.id))
|
|
185
|
-
for (const id of Array.from(startedRef.current)) {
|
|
186
|
-
if (!contextIds.has(id)) startedRef.current.delete(id)
|
|
187
|
-
}
|
|
188
|
-
}, [editor, list, childCount])
|
|
189
|
-
|
|
190
|
-
// Cross-tree applier — two paths:
|
|
191
|
-
//
|
|
192
|
-
// 1. Review-mode accept. Banner / chat-sidebar pill calls
|
|
193
|
-
// `pendingSuggestions.approve(id)` for a suggestion we've already
|
|
194
|
-
// started a diff for. Clear the diff state — current doc IS the
|
|
195
|
-
// accepted state.
|
|
196
|
-
// 2. Auto-mode direct apply. AI tool binding bypassed the queue and
|
|
197
|
-
// called the registry's applier with a synthetic suggestion
|
|
198
|
-
// carrying `meta.surgical` (the suggestion was never pushed to
|
|
199
|
-
// the context, so it's not in `startedRef`). Plan the modifier
|
|
200
|
-
// and dispatch it as a plain transaction — no diff overlay, no
|
|
201
|
-
// Accept / Reject step. Mirrors `set_value` auto-mode, which
|
|
202
|
-
// writes directly via the same registry.
|
|
203
|
-
useEffect(() => {
|
|
204
|
-
if (!editor) return
|
|
205
|
-
const applier: PendingSuggestionApplier = (suggestion) => {
|
|
206
|
-
if (startedRef.current.has(suggestion.id)) {
|
|
207
|
-
editor.commands.acceptAiInlineDiff()
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
const surgical = readSurgicalMeta(suggestion)
|
|
211
|
-
if (!surgical) return
|
|
212
|
-
const modifier = planSurgicalModifier(editor, surgical)
|
|
213
|
-
if (!modifier) return
|
|
214
|
-
editor.commands.command(({ tr, dispatch }) => {
|
|
215
|
-
modifier(tr)
|
|
216
|
-
if (!tr.docChanged) return false
|
|
217
|
-
if (dispatch) dispatch(tr)
|
|
218
|
-
return true
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
return registerPendingSuggestionApplier(formId, fieldName, applier)
|
|
222
|
-
}, [editor, fieldName, formId])
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function hasEditorRange(s: PendingSuggestion): boolean {
|
|
226
|
-
const meta = (s.meta ?? {}) as Record<string, unknown>
|
|
227
|
-
const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
|
|
228
|
-
return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Surgical op carried in `PendingSuggestion.meta.surgical`. The pilotiq-
|
|
233
|
-
* pro `update_form_state` client handler stamps this when the AI agent
|
|
234
|
-
* picks a block-level op instead of `set_value`.
|
|
235
|
-
*
|
|
236
|
-
* `content` is HTML for replace/insert ops, ignored otherwise. `mark` +
|
|
237
|
-
* `range` apply only to the mark op. Discriminated union; readers should
|
|
238
|
-
* narrow on `op`.
|
|
239
|
-
*/
|
|
240
|
-
type SurgicalOp =
|
|
241
|
-
| { op: 'replace_block'; blockIndex: number; content: string }
|
|
242
|
-
| { op: 'insert_block_before'; blockIndex: number; content: string }
|
|
243
|
-
| { op: 'delete_block'; blockIndex: number }
|
|
244
|
-
| { op: 'update_block_mark'; blockIndex: number; mark: string; range: BlockMarkRange; apply: boolean; attrs?: Record<string, unknown> }
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Either a single op (when the AI emitted only one surgical change) or
|
|
248
|
-
* an `{ ops: [...] }` batch (when the AI emitted multiple surgical ops
|
|
249
|
-
* in one `update_form_state` tool call). We apply a batch as a single
|
|
250
|
-
* combined diff so the user sees one Accept / Reject for the whole set.
|
|
251
|
-
*/
|
|
252
|
-
type SurgicalMeta = SurgicalOp | { ops: SurgicalOp[] }
|
|
253
|
-
|
|
254
|
-
function parseSurgicalOp(obj: Record<string, unknown>): SurgicalOp | null {
|
|
255
|
-
const op = obj['op']
|
|
256
|
-
const blockIndex = obj['blockIndex']
|
|
257
|
-
if (typeof blockIndex !== 'number') return null
|
|
258
|
-
switch (op) {
|
|
259
|
-
case 'replace_block':
|
|
260
|
-
case 'insert_block_before': {
|
|
261
|
-
const content = obj['content']
|
|
262
|
-
if (typeof content !== 'string') return null
|
|
263
|
-
return { op, blockIndex, content }
|
|
264
|
-
}
|
|
265
|
-
case 'delete_block':
|
|
266
|
-
return { op, blockIndex }
|
|
267
|
-
case 'update_block_mark': {
|
|
268
|
-
const mark = obj['mark']
|
|
269
|
-
const range = obj['range'] as { from?: unknown; to?: unknown } | undefined
|
|
270
|
-
const apply = obj['apply']
|
|
271
|
-
const attrs = obj['attrs']
|
|
272
|
-
if (typeof mark !== 'string') return null
|
|
273
|
-
if (!range || typeof range.from !== 'number' || typeof range.to !== 'number') return null
|
|
274
|
-
if (typeof apply !== 'boolean') return null
|
|
275
|
-
return {
|
|
276
|
-
op,
|
|
277
|
-
blockIndex,
|
|
278
|
-
mark,
|
|
279
|
-
range: { from: range.from, to: range.to },
|
|
280
|
-
apply,
|
|
281
|
-
...(attrs && typeof attrs === 'object' ? { attrs: attrs as Record<string, unknown> } : {}),
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
default:
|
|
285
|
-
return null
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function readSurgicalMeta(s: PendingSuggestion): SurgicalMeta | null {
|
|
290
|
-
const meta = (s.meta ?? {}) as Record<string, unknown>
|
|
291
|
-
const raw = meta['surgical']
|
|
292
|
-
if (!raw || typeof raw !== 'object') return null
|
|
293
|
-
const obj = raw as Record<string, unknown>
|
|
294
|
-
// Batch form: { ops: [SurgicalOp, ...] }
|
|
295
|
-
if (Array.isArray(obj['ops'])) {
|
|
296
|
-
const parsed: SurgicalOp[] = []
|
|
297
|
-
for (const entry of obj['ops'] as unknown[]) {
|
|
298
|
-
if (!entry || typeof entry !== 'object') continue
|
|
299
|
-
const op = parseSurgicalOp(entry as Record<string, unknown>)
|
|
300
|
-
if (op) parsed.push(op)
|
|
301
|
-
}
|
|
302
|
-
if (parsed.length === 0) return null
|
|
303
|
-
return { ops: parsed }
|
|
304
|
-
}
|
|
305
|
-
return parseSurgicalOp(obj)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function planOp(editor: Editor, op: SurgicalOp): TransactionModifier | null {
|
|
309
|
-
switch (op.op) {
|
|
310
|
-
case 'replace_block': return planReplaceBlock(editor, op.blockIndex, op.content)
|
|
311
|
-
case 'insert_block_before': return planInsertBlockBefore(editor, op.blockIndex, op.content)
|
|
312
|
-
case 'delete_block': return planDeleteBlock(editor, op.blockIndex)
|
|
313
|
-
case 'update_block_mark': return planUpdateBlockMark(editor, op.blockIndex, op.mark, op.range, op.apply, op.attrs)
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Translate a surgical meta into a single TransactionModifier the diff
|
|
319
|
-
* extension can wrap with a snapshot. For batches, modifiers are
|
|
320
|
-
* computed against the original (pre-transaction) doc and then applied
|
|
321
|
-
* in DESC `blockIndex` order — each subsequent modifier touches earlier
|
|
322
|
-
* positions, so the prior modifiers' edits (at higher positions) don't
|
|
323
|
-
* shift the absolute positions the later modifiers were planned with.
|
|
324
|
-
*
|
|
325
|
-
* Returns null when the batch has no plannable ops (all out-of-range /
|
|
326
|
-
* unparseable). Drops individual non-plannable ops from a batch but
|
|
327
|
-
* still runs whatever did plan, so a single bad op doesn't kill the
|
|
328
|
-
* whole batch.
|
|
329
|
-
*/
|
|
330
|
-
function planSurgicalModifier(editor: Editor, surgical: SurgicalMeta): TransactionModifier | null {
|
|
331
|
-
if ('ops' in surgical) {
|
|
332
|
-
const sorted = [...surgical.ops].sort((a, b) => b.blockIndex - a.blockIndex)
|
|
333
|
-
const modifiers: TransactionModifier[] = []
|
|
334
|
-
for (const op of sorted) {
|
|
335
|
-
const mod = planOp(editor, op)
|
|
336
|
-
if (mod) modifiers.push(mod)
|
|
337
|
-
}
|
|
338
|
-
if (modifiers.length === 0) return null
|
|
339
|
-
return (tr) => { for (const mod of modifiers) mod(tr) }
|
|
340
|
-
}
|
|
341
|
-
return planOp(editor, surgical)
|
|
342
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react'
|
|
2
|
-
import type { Editor } from '@tiptap/core'
|
|
3
|
-
import {
|
|
4
|
-
registerPendingSuggestionApplier,
|
|
5
|
-
usePendingSuggestionsForField,
|
|
6
|
-
useFormId,
|
|
7
|
-
type PendingSuggestion,
|
|
8
|
-
type PendingSuggestionApplier,
|
|
9
|
-
} from '@pilotiq/pilotiq/react'
|
|
10
|
-
import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Two-way sync between the cross-package `<PendingSuggestionsContext>`
|
|
14
|
-
* queue and this editor's `AiSuggestionExtension` state.
|
|
15
|
-
*
|
|
16
|
-
* - **Context → editor**: every entry whose `meta.editorRange = { from, to }`
|
|
17
|
-
* is present and whose `suggestedValue` is a string gets pushed into the
|
|
18
|
-
* editor as an inline-diff hunk via `addAiSuggestion`. Entries leaving the
|
|
19
|
-
* queue are removed from the editor via `rejectAiSuggestion` (no doc edit).
|
|
20
|
-
*
|
|
21
|
-
* - **Editor → context**: when a chip's Approve / Reject button removes a
|
|
22
|
-
* hunk from the editor's plugin state, the matching id is dismissed from
|
|
23
|
-
* the queue (`dismiss(id)`) so other surfaces (e.g. the chat-sidebar pill,
|
|
24
|
-
* a future FieldShell overlay) clear in lock-step. The doc mutation
|
|
25
|
-
* itself happens inside the editor — context is just a notification.
|
|
26
|
-
*
|
|
27
|
-
* Cycle protection: the hook tracks which ids it has personally pushed to
|
|
28
|
-
* the editor (`pushed`). The Context→editor pass never re-pushes an id that's
|
|
29
|
-
* already there, and the Editor→context pass only dismisses ids that this
|
|
30
|
-
* hook had previously pushed (so an id added directly by host code via
|
|
31
|
-
* `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
|
|
32
|
-
* a context that never knew about it).
|
|
33
|
-
*
|
|
34
|
-
* **Whole-field fallback** (chat-driven suggestions). Producers like
|
|
35
|
-
* `@pilotiq-pro/ai`'s `update_form_state` tool push suggestions that target
|
|
36
|
-
* the whole field — no `meta.editorRange`, just `suggestedValue` as a string.
|
|
37
|
-
* Without `editorRange` the bridge can't render the inline-diff chip widget
|
|
38
|
-
* (it has nowhere to anchor), so the host renderer passes an
|
|
39
|
-
* `onApplyWholeField(value)` callback. When the chat-sidebar Approve fires
|
|
40
|
-
* for a non-bridge-pushed id, the registered applier calls this callback
|
|
41
|
-
* instead of no-op'ing — letting each renderer apply the suggestion the
|
|
42
|
-
* right way for its shape (plain text → `plainTextToDoc`, markdown → set
|
|
43
|
-
* markdown source, richtext → setContent with HTML/JSON). The host is also
|
|
44
|
-
* responsible for the Approve UI — FieldShell hides its legacy overlay
|
|
45
|
-
* whenever a Tiptap renderer is mounted (richtext / markdown / collab text).
|
|
46
|
-
*/
|
|
47
|
-
export interface UseAiSuggestionBridgeOptions {
|
|
48
|
-
/**
|
|
49
|
-
* Apply a whole-field suggestion that lacks `meta.editorRange`. Each
|
|
50
|
-
* Tiptap renderer passes its own implementation (different content
|
|
51
|
-
* shapes — plain text, markdown source, HTML/JSON). Omit for editors
|
|
52
|
-
* that should ignore whole-field suggestions entirely.
|
|
53
|
-
*/
|
|
54
|
-
onApplyWholeField?: (suggestedValue: string) => void
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Synthesize a `{ from, to }` range for whole-field suggestions so the
|
|
58
|
-
* inline-diff chip widget can render BEFORE the user approves. The
|
|
59
|
-
* extension's `applyApprove` inserts a plain text node spanning the
|
|
60
|
-
* synthesized range — safe only for editors whose schema accepts a
|
|
61
|
-
* text-node replacement covering the whole doc (CollabTextRenderer's
|
|
62
|
-
* plain-text schema fits; richtext / markdown lose formatting if
|
|
63
|
-
* approved that way). Return `undefined` to skip synthesis and fall
|
|
64
|
-
* through to the legacy `onApplyWholeField` callback (silent swap).
|
|
65
|
-
*/
|
|
66
|
-
synthesizeWholeFieldRange?: (
|
|
67
|
-
editor: Editor,
|
|
68
|
-
suggestion: PendingSuggestion,
|
|
69
|
-
) => { from: number; to: number } | undefined
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function useAiSuggestionBridge(
|
|
73
|
-
editor: Editor | null,
|
|
74
|
-
fieldName: string,
|
|
75
|
-
options: UseAiSuggestionBridgeOptions = {},
|
|
76
|
-
): void {
|
|
77
|
-
const { list, dismiss } = usePendingSuggestionsForField(fieldName)
|
|
78
|
-
// Scope the applier under the surrounding form's id — same reasoning
|
|
79
|
-
// as `useAiInlineDiff`: two editors with the same field name across
|
|
80
|
-
// different forms (main edit form vs. a Replicate modal, say) would
|
|
81
|
-
// otherwise race on `registerPendingSuggestionApplier(undefined, …)`
|
|
82
|
-
// and the last-mounted editor would steal every approval.
|
|
83
|
-
const formId = useFormId()
|
|
84
|
-
|
|
85
|
-
// Hold the latest `dismiss` in a ref so the editor-side listener — which
|
|
86
|
-
// installs once per editor — always reaches the up-to-date context API.
|
|
87
|
-
const dismissRef = useRef(dismiss)
|
|
88
|
-
useEffect(() => { dismissRef.current = dismiss }, [dismiss])
|
|
89
|
-
|
|
90
|
-
// Same ref pattern for the whole-field applier — captured here so the
|
|
91
|
-
// applier closure registered below stays stable across re-renders without
|
|
92
|
-
// re-registering on every option change.
|
|
93
|
-
const onApplyWholeFieldRef = useRef(options.onApplyWholeField)
|
|
94
|
-
useEffect(() => { onApplyWholeFieldRef.current = options.onApplyWholeField }, [options.onApplyWholeField])
|
|
95
|
-
const synthesizeRangeRef = useRef(options.synthesizeWholeFieldRange)
|
|
96
|
-
useEffect(() => { synthesizeRangeRef.current = options.synthesizeWholeFieldRange }, [options.synthesizeWholeFieldRange])
|
|
97
|
-
|
|
98
|
-
// Set of ids this hook pushed; used by both directions for cycle control.
|
|
99
|
-
const pushedRef = useRef<Set<string>>(new Set())
|
|
100
|
-
// Subset of `pushedRef` whose range was synthesized (no producer-supplied
|
|
101
|
-
// `meta.editorRange`). Approving these must NOT route through the chip's
|
|
102
|
-
// plain-text replace — that would clobber HTML / markdown formatting on
|
|
103
|
-
// rich editors. Instead the applier delegates to `onApplyWholeField`,
|
|
104
|
-
// which sets content via the renderer's content-shape-aware command
|
|
105
|
-
// (`setContent(plainTextToDoc(...))` / `setContent(markdownSrc)` /
|
|
106
|
-
// `setContent(html)`), then clears the chip without a doc edit.
|
|
107
|
-
const synthesizedRef = useRef<Set<string>>(new Set())
|
|
108
|
-
|
|
109
|
-
// Context → editor.
|
|
110
|
-
useEffect(() => {
|
|
111
|
-
if (!editor) return
|
|
112
|
-
const contextIds = new Set(list.map(s => s.id))
|
|
113
|
-
|
|
114
|
-
for (const s of list) {
|
|
115
|
-
if (pushedRef.current.has(s.id)) continue
|
|
116
|
-
const meta = (s.meta ?? {}) as Record<string, unknown>
|
|
117
|
-
const rawRange = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
|
|
118
|
-
let range: { from: number; to: number } | undefined
|
|
119
|
-
let isSynthesized = false
|
|
120
|
-
if (rawRange && typeof rawRange.from === 'number' && typeof rawRange.to === 'number') {
|
|
121
|
-
range = { from: rawRange.from, to: rawRange.to }
|
|
122
|
-
} else {
|
|
123
|
-
// Producer didn't supply a range — let the renderer synthesize one
|
|
124
|
-
// so the inline-diff chip widget can still render. Renderers that
|
|
125
|
-
// can't safely round-trip a plain-text replace (richtext/markdown
|
|
126
|
-
// losing formatting on approve) STILL benefit by synthesizing for
|
|
127
|
-
// visualization — the applier below routes Approve through
|
|
128
|
-
// `onApplyWholeField` for synthesized ids instead of the chip's
|
|
129
|
-
// text-node replace.
|
|
130
|
-
range = synthesizeRangeRef.current?.(editor, s)
|
|
131
|
-
if (!range) continue
|
|
132
|
-
isSynthesized = true
|
|
133
|
-
}
|
|
134
|
-
const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : ''
|
|
135
|
-
editor.commands.addAiSuggestion({
|
|
136
|
-
id: s.id,
|
|
137
|
-
from: range.from,
|
|
138
|
-
to: range.to,
|
|
139
|
-
replacement,
|
|
140
|
-
...(s.source ? { source: s.source } : {}),
|
|
141
|
-
})
|
|
142
|
-
pushedRef.current.add(s.id)
|
|
143
|
-
if (isSynthesized) synthesizedRef.current.add(s.id)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
for (const id of Array.from(pushedRef.current)) {
|
|
147
|
-
if (contextIds.has(id)) continue
|
|
148
|
-
// Context dropped the suggestion — remove from editor without
|
|
149
|
-
// mutating the doc (rejectAiSuggestion drops state only).
|
|
150
|
-
editor.commands.rejectAiSuggestion(id)
|
|
151
|
-
pushedRef.current.delete(id)
|
|
152
|
-
synthesizedRef.current.delete(id)
|
|
153
|
-
}
|
|
154
|
-
}, [editor, list])
|
|
155
|
-
|
|
156
|
-
// Editor → context.
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
if (!editor) return
|
|
159
|
-
const handler = () => {
|
|
160
|
-
const ps = aiSuggestionPluginKey.getState(editor.state)
|
|
161
|
-
if (!ps) return
|
|
162
|
-
const editorIds = new Set(ps.suggestions.map((s: { id: string }) => s.id))
|
|
163
|
-
for (const id of Array.from(pushedRef.current)) {
|
|
164
|
-
if (editorIds.has(id)) continue
|
|
165
|
-
// Chip removed the suggestion (Approve mutated the doc, Reject did
|
|
166
|
-
// not — either way it's gone from editor state). Mirror to context.
|
|
167
|
-
pushedRef.current.delete(id)
|
|
168
|
-
dismissRef.current(id)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
editor.on('transaction', handler)
|
|
172
|
-
return () => { editor.off('transaction', handler) }
|
|
173
|
-
}, [editor])
|
|
174
|
-
|
|
175
|
-
// Cross-tree applier (Phase 8.5). When an aggregate consumer (e.g. a
|
|
176
|
-
// chat-sidebar pending-pill) calls `pendingSuggestions.approve(id)`,
|
|
177
|
-
// the pro provider looks up the applier registered for this
|
|
178
|
-
// `(formId, fieldName)` and invokes it. We translate that into the
|
|
179
|
-
// editor's own approve command — same path the inline chip click takes.
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
if (!editor) return
|
|
182
|
-
const applier: PendingSuggestionApplier = (suggestion) => {
|
|
183
|
-
const apply = onApplyWholeFieldRef.current
|
|
184
|
-
const hasSynthesized = synthesizedRef.current.has(suggestion.id)
|
|
185
|
-
const hasPushed = pushedRef.current.has(suggestion.id)
|
|
186
|
-
|
|
187
|
-
// Synthesized whole-field range — the chip rendered for visualization,
|
|
188
|
-
// but routing Approve through the editor's `approveAiSuggestion` would
|
|
189
|
-
// do a plain-text replace and clobber HTML / markdown formatting.
|
|
190
|
-
// Delegate to the renderer-supplied applier (content-shape-aware)
|
|
191
|
-
// and clear the chip state without a doc edit.
|
|
192
|
-
if (hasSynthesized && apply && typeof suggestion.suggestedValue === 'string') {
|
|
193
|
-
apply(suggestion.suggestedValue)
|
|
194
|
-
editor.commands.rejectAiSuggestion(suggestion.id)
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
// Producer-supplied editor range — surgical edit. Forward Approve to
|
|
198
|
-
// the editor command; the transaction listener above mirrors the
|
|
199
|
-
// dismiss back into context.
|
|
200
|
-
if (hasPushed) {
|
|
201
|
-
editor.chain().focus().approveAiSuggestion(suggestion.id).run()
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
// Whole-field path WITHOUT visualization — producer skipped the range
|
|
205
|
-
// and the renderer didn't synthesize. Same applier as above, no chip
|
|
206
|
-
// to clear. Context's `approve()` dismisses the queue entry.
|
|
207
|
-
if (apply && typeof suggestion.suggestedValue === 'string') {
|
|
208
|
-
apply(suggestion.suggestedValue)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// formId comes from `useFormId()` on the form-context side — scopes
|
|
212
|
-
// the registration per-form so multi-form pages route approvals to
|
|
213
|
-
// the matching editor. Falls back to wildcard (`undefined`) when no
|
|
214
|
-
// FormRenderer is up-tree (modal action schemas, action-modal forms
|
|
215
|
-
// mounted outside the main page form).
|
|
216
|
-
return registerPendingSuggestionApplier(formId, fieldName, applier)
|
|
217
|
-
}, [editor, fieldName, formId])
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Re-export the pending-suggestion type for consumers that import the hook
|
|
221
|
-
// from this module directly — saves them a separate `@pilotiq/pilotiq/react`
|
|
222
|
-
// import when wiring an external producer.
|
|
223
|
-
export type { PendingSuggestion }
|
package/src/register.test.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { getFieldRenderer } from '@pilotiq/pilotiq/react'
|
|
4
|
-
|
|
5
|
-
import { registerTiptap } from './register.js'
|
|
6
|
-
|
|
7
|
-
describe('registerTiptap', () => {
|
|
8
|
-
it('installs a renderer for fieldType="richtext"', () => {
|
|
9
|
-
assert.equal(getFieldRenderer('richtext'), undefined)
|
|
10
|
-
registerTiptap()
|
|
11
|
-
const renderer = getFieldRenderer('richtext')
|
|
12
|
-
assert.equal(typeof renderer, 'function')
|
|
13
|
-
})
|
|
14
|
-
})
|
package/src/register.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { registerFieldRenderer, registerCollabTextRenderer, registerMarkdownEditor } from '@pilotiq/pilotiq/react'
|
|
2
|
-
import { registerRichTextRenderer } from '@pilotiq/pilotiq/richtext'
|
|
3
|
-
import { TiptapEditor } from './react/TiptapEditor.js'
|
|
4
|
-
import { CollabTextRenderer } from './react/CollabTextRenderer.js'
|
|
5
|
-
import { MarkdownEditor } from './react/MarkdownEditor.js'
|
|
6
|
-
import { renderRichTextToHtml, isRichTextValue } from './render.js'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Register the Tiptap editor as the pilotiq renderer for `fieldType: 'richtext'`.
|
|
10
|
-
*
|
|
11
|
-
* Call once in your app's client-side entry point:
|
|
12
|
-
*
|
|
13
|
-
* ```ts
|
|
14
|
-
* import { registerTiptap } from '@pilotiq/tiptap'
|
|
15
|
-
* registerTiptap()
|
|
16
|
-
* ```
|
|
17
|
-
*
|
|
18
|
-
* Also wires the read-side renderer (`@pilotiq/pilotiq/richtext`) so that
|
|
19
|
-
* `TextEntry` / `TextColumn` auto-detect Tiptap content and render finished
|
|
20
|
-
* HTML on `ViewPage` / list tables — without shipping the editor parser to
|
|
21
|
-
* display-only pages.
|
|
22
|
-
*
|
|
23
|
-
* Without this call, `RichTextField` form fields render as nothing —
|
|
24
|
-
* pilotiq's SchemaRenderer can't find a renderer for the `'richtext'` type.
|
|
25
|
-
*/
|
|
26
|
-
export function registerTiptap(): void {
|
|
27
|
-
registerFieldRenderer('richtext', TiptapEditor)
|
|
28
|
-
registerRichTextRenderer(renderRichTextToHtml, isRichTextValue)
|
|
29
|
-
// Phase B — opt every plain-text field in the panel into y-prosemirror
|
|
30
|
-
// backing when collab is on. `TextLikeInput` checks this registry; if it's
|
|
31
|
-
// populated AND a `<RecordCollabRoom>` is up-tree AND the field hasn't opted
|
|
32
|
-
// out via `.collab(false)`, the renderer mounts `CollabTextRenderer`
|
|
33
|
-
// instead of the legacy `Y.Text` + `computeDelta` + `preserveCursor` path.
|
|
34
|
-
registerCollabTextRenderer(CollabTextRenderer)
|
|
35
|
-
// WYSIWYG markdown editor — replaces `MarkdownField`'s legacy textarea +
|
|
36
|
-
// manual-toolbar path with a real rich editor that serializes to markdown
|
|
37
|
-
// via `tiptap-markdown` on every change. Collab-aware on the same
|
|
38
|
-
// `useCollabRoom()` + `getCollabExtensions()` plumbing as the rich-text
|
|
39
|
-
// editor. Without `@pilotiq/tiptap` installed, `MarkdownInput` falls back
|
|
40
|
-
// to the textarea path so panels that skip the adapter still work.
|
|
41
|
-
registerMarkdownEditor(MarkdownEditor)
|
|
42
|
-
}
|