From 5fde6cdf3e73ff4eaf56a3f317c9059d64b34dcc Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:32:55 -0800 Subject: [PATCH] Polishing mermaid rendering --- .../chat-webview-src/index.ts | 253 +++++++++++++++++- extensions/mermaid-chat-features/package.json | 14 + .../mermaid-chat-features/src/extension.ts | 25 +- .../browser/mainThreadChatOutputRenderer.ts | 2 +- .../chat/browser/chatOutputItemRenderer.ts | 14 +- .../toolInvocationParts/chatToolOutputPart.ts | 12 +- 6 files changed, 309 insertions(+), 11 deletions(-) diff --git a/extensions/mermaid-chat-features/chat-webview-src/index.ts b/extensions/mermaid-chat-features/chat-webview-src/index.ts index 35a67139ffd..d26b3db2742 100644 --- a/extensions/mermaid-chat-features/chat-webview-src/index.ts +++ b/extensions/mermaid-chat-features/chat-webview-src/index.ts @@ -4,6 +4,225 @@ *--------------------------------------------------------------------------------------------*/ import mermaid, { MermaidConfig } from 'mermaid'; + +interface VsCodeApi { + getState(): any; + setState(state: any): void; + postMessage(message: any): void; +} + +declare function acquireVsCodeApi(): VsCodeApi; + + +interface PanZoomState { + scale: number; + translateX: number; + translateY: number; +} + +class PanZoomHandler { + private scale = 1; + private translateX = 0; + private translateY = 0; + + private isPanning = false; + private hasDragged = false; + private startX = 0; + private startY = 0; + + private readonly minScale = 0.1; + private readonly maxScale = 5; + private readonly zoomFactor = 0.002; + + constructor( + private readonly container: HTMLElement, + private readonly content: HTMLElement, + private readonly vscode: VsCodeApi + ) { + this.container = container; + this.content = content; + this.content.style.transformOrigin = '0 0'; + this.container.style.overflow = 'hidden'; + this.container.style.cursor = 'grab'; + this.setupEventListeners(); + this.restoreState(); + } + + private setupEventListeners(): void { + // Pan with mouse drag + this.container.addEventListener('mousedown', e => this.handleMouseDown(e)); + document.addEventListener('mousemove', e => this.handleMouseMove(e)); + document.addEventListener('mouseup', () => this.handleMouseUp()); + + // Click to zoom (Alt+click = zoom in, Alt+Shift+click = zoom out) + this.container.addEventListener('click', e => this.handleClick(e)); + + // Trackpad: pinch = zoom, Alt + two-finger scroll = zoom + this.container.addEventListener('wheel', e => this.handleWheel(e), { passive: false }); + + // Update cursor when Alt/Option key is pressed + this.container.addEventListener('mousemove', e => this.updateCursorFromModifier(e)); + this.container.addEventListener('mouseenter', e => this.updateCursorFromModifier(e)); + window.addEventListener('keydown', e => this.handleKeyChange(e)); + window.addEventListener('keyup', e => this.handleKeyChange(e)); + } + + private handleKeyChange(e: KeyboardEvent): void { + if ((e.key === 'Alt' || e.key === 'Shift') && !this.isPanning) { + e.preventDefault(); + if (e.altKey && !e.shiftKey) { + this.container.style.cursor = 'zoom-in'; + } else if (e.altKey && e.shiftKey) { + this.container.style.cursor = 'zoom-out'; + } else { + this.container.style.cursor = 'grab'; + } + } + } + + private updateCursorFromModifier(e: MouseEvent): void { + if (this.isPanning) { + return; + } + if (e.altKey && !e.shiftKey) { + this.container.style.cursor = 'zoom-in'; + } else if (e.altKey && e.shiftKey) { + this.container.style.cursor = 'zoom-out'; + } else { + this.container.style.cursor = 'grab'; + } + } + + private handleClick(e: MouseEvent): void { + // Only zoom on click if Alt is held and we didn't drag + if (!e.altKey || this.hasDragged) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const rect = this.container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Alt+Shift+click = zoom out, Alt+click = zoom in + const factor = e.shiftKey ? 0.8 : 1.25; + this.zoomAtPoint(factor, x, y); + } + + private handleWheel(e: WheelEvent): void { + // ctrlKey is set by browsers for pinch-to-zoom gestures + const isPinchZoom = e.ctrlKey; + + if (isPinchZoom || e.altKey) { + // Pinch gesture or Alt + two-finger drag = zoom + e.preventDefault(); + e.stopPropagation(); + + const rect = this.container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate zoom (scroll up = zoom in, scroll down = zoom out) + const delta = -e.deltaY * this.zoomFactor; + const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * (1 + delta))); + + // Zoom toward mouse position + const scaleFactor = newScale / this.scale; + this.translateX = mouseX - (mouseX - this.translateX) * scaleFactor; + this.translateY = mouseY - (mouseY - this.translateY) * scaleFactor; + this.scale = newScale; + + this.applyTransform(); + this.saveState(); + } + } + + private handleMouseDown(e: MouseEvent): void { + if (e.button !== 0) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.isPanning = true; + this.hasDragged = false; + this.startX = e.clientX - this.translateX; + this.startY = e.clientY - this.translateY; + this.container.style.cursor = 'grabbing'; + } + + private handleMouseMove(e: MouseEvent): void { + if (!this.isPanning) { + return; + } + const dx = e.clientX - this.startX - this.translateX; + const dy = e.clientY - this.startY - this.translateY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + this.hasDragged = true; + } + this.translateX = e.clientX - this.startX; + this.translateY = e.clientY - this.startY; + this.applyTransform(); + } + + private handleMouseUp(): void { + if (this.isPanning) { + this.isPanning = false; + this.container.style.cursor = 'grab'; + this.saveState(); + } + } + + private applyTransform(): void { + this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`; + } + + private saveState(): void { + const currentState = this.vscode.getState() || {}; + this.vscode.setState({ + ...currentState, + panZoom: { + scale: this.scale, + translateX: this.translateX, + translateY: this.translateY + } + }); + } + + private restoreState(): void { + const state = this.vscode.getState(); + if (state?.panZoom) { + const panZoom = state.panZoom as PanZoomState; + this.scale = panZoom.scale ?? 1; + this.translateX = panZoom.translateX ?? 0; + this.translateY = panZoom.translateY ?? 0; + this.applyTransform(); + } + } + + public reset(): void { + this.scale = 1; + this.translateX = 0; + this.translateY = 0; + this.applyTransform(); + this.saveState(); + } + + private zoomAtPoint(factor: number, x: number, y: number): void { + const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * factor)); + const scaleFactor = newScale / this.scale; + this.translateX = x - (x - this.translateX) * scaleFactor; + this.translateY = y - (y - this.translateY) * scaleFactor; + this.scale = newScale; + this.applyTransform(); + this.saveState(); + } +} + + + + function getMermaidTheme() { return document.body.classList.contains('vscode-dark') || (document.body.classList.contains('vscode-high-contrast') && !document.body.classList.contains('vscode-high-contrast-light')) ? 'dark' @@ -17,23 +236,50 @@ type State = { let state: State | undefined = undefined; -function init() { +async function init() { const diagram = document.querySelector('.mermaid'); - if (!diagram) { + if (!diagram || !(diagram instanceof HTMLElement)) { return; } + const vscode = acquireVsCodeApi(); + const theme = getMermaidTheme(); state = { diagramText: diagram.textContent ?? '', theme }; + // Wrap the diagram for pan/zoom support + const wrapper = document.createElement('div'); + wrapper.className = 'mermaid-wrapper'; + wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;'; + + const content = document.createElement('div'); + content.className = 'mermaid-content'; + + // Move the diagram into the content wrapper + diagram.parentNode?.insertBefore(wrapper, diagram); + content.appendChild(diagram); + wrapper.appendChild(content); + + // Run mermaid const config: MermaidConfig = { - startOnLoad: true, + startOnLoad: false, theme, }; mermaid.initialize(config); + + await mermaid.run({ nodes: [diagram] }); + const panZoomHandler = new PanZoomHandler(wrapper, content, vscode); + + // Listen for messages from the extension + window.addEventListener('message', event => { + const message = event.data; + if (message.type === 'resetPanZoom') { + panZoomHandler.reset(); + } + }); } function tryUpdate() { @@ -70,4 +316,3 @@ new MutationObserver(() => { }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); init(); - diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 2311521c9b1..f1dc97702cd 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -26,6 +26,20 @@ "browser": "./dist/browser/extension", "activationEvents": [], "contributes": { + "commands": [ + { + "command": "mermaid-chat.resetPanZoom", + "title": "Reset Pan and Zoom" + } + ], + "menus": { + "webview/context": [ + { + "command": "mermaid-chat.resetPanZoom", + "when": "webviewId == 'vscode.chatMermaidDiagram'" + } + ] + }, "configuration": { "title": "Mermaid Chat Features", "properties": { diff --git a/extensions/mermaid-chat-features/src/extension.ts b/extensions/mermaid-chat-features/src/extension.ts index 51294649f4f..b600f8a49f4 100644 --- a/extensions/mermaid-chat-features/src/extension.ts +++ b/extensions/mermaid-chat-features/src/extension.ts @@ -15,8 +15,20 @@ const viewType = 'vscode.chatMermaidDiagram'; */ const mime = 'text/vnd.mermaid'; +// Track active webviews for reset command +let activeWebview: vscode.Webview | undefined; + export function activate(context: vscode.ExtensionContext) { + // Register the reset pan/zoom command + context.subscriptions.push( + vscode.commands.registerCommand('mermaid-chat.resetPanZoom', () => { + if (activeWebview) { + activeWebview.postMessage({ type: 'resetPanZoom' }); + } + }) + ); + // Register tools context.subscriptions.push( vscode.lm.registerTool<{ markup: string }>('renderMermaidDiagram', { @@ -35,6 +47,9 @@ export function activate(context: vscode.ExtensionContext) { async renderChatOutput({ value }, webview, _ctx, _token) { const mermaidSource = new TextDecoder().decode(value); + // Track this webview for the reset command + activeWebview = webview; + // Set the options for the webview const mediaRoot = vscode.Uri.joinPath(context.extensionUri, 'chat-webview-out'); webview.options = { @@ -54,10 +69,16 @@ export function activate(context: vscode.ExtensionContext) {
${escapeHtmlText(mermaidSource)}
diff --git a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts
index 4486ab0c573..08d5b729a21 100644
--- a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts
+++ b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts
@@ -45,7 +45,7 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre
serializeBuffersForPostMessage: true,
});
- this._proxy.$renderChatOutput(viewType, mime, VSBuffer.wrap(data), webviewHandle, token);
+ return this._proxy.$renderChatOutput(viewType, mime, VSBuffer.wrap(data), webviewHandle, token);
},
}, {
extension: { id: extensionId, location: URI.revive(extensionLocation) }
diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts
index b41fa1750eb..9e150d731cc 100644
--- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts
+++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts
@@ -15,6 +15,7 @@ import { autorun } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import * as nls from '../../../../nls.js';
+import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IWebview, IWebviewService, WebviewContentPurpose } from '../../../contrib/webview/browser/webview.js';
@@ -51,10 +52,12 @@ export interface RenderedOutputPart extends IDisposable {
interface RenderOutputPartWebviewOptions {
readonly origin?: string;
+ readonly webviewState?: string;
}
interface RendererEntry {
+ readonly viewType: string;
readonly renderer: IChatOutputItemRenderer;
readonly options: RegisterOptions;
}
@@ -69,8 +72,9 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
private readonly _renderers = new Map*viewType*/ string, RendererEntry>();
constructor(
- @IWebviewService private readonly _webviewService: IWebviewService,
+ @IContextKeyService private readonly _contextKeyService: IContextKeyService,
@IExtensionService private readonly _extensionService: IExtensionService,
+ @IWebviewService private readonly _webviewService: IWebviewService,
) {
super();
@@ -80,7 +84,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
}
registerRenderer(viewType: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable {
- this._renderers.set(viewType, { renderer, options });
+ this._renderers.set(viewType, { viewType, renderer, options });
return {
dispose: () => {
this._renderers.delete(viewType);
@@ -103,6 +107,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
const webview = store.add(this._webviewService.createWebviewElement({
title: '',
origin: webviewOptions.origin ?? generateUuid(),
+ providedViewType: rendererData.viewType,
options: {
enableFindWidget: false,
purpose: WebviewContentPurpose.ChatOutputItem,
@@ -111,6 +116,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput
contentOptions: {},
extension: rendererData.options.extension ? rendererData.options.extension : undefined,
}));
+ webview.setContextKeyService(store.add(this._contextKeyService.createScoped(parent)));
const onDidChangeHeight = store.add(new Emitter