| 1 | /** |
| 2 | * Markdown into a page's Yjs document: how templates, agents' edits and |
| 3 | * accepted suggestions become blocks. Pure (yjs only). |
| 4 | * |
| 5 | * `parseMarkdown` reads the Markdown agents write (CommonMark plus GitHub's |
| 6 | * tables, task lists and alerts, `$$` math and Mermaid fences) into block |
| 7 | * specs; `writeBlocks` turns specs into BlockNote's `blockContainer` |
| 8 | * elements, with the attributes the editor gives its own blocks, so the |
| 9 | * editor shows them exactly as if typed. The inverse is markdown.ts. |
| 10 | */ |
| 11 | import * as Y from "yjs"; |
| 12 | |
| 13 | export type Marks = Record<string, object>; |
| 14 | export type Run = { text: string; marks?: Marks } | { node: "mention"; attrs: Record<string, string> }; |
| 15 | |
| 16 | export type BlockSpec = { |
| 17 | type: string; |
| 18 | props?: Record<string, string | number | boolean>; |
| 19 | /** Inline content; a code block's text is one plain run. */ |
| 20 | content?: Run[]; |
| 21 | /** A table's cells: rows of cells of runs. */ |
| 22 | rows?: Run[][][]; |
| 23 | children?: BlockSpec[]; |
| 24 | }; |
| 25 | |
| 26 | const ALERT_KINDS: Record<string, string> = { NOTE: "info", IMPORTANT: "info", TIP: "success", WARNING: "warning", CAUTION: "danger" }; |
| 27 | |
| 28 | // ── Inline ──────────────────────────────────────────────────────────────── |
| 29 | |
| 30 | /** Inline Markdown as runs: bold, italic, strike, underline, code, links. */ |
| 31 | export function parseInline(text: string, marks: Marks = {}): Run[] { |
| 32 | const runs: Run[] = []; |
| 33 | let plain = ""; |
| 34 | const flush = () => { |
| 35 | if (plain) runs.push(Object.keys(marks).length ? { text: plain, marks: { ...marks } } : { text: plain }); |
| 36 | plain = ""; |
| 37 | }; |
| 38 | let i = 0; |
| 39 | while (i < text.length) { |
| 40 | const rest = text.slice(i); |
| 41 | // Escapes. |
| 42 | if (rest[0] === "\\" && rest.length > 1 && /[\\`*_{}[\]()#+\-.!~|<>$]/.test(rest[1]!)) { |
| 43 | plain += rest[1]; |
| 44 | i += 2; |
| 45 | continue; |
| 46 | } |
| 47 | // Hard break: a backslash at the end of a line. |
| 48 | if (rest.startsWith("\\\n")) { |
| 49 | plain += "\n"; |
| 50 | i += 2; |
| 51 | continue; |
| 52 | } |
| 53 | // Code. |
| 54 | const code = /^(`+)([\s\S]*?[^`])\1(?!`)/.exec(rest); |
| 55 | if (code) { |
| 56 | flush(); |
| 57 | runs.push({ text: code[2]!.replace(/^ (.*) $/, "$1"), marks: { ...marks, code: {} } }); |
| 58 | i += code[0].length; |
| 59 | continue; |
| 60 | } |
| 61 | // Images inline: keep as a link to the image. |
| 62 | const link = /^!?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.exec(rest); |
| 63 | if (link) { |
| 64 | flush(); |
| 65 | runs.push(...parseInline(link[1]! || link[2]!, { ...marks, link: { href: link[2]! } })); |
| 66 | i += link[0].length; |
| 67 | continue; |
| 68 | } |
| 69 | const auto = /^<(https?:\/\/[^>\s]+)>/.exec(rest); |
| 70 | if (auto) { |
| 71 | flush(); |
| 72 | runs.push({ text: auto[1]!, marks: { ...marks, link: { href: auto[1]! } } }); |
| 73 | i += auto[0].length; |
| 74 | continue; |
| 75 | } |
| 76 | const underline = /^<u>([\s\S]+?)<\/u>/.exec(rest); |
| 77 | if (underline) { |
| 78 | flush(); |
| 79 | runs.push(...parseInline(underline[1]!, { ...marks, underline: {} })); |
| 80 | i += underline[0].length; |
| 81 | continue; |
| 82 | } |
| 83 | const strong = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest); |
| 84 | if (strong) { |
| 85 | flush(); |
| 86 | runs.push(...parseInline(strong[2]!, { ...marks, bold: {} })); |
| 87 | i += strong[0].length; |
| 88 | continue; |
| 89 | } |
| 90 | const strike = /^~~(?=\S)([\s\S]*?\S)~~/.exec(rest); |
| 91 | if (strike) { |
| 92 | flush(); |
| 93 | runs.push(...parseInline(strike[1]!, { ...marks, strike: {} })); |
| 94 | i += strike[0].length; |
| 95 | continue; |
| 96 | } |
| 97 | // `_` only at a word boundary, so snake_case stays as written. |
| 98 | const em = /^\*(?=\S)([\s\S]*?\S)\*(?!\*)/.exec(rest) ?? (/[A-Za-z0-9]$/.test(text.slice(0, i)) ? null : /^_(?=\S)([\s\S]*?\S)_(?![A-Za-z0-9])/.exec(rest)); |
| 99 | if (em) { |
| 100 | flush(); |
| 101 | runs.push(...parseInline(em[1]!, { ...marks, italic: {} })); |
| 102 | i += em[0].length; |
| 103 | continue; |
| 104 | } |
| 105 | plain += rest[0]; |
| 106 | i += 1; |
| 107 | } |
| 108 | flush(); |
| 109 | return merge(runs); |
| 110 | } |
| 111 | |
| 112 | function sameMarks(a: Marks | undefined, b: Marks | undefined): boolean { |
| 113 | return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {}); |
| 114 | } |
| 115 | |
| 116 | function merge(runs: Run[]): Run[] { |
| 117 | const out: Run[] = []; |
| 118 | for (const run of runs) { |
| 119 | const last = out[out.length - 1]; |
| 120 | if (last && "text" in last && "text" in run && sameMarks(last.marks, run.marks)) last.text += run.text; |
| 121 | else out.push("text" in run ? { ...run } : run); |
| 122 | } |
| 123 | return out.filter((r) => !("text" in r) || r.text !== ""); |
| 124 | } |
| 125 | |
| 126 | // ── Blocks ──────────────────────────────────────────────────────────────── |
| 127 | |
| 128 | type Line = { indent: number; text: string }; |
| 129 | |
| 130 | function lineOf(raw: string): Line { |
| 131 | const expanded = raw.replace(/\t/g, " "); |
| 132 | const indent = /^ */.exec(expanded)![0].length; |
| 133 | return { indent, text: expanded.slice(indent) }; |
| 134 | } |
| 135 | |
| 136 | const LIST_ITEM = /^([-*+]|\d{1,9}[.)])\s+(.*)$/; |
| 137 | const TABLE_RULE = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/; |
| 138 | |
| 139 | function splitRow(line: string): string[] { |
| 140 | let s = line.trim(); |
| 141 | if (s.startsWith("|")) s = s.slice(1); |
| 142 | if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1); |
| 143 | const cells: string[] = []; |
| 144 | let cell = ""; |
| 145 | for (let i = 0; i < s.length; i++) { |
| 146 | if (s[i] === "\\" && s[i + 1] === "|") { |
| 147 | cell += "|"; |
| 148 | i++; |
| 149 | } else if (s[i] === "|") { |
| 150 | cells.push(cell.trim()); |
| 151 | cell = ""; |
| 152 | } else cell += s[i]; |
| 153 | } |
| 154 | cells.push(cell.trim()); |
| 155 | return cells; |
| 156 | } |
| 157 | |
| 158 | function isBlockStart(text: string): boolean { |
| 159 | return /^(#{1,6}\s|>|```|~~~|\$\$|<details>|(-{3,}|\*{3,}|_{3,})\s*$)/.test(text) || LIST_ITEM.test(text) || /^!\[[^\]]*\]\([^)]+\)\s*$/.test(text); |
| 160 | } |
| 161 | |
| 162 | /** Markdown as block specs. */ |
| 163 | export function parseMarkdown(markdown: string): BlockSpec[] { |
| 164 | const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n"); |
| 165 | return parseLines(lines.map(lineOf), 0, lines.length); |
| 166 | } |
| 167 | |
| 168 | function parseLines(lines: Line[], from: number, to: number): BlockSpec[] { |
| 169 | const out: BlockSpec[] = []; |
| 170 | let i = from; |
| 171 | while (i < to) { |
| 172 | const line = lines[i]!; |
| 173 | const text = line.text; |
| 174 | if (!text.trim()) { |
| 175 | i++; |
| 176 | continue; |
| 177 | } |
| 178 | // Fenced code, Mermaid. |
| 179 | const fenceOpen = /^(`{3,}|~{3,})\s*([^\s`]*)/.exec(text); |
| 180 | if (fenceOpen) { |
| 181 | const marker = fenceOpen[1]!; |
| 182 | const lang = fenceOpen[2]!.toLowerCase(); |
| 183 | const body: string[] = []; |
| 184 | i++; |
| 185 | while (i < to && !lines[i]!.text.startsWith(marker)) { |
| 186 | body.push(" ".repeat(Math.max(0, lines[i]!.indent - line.indent)) + lines[i]!.text); |
| 187 | i++; |
| 188 | } |
| 189 | i++; |
| 190 | const code = body.join("\n"); |
| 191 | if (lang === "mermaid") out.push({ type: "mermaid", props: { code } }); |
| 192 | else if (lang === "math" || lang === "latex" || lang === "katex") out.push({ type: "math", props: { expression: code } }); |
| 193 | else out.push({ type: "codeBlock", props: { language: lang || "text" }, content: code ? [{ text: code }] : [] }); |
| 194 | continue; |
| 195 | } |
| 196 | // Math. |
| 197 | if (text.startsWith("$$")) { |
| 198 | const same = /^\$\$(.+)\$\$\s*$/.exec(text); |
| 199 | if (same) { |
| 200 | out.push({ type: "math", props: { expression: same[1]!.trim() } }); |
| 201 | i++; |
| 202 | continue; |
| 203 | } |
| 204 | const body: string[] = []; |
| 205 | i++; |
| 206 | while (i < to && !lines[i]!.text.startsWith("$$")) body.push(lines[i++]!.text); |
| 207 | i++; |
| 208 | out.push({ type: "math", props: { expression: body.join("\n").trim() } }); |
| 209 | continue; |
| 210 | } |
| 211 | // Headings. |
| 212 | const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(text); |
| 213 | if (heading) { |
| 214 | out.push({ type: "heading", props: { level: Math.min(heading[1]!.length, 6) }, content: parseInline(heading[2]!) }); |
| 215 | i++; |
| 216 | continue; |
| 217 | } |
| 218 | // Dividers. |
| 219 | if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(text)) { |
| 220 | out.push({ type: "divider" }); |
| 221 | i++; |
| 222 | continue; |
| 223 | } |
| 224 | // Toggles. |
| 225 | if (text.startsWith("<details>")) { |
| 226 | let summary = ""; |
| 227 | const inner: Line[] = []; |
| 228 | let depth = 0; |
| 229 | const first = text.slice("<details>".length); |
| 230 | const queue = first.trim() ? [{ indent: 0, text: first.trim() }] : []; |
| 231 | i++; |
| 232 | const take = (l: Line): boolean => { |
| 233 | const sm = /^<summary>([\s\S]*?)<\/summary>\s*(.*)$/.exec(l.text); |
| 234 | if (sm && !summary) { |
| 235 | summary = sm[1]!; |
| 236 | if (sm[2]) inner.push({ indent: 0, text: sm[2] }); |
| 237 | return false; |
| 238 | } |
| 239 | if (l.text.startsWith("<details>")) depth++; |
| 240 | if (l.text.startsWith("</details>")) { |
| 241 | if (depth === 0) return true; |
| 242 | depth--; |
| 243 | } |
| 244 | inner.push(l); |
| 245 | return false; |
| 246 | }; |
| 247 | let closed = false; |
| 248 | for (const l of queue) if (take(l)) closed = true; |
| 249 | while (!closed && i < to) { |
| 250 | closed = take(lines[i]!); |
| 251 | i++; |
| 252 | } |
| 253 | out.push({ type: "toggleListItem", content: parseInline(summary.trim()), children: parseLines(inner, 0, inner.length) }); |
| 254 | continue; |
| 255 | } |
| 256 | // Quotes and callouts. |
| 257 | if (text.startsWith(">")) { |
| 258 | const body: string[] = []; |
| 259 | while (i < to && lines[i]!.text.startsWith(">")) { |
| 260 | body.push(lines[i]!.text.replace(/^>\s?/, "")); |
| 261 | i++; |
| 262 | } |
| 263 | const alert = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i.exec(body[0] ?? ""); |
| 264 | if (alert) { |
| 265 | const rest = [alert[2]!, ...body.slice(1)].filter((l, n) => n > 0 || l.trim()).join("\n").trim(); |
| 266 | out.push({ type: "callout", props: { kind: ALERT_KINDS[alert[1]!.toUpperCase()] ?? "info" }, content: parseInline(joinParagraph(rest)) }); |
| 267 | } else { |
| 268 | out.push({ type: "quote", content: parseInline(joinParagraph(body.join("\n").trim())) }); |
| 269 | } |
| 270 | continue; |
| 271 | } |
| 272 | // Images on a line of their own. |
| 273 | const image = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*$/.exec(text); |
| 274 | if (image) { |
| 275 | out.push({ type: "image", props: { url: image[2]!, caption: image[1]!, name: image[1]! } }); |
| 276 | i++; |
| 277 | continue; |
| 278 | } |
| 279 | // Tables. |
| 280 | if (text.includes("|") && i + 1 < to && TABLE_RULE.test(lines[i + 1]!.text)) { |
| 281 | const rows: Run[][][] = [splitRow(text).map((c) => parseInline(c))]; |
| 282 | i += 2; |
| 283 | while (i < to && lines[i]!.text.includes("|") && lines[i]!.text.trim()) { |
| 284 | rows.push(splitRow(lines[i]!.text).map((c) => parseInline(c))); |
| 285 | i++; |
| 286 | } |
| 287 | out.push({ type: "table", rows }); |
| 288 | continue; |
| 289 | } |
| 290 | // Lists. |
| 291 | const item = LIST_ITEM.exec(text); |
| 292 | if (item) { |
| 293 | const base = line.indent; |
| 294 | const marker = item[1]!; |
| 295 | const contentIndent = base + marker.length + 1; |
| 296 | let first = item[2]!; |
| 297 | let type = /^\d/.test(marker) ? "numberedListItem" : "bulletListItem"; |
| 298 | const props: Record<string, string | number | boolean> = {}; |
| 299 | const task = /^\[([ xX])\]\s+(.*)$/.exec(first); |
| 300 | if (task && type === "bulletListItem") { |
| 301 | type = "checkListItem"; |
| 302 | props.checked = task[1] !== " "; |
| 303 | first = task[2]!; |
| 304 | } |
| 305 | if (type === "numberedListItem") { |
| 306 | const start = parseInt(marker, 10); |
| 307 | if (start !== 1 && !out.some((b) => b.type === "numberedListItem")) props.start = start; |
| 308 | } |
| 309 | i++; |
| 310 | // Continuation lines of the item's own paragraph. |
| 311 | const own = [first]; |
| 312 | while (i < to && lines[i]!.text.trim() && lines[i]!.indent >= contentIndent - 1 && !isBlockStart(lines[i]!.text) && !LIST_ITEM.test(lines[i]!.text)) { |
| 313 | own.push(lines[i]!.text); |
| 314 | i++; |
| 315 | } |
| 316 | // Nested blocks: lines indented past the marker (blank lines allowed between). |
| 317 | const nested: Line[] = []; |
| 318 | while (i < to) { |
| 319 | const l = lines[i]!; |
| 320 | if (!l.text.trim()) { |
| 321 | // A blank line ends the item unless more indented content follows. |
| 322 | let j = i + 1; |
| 323 | while (j < to && !lines[j]!.text.trim()) j++; |
| 324 | if (j < to && lines[j]!.indent > base && lines[j]!.indent >= Math.min(contentIndent, base + 2)) { |
| 325 | for (; i < j; i++) nested.push({ indent: 0, text: "" }); |
| 326 | continue; |
| 327 | } |
| 328 | break; |
| 329 | } |
| 330 | if (l.indent > base && l.indent >= Math.min(contentIndent, base + 2)) { |
| 331 | nested.push({ indent: l.indent - Math.min(contentIndent, l.indent), text: l.text }); |
| 332 | i++; |
| 333 | continue; |
| 334 | } |
| 335 | break; |
| 336 | } |
| 337 | out.push({ type, props, content: parseInline(joinParagraph(own.join("\n"))), children: parseLines(nested, 0, nested.length) }); |
| 338 | continue; |
| 339 | } |
| 340 | // A paragraph: lines up to a blank line or another block. |
| 341 | const para = [text]; |
| 342 | i++; |
| 343 | while (i < to && lines[i]!.text.trim() && !isBlockStart(lines[i]!.text) && !(lines[i]!.text.includes("|") && i + 1 < to && TABLE_RULE.test(lines[i + 1]!.text))) { |
| 344 | para.push(lines[i]!.text); |
| 345 | i++; |
| 346 | } |
| 347 | out.push({ type: "paragraph", content: parseInline(joinParagraph(para.join("\n"))) }); |
| 348 | } |
| 349 | return out; |
| 350 | } |
| 351 | |
| 352 | /** Soft line breaks become spaces; a trailing backslash or two spaces stay a hard break. */ |
| 353 | function joinParagraph(text: string): string { |
| 354 | return text.replace(/( {2,}|\\)\n/g, "\\\n").replace(/([^\\])\n/g, "$1 "); |
| 355 | } |
| 356 | |
| 357 | // ── Into Yjs ────────────────────────────────────────────────────────────── |
| 358 | |
| 359 | /** The attributes BlockNote gives each of its blocks when it makes them. */ |
| 360 | function defaults(type: string): Record<string, unknown> { |
| 361 | const colors = { backgroundColor: "default", textColor: "default" }; |
| 362 | switch (type) { |
| 363 | case "paragraph": |
| 364 | case "bulletListItem": |
| 365 | case "numberedListItem": |
| 366 | case "toggleListItem": |
| 367 | return { ...colors, textAlignment: "left" }; |
| 368 | case "checkListItem": |
| 369 | return { ...colors, textAlignment: "left", checked: false }; |
| 370 | case "heading": |
| 371 | return { ...colors, textAlignment: "left", level: 1, isToggleable: false }; |
| 372 | case "quote": |
| 373 | return { ...colors }; |
| 374 | case "codeBlock": |
| 375 | return { language: "text" }; |
| 376 | case "image": |
| 377 | return { textAlignment: "left", backgroundColor: "default", name: "", url: "", caption: "", showPreview: true }; |
| 378 | case "table": |
| 379 | return { textColor: "default" }; |
| 380 | case "callout": |
| 381 | return { ...colors, kind: "info" }; |
| 382 | case "mermaid": |
| 383 | return { code: "" }; |
| 384 | case "math": |
| 385 | return { expression: "" }; |
| 386 | default: |
| 387 | return {}; |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | function newBlockId(): string { |
| 392 | return crypto.randomUUID(); |
| 393 | } |
| 394 | |
| 395 | function textOf(runs: Run[] | undefined): (Y.XmlText | Y.XmlElement)[] { |
| 396 | const out: (Y.XmlText | Y.XmlElement)[] = []; |
| 397 | let text: Y.XmlText | null = null; |
| 398 | const pending: Run[] = []; |
| 399 | const flush = () => { |
| 400 | if (!pending.length) return; |
| 401 | text = new Y.XmlText(); |
| 402 | let at = 0; |
| 403 | for (const run of pending) { |
| 404 | if (!("text" in run)) continue; |
| 405 | text.insert(at, run.text, run.marks && Object.keys(run.marks).length ? run.marks : {}); |
| 406 | at += run.text.length; |
| 407 | } |
| 408 | out.push(text); |
| 409 | pending.length = 0; |
| 410 | }; |
| 411 | for (const run of runs ?? []) { |
| 412 | if ("text" in run) pending.push(run); |
| 413 | else { |
| 414 | flush(); |
| 415 | const el = new Y.XmlElement(run.node); |
| 416 | for (const [k, v] of Object.entries(run.attrs)) el.setAttribute(k, v); |
| 417 | out.push(el); |
| 418 | } |
| 419 | } |
| 420 | flush(); |
| 421 | return out; |
| 422 | } |
| 423 | |
| 424 | function tableNode(rows: Run[][][]): Y.XmlElement { |
| 425 | const table = new Y.XmlElement("table"); |
| 426 | for (const [k, v] of Object.entries(defaults("table"))) table.setAttribute(k, v as string); |
| 427 | const width = Math.max(1, ...rows.map((r) => r.length)); |
| 428 | table.insert( |
| 429 | 0, |
| 430 | rows.map((row) => { |
| 431 | const tr = new Y.XmlElement("tableRow"); |
| 432 | tr.insert( |
| 433 | 0, |
| 434 | Array.from({ length: width }, (_, n) => { |
| 435 | const cell = new Y.XmlElement("tableCell"); |
| 436 | for (const [k, v] of Object.entries({ textColor: "default", backgroundColor: "default", textAlignment: "left", colspan: 1, rowspan: 1 })) cell.setAttribute(k, v as unknown as string); |
| 437 | const p = new Y.XmlElement("tableParagraph"); |
| 438 | p.insert(0, textOf(row[n] ?? [])); |
| 439 | cell.insert(0, [p]); |
| 440 | return cell; |
| 441 | }), |
| 442 | ); |
| 443 | return tr; |
| 444 | }), |
| 445 | ); |
| 446 | return table; |
| 447 | } |
| 448 | |
| 449 | /** One block spec as a `blockContainer` (with its children), ready to insert. */ |
| 450 | export function containerOf(spec: BlockSpec): Y.XmlElement { |
| 451 | const container = new Y.XmlElement("blockContainer"); |
| 452 | container.setAttribute("id", newBlockId()); |
| 453 | let content: Y.XmlElement; |
| 454 | if (spec.type === "table") content = tableNode(spec.rows ?? []); |
| 455 | else { |
| 456 | content = new Y.XmlElement(spec.type); |
| 457 | for (const [k, v] of Object.entries({ ...defaults(spec.type), ...(spec.props ?? {}) })) content.setAttribute(k, v as string); |
| 458 | if (spec.content?.length) content.insert(0, textOf(spec.content)); |
| 459 | } |
| 460 | const parts: Y.XmlElement[] = [content]; |
| 461 | if (spec.children?.length) { |
| 462 | const group = new Y.XmlElement("blockGroup"); |
| 463 | group.insert(0, spec.children.map(containerOf)); |
| 464 | parts.push(group); |
| 465 | } |
| 466 | container.insert(0, parts); |
| 467 | return container; |
| 468 | } |
| 469 | |
| 470 | /** Block specs as containers. An empty list becomes one empty paragraph, as the editor keeps. */ |
| 471 | export function writeBlocks(specs: BlockSpec[]): Y.XmlElement[] { |
| 472 | return (specs.length ? specs : [{ type: "paragraph" }]).map(containerOf); |
| 473 | } |
| 474 | |
| 475 | /** The fragment's block group, made when the document is new. */ |
| 476 | export function blockGroupOf(fragment: Y.XmlFragment): Y.XmlElement { |
| 477 | const found = fragment.toArray().find((n): n is Y.XmlElement => n instanceof Y.XmlElement && n.nodeName === "blockGroup"); |
| 478 | if (found) return found; |
| 479 | const group = new Y.XmlElement("blockGroup"); |
| 480 | fragment.insert(fragment.length, [group]); |
| 481 | return group; |
| 482 | } |
| 483 | |
| 484 | /** Fills an empty document from Markdown (a template, an agent's new page). */ |
| 485 | export function seed(doc: Y.Doc, fragment: Y.XmlFragment, markdown: string): void { |
| 486 | doc.transact(() => { |
| 487 | const group = blockGroupOf(fragment); |
| 488 | if (group.length) group.delete(0, group.length); |
| 489 | group.insert(0, writeBlocks(parseMarkdown(markdown))); |
| 490 | }); |
| 491 | } |