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) { Mermaid Diagram - + + + - +
 							${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(); 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()); store.add(autorun(reader => { @@ -121,6 +127,10 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput } })); + if (webviewOptions.webviewState) { + webview.state = webviewOptions.webviewState; + } + webview.mountTo(parent, getWindow(parent)); await rendererData.renderer.renderOutputPart(mime, data, webview, token); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts index ef45b79f8bf..6faf547bd08 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -23,8 +23,9 @@ import { ChatProgressSubPart } from '../chatProgressContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; interface OutputState { - readonly webviewOrigin: string; + webviewOrigin: string; height: number; + webviewState?: string; } // TODO: see if we can reuse existing types instead of adding ChatToolOutputSubPart @@ -105,6 +106,9 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { if (partState.height) { parent.style.height = `${partState.height}px`; } + if (partState.webviewOrigin) { + partState.webviewOrigin = partState.webviewOrigin; + } const progressMessage = dom.$('span'); progressMessage.textContent = localize('loading', 'Rendering tool output...'); @@ -112,7 +116,7 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { parent.appendChild(progressPart.domNode); // TODO: we also need to show the tool output in the UI - this.chatOutputItemRendererService.renderOutputPart(details.output.mimeType, details.output.value.buffer, parent, { origin: partState.webviewOrigin }, this._disposeCts.token).then((renderedItem) => { + this.chatOutputItemRendererService.renderOutputPart(details.output.mimeType, details.output.value.buffer, parent, { origin: partState.webviewOrigin, webviewState: partState.webviewState }, this._disposeCts.token).then((renderedItem) => { if (this._disposeCts.token.isCancellationRequested) { return; } @@ -121,6 +125,10 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { progressPart.domNode.remove(); + this._register(renderedItem.webview.onDidUpdateState(e => { + partState.webviewState = e; + })); + this._register(renderedItem.onDidChangeHeight(newHeight => { partState.height = newHeight; }));