วาดบล็อก mermaid ในคำตอบของ Claude เป็นภาพกล่องและลูกศรสีตรงตำแหน่งเดิม รองรับ flowchart, sequenceDiagram, pie และตัวอักษรไทย

วาด Mermaid ในคำตอบเป็นภาพ: Claude ชอบตอบเป็นบล็อก ```mermaid แต่ใน terminal เห็นเป็นโค้ดดิบ Mod นี้วาดเป็นภาพกล่องและลูกศรสีตรงตำแหน่งเดิม รองรับ flowchart/graph (แนวตั้ง TD และแนวนอน LR), sequenceDiagram และ pie คิดความกว้างตัวอักษรไทยให้กล่องตรง (สระบน-ล่างและวรรณยุกต์ไม่กินช่อง) แบบที่ยังวาดไม่ได้จะแสดงโค้ดเดิม
| คำสั่ง | ทำอะไร | |
|---|---|---|
| `/mermaid on\ | off` | เปิด/ปิด |
| `/mermaid raw on\ | off` | แสดงโค้ดเดิมใต้ภาพด้วย |
/mermaid demo | ดูตัวอย่าง | |
/mermaid draw <โค้ด> | วาดโค้ดที่พิมพ์เอง ใช้ ; แทนขึ้นบรรทัด เช่น graph LR; A-->B |
/plugin install mermaid-inline-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
AssistantMessage ใหม่ แทนที่เฉพาะบล็อก mermaid (ส่วนอื่นใช้ Markdown)เปลี่ยนแค่ภาพที่เราเห็น ข้อความที่ Claude ได้รับยังเป็นโค้ด mermaid เหมือนเดิม ลูกศรที่มีป้ายหรือข้ามชั้นแสดงเป็นรายการใต้กล่อง
hooks/register.tsx 420 lines1/**
2 * ============================================================================
3 * MOD 102: Mermaid Inline TH - วาด Mermaid ในคำตอบเป็นภาพตัวอักษรสี (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: mermaid-draw และ cs-mermaid ใน awesome-claude-code-mods
6 *
7 * Claude ชอบตอบเป็นบล็อก ```mermaid แต่ใน terminal เห็นเป็นโค้ดดิบ อ่านยาก
8 *
9 * หน้าที่ของ Mod นี้:
10 * 1. เจอบล็อก ```mermaid ในคำตอบของ Claude วาดเป็นภาพกล่องและลูกศรสีแทน ตรงตำแหน่งเดิม
11 * 2. รองรับ flowchart / graph (TD, LR), sequenceDiagram และ pie
12 * แบบที่ยังวาดไม่ได้ (class, er, gantt ...) แสดงโค้ดเดิมพร้อมบอกว่ายังไม่รองรับ
13 * 3. คิดความกว้างตัวอักษรไทย (สระบน-ล่าง วรรณยุกต์ไม่กินช่อง) กล่องจึงตรง
14 * 4. /mermaid raw on ดูโค้ดเดิมใต้ภาพด้วย, /mermaid demo ดูตัวอย่าง
15 *
16 * Mod นี้เปลี่ยนแค่ "ภาพที่เราเห็น" ข้อความที่ Claude ได้รับและจำไว้ยังเป็นโค้ด mermaid เหมือนเดิม
17 */
18
19import type { Register } from 'claude-code'
20
21type Span = { text: string; color?: string; dim?: boolean }
22type Line = Span[]
23
24let enabled = true
25let showRaw = false
26
27// ==========================================================================
28// บทที่ 1: ความกว้างบนจอ (ไทยมีสระ/วรรณยุกต์ที่ซ้อนอยู่ในช่องเดิม, emoji/CJK กว้าง 2 ช่อง)
29// ==========================================================================
30function width(text: string): number {
31 let w = 0
32 for (const ch of text) {
33 const c = ch.codePointAt(0) ?? 0
34 if (c === 0x0e31 || (c >= 0x0e34 && c <= 0x0e3a) || (c >= 0x0e47 && c <= 0x0e4e) || (c >= 0x0300 && c <= 0x036f) || c === 0x200d || (c >= 0xfe00 && c <= 0xfe0f)) continue
35 w += (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60) || (c >= 0x1f300 && c <= 0x1faff) ? 2 : 1
36 }
37 return w
38}
39
40function pad(text: string, w: number): string {
41 return text + ' '.repeat(Math.max(0, w - width(text)))
42}
43
44function clip(text: string, max: number): string {
45 if (width(text) <= max) return text
46 let out = ''
47 for (const ch of text) {
48 if (width(`${out}${ch}…`) > max) break
49 out += ch
50 }
51 return `${out}…`
52}
53
54function lineWidth(line: Line): number {
55 return line.reduce((n, s) => n + width(s.text), 0)
56}
57
58// ==========================================================================
59// บทที่ 2: อ่าน flowchart
60// ==========================================================================
61type Shape = 'box' | 'round' | 'decision' | 'circle' | 'db'
62interface Node {
63 id: string
64 label: string
65 shape: Shape
66}
67interface Edge {
68 from: string
69 to: string
70 label: string
71 dotted: boolean
72}
73
74const SHAPE_RE: [RegExp, Shape][] = [
75 [/^\(\((.*)\)\)$/, 'circle'],
76 [/^\(\[(.*)\]\)$/, 'round'],
77 [/^\[\((.*)\)\]$/, 'db'],
78 [/^\[\[(.*)\]\]$/, 'box'],
79 [/^\{\{(.*)\}\}$/, 'decision'],
80 [/^\[\/(.*)\/\]$/, 'box'],
81 [/^\[(.*)\]$/, 'box'],
82 [/^\((.*)\)$/, 'round'],
83 [/^\{(.*)\}$/, 'decision'],
84 [/^>(.*)\]$/, 'box'],
85]
86// โหนดที่มีรูปทรง เช่น A[ข้อความ] B{ถาม?} C((วงกลม))
87const SHAPED = /([A-Za-z0-9_-]+)\s*(\(\(.*?\)\)|\(\[.*?\]\)|\[\(.*?\)\]|\[\[.*?\]\]|\{\{.*?\}\}|\[\/.*?\/\]|\[.*?\]|\(.*?\)|\{.*?\}|>.*?\])/g
88// ลูกศร: -->, ---, -.->, ==>, -- ข้อความ -->, -->|ข้อความ|
89const LINK = /\s*(?:(?:--|==|-\.)\s*([^-=.|>][^|>]*?)\s*)?(<?-->|<?==>|-\.->|\.->|---|===|-\.-|--[ox])\s*(?:\|([^|]*)\|)?\s*/g
90
91function cleanLabel(text: string): string {
92 return text
93 .replace(/^["'`]|["'`]$/g, '')
94 .replace(/<br\s*\/?>/gi, ' ')
95 .replace(/"/g, '"')
96 .trim()
97}
98
99function parseFlow(src: string): { dir: 'TD' | 'LR'; nodes: Map<string, Node>; edges: Edge[] } {
100 // บรรทัดใหม่และ ; คั่นคำสั่งได้ทั้งคู่
101 const statements = src.split(/\n|;/).map(s => s.trim()).filter(Boolean)
102 const head = /^(?:flowchart|graph)\s*(TD|TB|BT|LR|RL)?/i.exec(statements[0] ?? '')
103 const dir = /LR|RL/i.test(head?.[1] ?? '') ? 'LR' : 'TD'
104 const nodes = new Map<string, Node>()
105 const edges: Edge[] = []
106 const ensure = (id: string): void => {
107 if (!nodes.has(id)) nodes.set(id, { id, label: id, shape: 'box' })
108 }
109 for (const statement of statements.slice(1)) {
110 if (/^(%%|classDef|class\s|style\s|linkStyle|click\s|subgraph|end$|direction)/.test(statement)) continue
111 // 1) จดรูปทรง+ข้อความของโหนด แล้วเหลือแค่ id (ข้อความในวงเล็บจะไม่ไปปนกับลูกศร)
112 const masked = statement.replace(SHAPED, (_, id: string, shapeText: string) => {
113 for (const [re, shape] of SHAPE_RE) {
114 const s = re.exec(shapeText)
115 if (s) {
116 nodes.set(id, { id, label: cleanLabel(s[1] ?? id) || id, shape })
117 break
118 }
119 }
120 return id
121 })
122 // 2) ตัดตามลูกศร: กลุ่มโหนด, ลูกศร, กลุ่มโหนด, ลูกศร, ...
123 const groups: string[][] = []
124 const links: { label: string; dotted: boolean }[] = []
125 let at = 0
126 for (const m of masked.matchAll(LINK)) {
127 groups.push(masked.slice(at, m.index).split('&').map(s => s.trim()).filter(Boolean))
128 links.push({ label: cleanLabel(m[3] ?? m[1] ?? ''), dotted: (m[2] ?? '').includes('.') })
129 at = (m.index ?? 0) + m[0].length
130 }
131 groups.push(masked.slice(at).split('&').map(s => s.trim()).filter(Boolean))
132 for (const g of groups) g.forEach(ensure)
133 links.forEach((link, i) => {
134 for (const from of groups[i] ?? []) for (const to of groups[i + 1] ?? []) edges.push({ from, to, ...link })
135 })
136 }
137 return { dir, nodes, edges }
138}
139
140// ==========================================================================
141// บทที่ 3: จัดชั้น (level) แล้ววาดกล่อง
142// ==========================================================================
143const COLOR: Record<Shape, string> = { box: '#90caf9', round: '#81c784', decision: '#ffd54f', circle: '#4dd0e1', db: '#ce93d8' }
144const FRAME: Record<Shape, [string, string, string, string, string, string]> = {
145 box: ['┌', '─', '┐', '│', '└', '┘'],
146 round: ['╭', '─', '╮', '│', '╰', '╯'],
147 decision: ['╔', '═', '╗', '║', '╚', '╝'],
148 circle: ['╭', '─', '╮', '│', '╰', '╯'],
149 db: ['╭', '─', '╮', '│', '╰', '╯'],
150}
151
152function levels(nodes: Map<string, Node>, edges: Edge[]): string[][] {
153 const ids = [...nodes.keys()]
154 const incoming = new Set(edges.map(e => e.to))
155 const roots = ids.filter(id => !incoming.has(id))
156 const level = new Map<string, number>()
157 const queue = (roots.length ? roots : ids.slice(0, 1)).map(id => ({ id, d: 0 }))
158 // BFS: ให้ทุกโหนดอยู่ชั้นแรกที่เดินไปถึง ลูกศรย้อนกลับ (วนลูป) จึงไม่ทำให้ค้าง
159 while (queue.length) {
160 const { id, d } = queue.shift()!
161 if (level.has(id)) continue
162 level.set(id, d)
163 for (const e of edges) if (e.from === id && !level.has(e.to)) queue.push({ id: e.to, d: d + 1 })
164 }
165 for (const id of ids) if (!level.has(id)) level.set(id, 0)
166 const rows: string[][] = []
167 for (const id of ids) (rows[level.get(id)!] ??= []).push(id)
168 return rows.filter(Boolean)
169}
170
171function box(n: Node, maxLabel: number): Line[] {
172 const label = clip(n.label, maxLabel)
173 const [tl, h, tr, v, bl, br] = FRAME[n.shape]
174 const w = width(label) + 2
175 const c = COLOR[n.shape]
176 const mark = n.shape === 'decision' ? '◆' : n.shape === 'db' ? '⛁' : ''
177 const inner = mark ? `${mark}${label}` : label
178 const iw = Math.max(w, width(inner) + 2)
179 return [[{ text: tl + h.repeat(iw) + tr, color: c }], [{ text: v, color: c }, { text: ` ${pad(inner, iw - 2)} ` }, { text: v, color: c }], [{ text: bl + h.repeat(iw) + br, color: c }]]
180}
181
182function edgeText(e: Edge, nodes: Map<string, Node>): Line {
183 const name = (id: string): string => clip(nodes.get(id)?.label ?? id, 18)
184 const shaft = e.dotted ? '┄┄' : '──'
185 return [{ text: name(e.from) }, { text: ` ${shaft}${e.label ? ` ${clip(e.label, 16)} ` : ''}${shaft}▶ `, color: e.label ? '#ffb74d' : '#78909c' }, { text: name(e.to) }]
186}
187
188function drawFlow(src: string): Line[] {
189 const { dir, nodes, edges } = parseFlow(src)
190 if (nodes.size === 0) return []
191 const rows = levels(nodes, edges)
192 const out: Line[] = []
193 if (dir === 'TD') {
194 rows.forEach((row, i) => {
195 const boxes = row.map(id => box(nodes.get(id)!, 22))
196 // กล่องในชั้นเดียวกันวางเรียงแนวนอน
197 for (let r = 0; r < 3; r++) {
198 const line: Line = [{ text: ' ' }]
199 boxes.forEach((b, j) => {
200 if (j) line.push({ text: ' ' })
201 line.push(...b[r]!)
202 })
203 out.push(line)
204 }
205 const down = edges.filter(e => row.includes(e.from))
206 if (i < rows.length - 1 || down.length) {
207 const plain = down.every(e => !e.label) && down.length === 1 && rows[i + 1]?.includes(down[0]!.to)
208 if (plain) out.push([{ text: ' │', color: '#78909c' }], [{ text: ' ▼', color: '#78909c' }])
209 else for (const e of down) out.push([{ text: ' ↓ ', color: '#78909c' }, ...edgeText(e, nodes)])
210 }
211 })
212 return out
213 }
214 // LR: แต่ละชั้นเป็นคอลัมน์ วางกล่องซ้อนลงมา แล้วต่อคอลัมน์ด้วยลูกศร
215 const cols = rows.map(row => row.flatMap(id => box(nodes.get(id)!, 16)))
216 const height = Math.max(...cols.map(c => c.length))
217 const widths = cols.map(c => Math.max(...c.map(lineWidth)))
218 for (let r = 0; r < height; r++) {
219 const line: Line = [{ text: ' ' }]
220 cols.forEach((col, j) => {
221 const cell = col[r] ?? [{ text: '' }]
222 line.push(...cell, { text: ' '.repeat(widths[j]! - lineWidth(cell)) })
223 if (j < cols.length - 1) line.push({ text: r === 1 ? ' ──▶ ' : ' ', color: '#78909c' })
224 })
225 out.push(line)
226 }
227 // ลูกศรที่มีป้าย หรือข้ามคอลัมน์ บอกไว้ข้างล่างให้ชัด
228 const notes = edges.filter(e => e.label || rows.findIndex(r => r.includes(e.to)) !== rows.findIndex(r => r.includes(e.from)) + 1)
229 for (const e of notes) out.push([{ text: ' → ', color: '#78909c' }, ...edgeText(e, nodes)])
230 return out
231}
232
233// ==========================================================================
234// บทที่ 4: sequenceDiagram และ pie
235// ==========================================================================
236function drawSequence(src: string): Line[] {
237 const actors: string[] = []
238 const alias = new Map<string, string>()
239 const add = (id: string): void => {
240 if (!actors.includes(id)) actors.push(id)
241 }
242 const body: Line[] = []
243 let depth = 0
244 for (const raw of src.split('\n').slice(1)) {
245 const s = raw.trim()
246 if (!s || s.startsWith('%%') || /^autonumber/.test(s)) continue
247 const p = /^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?$/.exec(s)
248 if (p) {
249 add(p[1]!)
250 if (p[2]) alias.set(p[1]!, cleanLabel(p[2]))
251 continue
252 }
253 const indent = ' '.repeat(depth)
254 const block = /^(loop|alt|opt|par|critical|rect)\b\s*(.*)$/.exec(s)
255 if (block) {
256 body.push([{ text: ` ${indent}┌ ${block[1]} ${block[2]}`, color: '#ab47bc' }])
257 depth++
258 continue
259 }
260 if (/^else\b/.test(s)) {
261 body.push([{ text: ` ${' '.repeat(Math.max(0, depth - 1))}├ ${s}`, color: '#ab47bc' }])
262 continue
263 }
264 if (s === 'end') {
265 depth = Math.max(0, depth - 1)
266 body.push([{ text: ` ${' '.repeat(depth)}└`, color: '#ab47bc' }])
267 continue
268 }
269 const note = /^note\s+(?:over|left of|right of)\s+([^:]+):\s*(.*)$/i.exec(s)
270 if (note) {
271 body.push([{ text: ` ${indent}📝 ${note[1]!.trim()}: `, dim: true }, { text: cleanLabel(note[2] ?? '') }])
272 continue
273 }
274 const msg = /^([^-\s>]+)\s*(-->>|->>|-->|->|--x|-x|--\)|-\))\s*([+-]?)([^:]+?)\s*:\s*(.*)$/.exec(s)
275 if (msg) {
276 const from = msg[1]!
277 const to = msg[4]!.trim()
278 add(from)
279 add(to)
280 const dashed = msg[2]!.startsWith('--')
281 const reply = dashed ? '┄┄' : '──'
282 body.push([{ text: ` ${indent}` }, { text: alias.get(from) ?? from, color: '#90caf9' }, { text: ` ${reply} ${cleanLabel(msg[5] ?? '')} ${reply}▶ `, color: dashed ? '#78909c' : '#ffb74d' }, { text: alias.get(to) ?? to, color: '#90caf9' }])
283 }
284 }
285 const head: Line = [{ text: ' ' }]
286 actors.forEach((a, i) => {
287 if (i) head.push({ text: ' ' })
288 head.push({ text: `[ ${alias.get(a) ?? a} ]`, color: '#90caf9' })
289 })
290 return [head, [{ text: '' }], ...body]
291}
292
293function drawPie(src: string): Line[] {
294 const title = /^\s*pie\b(?:\s+showData)?(?:\s+title\s+(.*))?/i.exec(src.split('\n')[0] ?? '')?.[1]
295 const items = [...src.matchAll(/^\s*"([^"]+)"\s*:\s*([\d.]+)/gm)].map(m => ({ label: m[1]!, value: Number(m[2]) }))
296 const total = items.reduce((n, i) => n + i.value, 0) || 1
297 const labelW = Math.min(20, Math.max(...items.map(i => width(i.label)), 4))
298 const colors = ['#4fc3f7', '#81c784', '#ffd54f', '#ff8a65', '#ba68c8', '#4db6ac', '#f06292']
299 const out: Line[] = title ? [[{ text: ` ${cleanLabel(title)}` }]] : []
300 items.forEach((it, i) => {
301 const pct = (it.value / total) * 100
302 out.push([{ text: ` ${pad(clip(it.label, labelW), labelW)} ` }, { text: '█'.repeat(Math.max(1, Math.round(pct / 4))), color: colors[i % colors.length] }, { text: ` ${pct.toFixed(1)}%`, dim: true }])
303 })
304 return out
305}
306
307function draw(src: string): Line[] {
308 const kind = src.trim().split(/\s/)[0]?.toLowerCase() ?? ''
309 try {
310 if (kind === 'flowchart' || kind === 'graph') return drawFlow(src.trim())
311 if (kind === 'sequencediagram') return drawSequence(src.trim())
312 if (kind === 'pie') return drawPie(src.trim())
313 } catch {
314 return []
315 }
316 return []
317}
318
319function plain(lines: Line[]): string {
320 return lines.map(l => l.map(s => s.text).join('').trimEnd()).join('\n')
321}
322
323const DEMO = `flowchart TD
324 A([ผู้ใช้ส่งคำสั่ง]) --> B{มีไฟล์อยู่แล้ว?}
325 B -- ใช่ --> C[อ่านไฟล์]
326 B -- ไม่ --> D[สร้างไฟล์ใหม่]
327 C --> E((เสร็จ))
328 D --> E`
329
330const FENCE = /```mermaid[^\n]*\n([\s\S]*?)```/g
331
332export const register: Register = on => {
333 on('session.start', async ($, e, next) => {
334 const s = (await $.store.get('settings')) as { enabled?: boolean; showRaw?: boolean } | undefined
335 enabled = s?.enabled ?? true
336 showRaw = s?.showRaw ?? false
337 await $.command.register({ name: 'mermaid', description: 'วาดบล็อก mermaid ในคำตอบของ Claude เป็นภาพสี (/mermaid help)', argumentHint: 'on|off | raw on|off | demo' })
338 return next(e)
339 })
340
341 // ==========================================================================
342 // บทที่ 5: วาดคำตอบของ Claude ใหม่ แทนที่เฉพาะบล็อก mermaid
343 // ==========================================================================
344 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
345 const text = e.props.text
346 if (!enabled || !text.includes('```mermaid')) return next(e)
347 const { Box, Text, Markdown } = $.ui.resolve(e)
348 const parts: (string | { src: string; lines: Line[] })[] = []
349 let at = 0
350 for (const m of text.matchAll(FENCE)) {
351 parts.push(text.slice(at, m.index))
352 parts.push({ src: m[1] ?? '', lines: draw(m[1] ?? '') })
353 at = (m.index ?? 0) + m[0].length
354 }
355 parts.push(text.slice(at))
356 // ยังพิมพ์บล็อกไม่จบ (กำลังสตรีม) หรือวาดไม่ได้เลย: ปล่อยให้ engine วาดเอง
357 if (!parts.some(p => typeof p !== 'string' && p.lines.length)) return next(e)
358 return (
359 <Box flexDirection="column">
360 {parts.map(p =>
361 typeof p === 'string' ? (
362 p.trim() ? <Markdown text={p.trim()} /> : null
363 ) : (
364 <Box flexDirection="column" marginTop={1} marginBottom={1}>
365 {p.lines.length ? (
366 p.lines.map(line => (
367 <Text>
368 {line.map(s => (
369 <Text color={s.color} dimColor={s.dim}>
370 {s.text}
371 </Text>
372 ))}
373 </Text>
374 ))
375 ) : (
376 <Text dimColor>(Mermaid Inline: ยังวาดแผนภาพแบบนี้ไม่ได้ แสดงโค้ดเดิม)</Text>
377 )}
378 {(showRaw || !p.lines.length) && <Markdown text={`\`\`\`mermaid\n${p.src}\`\`\``} />}
379 </Box>
380 ),
381 )}
382 </Box>
383 )
384 })
385
386 on('command.run', { command: 'mermaid' }, async ($, e) => {
387 const [sub = '', arg] = e.args.trim().split(/\s+/)
388 const save = (): Promise<void> => $.store.set('settings', { enabled, showRaw })
389 if (sub === 'on' || sub === 'off') {
390 enabled = sub === 'on'
391 await save()
392 $.ui.invalidate('ui.render')
393 return { text: `🧜 Mermaid Inline: ${enabled ? 'เปิด' : 'ปิด'}` }
394 }
395 if (sub === 'raw' && (arg === 'on' || arg === 'off')) {
396 showRaw = arg === 'on'
397 await save()
398 $.ui.invalidate('ui.render')
399 return { text: `🧜 แสดงโค้ดเดิมใต้ภาพ: ${showRaw ? 'เปิด' : 'ปิด'}` }
400 }
401 if (sub === 'demo') return { text: `${DEMO}\n\n${plain(draw(DEMO))}` }
402 if (sub === 'draw') {
403 // วาดโค้ดที่พิมพ์มาเอง ใช้ ; แทนการขึ้นบรรทัดใหม่ เช่น /mermaid draw graph LR; A-->B; B-->C
404 const src = e.args.trim().slice(4).trim().replace(/;\s*/g, '\n')
405 const lines = draw(src)
406 return { text: lines.length ? plain(lines) : 'วาดไม่ได้ ตรวจโค้ด mermaid อีกครั้ง (รองรับ flowchart/graph, sequenceDiagram, pie)' }
407 }
408 return { text: '🧜 Mermaid Inline TH (Mod 102)\n/mermaid on|off เปิด/ปิด\n/mermaid raw on|off แสดงโค้ดเดิมใต้ภาพด้วย\n/mermaid demo ดูตัวอย่าง\n/mermaid draw <โค้ด> วาดโค้ดที่พิมพ์เอง ใช้ ; แทนขึ้นบรรทัด\nรองรับ flowchart/graph (TD, LR), sequenceDiagram, pie' }
409 })
410}
411
412/**
413 * ========================================================================
414 * การบ้าน / ต่อยอด
415 * ========================================================================
416 * 1. วาดเส้นเชื่อมจริงระหว่างกล่อง (┬ ┴ ├ ┤) แทนรายการลูกศร
417 * 2. รองรับ classDiagram และ erDiagram
418 * 3. ปุ่ม "เปิดใน mermaid.live" สร้างลิงก์จากโค้ดด้วย base64
419 */
420