Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/mermaid-chat-features/chat-webview-src/mermaidWebview.ts
5334 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
import mermaid, { MermaidConfig } from 'mermaid';
6
import { VsCodeApi } from './vscodeApi';
7
8
interface PanZoomState {
9
readonly scale: number;
10
readonly translateX: number;
11
readonly translateY: number;
12
}
13
14
export class PanZoomHandler {
15
private scale = 1;
16
private translateX = 0;
17
private translateY = 0;
18
19
private isPanning = false;
20
private hasDragged = false;
21
private hasInteracted = false;
22
private startX = 0;
23
private startY = 0;
24
25
private readonly minScale = 0.1;
26
private readonly maxScale = 5;
27
private readonly zoomFactor = 0.002;
28
29
constructor(
30
private readonly container: HTMLElement,
31
private readonly content: HTMLElement,
32
private readonly vscode: VsCodeApi
33
) {
34
this.container = container;
35
this.content = content;
36
this.content.style.transformOrigin = '0 0';
37
this.container.style.overflow = 'hidden';
38
this.container.style.cursor = 'default';
39
this.setupEventListeners();
40
}
41
42
/**
43
* Initializes the pan/zoom state - either restores from saved state or centers the content.
44
*/
45
public initialize(): void {
46
if (!this.restoreState()) {
47
// Use requestAnimationFrame to ensure layout is updated before centering
48
requestAnimationFrame(() => {
49
this.centerContent();
50
});
51
}
52
}
53
54
private setupEventListeners(): void {
55
// Pan with mouse drag
56
this.container.addEventListener('mousedown', e => this.handleMouseDown(e));
57
document.addEventListener('mousemove', e => this.handleMouseMove(e));
58
document.addEventListener('mouseup', () => this.handleMouseUp());
59
60
// Click to zoom (Alt+click = zoom in, Alt+Shift+click = zoom out)
61
this.container.addEventListener('click', e => this.handleClick(e));
62
63
// Trackpad: pinch = zoom, Alt + two-finger scroll = zoom
64
this.container.addEventListener('wheel', e => this.handleWheel(e), { passive: false });
65
66
// Update cursor when Alt/Option key is pressed
67
this.container.addEventListener('mousemove', e => this.updateCursorFromModifier(e));
68
this.container.addEventListener('mouseenter', e => this.updateCursorFromModifier(e));
69
window.addEventListener('keydown', e => this.handleKeyChange(e));
70
window.addEventListener('keyup', e => this.handleKeyChange(e));
71
72
// Re-center on resize if user hasn't interacted yet
73
window.addEventListener('resize', () => this.handleResize());
74
}
75
76
private handleKeyChange(e: KeyboardEvent): void {
77
if ((e.key === 'Alt' || e.key === 'Shift') && !this.isPanning) {
78
e.preventDefault();
79
if (e.altKey && !e.shiftKey) {
80
this.container.style.cursor = 'grab';
81
} else if (e.altKey && e.shiftKey) {
82
this.container.style.cursor = 'zoom-out';
83
} else {
84
this.container.style.cursor = 'default';
85
}
86
}
87
}
88
89
private updateCursorFromModifier(e: MouseEvent): void {
90
if (this.isPanning) {
91
return;
92
}
93
if (e.altKey && !e.shiftKey) {
94
this.container.style.cursor = 'grab';
95
} else if (e.altKey && e.shiftKey) {
96
this.container.style.cursor = 'zoom-out';
97
} else {
98
this.container.style.cursor = 'default';
99
}
100
}
101
102
private handleClick(e: MouseEvent): void {
103
// Only zoom on click if Alt is held and we didn't drag
104
if (!e.altKey || this.hasDragged) {
105
return;
106
}
107
108
e.preventDefault();
109
e.stopPropagation();
110
111
const rect = this.container.getBoundingClientRect();
112
const x = e.clientX - rect.left;
113
const y = e.clientY - rect.top;
114
115
// Alt+Shift+click = zoom out, Alt+click = zoom in
116
const factor = e.shiftKey ? 0.8 : 1.25;
117
this.zoomAtPoint(factor, x, y);
118
}
119
120
private handleWheel(e: WheelEvent): void {
121
// Only zoom when Alt is held (or ctrlKey for pinch-to-zoom gestures)
122
// ctrlKey is set by browsers for pinch-to-zoom gestures
123
const isPinchZoom = e.ctrlKey;
124
125
if (!e.altKey && !isPinchZoom) {
126
// Allow normal scrolling when Alt is not held
127
return;
128
}
129
130
if (isPinchZoom || e.altKey) {
131
// Pinch gesture or Alt + two-finger drag = zoom
132
e.preventDefault();
133
e.stopPropagation();
134
135
const rect = this.container.getBoundingClientRect();
136
const mouseX = e.clientX - rect.left;
137
const mouseY = e.clientY - rect.top;
138
139
// Calculate zoom (scroll up = zoom in, scroll down = zoom out)
140
// Pinch gestures have smaller deltaY values, so use a higher factor
141
const effectiveZoomFactor = isPinchZoom ? this.zoomFactor * 5 : this.zoomFactor;
142
const delta = -e.deltaY * effectiveZoomFactor;
143
const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * (1 + delta)));
144
145
// Zoom toward mouse position
146
const scaleFactor = newScale / this.scale;
147
this.translateX = mouseX - (mouseX - this.translateX) * scaleFactor;
148
this.translateY = mouseY - (mouseY - this.translateY) * scaleFactor;
149
this.scale = newScale;
150
151
this.applyTransform();
152
this.saveState();
153
}
154
}
155
156
private handleMouseDown(e: MouseEvent): void {
157
if (e.button !== 0 || !e.altKey) {
158
return;
159
}
160
e.preventDefault();
161
e.stopPropagation();
162
this.isPanning = true;
163
this.hasDragged = false;
164
this.startX = e.clientX - this.translateX;
165
this.startY = e.clientY - this.translateY;
166
this.container.style.cursor = 'grabbing';
167
}
168
169
private handleMouseMove(e: MouseEvent): void {
170
if (!this.isPanning) {
171
return;
172
}
173
174
// Handle case where mouse was released outside the webview
175
if (e.buttons === 0) {
176
this.handleMouseUp();
177
return;
178
}
179
180
const dx = e.clientX - this.startX - this.translateX;
181
const dy = e.clientY - this.startY - this.translateY;
182
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
183
this.hasDragged = true;
184
}
185
this.translateX = e.clientX - this.startX;
186
this.translateY = e.clientY - this.startY;
187
this.applyTransform();
188
}
189
190
private handleMouseUp(): void {
191
if (this.isPanning) {
192
this.isPanning = false;
193
this.container.style.cursor = 'default';
194
this.saveState();
195
}
196
}
197
198
private applyTransform(): void {
199
this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
200
}
201
202
private saveState(): void {
203
this.hasInteracted = true;
204
const currentState = this.vscode.getState() || {};
205
this.vscode.setState({
206
...currentState,
207
panZoom: {
208
scale: this.scale,
209
translateX: this.translateX,
210
translateY: this.translateY
211
}
212
});
213
}
214
215
private restoreState(): boolean {
216
const state = this.vscode.getState();
217
if (state?.panZoom) {
218
const panZoom = state.panZoom as PanZoomState;
219
this.scale = panZoom.scale ?? 1;
220
this.translateX = panZoom.translateX ?? 0;
221
this.translateY = panZoom.translateY ?? 0;
222
this.hasInteracted = true;
223
this.applyTransform();
224
return true;
225
}
226
return false;
227
}
228
229
private handleResize(): void {
230
if (!this.hasInteracted) {
231
this.centerContent();
232
}
233
}
234
235
/**
236
* Centers the content within the container.
237
*/
238
private centerContent(): void {
239
const containerRect = this.container.getBoundingClientRect();
240
241
// Get the SVG element inside the content - mermaid renders to an SVG
242
const svg = this.content.querySelector('svg');
243
if (!svg) {
244
return;
245
}
246
const svgRect = svg.getBoundingClientRect();
247
248
// Calculate the center position based on the SVG dimensions
249
this.translateX = (containerRect.width - svgRect.width) / 2;
250
this.translateY = (containerRect.height - svgRect.height) / 2;
251
252
this.applyTransform();
253
}
254
255
public reset(): void {
256
this.scale = 1;
257
this.translateX = 0;
258
this.translateY = 0;
259
this.hasInteracted = false;
260
this.applyTransform(); // Apply scale first so content size is correct
261
262
// Clear the saved pan/zoom state
263
const currentState = this.vscode.getState() || {};
264
delete currentState.panZoom;
265
this.vscode.setState(currentState);
266
267
// Use requestAnimationFrame to ensure layout is updated before centering
268
requestAnimationFrame(() => {
269
this.centerContent();
270
});
271
}
272
273
public zoomIn(): void {
274
const rect = this.container.getBoundingClientRect();
275
this.zoomAtPoint(1.25, rect.width / 2, rect.height / 2);
276
}
277
278
public zoomOut(): void {
279
const rect = this.container.getBoundingClientRect();
280
this.zoomAtPoint(0.8, rect.width / 2, rect.height / 2);
281
}
282
283
private zoomAtPoint(factor: number, x: number, y: number): void {
284
const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * factor));
285
const scaleFactor = newScale / this.scale;
286
this.translateX = x - (x - this.translateX) * scaleFactor;
287
this.translateY = y - (y - this.translateY) * scaleFactor;
288
this.scale = newScale;
289
this.applyTransform();
290
this.saveState();
291
}
292
}
293
294
export function getMermaidTheme(): 'dark' | 'default' {
295
return document.body.classList.contains('vscode-dark') || (document.body.classList.contains('vscode-high-contrast') && !document.body.classList.contains('vscode-high-contrast-light'))
296
? 'dark'
297
: 'default';
298
}
299
300
/**
301
* Unpersisted state
302
*/
303
interface LocalState {
304
readonly mermaidSource: string;
305
readonly theme: 'dark' | 'default';
306
}
307
308
interface PersistedState {
309
readonly mermaidSource: string;
310
readonly panZoom?: PanZoomState;
311
}
312
313
/**
314
* Re-renders the mermaid diagram when theme changes
315
*/
316
async function rerenderMermaidDiagram(
317
diagramElement: HTMLElement,
318
diagramText: string,
319
newTheme: 'dark' | 'default'
320
): Promise<void> {
321
diagramElement.textContent = diagramText;
322
delete diagramElement.dataset.processed;
323
324
mermaid.initialize({
325
theme: newTheme,
326
});
327
await mermaid.run({
328
nodes: [diagramElement]
329
});
330
}
331
332
export async function initializeMermaidWebview(vscode: VsCodeApi): Promise<PanZoomHandler | undefined> {
333
const diagram = document.querySelector<HTMLElement>('.mermaid');
334
if (!diagram) {
335
return;
336
}
337
338
// Capture diagram state
339
const theme = getMermaidTheme();
340
const diagramText = diagram.textContent ?? '';
341
let state: LocalState = {
342
mermaidSource: diagramText,
343
theme
344
};
345
346
// Save the mermaid source in the webview state
347
const currentState: PersistedState = vscode.getState() || {};
348
vscode.setState({
349
...currentState,
350
mermaidSource: diagramText
351
});
352
353
// Wrap the diagram for pan/zoom support
354
const wrapper = document.createElement('div');
355
wrapper.className = 'mermaid-wrapper';
356
wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;';
357
358
const content = document.createElement('div');
359
content.className = 'mermaid-content';
360
361
// Move the diagram into the content wrapper
362
diagram.parentNode?.insertBefore(wrapper, diagram);
363
content.appendChild(diagram);
364
wrapper.appendChild(content);
365
366
// Run mermaid
367
const config: MermaidConfig = {
368
startOnLoad: false,
369
theme,
370
};
371
mermaid.initialize(config);
372
await mermaid.run({ nodes: [diagram] });
373
374
// Show the diagram now that it's rendered
375
diagram.classList.add('rendered');
376
377
const panZoomHandler = new PanZoomHandler(wrapper, content, vscode);
378
panZoomHandler.initialize();
379
380
// Listen for messages from the extension
381
window.addEventListener('message', event => {
382
const message = event.data;
383
if (message.type === 'resetPanZoom') {
384
panZoomHandler.reset();
385
}
386
});
387
388
// Re-render when theme changes
389
new MutationObserver(() => {
390
const newTheme = getMermaidTheme();
391
if (state?.theme === newTheme) {
392
return;
393
}
394
395
const diagramNode = document.querySelector('.mermaid');
396
if (!diagramNode || !(diagramNode instanceof HTMLElement)) {
397
return;
398
}
399
400
state = {
401
mermaidSource: state?.mermaidSource ?? '',
402
theme: newTheme
403
};
404
405
rerenderMermaidDiagram(diagramNode, state.mermaidSource, newTheme);
406
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
407
408
return panZoomHandler;
409
}
410
411