/** * The 画板 view: workspace diagram file list + XML editor + live SVG preview * (the shared zero-dependency translator runs right in the browser), with * save-to-workspace, PNG export and SVG copy. * * @module dsh-drawio/client/board */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { JSX } from 'react' import type { DrawioRemote } from './api.ts' import type { ListEntry } from '../protocol.ts' import { diagramToSvg, parseDiagrams } from '../translate.ts' import { t } from './i18n.ts' import styles from './board.module.css' /** Session store shape the board reads the workspace root from. */ export interface SessionListStore { subscribe: (listener: () => void) => () => void getSnapshot: () => { current?: string; byId: Record } } const ICON_SVG = `` export { ICON_SVG } type SaveState = { kind: 'idle' } | { kind: 'busy' } | { kind: 'ok'; message: string } | { kind: 'err'; message: string } export function BoardView({ makeApi, sessions, fontFamily, }: { /** Binds one workspace root to a fresh API client (rebuilt on session switch). */ makeApi: (root: string) => DrawioRemote sessions: SessionListStore fontFamily: string }): JSX.Element { const root = useSyncExternalStoreSafe(sessions) const api = useMemo(() => (root === '' ? null : makeApi(root)), [root, makeApi]) const [entries, setEntries] = useState(null) const [listError, setListError] = useState(null) const [selected, setSelected] = useState(null) const [xml, setXml] = useState('') const [draftName, setDraftName] = useState('') const [saveState, setSaveState] = useState({ kind: 'idle' }) const [renderResult, setRenderResult] = useState<{ svg: string; vertices: number; edges: number } | null>(null) const [renderError, setRenderError] = useState(null) const [renderPending, setRenderPending] = useState(false) const previewRef = useRef(null) // ---- view modes: SVG-first; XML source and the file list are hidden // behind toggles so the diagram gets the whole column by default ----------- const [showSource, setShowSource] = useState(false) const [showFiles, setShowFiles] = useState(false) // ---- live follow: the board tracks the file the agent is editing ---------- const [followEnabled, setFollowEnabled] = useState(true) const [userEditing, setUserEditing] = useState(false) const [fileMtime, setFileMtime] = useState(null) const userEditingRef = useRef(false) userEditingRef.current = userEditing const followRef = useRef(true) followRef.current = followEnabled // ---- file list --------------------------------------------------------- const refresh = useCallback(async (): Promise => { if (root === '' || api === null) { setEntries(null) return } setListError(null) setEntries(null) try { const result = await api.list({ root }) setEntries(result.entries) } catch (error) { setListError(error instanceof Error ? error.message : String(error)) setEntries([]) } }, [root, api]) useEffect(() => { void refresh() }, [refresh]) // ---- open one file ------------------------------------------------------- const openFile = useCallback(async (path: string): Promise => { if (root === '' || api === null) return try { const result = await api.read({ root, path }) setSelected(path) setDraftName(path.split('/').pop() ?? path) setXml(result.content) setFileMtime(result.mtime) setSaveState({ kind: 'idle' }) } catch (error) { setRenderError(error instanceof Error ? error.message : String(error)) } }, [root, api]) // ---- live render (debounced) ---------------------------------------------- useEffect(() => { if (xml.trim() === '') { setRenderResult(null) setRenderError(null) return } setRenderPending(true) const timer = setTimeout(() => { try { const diagrams = parseDiagrams(xml) const diagram = diagrams[0]! let vertices = 0 let edges = 0 const walk = (cells: typeof diagram.cells): void => { for (const cell of cells) { if (cell.edge) edges += 1 else if (cell.vertex) vertices += 1 walk(cell.children as typeof diagram.cells) } } walk(diagram.cells) const svg = diagramToSvg(diagram, { fontFamily }) setRenderResult({ svg, vertices, edges }) setRenderError(null) } catch (error) { setRenderResult(null) setRenderError(error instanceof Error ? error.message : String(error)) } finally { setRenderPending(false) } }, 350) return () => { clearTimeout(timer) setRenderPending(false) } }, [xml, fontFamily]) // ---- save ----------------------------------------------------------------- const saveFile = useCallback(async (): Promise => { if (root === '' || api === null || xml.trim() === '') return const name = draftName.trim() === '' ? 'diagram.drawio' : draftName.trim() const target = name.endsWith('.drawio') || name.endsWith('.xml') || name.endsWith('.svg') ? name : `${name}.drawio` const dir = selected === null ? '' : selected.split('/').slice(0, -1).join('/') const path = dir === '' ? target : `${dir}/${target}` setSaveState({ kind: 'busy' }) try { const result = await api.save({ root, path, content: xml }) setSelected(result.path) setSaveState({ kind: 'ok', message: `${t('save.ok')}: ${result.path}` }) void refresh() } catch (error) { setSaveState({ kind: 'err', message: error instanceof Error ? error.message : String(error) }) } }, [root, api, xml, draftName, selected, refresh]) // ---- export PNG ------------------------------------------------------------- const exportPng = useCallback(async (): Promise => { if (renderResult === null) return try { const blob = new Blob([renderResult.svg], { type: 'image/svg+xml;charset=utf-8' }) const url = URL.createObjectURL(blob) const image = new Image() await new Promise((resolveImage, reject) => { image.onload = () => resolveImage() image.onerror = () => reject(new Error('SVG decode failed')) image.src = url }) const canvas = document.createElement('canvas') const scale = 2 canvas.width = image.naturalWidth * scale canvas.height = image.naturalHeight * scale const ctx2d = canvas.getContext('2d') if (ctx2d === null) throw new Error('canvas unavailable') ctx2d.fillStyle = '#ffffff' ctx2d.fillRect(0, 0, canvas.width, canvas.height) ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height) URL.revokeObjectURL(url) const pngUrl = canvas.toDataURL('image/png') const anchor = document.createElement('a') anchor.href = pngUrl anchor.download = `${(draftName || 'diagram').replace(/\.(drawio|xml|svg)$/i, '')}.png` anchor.click() setSaveState({ kind: 'ok', message: t('export.done') }) } catch (error) { setSaveState({ kind: 'err', message: `${t('export.err')}: ${error instanceof Error ? error.message : String(error)}` }) } }, [renderResult, draftName]) // ---- copy SVG ----------------------------------------------------------------- const copySvg = useCallback(async (): Promise => { if (renderResult === null) return try { await navigator.clipboard.writeText(renderResult.svg) setSaveState({ kind: 'ok', message: t('copied') }) } catch (error) { setSaveState({ kind: 'err', message: error instanceof Error ? error.message : String(error) }) } }, [renderResult]) // ---- official drawio editor mode --------------------------------------------- const [editorOpen, setEditorOpen] = useState(false) const [editorNonce, setEditorNonce] = useState(0) const [lastKnownMtime, setLastKnownMtime] = useState(null) const editorRef = useRef(null) const buildEditorUrl = useCallback((root: string, path: string): string => { // Encode the raw URL exactly ONCE. The other query params are flat values; // putting `raw` through URLSearchParams would double-encode the % facets. const raw = `/dsh-drawio/raw?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}` const params = new URLSearchParams({ embed: '1', spin: '1', proto: 'json', ui: 'min', mod: '0', }) return `/drawio/index.html?${params.toString()}&url=${encodeURIComponent(raw)}` }, []) // Open the editor for the selected file. const openEditor = useCallback(async (): Promise => { if (root === '' || api === null || selected === null) return try { const info = await api.read({ root, path: selected }) setLastKnownMtime(info.mtime) setEditorNonce((n) => n + 1) setEditorOpen(true) setSaveState({ kind: 'idle' }) } catch (error) { setSaveState({ kind: 'err', message: `${t('action.editor')}: ${error instanceof Error ? error.message : String(error)}` }) } }, [root, api, selected]) // Editor -> board save bridge (drawio posts {event:'save', xml} to the opener). const persistFromEditor = useCallback(async (xml: string): Promise => { if (root === '' || api === null || selected === null) return setSaveState({ kind: 'busy' }) try { const result = await api.save({ root, path: selected, content: xml }) setLastKnownMtime(result.mtime) // Sync the editor's saved content into the board state so the XML editor // and the SVG preview re-render immediately (not only after a reload). setXml(xml) setSaveState({ kind: 'ok', message: `${t('save.ok')}: ${result.path}` }) void refresh() } catch (error) { setSaveState({ kind: 'err', message: error instanceof Error ? error.message : String(error) }) } }, [root, api, selected, refresh]) // Inject the diagram XML into the drawio editor. drawio does not require a // handshake to receive the load message, and its emit of `init` is // unreliable, so inject on a retry loop: every ~2s, cross-check whether the // canvas has rendered shapes; if not, push the XML (object + legacy string // forms) and keep polling until it lands or the budget runs out. const injectEditorXml = useCallback(async (): Promise => { if (root === '' || api === null || selected === null || !editorOpen) return for (let attempt = 0; attempt < 14; attempt += 1) { if (!editorOpen) return const iframe = editorRef.current if (iframe === undefined || iframe === null) { await new Promise((resolve) => window.setTimeout(resolve, 1500)) continue } // Rendered already? Stop injecting (avoids resetting user edits). try { const doc = iframe.contentDocument const rendered = doc !== null && doc.querySelectorAll('.geDiagramContainer svg path, .geDiagramContainer svg rect, .geDiagramContainer foreignObject').length > 0 if (rendered) return } catch { // Cross-origin or not ready: keep going. } try { const info = await api.read({ root, path: selected }) const target = iframe.contentWindow if (target !== null) { target.postMessage({ event: 'load', data: { xml: info.content } }, window.location.origin) target.postMessage(JSON.stringify({ action: 'load', xml: info.content }), window.location.origin) } } catch { // File unreadable: nothing to inject yet. } await new Promise((resolve) => window.setTimeout(resolve, 2000)) } }, [editorOpen, root, api, selected]) useEffect(() => { if (!editorOpen) return // Kick off right away; init acts as a soft first trigger. void injectEditorXml() const onMessage = (event: MessageEvent): void => { const frame = editorRef.current?.contentWindow if (frame === undefined || event.source !== frame) return let data = event.data // drawio posts its embed messages as JSON strings. if (typeof data === 'string') { try { data = JSON.parse(data) } catch { return } } if (typeof data !== 'object' || data === null) return const message = data as { event?: string; xml?: string } if (message.event === 'init') { void injectEditorXml() return } if (message.event === 'save' && typeof message.xml === 'string') { void persistFromEditor(message.xml) } } window.addEventListener('message', onMessage) return () => window.removeEventListener('message', onMessage) }, [editorOpen, root, api, selected, persistFromEditor, injectEditorXml]) // External change -> auto-reload the editor (the agent may have just edited // the file). Only reload when the mtime moved without this editor writing it. useEffect(() => { if (!editorOpen || root === '' || api === null || selected === null) return const timer = setInterval(async () => { try { const info = await api.read({ root, path: selected }) if (info.mtime !== lastKnownMtime) { const wasEditorSave = lastKnownMtime !== null && info.mtime > lastKnownMtime setLastKnownMtime(info.mtime) if (wasEditorSave) { setEditorNonce((n) => n + 1) setSaveState({ kind: 'ok', message: t('editor.externalReload') }) } } } catch { // File gone or unreadable: keep the editor as-is. } }, 2500) return () => clearInterval(timer) }, [editorOpen, root, api, selected, lastKnownMtime]) const closeEditor = (): void => { setEditorOpen(false) } // The host broadcasts agent drawio activity; when the agent starts drawing // a file this board is not showing, open it (auto-open flow). useEffect(() => { const onOpenPath = (event: Event): void => { const path = (event as CustomEvent).detail if (typeof path !== 'string' || path === '') return setSelected((prev) => { if (prev !== path) void openFile(path) return prev }) } window.addEventListener('dsh-drawio:open-path', onOpenPath) return () => window.removeEventListener('dsh-drawio:open-path', onOpenPath) }, [openFile]) // Live follow (preview mode): every 1.5s re-read the open file; when the // agent rewrote it (mtime moved), sync the XML editor + preview instantly — // unless the user is mid-edit (their keystrokes must not be clobbered). useEffect(() => { if (root === '' || api === null || selected === null || editorOpen) return let last = fileMtime const timer = window.setInterval(async () => { if (!followRef.current || userEditingRef.current) { try { const info = await api.read({ root, path: selected }) last = info.mtime setFileMtime(info.mtime) } catch { // transient read failure: keep last mtime } return } try { const info = await api.read({ root, path: selected }) if (info.mtime !== last) { last = info.mtime setFileMtime(info.mtime) setXml(info.content) setSaveState({ kind: 'ok', message: t('follow.synced') }) } } catch { // file gone/unreadable: keep the current view } }, 1500) return () => window.clearInterval(timer) }, [root, api, selected, editorOpen, fileMtime]) // File list auto-refresh: catch agent-created/deleted diagrams. useEffect(() => { if (root === '' || api === null) return const timer = window.setInterval(() => { if (!followRef.current) return void api.list({ root }).then((result) => { setEntries((prev) => { if (prev === null) return prev const key = (list: typeof prev): string => list.map(e => `${e.path}:${e.mtime}`).join('|') return key(result.entries) === key(prev) ? prev : result.entries }) }).catch(() => {}) }, 5000) return () => window.clearInterval(timer) }, [root, api]) const newFile = (): void => { setSelected(null) setDraftName(`diagram-${Date.now().toString(36)}.drawio`) setXml('') setRenderResult(null) setRenderError(null) setSaveState({ kind: 'idle' }) } if (root === '') { return (
{t('panel.noRoot')}
) } return (
{t('panel.title')} {root} setDraftName(event.target.value)} />
{showFiles && (
{entries === null ? t('files.loading') : `${entries.length} files`}
{listError !== null &&
{listError}
} {entries === null && listError === null &&
{t('files.loading')}
} {entries !== null && entries.length === 0 && listError === null &&
{t('files.empty')}
} {entries?.map((entry) => ( ))}
)}
{editorOpen && selected !== null ? ( <>
{selected} · {t('action.editor')} {saveState.kind === 'ok' && {saveState.message}} {saveState.kind === 'err' && {saveState.message}}