Path: blob/main/extensions/mermaid-chat-features/chat-webview-src/mermaidWebview.ts
5334 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import mermaid, { MermaidConfig } from 'mermaid';5import { VsCodeApi } from './vscodeApi';67interface PanZoomState {8readonly scale: number;9readonly translateX: number;10readonly translateY: number;11}1213export class PanZoomHandler {14private scale = 1;15private translateX = 0;16private translateY = 0;1718private isPanning = false;19private hasDragged = false;20private hasInteracted = false;21private startX = 0;22private startY = 0;2324private readonly minScale = 0.1;25private readonly maxScale = 5;26private readonly zoomFactor = 0.002;2728constructor(29private readonly container: HTMLElement,30private readonly content: HTMLElement,31private readonly vscode: VsCodeApi32) {33this.container = container;34this.content = content;35this.content.style.transformOrigin = '0 0';36this.container.style.overflow = 'hidden';37this.container.style.cursor = 'default';38this.setupEventListeners();39}4041/**42* Initializes the pan/zoom state - either restores from saved state or centers the content.43*/44public initialize(): void {45if (!this.restoreState()) {46// Use requestAnimationFrame to ensure layout is updated before centering47requestAnimationFrame(() => {48this.centerContent();49});50}51}5253private setupEventListeners(): void {54// Pan with mouse drag55this.container.addEventListener('mousedown', e => this.handleMouseDown(e));56document.addEventListener('mousemove', e => this.handleMouseMove(e));57document.addEventListener('mouseup', () => this.handleMouseUp());5859// Click to zoom (Alt+click = zoom in, Alt+Shift+click = zoom out)60this.container.addEventListener('click', e => this.handleClick(e));6162// Trackpad: pinch = zoom, Alt + two-finger scroll = zoom63this.container.addEventListener('wheel', e => this.handleWheel(e), { passive: false });6465// Update cursor when Alt/Option key is pressed66this.container.addEventListener('mousemove', e => this.updateCursorFromModifier(e));67this.container.addEventListener('mouseenter', e => this.updateCursorFromModifier(e));68window.addEventListener('keydown', e => this.handleKeyChange(e));69window.addEventListener('keyup', e => this.handleKeyChange(e));7071// Re-center on resize if user hasn't interacted yet72window.addEventListener('resize', () => this.handleResize());73}7475private handleKeyChange(e: KeyboardEvent): void {76if ((e.key === 'Alt' || e.key === 'Shift') && !this.isPanning) {77e.preventDefault();78if (e.altKey && !e.shiftKey) {79this.container.style.cursor = 'grab';80} else if (e.altKey && e.shiftKey) {81this.container.style.cursor = 'zoom-out';82} else {83this.container.style.cursor = 'default';84}85}86}8788private updateCursorFromModifier(e: MouseEvent): void {89if (this.isPanning) {90return;91}92if (e.altKey && !e.shiftKey) {93this.container.style.cursor = 'grab';94} else if (e.altKey && e.shiftKey) {95this.container.style.cursor = 'zoom-out';96} else {97this.container.style.cursor = 'default';98}99}100101private handleClick(e: MouseEvent): void {102// Only zoom on click if Alt is held and we didn't drag103if (!e.altKey || this.hasDragged) {104return;105}106107e.preventDefault();108e.stopPropagation();109110const rect = this.container.getBoundingClientRect();111const x = e.clientX - rect.left;112const y = e.clientY - rect.top;113114// Alt+Shift+click = zoom out, Alt+click = zoom in115const factor = e.shiftKey ? 0.8 : 1.25;116this.zoomAtPoint(factor, x, y);117}118119private handleWheel(e: WheelEvent): void {120// Only zoom when Alt is held (or ctrlKey for pinch-to-zoom gestures)121// ctrlKey is set by browsers for pinch-to-zoom gestures122const isPinchZoom = e.ctrlKey;123124if (!e.altKey && !isPinchZoom) {125// Allow normal scrolling when Alt is not held126return;127}128129if (isPinchZoom || e.altKey) {130// Pinch gesture or Alt + two-finger drag = zoom131e.preventDefault();132e.stopPropagation();133134const rect = this.container.getBoundingClientRect();135const mouseX = e.clientX - rect.left;136const mouseY = e.clientY - rect.top;137138// Calculate zoom (scroll up = zoom in, scroll down = zoom out)139// Pinch gestures have smaller deltaY values, so use a higher factor140const effectiveZoomFactor = isPinchZoom ? this.zoomFactor * 5 : this.zoomFactor;141const delta = -e.deltaY * effectiveZoomFactor;142const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * (1 + delta)));143144// Zoom toward mouse position145const scaleFactor = newScale / this.scale;146this.translateX = mouseX - (mouseX - this.translateX) * scaleFactor;147this.translateY = mouseY - (mouseY - this.translateY) * scaleFactor;148this.scale = newScale;149150this.applyTransform();151this.saveState();152}153}154155private handleMouseDown(e: MouseEvent): void {156if (e.button !== 0 || !e.altKey) {157return;158}159e.preventDefault();160e.stopPropagation();161this.isPanning = true;162this.hasDragged = false;163this.startX = e.clientX - this.translateX;164this.startY = e.clientY - this.translateY;165this.container.style.cursor = 'grabbing';166}167168private handleMouseMove(e: MouseEvent): void {169if (!this.isPanning) {170return;171}172173// Handle case where mouse was released outside the webview174if (e.buttons === 0) {175this.handleMouseUp();176return;177}178179const dx = e.clientX - this.startX - this.translateX;180const dy = e.clientY - this.startY - this.translateY;181if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {182this.hasDragged = true;183}184this.translateX = e.clientX - this.startX;185this.translateY = e.clientY - this.startY;186this.applyTransform();187}188189private handleMouseUp(): void {190if (this.isPanning) {191this.isPanning = false;192this.container.style.cursor = 'default';193this.saveState();194}195}196197private applyTransform(): void {198this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;199}200201private saveState(): void {202this.hasInteracted = true;203const currentState = this.vscode.getState() || {};204this.vscode.setState({205...currentState,206panZoom: {207scale: this.scale,208translateX: this.translateX,209translateY: this.translateY210}211});212}213214private restoreState(): boolean {215const state = this.vscode.getState();216if (state?.panZoom) {217const panZoom = state.panZoom as PanZoomState;218this.scale = panZoom.scale ?? 1;219this.translateX = panZoom.translateX ?? 0;220this.translateY = panZoom.translateY ?? 0;221this.hasInteracted = true;222this.applyTransform();223return true;224}225return false;226}227228private handleResize(): void {229if (!this.hasInteracted) {230this.centerContent();231}232}233234/**235* Centers the content within the container.236*/237private centerContent(): void {238const containerRect = this.container.getBoundingClientRect();239240// Get the SVG element inside the content - mermaid renders to an SVG241const svg = this.content.querySelector('svg');242if (!svg) {243return;244}245const svgRect = svg.getBoundingClientRect();246247// Calculate the center position based on the SVG dimensions248this.translateX = (containerRect.width - svgRect.width) / 2;249this.translateY = (containerRect.height - svgRect.height) / 2;250251this.applyTransform();252}253254public reset(): void {255this.scale = 1;256this.translateX = 0;257this.translateY = 0;258this.hasInteracted = false;259this.applyTransform(); // Apply scale first so content size is correct260261// Clear the saved pan/zoom state262const currentState = this.vscode.getState() || {};263delete currentState.panZoom;264this.vscode.setState(currentState);265266// Use requestAnimationFrame to ensure layout is updated before centering267requestAnimationFrame(() => {268this.centerContent();269});270}271272public zoomIn(): void {273const rect = this.container.getBoundingClientRect();274this.zoomAtPoint(1.25, rect.width / 2, rect.height / 2);275}276277public zoomOut(): void {278const rect = this.container.getBoundingClientRect();279this.zoomAtPoint(0.8, rect.width / 2, rect.height / 2);280}281282private zoomAtPoint(factor: number, x: number, y: number): void {283const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * factor));284const scaleFactor = newScale / this.scale;285this.translateX = x - (x - this.translateX) * scaleFactor;286this.translateY = y - (y - this.translateY) * scaleFactor;287this.scale = newScale;288this.applyTransform();289this.saveState();290}291}292293export function getMermaidTheme(): 'dark' | 'default' {294return document.body.classList.contains('vscode-dark') || (document.body.classList.contains('vscode-high-contrast') && !document.body.classList.contains('vscode-high-contrast-light'))295? 'dark'296: 'default';297}298299/**300* Unpersisted state301*/302interface LocalState {303readonly mermaidSource: string;304readonly theme: 'dark' | 'default';305}306307interface PersistedState {308readonly mermaidSource: string;309readonly panZoom?: PanZoomState;310}311312/**313* Re-renders the mermaid diagram when theme changes314*/315async function rerenderMermaidDiagram(316diagramElement: HTMLElement,317diagramText: string,318newTheme: 'dark' | 'default'319): Promise<void> {320diagramElement.textContent = diagramText;321delete diagramElement.dataset.processed;322323mermaid.initialize({324theme: newTheme,325});326await mermaid.run({327nodes: [diagramElement]328});329}330331export async function initializeMermaidWebview(vscode: VsCodeApi): Promise<PanZoomHandler | undefined> {332const diagram = document.querySelector<HTMLElement>('.mermaid');333if (!diagram) {334return;335}336337// Capture diagram state338const theme = getMermaidTheme();339const diagramText = diagram.textContent ?? '';340let state: LocalState = {341mermaidSource: diagramText,342theme343};344345// Save the mermaid source in the webview state346const currentState: PersistedState = vscode.getState() || {};347vscode.setState({348...currentState,349mermaidSource: diagramText350});351352// Wrap the diagram for pan/zoom support353const wrapper = document.createElement('div');354wrapper.className = 'mermaid-wrapper';355wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;';356357const content = document.createElement('div');358content.className = 'mermaid-content';359360// Move the diagram into the content wrapper361diagram.parentNode?.insertBefore(wrapper, diagram);362content.appendChild(diagram);363wrapper.appendChild(content);364365// Run mermaid366const config: MermaidConfig = {367startOnLoad: false,368theme,369};370mermaid.initialize(config);371await mermaid.run({ nodes: [diagram] });372373// Show the diagram now that it's rendered374diagram.classList.add('rendered');375376const panZoomHandler = new PanZoomHandler(wrapper, content, vscode);377panZoomHandler.initialize();378379// Listen for messages from the extension380window.addEventListener('message', event => {381const message = event.data;382if (message.type === 'resetPanZoom') {383panZoomHandler.reset();384}385});386387// Re-render when theme changes388new MutationObserver(() => {389const newTheme = getMermaidTheme();390if (state?.theme === newTheme) {391return;392}393394const diagramNode = document.querySelector('.mermaid');395if (!diagramNode || !(diagramNode instanceof HTMLElement)) {396return;397}398399state = {400mermaidSource: state?.mermaidSource ?? '',401theme: newTheme402};403404rerenderMermaidDiagram(diagramNode, state.mermaidSource, newTheme);405}).observe(document.body, { attributes: true, attributeFilter: ['class'] });406407return panZoomHandler;408}409410411