Anteprima dei diagrammi draw.io nel pannello, aggiornata quando Claude o tu li modificate

Un pannello che mostra i diagrammi draw.io (.drawio) e li ridisegna quando Claude o tu li modificate. Così vedi il diagramma senza uscire da Claude Code.
/drawio percorso/file.drawio apre il diagramma nel pannello. Se il file non esiste lo crea vuoto e risponde «Creato …, un diagramma vuoto: chiedi a Claude di disegnarci qualcosa.». Senza argomento apre il pannello; se è vuoto ricorda l'uso.
| Tasto | Cosa fa |
|---|---|
1–9 | passa al diagramma di quella scheda (solo i primi nove) |
p / n | pagina precedente / successiva (solo con più pagine) |
g | «Anteprima grande»: apre il PNG in Anteprima di macOS (quando il PNG c'è) |
v | «Modifica in VS Code» |
i | passa da «Vista a caratteri» a «Vista immagine» e ritorno (solo dove le immagini si vedono) |
d | «Apri in draw.io» |
r | «Aggiorna»: esporta di nuovo |
x | «Chiudi»: toglie il diagramma dal pannello, non cancella il file |
Il pannello mostra le schede, il percorso, la pagina e poi il diagramma. Nel terminale è un'immagine PNG esportata da draw.io. Altrove, per esempio nell'app desktop, è un disegno a caratteri ricavato dall'XML. Mentre esporta scrive «Disegno l’anteprima…». Se l'esportazione fallisce mostra l'errore di draw.io e un pulsante «Riprova».
Edit o Write riuscito su un file .drawio lo mette nel pannello e lo riesporta. L'esportazione parte dopo la risposta all'agente, che non aspetta il disegno..drawio aperti nel pannello (contenuto e data di modifica)./drawio riceve un file che non esiste; i PNG in ~/Library/Caches/drawio-viewer/, uno per file e pagina, che la mod non cancella.mkdir -p ~/Library/Caches/drawio-viewer/Applications/draw.io.app/Contents/MacOS/draw.io --export --format png --scale 2 --border 16 --page-index <pagina> --output <png> <file> (timeout 60 secondi)sips -g pixelWidth -g pixelHeight <png> per la misura dell'immaginecode <file>, open -a draw.io <file>, open -a Preview <png>HOME, CLAUDE_CODE_SESSION_KIND, CLAUDE_CODE_FORCE_TERMINAL_IMAGES./Applications, sips, open -a, ~/Library/Caches); altrove il pannello dice che draw.io non c'è e mostra solo il disegno a caratteri./Applications/draw.io.app (brew install --cask drawio) per il PNG. Senza, il pannello lo dice e mostra il disegno a caratteri.code nel PATH, per «Modifica in VS Code».CLAUDE_CODE_SESSION_KIND uguale a bg). Lì il pannello mostra il disegno a caratteri e questo avviso: «Qui Claude Code spegne le immagini: per vedere il PNG aggiungi "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" in "env" di ~/.claude/settings.json e riavvia Claude.». Va bene qualunque valore diverso da vuoto, 0 e false./drawio si apre a ogni larghezza.claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install drawio-viewer@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install drawio-viewer --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
I test sono 38, in 3 file.
hooks/register.tsx 242 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Diagram } from '../types'
5import {
6 DRAWIO_BIN,
7 EMPTY_DIAGRAM,
8 exportArgv,
9 isCompressed,
10 isDrawioPath,
11 lastLine,
12 pageCount,
13 parseSips,
14 previewName,
15 upsert,
16} from './diagram'
17import { pagesOf, parseModel } from './drawioxml'
18import type { Actions } from './view'
19import { emptyView, paneView } from './view'
20
21const PLUGIN = 'drawio-viewer'
22const PANE = 'drawio-viewer'
23const TITLE = 'draw.io'
24/** How often an open pane looks for a save from VS Code or draw.io. */
25const POLL_MS = 2_000
26/** draw.io starts Electron for each export: the first can take a while. */
27const EXPORT_TIMEOUT_MS = 60_000
28const USAGE = 'Uso: /drawio percorso/file.drawio (se il file non esiste lo crea vuoto).'
29
30const diagrams = atom({ plugin: 'drawio-viewer', key: 'diagrams' } as const, [])
31const selected = atom({ plugin: 'drawio-viewer', key: 'selected' } as const, null)
32const hasAutoOpened = atom({ plugin: 'drawio-viewer', key: 'hasAutoOpened' } as const, false)
33const isTextView = atom({ plugin: 'drawio-viewer', key: 'isTextView' } as const, false)
34
35/** Paths being exported now, so a poll and an edit never export one file twice at once. */
36const exporting = new Set<string>()
37/** Paths whose file changed while they were exporting: exported again once that ends. */
38const again = new Set<string>()
39
40const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
41
42const autoOpenOnce = async ($: EngineInterface) => {
43 if (await read($, hasAutoOpened)) return
44 await update($, hasAutoOpened, () => true)
45 const opened = await openPane($)
46 if (!opened.isPlaced) $.ui.toast('draw.io: allarga il terminale (144 colonne) o usa /drawio')
47}
48
49const setDiagram = ($: EngineInterface, path: string, fn: (d: Diagram) => Diagram) =>
50 update($, diagrams, list => upsert(list, path, fn))
51
52const cacheDir = async ($: EngineInterface): Promise<string> => {
53 const home = (await $.env.get('HOME')) ?? '/tmp'
54 const dir = `${home}/Library/Caches/drawio-viewer`
55 await $.process.run(['mkdir', '-p', dir])
56 return dir
57}
58
59/** Exports the diagram's page to PNG and records it; any failure is shown in the pane, never thrown. */
60const exportOnce = async ($: EngineInterface, path: string) => {
61 const { mtimeMs } = await $.fs.stat(path)
62 const xml = await $.fs.read(path)
63 const pages = pageCount(xml)
64 const current = (await read($, diagrams)).find(d => d.path === path)
65 const page = Math.min(current?.page ?? 1, pages)
66 const compressed = isCompressed(xml)
67 // The text view needs only the XML: it is ready before (and without) draw.io.
68 const model = compressed ? null : parseModel(pagesOf(xml)[page - 1] ?? '')
69 await setDiagram($, path, d => ({ ...d, page, pageCount: pages, isCompressed: compressed, mtimeMs, model, status: 'exporting' }))
70
71 if (!(await $.fs.exists(DRAWIO_BIN))) {
72 await setDiagram($, path, d => ({ ...d, status: 'no-drawio', error: null }))
73 return
74 }
75
76 const out = `${await cacheDir($)}/${previewName(path, page)}`
77 const ran = await $.process.run(exportArgv(path, page, out), { timeoutMs: EXPORT_TIMEOUT_MS })
78 if (ran.exitCode !== 0) {
79 await setDiagram($, path, d => ({ ...d, status: 'failed', error: lastLine(ran.stderr || ran.stdout) }))
80 return
81 }
82 const size = parseSips((await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', out])).stdout)
83 if (size === null) {
84 await setDiagram($, path, d => ({ ...d, status: 'failed', error: 'draw.io non ha scritto un PNG leggibile' }))
85 return
86 }
87 const generation = await $.clock.now()
88 await setDiagram($, path, d => ({ ...d, status: 'ready', error: null, preview: { png: out, ...size, generation } }))
89}
90
91/**
92 * Claude Code draws no pictures in a background session (`claude --bg`, or
93 * one it moved behind its pty host) unless CLAUDE_CODE_FORCE_TERMINAL_IMAGES
94 * is set: Ghostty would show them, the engine just does not try.
95 */
96const areImagesBlocked = async ($: EngineInterface): Promise<boolean> => {
97 const forced = (await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) ?? ''
98 const isForced = forced !== '' && forced !== '0' && forced.toLowerCase() !== 'false'
99 return (await $.env.get('CLAUDE_CODE_SESSION_KIND')) === 'bg' && !isForced
100}
101
102const refresh = async ($: EngineInterface, path: string) => {
103 if (exporting.has(path)) {
104 again.add(path)
105 return
106 }
107 exporting.add(path)
108 try {
109 do {
110 again.delete(path)
111 await exportOnce($, path)
112 } while (again.has(path))
113 } catch (error) {
114 await setDiagram($, path, d => ({ ...d, status: 'failed', error: String(error) }))
115 } finally {
116 exporting.delete(path)
117 }
118}
119
120/** While the pane is shown, re-exports a diagram saved from outside (VS Code, draw.io). */
121const pollChanges = async ($: EngineInterface) => {
122 try {
123 const isShown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
124 if (!isShown) return
125 for (const d of await read($, diagrams)) {
126 if (exporting.has(d.path) || d.status === 'no-drawio') continue
127 const { mtimeMs } = await $.fs.stat(d.path)
128 if (mtimeMs > d.mtimeMs) await refresh($, d.path)
129 }
130 } catch (error) {
131 $.ui.log(`${PLUGIN}: controllo dei file non riuscito: ${String(error)}`, { to: 'debug' })
132 }
133}
134
135/** `/drawio file.drawio`: shows the file, writing an empty diagram first when it does not exist. */
136const showFile = async ($: EngineInterface, arg: string): Promise<string> => {
137 if (!isDrawioPath(arg)) return `${USAGE} «${arg}» non finisce in .drawio.`
138 const isMissing = !(await $.fs.exists(arg))
139 if (isMissing) await $.fs.write(arg, EMPTY_DIAGRAM)
140 const path = (await $.fs.stat(arg, { resolve: true })).realPath ?? arg
141
142 await setDiagram($, path, d => ({ ...d, status: 'exporting' }))
143 await update($, selected, () => path)
144 await openPane($)
145 $.clock.after(0, () => void refresh($, path))
146
147 return isMissing ? `Creato ${arg}, un diagramma vuoto: chiedi a Claude di disegnarci qualcosa.` : `Aperto ${arg}.`
148}
149
150type Ran = { deny?: string; isError?: true }
151
152const actionsFor = ($: EngineInterface): Actions => ({
153 select: path => update($, selected, () => path),
154 turnPage: async (d, delta) => {
155 const page = Math.min(d.pageCount, Math.max(1, d.page + delta))
156 await setDiagram($, d.path, x => ({ ...x, page }))
157 await refresh($, d.path)
158 },
159 refresh: path => refresh($, path),
160 openInEditor: path => $.process.run(['code', path]),
161 openInDrawio: path => $.process.run(['open', '-a', 'draw.io', path]),
162 openPreview: png => $.process.run(['open', '-a', 'Preview', png]),
163 toggleView: () => update($, isTextView, isText => !isText),
164 close: async path => {
165 await update($, diagrams, list => list.filter(d => d.path !== path))
166 await update($, selected, () => null)
167 },
168})
169
170// A diagram Claude edits is shown and re-exported once the edit has run.
171const afterEdit = async ($: EngineInterface, path: string, ran: Ran) => {
172 if (ran.deny !== undefined || ran.isError === true || !isDrawioPath(path)) return
173 try {
174 await setDiagram($, path, d => ({ ...d, status: 'exporting' }))
175 await update($, selected, () => path)
176 await autoOpenOnce($)
177 // Exporting takes seconds: Claude gets the edit's answer now, the picture follows.
178 $.clock.after(0, () => void refresh($, path))
179 } catch (error) {
180 $.ui.log(`${PLUGIN}: anteprima non aggiornata: ${String(error)}`, { to: 'debug' })
181 }
182}
183
184export const register: Register = on => {
185 on('session.start', async ($, e, next) => {
186 await $.command.register({
187 name: 'drawio',
188 description: 'Mostra un diagramma draw.io nel pannello (lo crea se non esiste)',
189 argumentHint: '<file.drawio>',
190 })
191 $.clock.every(POLL_MS, () => void pollChanges($))
192
193 return next(e)
194 })
195
196 on('command.run', { command: 'drawio' }, async ($, e) => {
197 const arg = e.args.trim()
198 if (arg === '') {
199 await openPane($)
200 return { text: (await read($, diagrams)).length === 0 ? USAGE : 'Pannello draw.io aperto.' }
201 }
202 try {
203 return { text: await showFile($, arg) }
204 } catch (error) {
205 return { text: `Non riesco ad aprire ${arg}: ${String(error)}` }
206 }
207 })
208
209 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
210 const ran = await next(e)
211 await afterEdit($, e.file_path, ran)
212 return ran
213 }).catch(($, e, next) => next(e))
214
215 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
216 const ran = await next(e)
217 await afterEdit($, e.file_path, ran)
218 return ran
219 }).catch(($, e, next) => next(e))
220
221 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222 const el = $.ui.resolve(e)
223 const list = await read($, diagrams)
224 const wanted = await read($, selected)
225 const current = list.find(d => d.path === wanted) ?? list[list.length - 1]
226 if (current === undefined) return emptyView(el)
227
228 const columns = Math.max(20, e.props.bodyColumns)
229 const rows = e.props.scroll.bodyRows
230 const isText = await read($, isTextView)
231 // Only the terminal paints cells; elsewhere the pane shows the drawing in characters.
232 const canShowImages = e.surface === 'terminal'
233 const blocked = await areImagesBlocked($)
234
235 return paneView(
236 el,
237 { diagrams: list, current, columns, rows, isTextView: isText, canShowImages, areImagesBlocked: blocked },
238 actionsFor($),
239 )
240 })
241}
242hooks/diagram.ts 111 lines1import type { Diagram } from '../types'
2
3/** draw.io Desktop's command line, as the brew cask installs it. */
4export const DRAWIO_BIN = '/Applications/draw.io.app/Contents/MacOS/draw.io'
5export const INSTALL_HINT = 'brew install --cask drawio'
6/** Export at twice the size: the terminal scales down, a sharper picture. */
7const EXPORT_SCALE = 2
8const EXPORT_BORDER = 16
9/** Most cells an Image may span either way. */
10const MAX_CELLS = 255
11/** A terminal cell is about twice as tall as it is wide. */
12const CELL_ASPECT = 2
13
14/** True for a file the pane previews: an uncompressed or compressed .drawio. */
15export const isDrawioPath = (path: string): boolean => /\.drawio$/i.test(path)
16
17/** Pages in a diagram file: one `<diagram>` element each, at least one. */
18export const pageCount = (xml: string): number => Math.max(1, (xml.match(/<diagram[\s/>]/g) ?? []).length)
19
20/** A diagram saved compressed keeps its pages as base64 text instead of `<mxGraphModel>` XML. */
21export const isCompressed = (xml: string): boolean => /<diagram[\s/>]/.test(xml) && !/<mxGraphModel[\s/>]/.test(xml)
22
23/** A safe file name for a diagram's preview: its path with every other character as `_`. */
24export const previewName = (path: string, page: number): string =>
25 `${path.replace(/^\/+/, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(-180)}-p${page}.png`
26
27/** The argv that has draw.io write one page of a diagram as PNG. */
28export const exportArgv = (path: string, page: number, out: string): string[] => [
29 DRAWIO_BIN,
30 '--export',
31 '--format',
32 'png',
33 '--scale',
34 String(EXPORT_SCALE),
35 '--border',
36 String(EXPORT_BORDER),
37 '--page-index',
38 String(page),
39 '--output',
40 out,
41 path,
42]
43
44/** Reads `sips -g pixelWidth -g pixelHeight` into the PNG's size, or null. */
45export const parseSips = (stdout: string): { width: number; height: number } | null => {
46 const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1])
47 const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1])
48 return width > 0 && height > 0 ? { width, height } : null
49}
50
51/**
52 * The cells a picture of `width`×`height` pixels spans to fit `columns`×`rows`
53 * without distortion, cells being about twice as tall as wide.
54 */
55export const fitImage = (
56 width: number,
57 height: number,
58 columns: number,
59 rows: number,
60): { columns: number; rows: number } => {
61 const ratio = height / width / CELL_ASPECT
62 const byWidth = Math.min(columns, MAX_CELLS)
63 const byHeight = Math.floor(Math.min(rows, MAX_CELLS) / ratio)
64 const cols = Math.max(1, Math.min(byWidth, byHeight))
65
66 return { columns: cols, rows: Math.max(1, Math.min(MAX_CELLS, Math.round(cols * ratio))) }
67}
68
69/** An empty, uncompressed diagram: what `/drawio new.drawio` writes for a file that does not exist. */
70export const EMPTY_DIAGRAM = [
71 '<mxfile host="drawio-viewer">',
72 ' <diagram id="page-1" name="Pagina 1">',
73 ' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" page="1" pageWidth="827" pageHeight="1169">',
74 ' <root>',
75 ' <mxCell id="0" />',
76 ' <mxCell id="1" parent="0" />',
77 ' </root>',
78 ' </mxGraphModel>',
79 ' </diagram>',
80 '</mxfile>',
81 '',
82].join('\n')
83
84/** A diagram not exported yet. */
85export const newDiagram = (path: string): Diagram => ({
86 path,
87 page: 1,
88 pageCount: 1,
89 isCompressed: false,
90 mtimeMs: 0,
91 status: 'exporting',
92 error: null,
93 preview: null,
94 model: null,
95})
96
97/** Adds or replaces a diagram by path, keeping the list's order. Never mutates. */
98export const upsert = (list: readonly Diagram[], path: string, fn: (d: Diagram) => Diagram): Diagram[] =>
99 list.some(d => d.path === path)
100 ? list.map(d => (d.path === path ? fn(d) : d))
101 : [...list, fn(newDiagram(path))]
102
103/** The last line of a command's error output, short enough for the pane. */
104export const lastLine = (text: string): string =>
105 text.trim().split('\n').filter(Boolean).pop()?.slice(0, 200) ?? 'errore sconosciuto'
106
107const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1) || path
108
109/** The tab label: the file name without `.drawio`. */
110export const diagramLabel = (path: string): string => baseName(path).replace(/\.drawio$/i, '')
111hooks/drawioxml.ts 155 lines1import type { DiagramModel, DiagramEdge, DiagramVertex, VertexShape } from '../types'
2
3type Attrs = Record<string, string>
4type RawCell = { attrs: Attrs; body: string }
5
6const ENTITIES: Record<string, string> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' }
7
8/** Decodes the XML entities draw.io writes in attribute values. */
9const decodeEntities = (text: string): string =>
10 text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
11 if (code[0] === '#') {
12 const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
13 return Number.isFinite(n) ? String.fromCodePoint(n) : whole
14 }
15 return ENTITIES[code.toLowerCase()] ?? whole
16 })
17
18const parseAttrs = (text: string): Attrs =>
19 Object.fromEntries([...text.matchAll(/([\w:-]+)="([^"]*)"/g)].map(m => [m[1] ?? '', decodeEntities(m[2] ?? '')]))
20
21/** A label as plain text: HTML line breaks kept as newlines, every other tag dropped. */
22export const labelText = (value: string): string =>
23 decodeEntities(
24 value
25 .replace(/<br\s*\/?>/gi, '\n')
26 .replace(/<\/(div|p|li)>/gi, '\n')
27 .replace(/<[^>]+>/g, ''),
28 )
29 .split('\n')
30 .map(line => line.replace(/\s+/g, ' ').trim())
31 .filter(line => line !== '')
32 .join('\n')
33
34/** Reads `key=value;flag;` into a map; a bare word (the shape, e.g. `ellipse`) maps to "1". */
35export const parseStyle = (style: string): Attrs =>
36 Object.fromEntries(
37 style
38 .split(';')
39 .filter(Boolean)
40 .map(part => {
41 const at = part.indexOf('=')
42 return at < 0 ? [part, '1'] : [part.slice(0, at), part.slice(at + 1)]
43 }),
44 )
45
46/** The inside of each `<diagram>`: one string per page, in order. */
47export const pagesOf = (xml: string): string[] =>
48 [...xml.matchAll(/<diagram\b[^>]*>([\s\S]*?)<\/diagram>/g)].map(m => m[1] ?? '')
49
50/** Every cell, a `<UserObject>`/`<object>` wrapper's id and label folded into its inner `<mxCell>`. */
51const rawCells = (page: string): RawCell[] => {
52 const wrapped: RawCell[] = []
53 const rest = page.replace(/<(UserObject|object)\b([^>]*)>([\s\S]*?)<\/\1>/g, (_whole, _tag, outer: string, inner: string) => {
54 const wrapper = parseAttrs(outer)
55 const cell = /<mxCell\b([^>]*?)(?:\/>|>([\s\S]*?)<\/mxCell>)/.exec(inner)
56 const attrs = { ...parseAttrs(cell?.[1] ?? ''), id: wrapper.id ?? '', value: wrapper.label ?? wrapper.value ?? '' }
57 wrapped.push({ attrs, body: cell?.[2] ?? '' })
58 return ''
59 })
60 const plain = [...rest.matchAll(/<mxCell\b([^>]*?)(?:\/>|>([\s\S]*?)<\/mxCell>)/g)].map(m => ({
61 attrs: parseAttrs(m[1] ?? ''),
62 body: m[2] ?? '',
63 }))
64 return [...plain, ...wrapped]
65}
66
67const num = (value: string | undefined): number => {
68 const n = Number(value)
69 return Number.isFinite(n) ? n : 0
70}
71
72const color = (value: string | undefined): string | null =>
73 value === undefined || value === 'none' || value === 'default' || value === '' ? null : value
74
75const shapeOf = (style: Attrs): VertexShape => {
76 if (style.text === '1' || style.shape === 'text') return 'text'
77 if (style.ellipse === '1' || style.shape === 'ellipse') return 'ellipse'
78 if (style.rhombus === '1' || style.shape === 'rhombus') return 'rhombus'
79 return 'box'
80}
81
82const point = (body: string, as: string): { x: number; y: number } | null => {
83 const tag = [...body.matchAll(/<mxPoint\b([^>]*)\/?>/g)].map(m => parseAttrs(m[1] ?? '')).find(a => a.as === as)
84 return tag === undefined ? null : { x: num(tag.x), y: num(tag.y) }
85}
86
87/**
88 * One page's vertices and edges, positions absolute (a group's children moved
89 * by the group's corner), an edge's label children folded into its label.
90 */
91export const parseModel = (page: string): DiagramModel => {
92 const cells = rawCells(page)
93 const byId = new Map(cells.map(c => [c.attrs.id ?? '', c]))
94 const edgeIds = new Set(cells.filter(c => c.attrs.edge === '1').map(c => c.attrs.id ?? ''))
95
96 const corner = (id: string | undefined, depth = 0): { x: number; y: number } => {
97 const cell = id === undefined ? undefined : byId.get(id)
98 if (cell === undefined || cell.attrs.vertex !== '1' || depth > 20) return { x: 0, y: 0 }
99 const geo = parseAttrs(/<mxGeometry\b([^>]*)/.exec(cell.body)?.[1] ?? '')
100 const up = corner(cell.attrs.parent, depth + 1)
101 return { x: up.x + num(geo.x), y: up.y + num(geo.y) }
102 }
103
104 const edgeLabels = new Map<string, string>()
105 const vertices: DiagramVertex[] = cells
106 .filter(c => c.attrs.vertex === '1')
107 .flatMap(c => {
108 const label = labelText(c.attrs.value ?? '')
109 if (edgeIds.has(c.attrs.parent ?? '')) {
110 if (label !== '') edgeLabels.set(c.attrs.parent ?? '', label)
111 return []
112 }
113 const geo = parseAttrs(/<mxGeometry\b([^>]*)/.exec(c.body)?.[1] ?? '')
114 const style = parseStyle(c.attrs.style ?? '')
115 const at = corner(c.attrs.id)
116 return [
117 {
118 id: c.attrs.id ?? '',
119 label,
120 x: at.x,
121 y: at.y,
122 width: Math.max(num(geo.width), 1),
123 height: Math.max(num(geo.height), 1),
124 shape: shapeOf(style),
125 fill: color(style.fillColor),
126 stroke: color(style.strokeColor),
127 font: color(style.fontColor),
128 isRounded: style.rounded === '1',
129 isDashed: style.dashed === '1',
130 hasBorder: style.strokeColor !== 'none',
131 },
132 ]
133 })
134
135 const edges: DiagramEdge[] = cells
136 .filter(c => c.attrs.edge === '1')
137 .map(c => {
138 const style = parseStyle(c.attrs.style ?? '')
139 const own = labelText(c.attrs.value ?? '')
140 return {
141 id: c.attrs.id ?? '',
142 label: own !== '' ? own : (edgeLabels.get(c.attrs.id ?? '') ?? ''),
143 source: c.attrs.source ?? null,
144 target: c.attrs.target ?? null,
145 sourcePoint: point(c.body, 'sourcePoint'),
146 targetPoint: point(c.body, 'targetPoint'),
147 stroke: color(style.strokeColor),
148 isDashed: style.dashed === '1',
149 hasArrow: style.endArrow !== 'none',
150 }
151 })
152
153 return { vertices, edges }
154}
155hooks/view.tsx 187 lines1import type { ElementTable } from 'claude-code'
2
3import type { Diagram } from '../types'
4import { diagramLabel, fitImage, INSTALL_HINT } from './diagram'
5import { drawDiagram } from './textview'
6
7/** Rows the pane keeps for its own lines around the picture. */
8const CHROME_ROWS = 9
9const MIN_IMAGE_ROWS = 8
10const MAX_HOTKEYS = 9
11
12export type Actions = {
13 select: (path: string) => unknown
14 turnPage: (diagram: Diagram, delta: number) => unknown
15 refresh: (path: string) => unknown
16 openInEditor: (path: string) => unknown
17 openInDrawio: (path: string) => unknown
18 openPreview: (png: string) => unknown
19 toggleView: () => unknown
20 close: (path: string) => unknown
21}
22
23export type ViewState = {
24 diagrams: Diagram[]
25 current: Diagram
26 columns: number
27 rows: number
28 /** The drawing in characters instead of the PNG. */
29 isTextView: boolean
30 /** The surface draws pictures: the terminal does, the desktop app does not. */
31 canShowImages: boolean
32 /** A background session that Claude Code keeps from drawing pictures (see `areImagesBlocked`). */
33 areImagesBlocked: boolean
34}
35
36const IMAGES_HINT =
37 'Qui Claude Code spegne le immagini: per vedere il PNG aggiungi "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" in "env" di ~/.claude/settings.json e riavvia Claude.'
38
39const tabs = (el: ElementTable, s: ViewState, act: Actions) => (
40 <el.Box flexDirection="row" flexWrap="wrap" columnGap={1}>
41 <el.Text bold color="claude">◆ draw.io</el.Text>
42 {s.diagrams.map((d, i) => (
43 <el.Button
44 key={`tab:${d.path}`}
45 label={`${d.path === s.current.path ? '●' : '◇'} ${diagramLabel(d.path)}`}
46 hotkey={i < MAX_HOTKEYS ? String(i + 1) : undefined}
47 variant={d.path === s.current.path ? 'primary' : undefined}
48 dimColor={d.path !== s.current.path}
49 onPress={() => act.select(d.path)}
50 />
51 ))}
52 </el.Box>
53)
54
55const pageControls = (el: ElementTable, d: Diagram, act: Actions) =>
56 d.pageCount > 1 && (
57 <el.Box flexDirection="row" columnGap={1}>
58 <el.Button key="page-prev" label="◀" plain hotkey="p" onPress={() => act.turnPage(d, -1)} />
59 <el.Text dimColor>
60 pagina {d.page}/{d.pageCount}
61 </el.Text>
62 <el.Button key="page-next" label="▶" plain hotkey="n" onPress={() => act.turnPage(d, 1)} />
63 </el.Box>
64 )
65
66const statusLine = (el: ElementTable, d: Diagram, act: Actions) => {
67 if (d.status === 'no-drawio') {
68 return (
69 <el.Box flexDirection="column">
70 <el.Text color="warning">draw.io Desktop non è installato: senza, il diagramma si vede solo a caratteri, senza immagine né «Anteprima grande».</el.Text>
71 <el.Text>
72 <el.Text dimColor>Installalo con </el.Text>
73 <el.Text bold>{INSTALL_HINT}</el.Text>
74 <el.Text dimColor>, poi premi «Aggiorna».</el.Text>
75 </el.Text>
76 </el.Box>
77 )
78 }
79 if (d.status === 'failed') {
80 return (
81 <el.Box flexDirection="row" columnGap={1}>
82 <el.Text color="error">Anteprima non riuscita: {d.error ?? 'errore sconosciuto'}</el.Text>
83 <el.Button key="retry" label="Riprova" plain onPress={() => act.refresh(d.path)} />
84 </el.Box>
85 )
86 }
87 if (d.status === 'exporting') return <el.Text dimColor italic>Disegno l’anteprima…</el.Text>
88 return null
89}
90
91const textDrawing = (el: ElementTable, s: ViewState) => {
92 // A diagram saved by version 0.1 has no model yet: shown as not read until its next refresh.
93 const model = s.current.model ?? null
94 if (model === null) {
95 return s.current.isCompressed ? null : <el.Text dimColor italic>Leggo il diagramma…</el.Text>
96 }
97 const rows = drawDiagram(model, s.columns)
98 if (rows.length === 0) return <el.Text dimColor>Pagina vuota: chiedi a Claude di disegnarci qualcosa.</el.Text>
99
100 return (
101 <el.Box key="drawing" flexDirection="column">
102 {rows.map((row, r) => (
103 <el.Text key={`row:${r}`} wrap="truncate-end">
104 {row.length === 0
105 ? ' '
106 : row.map(span => (
107 <el.Text color={span.fg} backgroundColor={span.bg} bold={span.bold} dimColor={span.dim}>
108 {span.text}
109 </el.Text>
110 ))}
111 </el.Text>
112 ))}
113 </el.Box>
114 )
115}
116
117const isPictureShown = (el: ElementTable, s: ViewState) =>
118 !s.isTextView && s.canShowImages && !s.areImagesBlocked && s.current.preview !== null && 'Image' in el
119
120const picture = (el: ElementTable, s: ViewState) => {
121 const preview = s.current.preview
122 if (preview === null || !('Image' in el)) return null
123 const size = fitImage(preview.width, preview.height, s.columns, Math.max(MIN_IMAGE_ROWS, s.rows - CHROME_ROWS))
124
125 return (
126 <el.Image
127 key="preview"
128 source={{ file: preview.png, format: 'png', generation: preview.generation }}
129 columns={size.columns}
130 rows={size.rows}
131 alt={`Diagramma ${diagramLabel(s.current.path)}, pagina ${s.current.page}`}
132 />
133 )
134}
135
136const actions = (el: ElementTable, d: Diagram, act: Actions, s: ViewState) => (
137 <el.Box flexDirection="row" columnGap={2}>
138 {d.preview !== null && (
139 <el.Button key="open-preview" label="Anteprima grande" hotkey="g" variant="primary" onPress={() => act.openPreview(d.preview?.png ?? '')} />
140 )}
141 <el.Button key="open-editor" label="Modifica in VS Code" hotkey="v" onPress={() => act.openInEditor(d.path)} />
142 {s.canShowImages && !s.areImagesBlocked && (
143 <el.Button key="toggle-view" label={s.isTextView ? 'Vista immagine' : 'Vista a caratteri'} hotkey="i" plain dimColor onPress={act.toggleView} />
144 )}
145 <el.Button key="open-drawio" label="Apri in draw.io" hotkey="d" plain onPress={() => act.openInDrawio(d.path)} />
146 <el.Button key="refresh" label="Aggiorna" hotkey="r" plain dimColor onPress={() => act.refresh(d.path)} />
147 <el.Button key="close" label="Chiudi" hotkey="x" plain dimColor onPress={() => act.close(d.path)} />
148 </el.Box>
149)
150
151/** The pane with at least one diagram. */
152export const paneView = (el: ElementTable, s: ViewState, act: Actions) => {
153 const d = s.current
154
155 return (
156 <el.Box flexDirection="column" gap={1}>
157 {tabs(el, s, act)}
158 <el.Box flexDirection="row" justifyContent="space-between">
159 <el.Text bold>{d.path.replace(/^\/Users\/[^/]+/, '~')}</el.Text>
160 {pageControls(el, d, act)}
161 </el.Box>
162 {d.isCompressed && (
163 <el.Text color="warning">
164 Diagramma salvato compresso: Claude non riesce a modificarlo bene. In draw.io togli «Compresso» da
165 File › Proprietà e salva.
166 </el.Text>
167 )}
168 {statusLine(el, d, act)}
169 {isPictureShown(el, s) ? picture(el, s) : textDrawing(el, s)}
170 {s.canShowImages && s.areImagesBlocked && <el.Text dimColor>{IMAGES_HINT}</el.Text>}
171 {actions(el, d, act, s)}
172 <el.Text dimColor>
173 Per modificarlo chiedi a Claude (es. «aggiungi un nodo Database collegato all’API») o usa VS Code: l’anteprima
174 si aggiorna da sola quando il file cambia.
175 </el.Text>
176 </el.Box>
177 )
178}
179
180/** The pane before any diagram. */
181export const emptyView = (el: ElementTable) => (
182 <el.Box flexDirection="column">
183 <el.Text bold color="claude">◆ draw.io</el.Text>
184 <el.Text dimColor>Nessun diagramma aperto. Usa /drawio percorso/file.drawio: se il file non esiste lo crea vuoto.</el.Text>
185 </el.Box>
186)
187hooks/textview.ts 201 lines1import type { DiagramModel, DiagramVertex } from '../types'
2
3/** One run of characters drawn in one style. */
4export type Span = { text: string; fg?: string; bg?: string; bold?: boolean; dim?: boolean }
5
6type Cell = { ch: string; fg?: string; bg?: string; bold?: boolean; dim?: boolean; lines: number }
7type Rect = { c0: number; r0: number; c1: number; r1: number }
8type Pt = { c: number; r: number }
9
10/** A terminal cell is about twice as tall as wide: rows take half the columns' scale. */
11const ROW_SCALE = 0.5
12const MAX_ROWS = 150
13const MIN_BOX_COLS = 5
14const MIN_BOX_ROWS = 3
15/** Text on a coloured fill: dark, as draw.io draws it by default. */
16const ON_FILL = '#1a1a1a'
17
18const UP = 1
19const DOWN = 2
20const LEFT = 4
21const RIGHT = 8
22const LINE: Record<number, string> = {
23 [UP]: '│', [DOWN]: '│', [UP | DOWN]: '│', [LEFT]: '─', [RIGHT]: '─', [LEFT | RIGHT]: '─',
24 [DOWN | RIGHT]: '╭', [DOWN | LEFT]: '╮', [UP | RIGHT]: '╰', [UP | LEFT]: '╯',
25 [UP | DOWN | RIGHT]: '├', [UP | DOWN | LEFT]: '┤', [DOWN | LEFT | RIGHT]: '┬', [UP | LEFT | RIGHT]: '┴',
26 [UP | DOWN | LEFT | RIGHT]: '┼',
27}
28const DASHED: Record<string, string> = { '│': '┆', '─': '┄' }
29
30const fit = (text: string, width: number): string =>
31 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
32
33/**
34 * Draws a diagram page with box-drawing characters in at most `columns`
35 * cells across: boxes at their scaled places with colours and labels,
36 * connectors routed at right angles with an arrowhead and their label.
37 */
38export const drawDiagram = (model: DiagramModel, columns: number): Span[][] => {
39 const shown = model.vertices
40 if (shown.length === 0) return []
41
42 const points = model.edges.flatMap(e => [e.sourcePoint, e.targetPoint]).filter(p => p !== null)
43 const minX = Math.min(...shown.map(v => v.x), ...points.map(p => p.x))
44 const minY = Math.min(...shown.map(v => v.y), ...points.map(p => p.y))
45 const maxX = Math.max(...shown.map(v => v.x + v.width), ...points.map(p => p.x))
46 const maxY = Math.max(...shown.map(v => v.y + v.height), ...points.map(p => p.y))
47 const cols = Math.max(20, columns)
48 const sx = (cols - 1) / Math.max(maxX - minX, 1)
49 const sy = sx * ROW_SCALE
50 const rows = Math.min(MAX_ROWS, Math.ceil((maxY - minY) * sy) + MIN_BOX_ROWS)
51 const grid: Cell[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ ch: ' ', lines: 0 })))
52
53 const inGrid = (p: Pt) => p.r >= 0 && p.r < rows && p.c >= 0 && p.c < cols
54 const at = (p: Pt): Cell | undefined => (inGrid(p) ? grid[p.r]?.[p.c] : undefined)
55 const toCell = (x: number, y: number): Pt => ({ c: Math.round((x - minX) * sx), r: Math.round((y - minY) * sy) })
56
57 const rects = new Map<string, Rect>(
58 shown.map(v => {
59 const a = toCell(v.x, v.y)
60 const b = toCell(v.x + v.width, v.y + v.height)
61 return [v.id, { c0: a.c, r0: a.r, c1: Math.min(cols - 1, Math.max(b.c, a.c + MIN_BOX_COLS - 1)), r1: Math.min(rows - 1, Math.max(b.r, a.r + MIN_BOX_ROWS - 1)) }]
62 }),
63 )
64 const isInBox = (p: Pt) => [...rects.values()].some(r => p.c >= r.c0 && p.c <= r.c1 && p.r >= r.r0 && p.r <= r.r1)
65
66 // Connectors first: boxes drawn after them cover any line that crosses a box.
67 const labels: { at: Pt; text: string }[] = []
68 for (const edge of model.edges) {
69 const route = routeEdge(edge.source === null ? null : (rects.get(edge.source) ?? null), edge.target === null ? null : (rects.get(edge.target) ?? null), edge.sourcePoint && toCell(edge.sourcePoint.x, edge.sourcePoint.y), edge.targetPoint && toCell(edge.targetPoint.x, edge.targetPoint.y))
70 if (route === null) continue
71 const style = { fg: edge.stroke ?? undefined, dim: edge.stroke === null }
72 for (let i = 1; i < route.path.length; i += 1) link(at, route.path[i - 1], route.path[i], style)
73 route.path.forEach(p => {
74 const cell = at(p)
75 if (cell === undefined) return
76 const ch = LINE[cell.lines] ?? cell.ch
77 cell.ch = edge.isDashed ? (DASHED[ch] ?? ch) : ch
78 })
79 const end = route.path[route.path.length - 1]
80 const endCell = end && at(end)
81 if (edge.hasArrow && endCell !== undefined) endCell.ch = route.arrow
82 if (edge.label !== '') labels.push({ at: route.labelAt, text: edge.label.replace(/\n/g, ' ') })
83 }
84
85 for (const v of shown) drawVertex(grid, v, rects.get(v.id))
86
87 for (const label of labels) {
88 const text = ` ${label.text} `
89 const start = Math.max(0, Math.min(cols - text.length, label.at.c - Math.floor(text.length / 2)))
90 ;[...text].forEach((ch, i) => {
91 const p = { c: start + i, r: label.at.r }
92 const cell = at(p)
93 if (cell !== undefined && !isInBox(p)) Object.assign(cell, { ch, fg: undefined, bg: undefined, dim: false, bold: false })
94 })
95 }
96
97 return grid.map(toSpans)
98}
99
100const link = (at: (p: Pt) => Cell | undefined, a: Pt | undefined, b: Pt | undefined, style: { fg?: string; dim: boolean }) => {
101 if (a === undefined || b === undefined) return
102 const from = at(a)
103 const to = at(b)
104 const [out, back] = b.r > a.r ? [DOWN, UP] : b.r < a.r ? [UP, DOWN] : b.c > a.c ? [RIGHT, LEFT] : [LEFT, RIGHT]
105 if (from !== undefined) Object.assign(from, { lines: from.lines | out, ...style })
106 if (to !== undefined) Object.assign(to, { lines: to.lines | back, ...style })
107}
108
109const centre = (r: Rect): Pt => ({ c: Math.round((r.c0 + r.c1) / 2), r: Math.round((r.r0 + r.r1) / 2) })
110
111/** Cells of a right-angled route from one box (or point) to another, its arrowhead and where its label goes. */
112const routeEdge = (s: Rect | null, t: Rect | null, sp: Pt | null, tp: Pt | null): { path: Pt[]; arrow: string; labelAt: Pt } | null => {
113 const a = s === null ? sp : centre(s)
114 const b = t === null ? tp : centre(t)
115 if (a === null || b === null) return null
116 // As draw.io lays out top-down: one box wholly above the other goes out the bottom, in the top.
117 const isStacked = s !== null && t !== null && (t.r0 > s.r1 || t.r1 < s.r0)
118 const isSideBySide = s !== null && t !== null && (t.c0 > s.c1 || t.c1 < s.c0)
119 const isVertical = isStacked || (!isSideBySide && Math.abs(b.r - a.r) * 2 >= Math.abs(b.c - a.c))
120
121 if (isVertical) {
122 const down = b.r >= a.r
123 const start = { c: a.c, r: s === null ? a.r : down ? s.r1 + 1 : s.r0 - 1 }
124 const end = { c: b.c, r: t === null ? b.r : down ? t.r0 - 1 : t.r1 + 1 }
125 const mid = Math.round((start.r + end.r) / 2)
126 return { path: orthogonal(start, end, mid, true), arrow: down ? '▼' : '▲', labelAt: { c: Math.round((start.c + end.c) / 2), r: mid } }
127 }
128 const right = b.c >= a.c
129 const start = { c: s === null ? a.c : right ? s.c1 + 1 : s.c0 - 1, r: a.r }
130 const end = { c: t === null ? b.c : right ? t.c0 - 1 : t.c1 + 1, r: b.r }
131 const mid = Math.round((start.c + end.c) / 2)
132 return { path: orthogonal(start, end, mid, false), arrow: right ? '▶' : '◀', labelAt: { c: mid, r: start.r === end.r ? start.r - 1 : Math.round((start.r + end.r) / 2) } }
133}
134
135/** start → bend at `mid` (a row when vertical, a column when not) → end, one cell per step. */
136const orthogonal = (start: Pt, end: Pt, mid: number, isVertical: boolean): Pt[] => {
137 const corners = isVertical
138 ? [start, { c: start.c, r: mid }, { c: end.c, r: mid }, end]
139 : [start, { c: mid, r: start.r }, { c: mid, r: end.r }, end]
140 return corners.reduce<Pt[]>((path, corner, i) => {
141 if (i === 0) return [corner]
142 const from = path[path.length - 1] ?? corner
143 const steps = Math.max(Math.abs(corner.c - from.c), Math.abs(corner.r - from.r))
144 const dc = Math.sign(corner.c - from.c)
145 const dr = Math.sign(corner.r - from.r)
146 return [...path, ...Array.from({ length: steps }, (_, k) => ({ c: from.c + dc * (k + 1), r: from.r + dr * (k + 1) }))]
147 }, [])
148}
149
150const drawVertex = (grid: Cell[][], v: DiagramVertex, r: Rect | undefined) => {
151 if (r === undefined) return
152 const isRound = v.isRounded || v.shape === 'ellipse'
153 const [tl, tr, bl, br] = isRound ? ['╭', '╮', '╰', '╯'] : ['┌', '┐', '└', '┘']
154 const h = v.isDashed ? '┄' : '─'
155 const vert = v.isDashed ? '┆' : '│'
156 const border = v.shape !== 'text' && v.hasBorder
157 const fill = v.shape === 'text' ? undefined : (v.fill ?? undefined)
158
159 for (let row = r.r0; row <= r.r1; row += 1) {
160 for (let col = r.c0; col <= r.c1; col += 1) {
161 const cell = grid[row]?.[col]
162 if (cell === undefined) continue
163 const isEdgeRow = row === r.r0 || row === r.r1
164 const isEdgeCol = col === r.c0 || col === r.c1
165 if (border && (isEdgeRow || isEdgeCol)) {
166 const ch = row === r.r0 ? (col === r.c0 ? tl : col === r.c1 ? tr : h) : row === r.r1 ? (col === r.c0 ? bl : col === r.c1 ? br : h) : vert
167 Object.assign(cell, { ch, fg: v.stroke ?? undefined, bg: undefined, dim: false, bold: false })
168 } else {
169 Object.assign(cell, { ch: ' ', fg: undefined, bg: fill, dim: false, bold: false })
170 }
171 }
172 }
173
174 const prefix = v.shape === 'rhombus' ? '◇ ' : ''
175 const inner = Math.max(1, r.c1 - r.c0 - (border ? 1 : -1))
176 const lines = `${prefix}${v.label}`.split('\n').filter(Boolean)
177 const top = r.r0 + Math.max(border ? 1 : 0, Math.floor((r.r1 - r.r0 + 1 - lines.length) / 2))
178 lines.forEach((line, i) => {
179 const row = top + i
180 if (row >= r.r1 + (border ? 0 : 1)) return
181 const text = fit(line, inner)
182 const start = r.c0 + Math.max(border ? 1 : 0, Math.floor((r.c1 - r.c0 + 1 - text.length) / 2))
183 ;[...text].forEach((ch, k) => {
184 const cell = grid[row]?.[start + k]
185 if (cell !== undefined) Object.assign(cell, { ch, fg: v.font ?? (fill ? ON_FILL : undefined), bg: fill, bold: i === 0 })
186 })
187 })
188}
189
190const sameStyle = (a: Span, c: Cell) => a.fg === c.fg && a.bg === c.bg && Boolean(a.bold) === Boolean(c.bold) && Boolean(a.dim) === Boolean(c.dim)
191
192/** A grid row as runs of one style, trailing blanks dropped. */
193const toSpans = (row: Cell[]): Span[] => {
194 const last = row.reduce((end, c, i) => (c.ch !== ' ' || c.bg !== undefined ? i : end), -1)
195 return row.slice(0, last + 1).reduce<Span[]>((spans, c) => {
196 const prev = spans[spans.length - 1]
197 if (prev !== undefined && sameStyle(prev, c)) return [...spans.slice(0, -1), { ...prev, text: prev.text + c.ch }]
198 return [...spans, { text: c.ch, fg: c.fg, bg: c.bg, bold: c.bold, dim: c.dim }]
199 }, [])
200}
201types/index.d.ts 76 lines1/** A rendered page of a diagram: the PNG draw.io wrote and its size in pixels. */
2export type Preview = {
3 png: string
4 width: number
5 height: number
6 /** Bumped on every export, so the terminal reads the same path again. */
7 generation: number
8}
9
10export type VertexShape = 'box' | 'ellipse' | 'rhombus' | 'text'
11
12/** A shape of the diagram, its position absolute in diagram pixels. */
13export type DiagramVertex = {
14 id: string
15 label: string
16 x: number
17 y: number
18 width: number
19 height: number
20 shape: VertexShape
21 fill: string | null
22 stroke: string | null
23 font: string | null
24 isRounded: boolean
25 isDashed: boolean
26 hasBorder: boolean
27}
28
29/** A connector between two shapes, or between loose points. */
30export type DiagramEdge = {
31 id: string
32 label: string
33 source: string | null
34 target: string | null
35 sourcePoint: { x: number; y: number } | null
36 targetPoint: { x: number; y: number } | null
37 stroke: string | null
38 isDashed: boolean
39 hasArrow: boolean
40}
41
42/** One page of a diagram, read from its XML: what the text view draws. */
43export type DiagramModel = { vertices: DiagramVertex[]; edges: DiagramEdge[] }
44
45/** Where a diagram's preview stands. */
46export type DiagramStatus = 'exporting' | 'ready' | 'failed' | 'no-drawio'
47
48/** A .drawio file shown in the pane. */
49export type Diagram = {
50 path: string
51 /** 1-based page shown. */
52 page: number
53 pageCount: number
54 /** Saved compressed: draw.io reads it, Claude cannot edit it reliably. */
55 isCompressed: boolean
56 /** The file's mtime at the last export, to notice a save from outside. */
57 mtimeMs: number
58 status: DiagramStatus
59 error: string | null
60 preview: Preview | null
61 /** The shown page read from the XML; null when compressed or unreadable. */
62 model: DiagramModel | null
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 'drawio-viewer': {
68 diagrams: Diagram[]
69 selected: string | null
70 hasAutoOpened: boolean
71 /** The drawing from the XML in characters, instead of the PNG. */
72 isTextView: boolean
73 }
74 }
75}
76