feat(vscode): export diagrams as SVG/PNG from the Docify preview panel#2675
feat(vscode): export diagrams as SVG/PNG from the Docify preview panel#2675YoofiTT96 wants to merge 8 commits into
Conversation
ea4dec0 to
15e655c
Compare
Adds SVG/PNG export to the diagram toolbar in the Docify preview tab. Export restores Mermaid's original viewBox (stripped by svg-pan-zoom), inlines the resolved font, and prevents foreignObject labels from being clipped, so both formats render the full diagram exactly as laid out.
Consolidates the two inline SVG/PNG export buttons in the diagram toolbar into a single "Export" button with a dropdown menu, using @vscode-elements/elements (vscode-button + vscode-context-menu) for a native VS Code look.
15e655c to
7f64ee4
Compare
There was a problem hiding this comment.
This looks great overall. Solid feature, good test coverage.
I'd be curious to hear what model/tooling you used and how you prompted it - and in particular what context you fed it about the existing architecture. Reason being: there are a few design choices that go against patterns already established in this extension, and I want to understand whether that's intentional divergence or something that slipped through.
The issues are all fixable and I'd be happy to pair on them. I've left inline comments on the specific files below.
| this.viewModel.handleToggleLabels(showLabels) | ||
| } | ||
|
|
||
| public async handleExportDiagram(format: 'svg' | 'png', data: string, diagramIndex: number): Promise<void> { |
There was a problem hiding this comment.
This method combines three responsibilities in one place:
- Path/filename computation (business logic -
dirname,basename, fallback to workspace root) - Save dialog interaction (VSCode UI -
showSaveDialog) - File write + error handling (I/O -
workspace.fs.writeFile)
The extension follows MVVM where ViewModels are framework-free (no vscode imports) and the panel is the View layer that only handles VSCode-specific I/O (see calm-plugins/vscode/AGENTS.md - Key Design Principles):
- Framework Isolation: ViewModels have NO
vscodeimports- Dependency Inversion: Core depends on ports, not VSCode
The path computation logic should live in a ViewModel or a small ExportService - the panel just calls showSaveDialog and writeFile with whatever the ViewModel gives it.
Compare with how handleRunDocify (same file) delegates to DocifyService and emits results via the ViewModel event system. This new handler should follow the same shape.
There was a problem hiding this comment.
Agreed. New change pulls the filename/path logic and the SVG-vs-PNG data decoding into a new DiagramExportService— a plain class with no vscode import, same shape as the existing ModelService. The panel now just calls diagramExportService.computeDefaultPath(...) to get a path, hands that to showSaveDialog, then calls diagramExportService.decodeExportData(...) before workspace.fs.writeFile
| * svg-pan-zoom strips it). Build the same fixed clone used for SVG export and render | ||
| * it off-screen so html-to-image can compute its layout and styles before rasterizing. | ||
| */ | ||
| export async function rasterizeSvgElementToPng(svg: SVGSVGElement): Promise<string> { |
There was a problem hiding this comment.
Two concerns with this dependency:
1. CSP silent degradation: The webview's Content Security Policy (media/preview.html) sets default-src 'none' with no connect-src override, which means all fetch/XHR requests from within the webview are blocked. html-to-image uses fetch() internally to download font files so it can inline them into the exported image. Those fetches fail silently, so the library falls back to whatever system font is available.
Having watched the demo video, I think the fonts in the exported PNG are actually slightly different from what's rendered in the webview - but it's not noticeable unless you're looking for it. It'll get worse with custom fonts/themes though, and there's no warning when it happens.
Let me know if this is not the case.
2. Unnecessary dependency: You already have the fixed SVG clone from buildExportClone. Can we rasterize using native browser APIs available in the webview?
There was a problem hiding this comment.
Yes there was a very slight difference in node labels that I put down to rasterisation process + different formats. However, my attempts with using native browser apis + pipeline failed consistently with:
SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.
This is because drawing an SVG that contains a onto a canvas via the browser's native <img> decoder taints the canvas in Chromium even when the foreignObject content is 100% local/self-contained. Mermaid renders node/edge labels via <foreignObject> by default, so this fails on every diagram, not just ones with custom content.
html-to-image solves the problem by walking the DOM directly and inlining everything into their own representation before rasterizing, rather than handing the browser's native SVG decoder something it'll refuse to export. So the dependency is specific to mermaids rendering approach.
There was a problem hiding this comment.
Performed some more tests and the taint issue can be circumvented by switching from setting the img source from a blob url to data uri built fromt he SVG string. Data uris are known to increase bloat but it is transient and only lives for the duration of the call. For a hypothetical very large architecture multi MB diagram it would add a one time decode cost. Flagging this possibility but would be fine for majority of use cases.
The rasterization pipeline now does:
- Serialize the already-fixed SVG clone to a string (no network access).
- Encode it as a data: URI via Blob + FileReader (no network access — this is local encoding, not a fetch).
- Load that into an Image, draw to , read back via toDataURL() (no network access).
Also side steps the issue with possible future custom fonts and removes any issues with the CSP as no fetches are performed
| * Create the "Export" button with an SVG/PNG dropdown menu. | ||
| * Returns null if neither export callback was provided. | ||
| */ | ||
| private createExportControl(): HTMLElement | null { |
There was a problem hiding this comment.
DiagramControls was a focused single-responsibility class: zoom/pan UI controls (original file before this PR - 110 lines, just buttons + PanZoomManager calls).
This PR adds:
- Web component imports (
@vscode-elements/elements/dist/vscode-button/index.js,@vscode-elements/elements/dist/vscode-context-menu/index.js) - A dropdown menu with show/hide toggle state
- Export callback wiring
Two issues:
- SRP: Extract this to a separate
DiagramExportControlclass composed alongsideDiagramControlsindocify-tab.view.ts, not nested inside it. - Consistency: The existing zoom buttons are plain
<button class="diagram-control-btn">elements. The export control uses<vscode-button>+<vscode-context-menu>web components - creating visual inconsistency. Also, the imports use private distribution paths (/dist/vscode-button/index.js) that aren't part of the package's public API and can break on minor bumps.
There was a problem hiding this comment.
Fair point on mixing of concerns that should duly be extracted.
For the latter I used vscode elements intiially for the look and feel but was prepared to revert based on feedback as its cosmetic. It also provided some accessibility controls with using the keyboard to navigate the menu options. For now those controls can be implemented without the dependency. If more controls and menu options come up in the future the dependency can be reintroduced
Reverted to regular buttons
| export class ExportDiagramCmd implements WebviewCommand<{ type: 'exportDiagram'; format: 'svg' | 'png'; data: string; diagramIndex: number }> { | ||
| readonly type = 'exportDiagram' as const | ||
| constructor(private p: PreviewCommandTarget) { } |
There was a problem hiding this comment.
This is the first command in the registry that returns Promise<void> from execute(). But CommandRegistry.dispatch (line 43, same file) doesn't await it:
dispatch(msg: InMsg) { this.map.get(msg.type)?.execute(msg as any) }If the async chain rejects outside the try/catch in handleExportDiagram (e.g. panel disposed mid-write, a race with dispose()), the promise rejection is unhandled.
Minor fix - make dispatch async-aware:
async dispatch(msg: InMsg) { await this.map.get(msg.type)?.execute(msg as any) }Or add .catch() in the command itself. Not a blocker but worth hardening now that async commands are a thing.
For tooling Sonnet 4.6 with High Effort. Likely slipped through on these design choices that are present in the AGENTs.md. Main context deliberately called out was the merge of the previous docify feature addition. Expected it to detect the CLAUDE.md which points to the AGENTs.md but seems that did not happen. The session pattern-matched against the nearest existing code — handleExportDiagram got bolted onto the same function shape as whatever wasalready in preview-panel.ts, and the export dropdown got added directly inside DiagramControls because that's where the zoom buttons already lived . Will go through again along with proposed fixes Using the @vscode-elements/elements and html-to-image were deliberate. The former for the look and feel of the buttons to look more like native vs code options. It was previously rendering them as svg buttons much like the zoom controls. However this was more a cosmetic decision as the plain button elements were also suitable. The latter for PNG rasterisation but without checking the web views CSP. |
Extract DiagramExportService (framework-free) so preview-panel.ts only handles VSCode I/O (save dialog, file write) for diagram export, moving path computation and data decoding out of the View layer. Replace html-to-image with native Image+canvas rasterization for PNG export, loading the SVG via a data: URI rather than a blob: URL to avoid Chromium tainting the canvas when rasterizing SVGs containing foreignObject (which Mermaid uses for labels). Drops the html-to-image dependency and needs no CSP changes since no network fetch is involved. Extract DiagramExportControl from DiagramControls (was bolted onto the zoom/pan control class) and drop @vscode-elements/elements so the export button matches the existing plain-button toolbar styling and avoids importing the library's private dist paths. Adds Escape/Arrow-key/ focus-out keyboard handling to the export menu to replace the keyboard navigation the dropped dependency provided. Make CommandRegistry.dispatch catch rejections from async commands (e.g. ExportDiagramCmd) so a failure doesn't surface as an unhandled promise rejection.
…96/architecture-as-code into feat/vscode-diagram-export
|
Summary of what changed, plus a couple of things the fixes themselves surfaced along the way. 1. 2. 3. iew.ts 4. All four areas have new or updated unit tests; full suite passing (489 tests), lint clean, build verified, and manually exercised in the Extension Development Host (export SVG/PNG, toolbar styling, keyboard nav on the Export menu). Screen.Recording.2026-06-19.at.1.21.06.PM.mov |
Description
Adds SVG and PNG diagram export to the VSCode extension's Docify preview panel. An Export dropdown (built with
<vscode-button>+<vscode-context-menu>) appears in the diagram toolbar alongside the existing zoom controls. Selecting a format triggers VSCode's native save dialog and writes the file to disk.Closes #2674.
The VSCode companion to the CLI
--export-diagramsflag (#2634): the CLI handles batch export during documentation generation; this handles interactive single-diagram export from within the IDE.Key implementation details
Webview (
diagram-export.ts) — three fixes are applied to the live SVG before serialising, to ensure the exported file renders correctly as a standalone image:viewBox(stashed asdata-original-viewboxbeforesvg-pan-zoomstrips it), and sets explicitwidth/height, so the file has correct intrinsic dimensions.font-familyas a presentation attribute on the root<svg>, so unstyled edge-label text does not fall back to the viewer's default serif font.overflow: visibleon all<foreignObject>elements, so labels are never clipped if a different renderer measures glyphs slightly wider.For PNG, the same fixed clone is mounted in an off-screen
<div>(not on the clone itself, to avoidhtml-to-imageinliningposition: fixedinto the rasterised image) and rasterised at 2× pixel ratio.Extension host (
preview-panel.ts) —handleExportDiagram()opens a save dialog pre-filled with<arch-basename>-diagram-<n>.<ext>in the same directory as the open CALM file, then writes the SVG string or base64-decoded PNG buffer viavscode.workspace.fs.Type of Change
Affected Components
cli/)calm/)calm-ai/)calm-hub/)calm-hub-ui/)calm-server/)calm-widgets/)docs/)shared/)calm-plugins/vscode/)Commit Message Format ✅
Key commits follow conventional format:
feat(vscode): export Mermaid diagrams as SVG/PNG from Docify previewfeat(vscode): replace SVG/PNG export buttons with an Export dropdownfix(shared): center exported diagrams and match export font to IDE renderingTesting
New unit tests added:
diagram-export.spec.ts— coversserializeSvgElement,rasterizeSvgElementToPng,exportDiagram, and all three SVG fix helpers with jsdom mocks.diagram-controls.spec.ts— verifies the Export dropdown renders correctly, is absent when no callbacks are provided, and dispatches the correct callback on selection.preview-panel.spec.ts— verifieshandleExportDiagramopens the save dialog, writes the correct buffer, and shows the confirmation message.Checklist