@pilotiq/tiptap 3.10.4 → 3.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +4 -4
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +4 -5
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +8 -7
- package/dist/react/TiptapEditor.js.map +1 -1
- package/package.json +6 -3
- package/dist/collabShapes.d.ts +0 -22
- package/dist/collabShapes.d.ts.map +0 -1
- package/dist/collabShapes.js +0 -2
- package/dist/collabShapes.js.map +0 -1
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/collabShapes.ts +0 -22
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -230
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -606
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -776
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
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
|
-
'&': '&',
|
|
443
|
-
'<': '<',
|
|
444
|
-
'>': '>',
|
|
445
|
-
'"': '"',
|
|
446
|
-
"'": ''',
|
|
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
|
-
}
|
package/src/surgicalOps.ts
DELETED
|
@@ -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
|
-
}
|