@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/dist/react/CollabTextRenderer.d.ts.map +1 -1
  8. package/dist/react/CollabTextRenderer.js +4 -4
  9. package/dist/react/CollabTextRenderer.js.map +1 -1
  10. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  11. package/dist/react/MarkdownEditor.js +4 -5
  12. package/dist/react/MarkdownEditor.js.map +1 -1
  13. package/dist/react/TiptapEditor.d.ts.map +1 -1
  14. package/dist/react/TiptapEditor.js +8 -7
  15. package/dist/react/TiptapEditor.js.map +1 -1
  16. package/package.json +6 -3
  17. package/dist/collabShapes.d.ts +0 -22
  18. package/dist/collabShapes.d.ts.map +0 -1
  19. package/dist/collabShapes.js +0 -2
  20. package/dist/collabShapes.js.map +0 -1
  21. package/src/Block.ts +0 -75
  22. package/src/MentionProvider.ts +0 -153
  23. package/src/PlainTextEditor.dom.test.ts +0 -111
  24. package/src/PlainTextEditor.test.ts +0 -158
  25. package/src/PlainTextEditor.ts +0 -229
  26. package/src/RichTextField.test.ts +0 -447
  27. package/src/RichTextField.ts +0 -508
  28. package/src/collabShapes.ts +0 -22
  29. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  30. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  31. package/src/extensions/AiSuggestionExtension.ts +0 -522
  32. package/src/extensions/BlockNodeExtension.ts +0 -134
  33. package/src/extensions/DragHandleExtension.ts +0 -184
  34. package/src/extensions/GridExtension.test.ts +0 -31
  35. package/src/extensions/GridExtension.ts +0 -138
  36. package/src/extensions/MentionExtension.ts +0 -248
  37. package/src/extensions/MergeTagExtension.ts +0 -75
  38. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  39. package/src/extensions/SlashCommandExtension.ts +0 -332
  40. package/src/extensions/TextSizeMarks.ts +0 -73
  41. package/src/index.ts +0 -62
  42. package/src/markdownExtension.ts +0 -19
  43. package/src/markdownStorage.ts +0 -49
  44. package/src/plugin.test.ts +0 -19
  45. package/src/plugin.ts +0 -26
  46. package/src/react/AiSuggestionBanner.tsx +0 -185
  47. package/src/react/BlockNodeView.tsx +0 -99
  48. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  49. package/src/react/BlockSidePanel.test.ts +0 -412
  50. package/src/react/BlockSidePanel.tsx +0 -451
  51. package/src/react/CollabTextRenderer.tsx +0 -230
  52. package/src/react/FloatingToolbar.tsx +0 -304
  53. package/src/react/MarkdownEditor.tsx +0 -606
  54. package/src/react/MentionMenu.tsx +0 -120
  55. package/src/react/Palette.tsx +0 -86
  56. package/src/react/SlashMenu.tsx +0 -129
  57. package/src/react/TableFloatingToolbar.tsx +0 -154
  58. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  59. package/src/react/TiptapEditor.tsx +0 -776
  60. package/src/react/Toolbar.tsx +0 -438
  61. package/src/react/toolbarButtons.tsx +0 -579
  62. package/src/react/useAiInlineDiff.ts +0 -342
  63. package/src/react/useAiSuggestionBridge.ts +0 -223
  64. package/src/register.test.ts +0 -14
  65. package/src/register.ts +0 -42
  66. package/src/render.test.ts +0 -745
  67. package/src/render.ts +0 -480
  68. package/src/surgicalOps.ts +0 -205
  69. package/src/test/setup.ts +0 -64
package/src/render.ts DELETED
@@ -1,480 +0,0 @@
1
- /**
2
- * Server-safe Tiptap renderer.
3
- *
4
- * Walks a Tiptap JSON document (or HTML string) and returns an HTML string.
5
- * Pure function — no DOM, no Tiptap runtime, no React. Safe to call from
6
- * any server context: route handlers, page-data builders, RSC, edge.
7
- *
8
- * Output is intentionally NOT sanitized. Same posture as `Markdown` /
9
- * `Html` display primes in `@pilotiq/pilotiq` — admin-trusted authors only.
10
- * Text content gets HTML-escaped, link / image hrefs get scheme-checked,
11
- * but the surrounding markup is constructed by us, not parsed from user
12
- * input, so there's no place an unexpected tag can sneak in.
13
- *
14
- * Coverage matches what `RichTextField` ships in Phases A-G:
15
- * nodes — doc / paragraph / heading / blockquote / codeBlock / bulletList
16
- * / orderedList / listItem / horizontalRule / hardBreak / text
17
- * / image / table / tableRow / tableCell / tableHeader
18
- * / details / detailsSummary / detailsContent
19
- * / grid / gridColumn
20
- * / mergeTag / mention
21
- * marks — bold / italic / strike / underline / subscript / superscript
22
- * / code / link / textStyle (color) / highlight (color)
23
- * / lead (paragraph emphasis) / small (semantic <small>)
24
- * attrs — heading.level / orderedList.start / codeBlock.language
25
- * / paragraph.textAlign + heading.textAlign
26
- * / image.src + alt + title + width + height
27
- * / tableCell.colspan + rowspan + colwidth (also tableHeader)
28
- * / mergeTag.id / mention.id + label + trigger
29
- * custom blocks — render to `<div data-type="..." data-attrs="...">` so
30
- * consumers can replay or style by data-type.
31
- */
32
-
33
- export interface RenderRichTextOptions {
34
- /**
35
- * Override the rendering of a custom block (anything that isn't a
36
- * built-in node). Receives the raw node; return the HTML string.
37
- * Default emits `<div data-type="..." data-attrs="...">`.
38
- */
39
- renderBlock?: (node: TiptapNode) => string
40
- /**
41
- * Substitution map for `{{ tag }}` placeholders inserted via
42
- * `RichTextField.mergeTags(['name', …])`. When the renderer hits a
43
- * `mergeTag` node whose `id` is a key in this map, it emits the value
44
- * (HTML-escaped). Unmatched ids fall back to a styled `<span class="merge-tag">`
45
- * that preserves the placeholder visually.
46
- */
47
- mergeTags?: Record<string, string>
48
- /**
49
- * Override the label rendered inside a mention chip at read time. The
50
- * editor caches the label on insert (so static snapshots stay self-
51
- * contained), but rendered surfaces can call back into a directory or
52
- * cache to refresh stale names. Return `undefined` to fall back to the
53
- * cached label.
54
- */
55
- resolveMention?: (trigger: string, id: string) => string | undefined
56
- }
57
-
58
- /** Tiptap JSON node — structural, no runtime dep on `@tiptap/core`. */
59
- export interface TiptapNode {
60
- type: string
61
- text?: string
62
- attrs?: Record<string, unknown>
63
- content?: TiptapNode[]
64
- marks?: TiptapMark[]
65
- }
66
-
67
- export interface TiptapMark {
68
- type: string
69
- attrs?: Record<string, unknown>
70
- }
71
-
72
- /**
73
- * Render Tiptap content to HTML.
74
- *
75
- * Accepts:
76
- * - a Tiptap JSON document (`{ type: 'doc', content: [...] }`) — walked
77
- * and converted node-by-node;
78
- * - a JSON-encoded string of the same — parsed then walked;
79
- * - a raw HTML string — returned verbatim (the editor was configured
80
- * with `.storage('html')` and the value is already HTML).
81
- *
82
- * Returns `''` on null / undefined / unparseable input.
83
- */
84
- export function renderRichTextToHtml(content: unknown, opts: RenderRichTextOptions = {}): string {
85
- if (content === null || content === undefined) return ''
86
-
87
- if (typeof content === 'string') {
88
- const trimmed = content.trimStart()
89
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
90
- try {
91
- const parsed = JSON.parse(content)
92
- return renderNode(parsed, opts)
93
- } catch {
94
- return ''
95
- }
96
- }
97
- return content
98
- }
99
-
100
- if (typeof content === 'object') return renderNode(content as TiptapNode, opts)
101
-
102
- return ''
103
- }
104
-
105
- /**
106
- * Detect whether a value looks like Tiptap rich-text content. Conservative:
107
- * only matches the canonical document root (`{ type: 'doc', content: [...] }`),
108
- * either object-form or as a JSON-encoded string. Plain text and arbitrary
109
- * objects are NOT auto-detected — display surfaces should fall through to
110
- * their default formatter.
111
- */
112
- export function isRichTextValue(v: unknown): boolean {
113
- if (v === null || v === undefined) return false
114
- if (typeof v === 'object') return isTiptapDoc(v)
115
- if (typeof v === 'string') {
116
- const trimmed = v.trimStart()
117
- if (!trimmed.startsWith('{')) return false
118
- try {
119
- return isTiptapDoc(JSON.parse(v))
120
- } catch {
121
- return false
122
- }
123
- }
124
- return false
125
- }
126
-
127
- function isTiptapDoc(v: unknown): boolean {
128
- return (
129
- typeof v === 'object' && v !== null
130
- && (v as { type?: unknown }).type === 'doc'
131
- && Array.isArray((v as { content?: unknown }).content)
132
- )
133
- }
134
-
135
- // ─── Node walker ─────────────────────────────────────────────────────
136
-
137
- function renderNode(node: unknown, opts: RenderRichTextOptions): string {
138
- if (node === null || node === undefined) return ''
139
- if (Array.isArray(node)) return node.map(n => renderNode(n, opts)).join('')
140
- if (typeof node !== 'object') return ''
141
-
142
- const n = node as TiptapNode
143
-
144
- switch (n.type) {
145
- case 'doc': return renderChildren(n, opts)
146
- case 'paragraph': return wrap('p', n, opts, alignStyle(n))
147
- case 'heading': {
148
- const level = clampLevel(n.attrs?.['level'])
149
- return wrap(`h${level}`, n, opts, alignStyle(n))
150
- }
151
- case 'blockquote': return wrap('blockquote', n, opts)
152
- case 'codeBlock': {
153
- const lang = typeof n.attrs?.['language'] === 'string'
154
- ? n.attrs!['language'] as string
155
- : undefined
156
- const cls = lang ? ` class="language-${escapeAttr(lang)}"` : ''
157
- return `<pre><code${cls}>${renderChildren(n, opts)}</code></pre>`
158
- }
159
- case 'bulletList': return wrap('ul', n, opts)
160
- case 'orderedList': {
161
- const startRaw = n.attrs?.['start']
162
- const start = typeof startRaw === 'number' ? startRaw : Number(startRaw)
163
- const startAttr = Number.isFinite(start) && start !== 1 ? ` start="${Math.trunc(start)}"` : ''
164
- return `<ol${startAttr}>${renderChildren(n, opts)}</ol>`
165
- }
166
- case 'listItem': return wrap('li', n, opts)
167
- case 'horizontalRule': return '<hr>'
168
- case 'hardBreak': return '<br>'
169
- case 'image': return renderImage(n)
170
- case 'table': return renderTable(n, opts)
171
- case 'tableRow': return wrap('tr', n, opts)
172
- case 'tableCell': return renderCell('td', n, opts)
173
- case 'tableHeader': return renderCell('th', n, opts)
174
- case 'details': return renderDetails(n, opts)
175
- case 'detailsSummary': return wrap('summary', n, opts)
176
- // The editor's NodeView wraps content in a `<div data-type="detailsContent">`
177
- // for click handling, but the read-side HTML doesn't need a wrapper —
178
- // a `<details>` block's content sits directly after the `<summary>` per
179
- // the platform spec, which matches reader expectations.
180
- case 'detailsContent': return renderChildren(n, opts)
181
- case 'grid': return renderGrid(n, opts)
182
- case 'gridColumn': return wrap('div', n, opts)
183
- case 'mergeTag': return renderMergeTag(n, opts)
184
- case 'mention': return renderMention(n, opts)
185
- case 'text': return renderText(n)
186
- default:
187
- if (opts.renderBlock) return opts.renderBlock(n)
188
- return renderCustomBlock(n)
189
- }
190
- }
191
-
192
- function renderChildren(n: TiptapNode, opts: RenderRichTextOptions): string {
193
- if (!n.content) return ''
194
- return n.content.map(c => renderNode(c, opts)).join('')
195
- }
196
-
197
- function wrap(tag: string, n: TiptapNode, opts: RenderRichTextOptions, extraAttrs = ''): string {
198
- return `<${tag}${extraAttrs}>${renderChildren(n, opts)}</${tag}>`
199
- }
200
-
201
- function alignStyle(n: TiptapNode): string {
202
- const align = n.attrs?.['textAlign']
203
- if (typeof align !== 'string' || align === '' || align === 'left') return ''
204
- // `start` / `end` / `center` / `justify` — TextAlign extension allows-list.
205
- if (!/^[a-z]+$/.test(align)) return ''
206
- return ` style="text-align: ${align}"`
207
- }
208
-
209
- function clampLevel(raw: unknown): number {
210
- const n = typeof raw === 'number' ? raw : Number(raw)
211
- if (!Number.isFinite(n)) return 1
212
- return Math.min(6, Math.max(1, Math.trunc(n)))
213
- }
214
-
215
- // ─── Text + marks ────────────────────────────────────────────────────
216
-
217
- function renderText(n: TiptapNode): string {
218
- let html = escapeHtml(String(n.text ?? ''))
219
- const marks = Array.isArray(n.marks) ? n.marks : []
220
- // Tiptap stores marks innermost-first in array order; wrap from index
221
- // 0 outward so the first mark ends up nested deepest — matches Tiptap's
222
- // own DOM serialization.
223
- for (const mark of marks) html = wrapMark(html, mark)
224
- return html
225
- }
226
-
227
- function wrapMark(inner: string, mark: TiptapMark | undefined): string {
228
- if (!mark || typeof mark !== 'object') return inner
229
- const attrs = (mark.attrs ?? {}) as Record<string, unknown>
230
- switch (mark.type) {
231
- case 'bold': return `<strong>${inner}</strong>`
232
- case 'italic': return `<em>${inner}</em>`
233
- case 'strike': return `<s>${inner}</s>`
234
- case 'underline': return `<u>${inner}</u>`
235
- case 'subscript': return `<sub>${inner}</sub>`
236
- case 'superscript': return `<sup>${inner}</sup>`
237
- case 'code': return `<code>${inner}</code>`
238
- case 'lead': return `<span class="lead">${inner}</span>`
239
- case 'small': return `<small>${inner}</small>`
240
- case 'link': {
241
- const href = sanitizeUrl(attrs['href'])
242
- const target = typeof attrs['target'] === 'string'
243
- ? ` target="${escapeAttr(String(attrs['target']))}"` : ''
244
- const rel = attrs['target'] === '_blank' ? ' rel="noopener noreferrer"' : ''
245
- return `<a href="${href}"${target}${rel}>${inner}</a>`
246
- }
247
- case 'textStyle': {
248
- const color = attrs['color']
249
- if (typeof color !== 'string' || color === '') return inner
250
- const safe = sanitizeColor(color)
251
- if (!safe) return inner
252
- return `<span style="color: ${safe}">${inner}</span>`
253
- }
254
- case 'highlight': {
255
- const color = attrs['color']
256
- if (typeof color !== 'string' || color === '') return `<mark>${inner}</mark>`
257
- const safe = sanitizeColor(color)
258
- if (!safe) return `<mark>${inner}</mark>`
259
- return `<mark style="background-color: ${safe}">${inner}</mark>`
260
- }
261
- default:
262
- return inner
263
- }
264
- }
265
-
266
- // ─── Image ───────────────────────────────────────────────────────────
267
-
268
- function renderImage(n: TiptapNode): string {
269
- const attrs = (n.attrs ?? {}) as Record<string, unknown>
270
- const src = sanitizeUrl(attrs['src'])
271
- // Don't emit a broken `<img>` if src couldn't be sanitized to anything
272
- // meaningful. `sanitizeUrl` returns `'#'` for unsafe inputs — and `<img
273
- // src="#">` re-fetches the page, which is the worst possible default.
274
- if (src === '#') return ''
275
- const alt = typeof attrs['alt'] === 'string' ? ` alt="${escapeAttr(String(attrs['alt']))}"` : ' alt=""'
276
- const title = typeof attrs['title'] === 'string' ? ` title="${escapeAttr(String(attrs['title']))}"` : ''
277
- const width = pixelAttr('width', attrs['width'])
278
- const height = pixelAttr('height', attrs['height'])
279
- return `<img src="${src}"${alt}${title}${width}${height}>`
280
- }
281
-
282
- function pixelAttr(name: string, raw: unknown): string {
283
- if (raw === null || raw === undefined || raw === '') return ''
284
- const n = typeof raw === 'number' ? raw : Number(raw)
285
- if (!Number.isFinite(n) || n <= 0) return ''
286
- return ` ${name}="${Math.trunc(n)}"`
287
- }
288
-
289
- // ─── Tables ──────────────────────────────────────────────────────────
290
-
291
- /**
292
- * Render a Tiptap `table` node. The Tiptap table extension stores per-column
293
- * widths on individual cells via `colwidth: number[]` — we collect the widths
294
- * from the first row and emit a `<colgroup>` so read-side renders match the
295
- * editor's column proportions.
296
- */
297
- function renderTable(n: TiptapNode, opts: RenderRichTextOptions): string {
298
- const colgroup = buildColgroup(n)
299
- return `<table>${colgroup}<tbody>${renderChildren(n, opts)}</tbody></table>`
300
- }
301
-
302
- function buildColgroup(table: TiptapNode): string {
303
- const firstRow = table.content?.find((c) => c.type === 'tableRow')
304
- if (!firstRow || !firstRow.content) return ''
305
- const widths: (number | null)[] = []
306
- let hasAnyWidth = false
307
- for (const cell of firstRow.content) {
308
- if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue
309
- const colspan = clampPositiveInt(cell.attrs?.['colspan']) ?? 1
310
- const colwidth = cell.attrs?.['colwidth']
311
- const widthArr = Array.isArray(colwidth)
312
- ? colwidth.map((w) => clampPositiveInt(w))
313
- : []
314
- for (let i = 0; i < colspan; i++) {
315
- const w = widthArr[i] ?? null
316
- if (w !== null) hasAnyWidth = true
317
- widths.push(w)
318
- }
319
- }
320
- // Only emit a colgroup when at least one width is known. Tiptap's table
321
- // extension only sets colwidth after the user drags a column-resize handle —
322
- // out-of-the-box tables have no widths, so an always-emitted colgroup would
323
- // be noise.
324
- if (!hasAnyWidth) return ''
325
- const cols = widths.map((w) => w !== null ? `<col style="width: ${w}px">` : '<col>')
326
- return `<colgroup>${cols.join('')}</colgroup>`
327
- }
328
-
329
- function renderCell(tag: 'td' | 'th', n: TiptapNode, opts: RenderRichTextOptions): string {
330
- const attrs = (n.attrs ?? {}) as Record<string, unknown>
331
- const colspan = clampPositiveInt(attrs['colspan'])
332
- const rowspan = clampPositiveInt(attrs['rowspan'])
333
- const span = [
334
- colspan && colspan !== 1 ? ` colspan="${colspan}"` : '',
335
- rowspan && rowspan !== 1 ? ` rowspan="${rowspan}"` : '',
336
- ].join('')
337
- return `<${tag}${span}>${renderChildren(n, opts)}</${tag}>`
338
- }
339
-
340
- function clampPositiveInt(raw: unknown): number | null {
341
- if (raw === null || raw === undefined || raw === '') return null
342
- const n = typeof raw === 'number' ? raw : Number(raw)
343
- if (!Number.isFinite(n) || n <= 0) return null
344
- return Math.trunc(n)
345
- }
346
-
347
- // ─── Details (collapsible blocks) ────────────────────────────────────
348
-
349
- /**
350
- * Render a `details` node. The `open` attribute round-trips the editor's
351
- * open/closed state when `Details.configure({ persist: true })` is set —
352
- * matches the platform `<details open>` attribute exactly so a reader's
353
- * snapshot reflects how the author last left it.
354
- */
355
- function renderDetails(n: TiptapNode, opts: RenderRichTextOptions): string {
356
- const isOpen = n.attrs?.['open'] === true
357
- return `<details${isOpen ? ' open' : ''}>${renderChildren(n, opts)}</details>`
358
- }
359
-
360
- // ─── Grid (multi-column layout) ──────────────────────────────────────
361
-
362
- /**
363
- * Render a `grid` node. The `data-columns` attribute round-trips the
364
- * editor's column count; class names mirror the editor's `renderHTML` so
365
- * one stylesheet paints both surfaces (consumer owns the CSS — same
366
- * posture as `lead` / `small`).
367
- *
368
- * Out-of-range column counts (anything other than 2 or 3) clamp to 2 —
369
- * the schema enforces it on the editor side, but a tampered Tiptap JSON
370
- * fed straight to the renderer shouldn't paint a `grid-cols-99` class.
371
- */
372
- function renderGrid(n: TiptapNode, opts: RenderRichTextOptions): string {
373
- const cols = clampGridColumnsForRender(n.attrs?.['columns'])
374
- return `<div class="pilotiq-grid pilotiq-grid-cols-${cols}">${renderChildren(n, opts)}</div>`
375
- }
376
-
377
- function clampGridColumnsForRender(raw: unknown): 2 | 3 {
378
- const n = typeof raw === 'number' ? raw : Number(raw)
379
- if (!Number.isFinite(n)) return 2
380
- const trunc = Math.trunc(n)
381
- return trunc === 3 ? 3 : 2
382
- }
383
-
384
- // ─── Merge tags + mentions ───────────────────────────────────────────
385
-
386
- /**
387
- * Render a `mergeTag` atom — either substitute the value from
388
- * `opts.mergeTags` (HTML-escaped) or fall back to a styled `<span>` that
389
- * preserves the placeholder visually so server-rendered previews stay
390
- * informative when no substitution map is supplied.
391
- */
392
- function renderMergeTag(n: TiptapNode, opts: RenderRichTextOptions): string {
393
- const id = String((n.attrs ?? {})['id'] ?? '').trim()
394
- if (id === '') return ''
395
- const map = opts.mergeTags
396
- // Only substitute when the map explicitly carries the id — using
397
- // `Object.prototype.hasOwnProperty` so `null` / empty-string substitutions
398
- // still win over the fallback span.
399
- if (map && Object.prototype.hasOwnProperty.call(map, id)) {
400
- return escapeHtml(String(map[id] ?? ''))
401
- }
402
- return `<span class="merge-tag" data-id="${escapeAttr(id)}">{{ ${escapeHtml(id)} }}</span>`
403
- }
404
-
405
- /**
406
- * Render a `mention` atom as a styled `<span>` carrying the cached label.
407
- * `opts.resolveMention` can override the label per `(trigger, id)` pair —
408
- * useful for refreshing display names from a directory at render time.
409
- * Both `id` and `trigger` are required; missing either drops the chip.
410
- */
411
- function renderMention(n: TiptapNode, opts: RenderRichTextOptions): string {
412
- const attrs = (n.attrs ?? {}) as Record<string, unknown>
413
- const id = String(attrs['id'] ?? '').trim()
414
- const trigger = String(attrs['trigger'] ?? '').trim()
415
- if (id === '' || trigger === '') return ''
416
- const cached = String(attrs['label'] ?? '').trim()
417
- const resolved = opts.resolveMention?.(trigger, id)
418
- const label = resolved !== undefined ? resolved : (cached !== '' ? cached : id)
419
- return (
420
- `<span class="mention" data-trigger="${escapeAttr(trigger)}" data-id="${escapeAttr(id)}">` +
421
- escapeHtml(`${trigger}${label}`) +
422
- `</span>`
423
- )
424
- }
425
-
426
- // ─── Custom blocks ───────────────────────────────────────────────────
427
-
428
- function renderCustomBlock(n: TiptapNode): string {
429
- const type = String(n.type ?? '')
430
- const dataAttrs = n.attrs ? ` data-attrs="${escapeAttr(JSON.stringify(n.attrs))}"` : ''
431
- const inner = n.content ? renderChildren(n, {}) : ''
432
- return `<div data-type="${escapeAttr(type)}"${dataAttrs}>${inner}</div>`
433
- }
434
-
435
- // ─── Escapers + sanitizers ───────────────────────────────────────────
436
-
437
- function escapeHtml(s: string): string {
438
- return s.replace(/[&<>"']/g, ch => HTML_ESCAPES[ch] ?? ch)
439
- }
440
-
441
- const HTML_ESCAPES: Record<string, string> = {
442
- '&': '&amp;',
443
- '<': '&lt;',
444
- '>': '&gt;',
445
- '"': '&quot;',
446
- "'": '&#39;',
447
- }
448
-
449
- function escapeAttr(s: string): string {
450
- return escapeHtml(s)
451
- }
452
-
453
- /**
454
- * Allowlist for color values. Accepts hex (`#abc` / `#abcdef`), rgb()/rgba(),
455
- * hsl()/hsla(), oklch(), and named colors (alpha-only). Returns `''` for
456
- * anything that doesn't match — caller falls back to no inline style.
457
- */
458
- function sanitizeColor(raw: string): string {
459
- const v = raw.trim()
460
- if (v === '') return ''
461
- if (/^#[0-9a-f]{3,8}$/i.test(v)) return v
462
- if (/^rgba?\([^()<>"']+\)$/i.test(v)) return v
463
- if (/^hsla?\([^()<>"']+\)$/i.test(v)) return v
464
- if (/^oklch\([^()<>"']+\)$/i.test(v)) return v
465
- if (/^[a-z]+$/i.test(v)) return v
466
- return ''
467
- }
468
-
469
- /**
470
- * Block dangerous URL schemes. Anything that isn't an absolute http(s),
471
- * mailto, tel, anchor (#…), root-relative (/…), or relative path falls
472
- * back to `#`. Output is HTML-escaped.
473
- */
474
- function sanitizeUrl(raw: unknown): string {
475
- if (typeof raw !== 'string') return '#'
476
- const v = raw.trim()
477
- if (v === '') return '#'
478
- if (/^(?:javascript|data|vbscript):/i.test(v)) return '#'
479
- return escapeAttr(v)
480
- }
@@ -1,205 +0,0 @@
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
- import { parseMarkdownToHtml } from './markdownStorage.js'
26
-
27
- export type TransactionModifier = (tr: Transaction) => void
28
-
29
- /** Resolve the start position of the top-level child at `blockIndex`. */
30
- function blockStartPos(doc: ProseMirrorNode, blockIndex: number): number | null {
31
- if (!Number.isInteger(blockIndex) || blockIndex < 0 || blockIndex >= doc.childCount) return null
32
- let pos = 0
33
- for (let i = 0; i < blockIndex; i++) pos += doc.child(i).nodeSize
34
- return pos
35
- }
36
-
37
- /**
38
- * Parse content into a doc-replaceable Slice against the editor's
39
- * schema. Auto-detects markdown editors by sniffing for the
40
- * `tiptap-markdown` extension's `storage.markdown.parser`: if present,
41
- * the editor is a `MarkdownEditor` and AI-supplied `content` is
42
- * markdown source — run it through the markdown parser to produce
43
- * HTML first. Otherwise content is HTML (the `RichTextField` /
44
- * `TiptapEditor` path).
45
- *
46
- * Mirrors the same auto-detect strategy `MarkdownEditor.tsx` uses for
47
- * its `parseSuggestion` whole-field callback (see `useAiInlineDiff`),
48
- * so surgical ops on markdown fields stay consistent with the
49
- * existing whole-field replacement path.
50
- *
51
- * Returns `null` when DOM isn't available (SSR — shouldn't happen
52
- * here, but keeps the planner safe) or when the markdown parser
53
- * throws / returns a non-string (malformed content).
54
- */
55
- function parseContentToSlice(editor: Editor, content: string): ReturnType<typeof PMDOMParser.prototype.parseSlice> | null {
56
- if (typeof document === 'undefined') return null
57
- let html = content
58
- try {
59
- const parsed = parseMarkdownToHtml(editor, content)
60
- if (parsed !== undefined) html = parsed
61
- } catch { return null }
62
- const container = document.createElement('div')
63
- container.innerHTML = html
64
- return PMDOMParser.fromSchema(editor.schema).parseSlice(container)
65
- }
66
-
67
- /**
68
- * Replace the top-level block at `blockIndex` with the parsed content.
69
- * `content` is HTML for `RichTextField` (Tiptap) editors and markdown
70
- * source for `MarkdownField` (markdown-extension) editors —
71
- * auto-detected by `parseContentToSlice`. Multiple top-level nodes are
72
- * allowed and will all land where the original block was.
73
- */
74
- export function planReplaceBlock(
75
- editor: Editor,
76
- blockIndex: number,
77
- content: string,
78
- ): TransactionModifier | null {
79
- const doc = editor.state.doc
80
- const start = blockStartPos(doc, blockIndex)
81
- if (start === null) return null
82
- const slice = parseContentToSlice(editor, content)
83
- if (!slice) return null
84
- const end = start + doc.child(blockIndex).nodeSize
85
- return (tr) => { tr.replace(start, end, slice) }
86
- }
87
-
88
- /**
89
- * Insert one or more top-level nodes before the block at `blockIndex`.
90
- * `content` is HTML on `RichTextField` editors and markdown source on
91
- * `MarkdownField` editors — auto-detected by `parseContentToSlice`.
92
- * `blockIndex === doc.childCount` appends at the end.
93
- */
94
- export function planInsertBlockBefore(
95
- editor: Editor,
96
- blockIndex: number,
97
- content: string,
98
- ): TransactionModifier | null {
99
- const doc = editor.state.doc
100
- if (!Number.isInteger(blockIndex) || blockIndex < 0 || blockIndex > doc.childCount) return null
101
- const slice = parseContentToSlice(editor, content)
102
- if (!slice) return null
103
- let pos = 0
104
- for (let i = 0; i < blockIndex; i++) pos += doc.child(i).nodeSize
105
- return (tr) => { tr.replace(pos, pos, slice) }
106
- }
107
-
108
- /**
109
- * Delete the top-level block at `blockIndex`. Doc must retain at least
110
- * one child after the delete (most schemas require this) — refuses to
111
- * delete the last remaining block.
112
- */
113
- export function planDeleteBlock(
114
- editor: Editor,
115
- blockIndex: number,
116
- ): TransactionModifier | null {
117
- const doc = editor.state.doc
118
- const start = blockStartPos(doc, blockIndex)
119
- if (start === null) return null
120
- if (doc.childCount <= 1) return null
121
- const end = start + doc.child(blockIndex).nodeSize
122
- return (tr) => { tr.delete(start, end) }
123
- }
124
-
125
- export interface BlockMarkRange {
126
- /** 0-based text offset from the start of the block's content. */
127
- from: number
128
- /** Exclusive end offset. */
129
- to: number
130
- }
131
-
132
- /**
133
- * Apply or remove an inline mark on a range *within* the block at
134
- * `blockIndex`. `range.from` / `range.to` are text offsets relative to
135
- * the start of the block's content (so `0` is the first character of
136
- * the block, not the start of the doc).
137
- *
138
- * `apply = true` sets the mark (with optional `attrs`); `apply = false`
139
- * removes it. Unknown marks (not in the editor's schema) return `null`
140
- * so the caller can surface a clean error to the agent.
141
- */
142
- export function planUpdateBlockMark(
143
- editor: Editor,
144
- blockIndex: number,
145
- mark: string,
146
- range: BlockMarkRange,
147
- apply: boolean,
148
- attrs?: Record<string, unknown>,
149
- ): TransactionModifier | null {
150
- const doc = editor.state.doc
151
- const start = blockStartPos(doc, blockIndex)
152
- if (start === null) return null
153
- const markType: MarkType | undefined = editor.schema.marks[mark]
154
- if (!markType) return null
155
-
156
- const block = doc.child(blockIndex)
157
- const blockInner = start + 1 // step inside the block's opening token
158
- const contentMax = block.content.size
159
-
160
- if (!Number.isInteger(range.from) || !Number.isInteger(range.to)) return null
161
- const clampedFrom = Math.max(0, Math.min(range.from, contentMax))
162
- const clampedTo = Math.max(clampedFrom, Math.min(range.to, contentMax))
163
- if (clampedTo === clampedFrom) return null
164
-
165
- const from = blockInner + clampedFrom
166
- const to = blockInner + clampedTo
167
-
168
- if (apply) {
169
- const m: Mark = markType.create(attrs ?? null)
170
- return (tr) => { tr.addMark(from, to, m) }
171
- }
172
- return (tr) => { tr.removeMark(from, to, markType) }
173
- }
174
-
175
- /**
176
- * Summarize a doc's top-level structure as a numbered list the AI can
177
- * cite by index when proposing surgical ops. Each entry includes the
178
- * block index, node type, and a truncated text preview — enough for the
179
- * model to identify which block it wants to modify without sending the
180
- * whole HTML/markdown back through token-priced channels.
181
- *
182
- * Returns one line per top-level child:
183
- * `[0] heading: Welcome to the docs`
184
- * `[1] paragraph: Lorem ipsum dolor sit amet…`
185
- * `[2] bulletList: 3 items`
186
- */
187
- export function summarizeBlockStructure(doc: ProseMirrorNode, maxChars = 80): string {
188
- const lines: string[] = []
189
- for (let i = 0; i < doc.childCount; i++) {
190
- const node = doc.child(i)
191
- const text = node.textContent.trim().replace(/\s+/g, ' ')
192
- const preview = text.length === 0
193
- ? describeStructuralNode(node)
194
- : text.length > maxChars ? `${text.slice(0, maxChars)}…` : text
195
- lines.push(`[${i}] ${node.type.name}: ${preview}`)
196
- }
197
- return lines.join('\n')
198
- }
199
-
200
- function describeStructuralNode(node: ProseMirrorNode): string {
201
- const kids = node.childCount
202
- if (kids === 0) return '(empty)'
203
- if (kids === 1) return `1 ${node.firstChild?.type.name ?? 'child'}`
204
- return `${kids} children`
205
- }