@pilotiq/tiptap 3.5.0 → 3.7.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.
Files changed (38) hide show
  1. package/dist/extensions/AiInlineDiffExtension.d.ts +96 -0
  2. package/dist/extensions/AiInlineDiffExtension.d.ts.map +1 -0
  3. package/dist/extensions/AiInlineDiffExtension.js +216 -0
  4. package/dist/extensions/AiInlineDiffExtension.js.map +1 -0
  5. package/dist/extensions/AiSuggestionExtension.d.ts.map +1 -1
  6. package/dist/extensions/AiSuggestionExtension.js +51 -0
  7. package/dist/extensions/AiSuggestionExtension.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/react/AiSuggestionBanner.d.ts +72 -0
  13. package/dist/react/AiSuggestionBanner.d.ts.map +1 -0
  14. package/dist/react/AiSuggestionBanner.js +72 -0
  15. package/dist/react/AiSuggestionBanner.js.map +1 -0
  16. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  17. package/dist/react/MarkdownEditor.js +58 -20
  18. package/dist/react/MarkdownEditor.js.map +1 -1
  19. package/dist/react/TiptapEditor.d.ts.map +1 -1
  20. package/dist/react/TiptapEditor.js +43 -18
  21. package/dist/react/TiptapEditor.js.map +1 -1
  22. package/dist/react/useAiInlineDiff.d.ts +57 -0
  23. package/dist/react/useAiInlineDiff.d.ts.map +1 -0
  24. package/dist/react/useAiInlineDiff.js +218 -0
  25. package/dist/react/useAiInlineDiff.js.map +1 -0
  26. package/dist/surgicalOps.d.ts +72 -0
  27. package/dist/surgicalOps.d.ts.map +1 -0
  28. package/dist/surgicalOps.js +160 -0
  29. package/dist/surgicalOps.js.map +1 -0
  30. package/package.json +24 -22
  31. package/src/extensions/AiInlineDiffExtension.ts +286 -0
  32. package/src/extensions/AiSuggestionExtension.ts +51 -0
  33. package/src/index.ts +15 -0
  34. package/src/react/AiSuggestionBanner.tsx +184 -0
  35. package/src/react/MarkdownEditor.tsx +58 -19
  36. package/src/react/TiptapEditor.tsx +44 -16
  37. package/src/react/useAiInlineDiff.ts +267 -0
  38. package/src/surgicalOps.ts +186 -0
@@ -20,6 +20,9 @@ import type {
20
20
  } from '@pilotiq/pilotiq/react'
21
21
  import { useCollabRoom, getCollabExtensions, onProviderSynced } from '@pilotiq/pilotiq/react'
22
22
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
23
+ import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
24
+ import { AiSuggestionBanner } from './AiSuggestionBanner.js'
25
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
23
26
  import type { BlockMeta } from '../Block.js'
24
27
  import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
25
28
  import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
@@ -31,6 +34,7 @@ import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
31
34
  import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
32
35
  import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
33
36
  import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
37
+ import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js'
34
38
  import {
35
39
  MentionExtension,
36
40
  type MentionState,
@@ -315,10 +319,11 @@ function ClientEditor(props: ClientEditorProps) {
315
319
  fieldName: name,
316
320
  })] : [MentionExtension]),
317
321
  DragHandleExtension,
318
- // AI suggestions — always-on extension that tracks suggested edits as
319
- // inline strikethrough + Approve/Reject chip widgets. Idle until the
320
- // host calls `editor.commands.addAiSuggestion(...)`.
322
+ // AI suggestions — chip widget for surgical (range-anchored) edits.
321
323
  AiSuggestionExtension,
324
+ // AI inline diff — Tiptap-Pro-style visualization for whole-field
325
+ // suggestions via prosemirror-changeset. See AiInlineDiffExtension.
326
+ AiInlineDiffExtension,
322
327
  // Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
323
328
  // when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
324
329
  // registered a factory via `registerCollabExtensions`.
@@ -464,22 +469,35 @@ function ClientEditor(props: ClientEditorProps) {
464
469
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
465
470
  // extension. No-op when no provider is mounted (default no-op context).
466
471
  //
467
- // Whole-field handling: rare for RichTextField (chat-driven flows usually
468
- // emit range-anchored suggestions for richtext), but `update_form_state`
469
- // can still arrive with `set_value` on a rich field. The chip widget
470
- // renders over the whole doc for visualization; Approve routes through
471
- // `onApplyWholeField` so `setContent` parses the new HTML / JSON
472
- // correctly the chip's plain-text replace would lose all formatting.
472
+ // Whole-field handling: NO chip widget here. The chip's `textContent`
473
+ // renderer would surface raw HTML tags as literal text inside the
474
+ // green pill unparseable on multi-paragraph rewrites. Instead,
475
+ // `<AiSuggestionBanner>` mounts above the editor (see render below).
476
+ // Producer-supplied range suggestions still ride the inline chip
477
+ // those have a precise anchor worth visualizing in context.
478
+ const applyWholeField = (value: string): void => {
479
+ if (!editor || editor.isDestroyed) return
480
+ editor.commands.setContent(value)
481
+ }
473
482
  useAiSuggestionBridge(editor ?? null, name, {
474
- synthesizeWholeFieldRange: (ed) => ({
475
- from: 0,
476
- to: ed.state.doc.content.size,
477
- }),
478
- onApplyWholeField: (value) => {
479
- if (!editor || editor.isDestroyed) return
480
- editor.commands.setContent(value)
483
+ onApplyWholeField: applyWholeField,
484
+ })
485
+
486
+ // Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
487
+ // HTML ProseMirror Slice via the schema's DOMParser. Suggested values
488
+ // on a RichTextField are typically HTML (or marked-up JSON that the
489
+ // schema's DOMParser also handles via its serialized round-trip). For
490
+ // JSON suggestions, the schema may reject — falls back to banner-only.
491
+ useAiInlineDiff(editor ?? null, name, {
492
+ parseSuggestion: (ed, value) => {
493
+ try {
494
+ const container = document.createElement('div')
495
+ container.innerHTML = value
496
+ return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
497
+ } catch { return null }
481
498
  },
482
499
  })
500
+ const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
483
501
 
484
502
  // Re-render the toolbar when the selection / marks change so active-state
485
503
  // booleans stay fresh.
@@ -488,6 +506,16 @@ function ClientEditor(props: ClientEditorProps) {
488
506
  return (
489
507
  <div className="relative flex flex-col">
490
508
  <input type="hidden" name={name} value={serialized} />
509
+ <AiSuggestionBanner
510
+ fieldName={name}
511
+ onApplyWholeField={applyWholeField}
512
+ {...(isDiffActive && editor
513
+ ? {
514
+ onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
515
+ onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
516
+ }
517
+ : {})}
518
+ />
491
519
  {editor && toolbarGroups && toolbarGroups.length > 0 && (
492
520
  <Toolbar
493
521
  editor={editor}
@@ -0,0 +1,267 @@
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
+ type PendingSuggestion,
41
+ type PendingSuggestionApplier,
42
+ } from '@pilotiq/pilotiq/react'
43
+ import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
44
+ import {
45
+ planReplaceBlock,
46
+ planInsertBlockBefore,
47
+ planDeleteBlock,
48
+ planUpdateBlockMark,
49
+ type BlockMarkRange,
50
+ type TransactionModifier,
51
+ } from '../surgicalOps.js'
52
+
53
+ export interface UseAiInlineDiffOptions {
54
+ /**
55
+ * Parse the suggested string value into a ProseMirror Slice that's
56
+ * compatible with this editor's schema. Returns `null` to skip (e.g.
57
+ * malformed content, unsupported markup) — the suggestion stays in
58
+ * the queue but no diff renders, and the host's fallback path (banner
59
+ * with `onApplyWholeField`) takes over.
60
+ *
61
+ * Renderers implement this per content shape:
62
+ * - plain text → wrap each line in a `paragraph` node
63
+ * - markdown → run through the Markdown extension's parseMarkdown
64
+ * - HTML → DOMParser + ProseMirror's DOMParser.parse
65
+ */
66
+ parseSuggestion: (editor: Editor, value: string) => Slice | null
67
+ }
68
+
69
+ /**
70
+ * Returns whether a diff is currently active in the editor. Hosts use
71
+ * this to gate the banner's UI between the legacy `onApplyWholeField`
72
+ * mode and the diff-aware mode (Reject routes through
73
+ * `rejectAiInlineDiff` to revert the doc).
74
+ */
75
+ export function useIsAiInlineDiffActive(editor: Editor | null): boolean {
76
+ const active = useEditorState({
77
+ editor,
78
+ selector: ({ editor: ed }) => !!ed && aiInlineDiffPluginKey.getState(ed.state) !== null,
79
+ })
80
+ return active ?? false
81
+ }
82
+
83
+ export function useAiInlineDiff(
84
+ editor: Editor | null,
85
+ fieldName: string,
86
+ options: UseAiInlineDiffOptions,
87
+ ): void {
88
+ const { list } = usePendingSuggestionsForField(fieldName)
89
+
90
+ const parseRef = useRef(options.parseSuggestion)
91
+ useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
92
+
93
+ // Track which ids we've handed off to the editor's diff extension
94
+ // so we don't re-start the diff every render or for already-active
95
+ // suggestions.
96
+ const startedRef = useRef<Set<string>>(new Set())
97
+
98
+ // Context → editor: start the diff for each new whole-field /
99
+ // surgical-block suggestion. `meta.surgical` (if present) routes to a
100
+ // precise PM transaction; otherwise we treat the suggested value as a
101
+ // whole-field replacement. `meta.editorRange` (chip path) is filtered
102
+ // out — handled by AiSuggestionExtension elsewhere.
103
+ useEffect(() => {
104
+ if (!editor) return
105
+ const diffable = list.filter(s => !hasEditorRange(s))
106
+ for (const s of diffable) {
107
+ if (startedRef.current.has(s.id)) continue
108
+ // Bail when a different diff is already showing — one at a time.
109
+ // Producer should serialize calls; if not, the second suggestion
110
+ // sits in the queue until the first is approved/rejected.
111
+ if (aiInlineDiffPluginKey.getState(editor.state) !== null) continue
112
+
113
+ const surgical = readSurgicalMeta(s)
114
+ if (surgical) {
115
+ const modifier = planSurgicalModifier(editor, surgical)
116
+ if (!modifier) continue
117
+ editor.commands.applySurgicalAiInlineDiff(s.id, modifier)
118
+ startedRef.current.add(s.id)
119
+ continue
120
+ }
121
+
122
+ if (typeof s.suggestedValue !== 'string') continue
123
+ const slice = parseRef.current(editor, s.suggestedValue)
124
+ if (!slice) continue
125
+ editor.commands.startAiInlineDiff(s.id, slice)
126
+ startedRef.current.add(s.id)
127
+ }
128
+ // Cleanup: when a suggestion leaves the context AND we previously
129
+ // started a diff for it, the editor should drop the diff state too.
130
+ // Approve dismisses via context → here we drop from startedRef.
131
+ const contextIds = new Set(list.map(s => s.id))
132
+ for (const id of Array.from(startedRef.current)) {
133
+ if (!contextIds.has(id)) startedRef.current.delete(id)
134
+ }
135
+ }, [editor, list])
136
+
137
+ // Cross-tree applier — when the banner / chat-sidebar pill calls
138
+ // `pendingSuggestions.approve(id)` for one of our tracked suggestions,
139
+ // accept the diff. Editor is the source of truth for the new doc.
140
+ useEffect(() => {
141
+ if (!editor) return
142
+ const applier: PendingSuggestionApplier = (suggestion) => {
143
+ if (!startedRef.current.has(suggestion.id)) return
144
+ editor.commands.acceptAiInlineDiff()
145
+ }
146
+ return registerPendingSuggestionApplier(undefined, fieldName, applier)
147
+ }, [editor, fieldName])
148
+ }
149
+
150
+ function hasEditorRange(s: PendingSuggestion): boolean {
151
+ const meta = (s.meta ?? {}) as Record<string, unknown>
152
+ const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
153
+ return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
154
+ }
155
+
156
+ /**
157
+ * Surgical op carried in `PendingSuggestion.meta.surgical`. The pilotiq-
158
+ * pro `update_form_state` client handler stamps this when the AI agent
159
+ * picks a block-level op instead of `set_value`.
160
+ *
161
+ * `content` is HTML for replace/insert ops, ignored otherwise. `mark` +
162
+ * `range` apply only to the mark op. Discriminated union; readers should
163
+ * narrow on `op`.
164
+ */
165
+ type SurgicalOp =
166
+ | { op: 'replace_block'; blockIndex: number; content: string }
167
+ | { op: 'insert_block_before'; blockIndex: number; content: string }
168
+ | { op: 'delete_block'; blockIndex: number }
169
+ | { op: 'update_block_mark'; blockIndex: number; mark: string; range: BlockMarkRange; apply: boolean; attrs?: Record<string, unknown> }
170
+
171
+ /**
172
+ * Either a single op (when the AI emitted only one surgical change) or
173
+ * an `{ ops: [...] }` batch (when the AI emitted multiple surgical ops
174
+ * in one `update_form_state` tool call). We apply a batch as a single
175
+ * combined diff so the user sees one Accept / Reject for the whole set.
176
+ */
177
+ type SurgicalMeta = SurgicalOp | { ops: SurgicalOp[] }
178
+
179
+ function parseSurgicalOp(obj: Record<string, unknown>): SurgicalOp | null {
180
+ const op = obj['op']
181
+ const blockIndex = obj['blockIndex']
182
+ if (typeof blockIndex !== 'number') return null
183
+ switch (op) {
184
+ case 'replace_block':
185
+ case 'insert_block_before': {
186
+ const content = obj['content']
187
+ if (typeof content !== 'string') return null
188
+ return { op, blockIndex, content }
189
+ }
190
+ case 'delete_block':
191
+ return { op, blockIndex }
192
+ case 'update_block_mark': {
193
+ const mark = obj['mark']
194
+ const range = obj['range'] as { from?: unknown; to?: unknown } | undefined
195
+ const apply = obj['apply']
196
+ const attrs = obj['attrs']
197
+ if (typeof mark !== 'string') return null
198
+ if (!range || typeof range.from !== 'number' || typeof range.to !== 'number') return null
199
+ if (typeof apply !== 'boolean') return null
200
+ return {
201
+ op,
202
+ blockIndex,
203
+ mark,
204
+ range: { from: range.from, to: range.to },
205
+ apply,
206
+ ...(attrs && typeof attrs === 'object' ? { attrs: attrs as Record<string, unknown> } : {}),
207
+ }
208
+ }
209
+ default:
210
+ return null
211
+ }
212
+ }
213
+
214
+ function readSurgicalMeta(s: PendingSuggestion): SurgicalMeta | null {
215
+ const meta = (s.meta ?? {}) as Record<string, unknown>
216
+ const raw = meta['surgical']
217
+ if (!raw || typeof raw !== 'object') return null
218
+ const obj = raw as Record<string, unknown>
219
+ // Batch form: { ops: [SurgicalOp, ...] }
220
+ if (Array.isArray(obj['ops'])) {
221
+ const parsed: SurgicalOp[] = []
222
+ for (const entry of obj['ops'] as unknown[]) {
223
+ if (!entry || typeof entry !== 'object') continue
224
+ const op = parseSurgicalOp(entry as Record<string, unknown>)
225
+ if (op) parsed.push(op)
226
+ }
227
+ if (parsed.length === 0) return null
228
+ return { ops: parsed }
229
+ }
230
+ return parseSurgicalOp(obj)
231
+ }
232
+
233
+ function planOp(editor: Editor, op: SurgicalOp): TransactionModifier | null {
234
+ switch (op.op) {
235
+ case 'replace_block': return planReplaceBlock(editor, op.blockIndex, op.content)
236
+ case 'insert_block_before': return planInsertBlockBefore(editor, op.blockIndex, op.content)
237
+ case 'delete_block': return planDeleteBlock(editor, op.blockIndex)
238
+ case 'update_block_mark': return planUpdateBlockMark(editor, op.blockIndex, op.mark, op.range, op.apply, op.attrs)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Translate a surgical meta into a single TransactionModifier the diff
244
+ * extension can wrap with a snapshot. For batches, modifiers are
245
+ * computed against the original (pre-transaction) doc and then applied
246
+ * in DESC `blockIndex` order — each subsequent modifier touches earlier
247
+ * positions, so the prior modifiers' edits (at higher positions) don't
248
+ * shift the absolute positions the later modifiers were planned with.
249
+ *
250
+ * Returns null when the batch has no plannable ops (all out-of-range /
251
+ * unparseable). Drops individual non-plannable ops from a batch but
252
+ * still runs whatever did plan, so a single bad op doesn't kill the
253
+ * whole batch.
254
+ */
255
+ function planSurgicalModifier(editor: Editor, surgical: SurgicalMeta): TransactionModifier | null {
256
+ if ('ops' in surgical) {
257
+ const sorted = [...surgical.ops].sort((a, b) => b.blockIndex - a.blockIndex)
258
+ const modifiers: TransactionModifier[] = []
259
+ for (const op of sorted) {
260
+ const mod = planOp(editor, op)
261
+ if (mod) modifiers.push(mod)
262
+ }
263
+ if (modifiers.length === 0) return null
264
+ return (tr) => { for (const mod of modifiers) mod(tr) }
265
+ }
266
+ return planOp(editor, surgical)
267
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Surgical block-op planners for AI-driven precise edits.
3
+ *
4
+ * Each planner takes the editor + a logical block index + a payload and
5
+ * returns a `TransactionModifier` — a function the caller (typically
6
+ * `useAiInlineDiff`) feeds into
7
+ * `editor.commands.applySurgicalAiInlineDiff(id, modifier)`. The diff
8
+ * extension wraps the modifier in a snapshot-then-apply step so the
9
+ * inline-diff overlay renders against the precise changed range.
10
+ *
11
+ * "Block index" refers to a 0-based position across the doc's top-level
12
+ * children — what the AI agent sees as a numbered structural summary.
13
+ * Planners translate that to absolute ProseMirror positions internally.
14
+ *
15
+ * Planners return `null` when the request can't be satisfied (out-of-
16
+ * range index, unparseable HTML, unknown mark). Callers should treat
17
+ * `null` as "abort the surgical op" and surface a clear error to the
18
+ * agent so it can retry with a different plan.
19
+ */
20
+
21
+ import type { Editor } from '@tiptap/core'
22
+ import type { Transaction } from '@tiptap/pm/state'
23
+ import type { Mark, MarkType, Node as ProseMirrorNode } from '@tiptap/pm/model'
24
+ import { DOMParser as PMDOMParser } from '@tiptap/pm/model'
25
+
26
+ export type TransactionModifier = (tr: Transaction) => void
27
+
28
+ /** Resolve the start position of the top-level child at `blockIndex`. */
29
+ function blockStartPos(doc: ProseMirrorNode, blockIndex: number): number | null {
30
+ if (!Number.isInteger(blockIndex) || blockIndex < 0 || blockIndex >= doc.childCount) return null
31
+ let pos = 0
32
+ for (let i = 0; i < blockIndex; i++) pos += doc.child(i).nodeSize
33
+ return pos
34
+ }
35
+
36
+ /**
37
+ * Parse an HTML fragment into a doc-replaceable Slice. Uses the
38
+ * editor's own schema so block types unknown to the schema fail loudly
39
+ * rather than silently degrading.
40
+ *
41
+ * Returns `null` when DOM isn't available (SSR — shouldn't happen here,
42
+ * but keeps the planner safe).
43
+ */
44
+ function parseHtmlToSlice(editor: Editor, html: string): ReturnType<typeof PMDOMParser.prototype.parseSlice> | null {
45
+ if (typeof document === 'undefined') return null
46
+ const container = document.createElement('div')
47
+ container.innerHTML = html
48
+ return PMDOMParser.fromSchema(editor.schema).parseSlice(container)
49
+ }
50
+
51
+ /**
52
+ * Replace the top-level block at `blockIndex` with the parsed content.
53
+ * Caller-supplied `content` is HTML — multiple top-level nodes are
54
+ * allowed and will all land where the original block was.
55
+ */
56
+ export function planReplaceBlock(
57
+ editor: Editor,
58
+ blockIndex: number,
59
+ content: string,
60
+ ): TransactionModifier | null {
61
+ const doc = editor.state.doc
62
+ const start = blockStartPos(doc, blockIndex)
63
+ if (start === null) return null
64
+ const slice = parseHtmlToSlice(editor, content)
65
+ if (!slice) return null
66
+ const end = start + doc.child(blockIndex).nodeSize
67
+ return (tr) => { tr.replace(start, end, slice) }
68
+ }
69
+
70
+ /**
71
+ * Insert one or more top-level nodes (parsed from `content` HTML)
72
+ * before the block at `blockIndex`. `blockIndex === doc.childCount`
73
+ * appends at the end.
74
+ */
75
+ export function planInsertBlockBefore(
76
+ editor: Editor,
77
+ blockIndex: number,
78
+ content: string,
79
+ ): TransactionModifier | null {
80
+ const doc = editor.state.doc
81
+ if (!Number.isInteger(blockIndex) || blockIndex < 0 || blockIndex > doc.childCount) return null
82
+ const slice = parseHtmlToSlice(editor, content)
83
+ if (!slice) return null
84
+ let pos = 0
85
+ for (let i = 0; i < blockIndex; i++) pos += doc.child(i).nodeSize
86
+ return (tr) => { tr.replace(pos, pos, slice) }
87
+ }
88
+
89
+ /**
90
+ * Delete the top-level block at `blockIndex`. Doc must retain at least
91
+ * one child after the delete (most schemas require this) — refuses to
92
+ * delete the last remaining block.
93
+ */
94
+ export function planDeleteBlock(
95
+ editor: Editor,
96
+ blockIndex: number,
97
+ ): TransactionModifier | null {
98
+ const doc = editor.state.doc
99
+ const start = blockStartPos(doc, blockIndex)
100
+ if (start === null) return null
101
+ if (doc.childCount <= 1) return null
102
+ const end = start + doc.child(blockIndex).nodeSize
103
+ return (tr) => { tr.delete(start, end) }
104
+ }
105
+
106
+ export interface BlockMarkRange {
107
+ /** 0-based text offset from the start of the block's content. */
108
+ from: number
109
+ /** Exclusive end offset. */
110
+ to: number
111
+ }
112
+
113
+ /**
114
+ * Apply or remove an inline mark on a range *within* the block at
115
+ * `blockIndex`. `range.from` / `range.to` are text offsets relative to
116
+ * the start of the block's content (so `0` is the first character of
117
+ * the block, not the start of the doc).
118
+ *
119
+ * `apply = true` sets the mark (with optional `attrs`); `apply = false`
120
+ * removes it. Unknown marks (not in the editor's schema) return `null`
121
+ * so the caller can surface a clean error to the agent.
122
+ */
123
+ export function planUpdateBlockMark(
124
+ editor: Editor,
125
+ blockIndex: number,
126
+ mark: string,
127
+ range: BlockMarkRange,
128
+ apply: boolean,
129
+ attrs?: Record<string, unknown>,
130
+ ): TransactionModifier | null {
131
+ const doc = editor.state.doc
132
+ const start = blockStartPos(doc, blockIndex)
133
+ if (start === null) return null
134
+ const markType: MarkType | undefined = editor.schema.marks[mark]
135
+ if (!markType) return null
136
+
137
+ const block = doc.child(blockIndex)
138
+ const blockInner = start + 1 // step inside the block's opening token
139
+ const contentMax = block.content.size
140
+
141
+ if (!Number.isInteger(range.from) || !Number.isInteger(range.to)) return null
142
+ const clampedFrom = Math.max(0, Math.min(range.from, contentMax))
143
+ const clampedTo = Math.max(clampedFrom, Math.min(range.to, contentMax))
144
+ if (clampedTo === clampedFrom) return null
145
+
146
+ const from = blockInner + clampedFrom
147
+ const to = blockInner + clampedTo
148
+
149
+ if (apply) {
150
+ const m: Mark = markType.create(attrs ?? null)
151
+ return (tr) => { tr.addMark(from, to, m) }
152
+ }
153
+ return (tr) => { tr.removeMark(from, to, markType) }
154
+ }
155
+
156
+ /**
157
+ * Summarize a doc's top-level structure as a numbered list the AI can
158
+ * cite by index when proposing surgical ops. Each entry includes the
159
+ * block index, node type, and a truncated text preview — enough for the
160
+ * model to identify which block it wants to modify without sending the
161
+ * whole HTML/markdown back through token-priced channels.
162
+ *
163
+ * Returns one line per top-level child:
164
+ * `[0] heading: Welcome to the docs`
165
+ * `[1] paragraph: Lorem ipsum dolor sit amet…`
166
+ * `[2] bulletList: 3 items`
167
+ */
168
+ export function summarizeBlockStructure(doc: ProseMirrorNode, maxChars = 80): string {
169
+ const lines: string[] = []
170
+ for (let i = 0; i < doc.childCount; i++) {
171
+ const node = doc.child(i)
172
+ const text = node.textContent.trim().replace(/\s+/g, ' ')
173
+ const preview = text.length === 0
174
+ ? describeStructuralNode(node)
175
+ : text.length > maxChars ? `${text.slice(0, maxChars)}…` : text
176
+ lines.push(`[${i}] ${node.type.name}: ${preview}`)
177
+ }
178
+ return lines.join('\n')
179
+ }
180
+
181
+ function describeStructuralNode(node: ProseMirrorNode): string {
182
+ const kids = node.childCount
183
+ if (kids === 0) return '(empty)'
184
+ if (kids === 1) return `1 ${node.firstChild?.type.name ?? 'child'}`
185
+ return `${kids} children`
186
+ }