Move bierner.markdown-mermaid into core

This makes my mermaid markdown extension builtin. As part of this I've renamed `chat-mermaid-features` to the more generic `markdown-mermaid-features` since this once extension now contributes a consistent mermaid UX for chat, notebooks, and previews

Fixes #293028
This commit is contained in:
Matt Bierner
2026-05-13 16:04:49 -07:00
parent d503bd0e26
commit ea086aed28
51 changed files with 4995 additions and 1435 deletions
+3 -1
View File
@@ -27,7 +27,9 @@
**/extensions/markdown-language-features/media/**
**/extensions/markdown-language-features/notebook-out/**
**/extensions/markdown-math/notebook-out/**
**/extensions/mermaid-chat-features/chat-webview-out/**
**/extensions/mermaid-markdown-features/chat-webview-out/**
**/extensions/mermaid-markdown-features/markdown-preview-out/**
**/extensions/mermaid-markdown-features/notebook-out/**
**/extensions/notebook-renderers/renderer-out/index.js
**/extensions/simple-browser/media/index.js
**/extensions/terminal-suggest/src/completions/upstream/**
+7 -3
View File
@@ -50,7 +50,7 @@ export const unicodeFilter = Object.freeze<string[]>([
'!build/win32/**',
'!extensions/markdown-language-features/notebook-out/*.js',
'!extensions/markdown-math/notebook-out/**',
'!extensions/mermaid-chat-features/chat-webview-out/**',
'!extensions/mermaid-markdown-features/chat-webview-out/**',
'!extensions/ipynb/notebook-out/**',
'!extensions/notebook-renderers/renderer-out/**',
'!extensions/php-language-features/src/features/phpGlobalFunctions.ts',
@@ -150,7 +150,9 @@ export const indentationFilter = Object.freeze<string[]>([
'!**/*.tiff',
// except for built files
'!extensions/mermaid-chat-features/chat-webview-out/*.js',
'!extensions/mermaid-markdown-features/chat-webview-out/*.js',
'!extensions/mermaid-markdown-features/markdown-preview-out/*.js',
'!extensions/mermaid-markdown-features/notebook-out/*.js',
'!extensions/markdown-language-features/media/*.js',
'!extensions/markdown-language-features/notebook-out/*.js',
'!extensions/markdown-math/notebook-out/*.js',
@@ -204,7 +206,9 @@ export const copyrightFilter = Object.freeze<string[]>([
'!extensions/html-language-features/server/src/modes/typescript/*',
'!extensions/*/server/bin/*',
'!src/vs/workbench/contrib/terminal/common/scripts/psreadline/**',
'!extensions/mermaid-chat-features/chat-webview-out/**',
'!extensions/mermaid-markdown-features/chat-webview-out/**',
'!extensions/mermaid-markdown-features/markdown-preview-out/**',
'!extensions/mermaid-markdown-features/notebook-out/**',
// extensions/copilot has its own code style
'!extensions/copilot/**',
+1 -1
View File
@@ -77,7 +77,7 @@ const compilations = [
'extensions/markdown-math/tsconfig.json',
'extensions/media-preview/tsconfig.json',
'extensions/merge-conflict/tsconfig.json',
'extensions/mermaid-chat-features/tsconfig.json',
'extensions/mermaid-markdown-features/tsconfig.json',
'extensions/terminal-suggest/tsconfig.json',
'extensions/microsoft-authentication/tsconfig.json',
'extensions/notebook-renderers/tsconfig.json',
+1 -1
View File
@@ -632,7 +632,7 @@ const esbuildMediaScripts = [
'markdown-language-features/esbuild.notebook.mts',
'markdown-language-features/esbuild.webview.mts',
'markdown-math/esbuild.notebook.mts',
'mermaid-chat-features/esbuild.webview.mts',
'mermaid-markdown-features/esbuild.webview.mts',
'notebook-renderers/esbuild.notebook.mts',
'simple-browser/esbuild.webview.mts',
];
+1 -1
View File
@@ -38,7 +38,7 @@ export const dirs = [
'extensions/markdown-math',
'extensions/media-preview',
'extensions/merge-conflict',
'extensions/mermaid-chat-features',
'extensions/mermaid-markdown-features',
'extensions/microsoft-authentication',
'extensions/notebook-renderers',
'extensions/npm',
+5 -5
View File
@@ -2392,8 +2392,8 @@ export default tseslint.config(
'extensions/markdown-language-features/src/**/*.ts',
'extensions/markdown-language-features/notebook/**/*.ts',
'extensions/markdown-language-features/preview-src/**/*.ts',
'extensions/mermaid-chat-features/chat-webview-src/**/*.ts',
'extensions/mermaid-chat-features/src/**/*.ts',
'extensions/mermaid-markdown-features/preview-src/chat/**/*.ts',
'extensions/mermaid-markdown-features/src/**/*.ts',
'extensions/media-preview/src/**/*.ts',
'extensions/simple-browser/**/*.ts',
'extensions/typescript-language-features/**/*.ts',
@@ -2414,9 +2414,9 @@ export default tseslint.config(
'extensions/simple-browser/tsconfig.json',
'extensions/simple-browser/preview-src/tsconfig.json',
// Mermaid chat features
'extensions/mermaid-chat-features/tsconfig.json',
'extensions/mermaid-chat-features/chat-webview-src/tsconfig.json',
// Mermaid markdown features
'extensions/mermaid-markdown-features/tsconfig.json',
'extensions/mermaid-markdown-features/preview-src/chat/tsconfig.json',
// TypeScript
'extensions/typescript-language-features/tsconfig.json',
@@ -26,7 +26,7 @@ const BASE_ASK_AGENT_CONFIG: AgentConfig = {
agents: [],
tools: [
...DEFAULT_READ_TOOLS,
'vscode.mermaid-chat-features/renderMermaidDiagram',
'vscode.mermaid-markdown-features/renderMermaidDiagram',
],
body: '' // Generated dynamically in buildCustomizedConfig
};
+4 -3
View File
@@ -2,15 +2,16 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Common build script for extension scripts used in in webviews.
*/
import type esbuild from 'esbuild';
import { runBuild, type RunConfig } from './esbuild-common.mts';
const baseOptions = {
const baseOptions: esbuild.BuildOptions = {
bundle: true,
minify: true,
minify: false,
sourcemap: false,
format: 'esm' as const,
platform: 'browser' as const,
@@ -1 +0,0 @@
chat-webview-out
@@ -1,5 +0,0 @@
# Mermaid Chat Features
**Notice:** This extension is bundled with Visual Studio Code. It can be disabled but not uninstalled.
Adds basic [Mermaid.js](https://mermaid.js.org) diagram rendering to build-in chat.
@@ -1,24 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import path from 'path';
import { run } from '../esbuild-webview-common.mts';
const srcDir = path.join(import.meta.dirname, 'chat-webview-src');
const outDir = path.join(import.meta.dirname, 'chat-webview-out');
run({
entryPoints: {
'index': path.join(srcDir, 'index.ts'),
'index-editor': path.join(srcDir, 'index-editor.ts'),
'codicon': path.join(import.meta.dirname, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.css'),
},
srcDir,
outdir: outDir,
additionalOptions: {
loader: {
'.ttf': 'dataurl',
}
}
}, process.argv);
File diff suppressed because it is too large Load Diff
@@ -1,142 +0,0 @@
{
"name": "mermaid-chat-features",
"displayName": "%displayName%",
"description": "%description%",
"version": "10.0.0",
"publisher": "vscode",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/microsoft/vscode.git"
},
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
"engines": {
"vscode": "^1.104.0"
},
"enabledApiProposals": [
"chatOutputRenderer"
],
"capabilities": {
"virtualWorkspaces": true,
"untrustedWorkspaces": {
"supported": true
}
},
"main": "./out/extension",
"browser": "./dist/browser/extension",
"activationEvents": [
"onWebviewPanel:vscode.chat-mermaid-features.preview"
],
"contributes": {
"commands": [
{
"command": "_mermaid-chat.resetPanZoom",
"title": "Reset Pan and Zoom"
},
{
"command": "_mermaid-chat.openInEditor",
"title": "Open Diagram in Editor"
},
{
"command": "_mermaid-chat.copySource",
"title": "Copy Diagram Source"
}
],
"menus": {
"commandPalette": [
{
"command": "_mermaid-chat.resetPanZoom",
"when": "false"
},
{
"command": "_mermaid-chat.openInEditor",
"when": "false"
},
{
"command": "_mermaid-chat.copySource",
"when": "false"
}
],
"webview/context": [
{
"command": "_mermaid-chat.resetPanZoom",
"when": "webviewId == 'vscode.chat-mermaid-features.chatOutputItem'"
},
{
"command": "_mermaid-chat.copySource",
"when": "webviewId == 'vscode.chat-mermaid-features.chatOutputItem' || webviewId == 'vscode.chat-mermaid-features.preview'"
}
]
},
"configuration": {
"title": "Mermaid Chat Features",
"properties": {
"mermaid-chat.enabled": {
"type": "boolean",
"default": true,
"description": "%config.enabled.description%",
"scope": "application"
}
}
},
"chatOutputRenderers": [
{
"viewType": "vscode.chat-mermaid-features.chatOutputItem",
"mimeTypes": [
"text/vnd.mermaid"
],
"codeBlockLanguageIdentifiers": [
"mermaid"
]
}
],
"languageModelTools": [
{
"name": "renderMermaidDiagram",
"displayName": "Mermaid Renderer",
"toolReferenceName": "renderMermaidDiagram",
"canBeReferencedInPrompt": true,
"modelDescription": "Renders a Mermaid diagram from Mermaid.js markup.",
"userDescription": "Render a Mermaid.js diagrams from markup.",
"when": "config.mermaid-chat.enabled",
"inputSchema": {
"type": "object",
"properties": {
"markup": {
"type": "string",
"description": "The mermaid diagram markup to render as a Mermaid diagram. This should only be the markup of the diagram. Do not include a wrapping code block."
},
"title": {
"type": "string",
"description": "A short title that describes the diagram."
}
}
}
}
]
},
"scripts": {
"compile": "gulp compile-extension:mermaid-chat-features && npm run build-chat-webview",
"watch": "npm run build-chat-webview && gulp watch-extension:mermaid-chat-features",
"vscode:prepublish": "npm run build-ext && npm run build-chat-webview",
"build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:mermaid-chat-features",
"build-chat-webview": "node ./esbuild.webview.mts",
"compile-web": "npm-run-all2 -lp bundle-web typecheck-web",
"bundle-web": "node ./esbuild.browser.mts",
"typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit",
"watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web",
"watch-bundle-web": "node ./esbuild.browser.mts --watch",
"watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch"
},
"devDependencies": {
"@types/node": "^22.18.10",
"@vscode/codicons": "^0.0.36"
},
"dependencies": {
"dompurify": "^3.4.1",
"mermaid": "^11.15.0"
},
"overrides": {
"lodash-es": "4.18.1"
}
}
@@ -1,5 +0,0 @@
{
"displayName": "Mermaid Chat Features",
"description": "Adds Mermaid diagram support to built-in chats.",
"config.enabled.description": "Enable a tool for Mermaid diagram rendering in chat responses."
}
@@ -1,35 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { registerChatSupport } from './chatOutputRenderer';
import { MermaidEditorManager } from './editorManager';
import { MermaidWebviewManager } from './webviewManager';
export function activate(context: vscode.ExtensionContext) {
const webviewManager = new MermaidWebviewManager();
const editorManager = new MermaidEditorManager(context.extensionUri, webviewManager);
context.subscriptions.push(editorManager);
// Register chat support
context.subscriptions.push(registerChatSupport(context, webviewManager, editorManager));
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand('_mermaid-chat.resetPanZoom', (ctx?: { mermaidWebviewId?: string }) => {
webviewManager.resetPanZoom(ctx?.mermaidWebviewId);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('_mermaid-chat.copySource', (ctx?: { mermaidWebviewId?: string }) => {
const webviewInfo = ctx?.mermaidWebviewId ? webviewManager.getWebview(ctx.mermaidWebviewId) : webviewManager.activeWebview;
if (webviewInfo) {
vscode.env.clipboard.writeText(webviewInfo.mermaidSource);
}
})
);
}
@@ -0,0 +1,3 @@
chat-webview-out
markdown-preview-out
notebook-out
@@ -1,4 +1,5 @@
src/**
preview-src/**
esbuild.*
cgmanifest.json
package-lock.json
@@ -0,0 +1,5 @@
# Mermaid Markdown Features
**Notice:** This extension is bundled with Visual Studio Code. It can be disabled but not uninstalled.
Adds [Mermaid.js](https://mermaid.js.org) diagram rendering to built-in chat, Markdown previews, and notebooks.
@@ -0,0 +1,81 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import path from 'path';
import esbuild, { type Plugin } from 'esbuild';
import { run } from '../esbuild-webview-common.mts';
const rootDir = import.meta.dirname;
const previewSrcDir = path.join(rootDir, 'preview-src');
const chatSrcDir = path.join(previewSrcDir, 'chat');
const cssTextPlugin: Plugin = {
name: 'css-text',
setup(build) {
build.onLoad({ filter: /diagramStyles\.css$/ }, async args => {
const result = await esbuild.build({
entryPoints: [args.path],
bundle: true,
minify: true,
write: false,
loader: {
'.ttf': 'dataurl',
'.woff': 'dataurl',
'.woff2': 'dataurl',
},
});
const css = result.outputFiles[0].text;
return {
contents: `export default ${JSON.stringify(css)};`,
loader: 'js',
};
});
},
};
const mermaidMarkdownBuildOptions: Partial<esbuild.BuildOptions> = {
loader: {
'.ttf': 'dataurl',
},
plugins: [cssTextPlugin],
minify: false,
};
await Promise.all([
// Chat
run({
entryPoints: {
'index': path.join(chatSrcDir, 'index.ts'),
'index-editor': path.join(chatSrcDir, 'index-editor.ts'),
'codicon': path.join(rootDir, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.css'),
},
srcDir: chatSrcDir,
outdir: path.join(rootDir, 'chat-webview-out'),
additionalOptions: {
loader: {
'.ttf': 'dataurl',
},
}
}, process.argv),
// Markdown preview
run({
entryPoints: {
'index': path.join(previewSrcDir, 'markdown', 'index.ts'),
},
srcDir: rootDir,
outdir: path.join(rootDir, 'markdown-preview-out'),
additionalOptions: mermaidMarkdownBuildOptions,
}, process.argv),
// Notebook
run({
entryPoints: {
'index': path.join(previewSrcDir, 'notebook', 'index.ts'),
},
srcDir: rootDir,
outdir: path.join(rootDir, 'notebook-out'),
additionalOptions: {
...mermaidMarkdownBuildOptions,
},
}, process.argv),
]);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
{
"name": "mermaid-markdown-features",
"displayName": "%displayName%",
"description": "%description%",
"version": "10.0.0",
"publisher": "vscode",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/microsoft/vscode.git"
},
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
"engines": {
"vscode": "^1.104.0"
},
"enabledApiProposals": [
"chatOutputRenderer"
],
"capabilities": {
"virtualWorkspaces": true,
"untrustedWorkspaces": {
"supported": true
}
},
"main": "./out/extension",
"browser": "./dist/browser/extension",
"activationEvents": [
"onWebviewPanel:vscode.mermaid-markdown-features.preview"
],
"contributes": {
"commands": [
{
"command": "_mermaid-markdown.resetPanZoom",
"title": "%command.resetPanZoom.title%"
},
{
"command": "_mermaid-markdown.openInEditor",
"title": "%command.openInEditor.title%"
},
{
"command": "_mermaid-markdown.copySource",
"title": "%command.copySource.title%"
}
],
"menus": {
"commandPalette": [
{
"command": "_mermaid-markdown.resetPanZoom",
"when": "false"
},
{
"command": "_mermaid-markdown.openInEditor",
"when": "false"
},
{
"command": "_mermaid-markdown.copySource",
"when": "false"
}
],
"webview/context": [
{
"command": "_mermaid-markdown.openInEditor",
"when": "webviewId == 'vscode.mermaid-markdown-features.chatOutputItem' || ((webviewId == 'markdown.preview' || webviewId == 'vscode.markdown.preview.editor' || webviewId == 'notebook.output') && webviewSection == 'mermaid')",
"group": "navigation@1"
},
{
"command": "_mermaid-markdown.copySource",
"when": "webviewId == 'vscode.mermaid-markdown-features.chatOutputItem' || webviewId == 'vscode.mermaid-markdown-features.preview' || ((webviewId == 'markdown.preview' || webviewId == 'vscode.markdown.preview.editor' || webviewId == 'notebook.output') && webviewSection == 'mermaid')",
"group": "navigation@2"
},
{
"command": "_mermaid-markdown.resetPanZoom",
"when": "webviewId == 'vscode.mermaid-markdown-features.chatOutputItem' || webviewId == 'vscode.mermaid-markdown-features.preview'",
"group": "navigation@3"
}
]
},
"configuration": {
"title": "%config.title%",
"properties": {
"mermaid-markdown.enabled": {
"type": "boolean",
"default": true,
"description": "%config.enabled.description%",
"scope": "application"
},
"markdown-mermaid.lightModeTheme": {
"order": 0,
"type": "string",
"enum": [
"base",
"forest",
"dark",
"default",
"neutral"
],
"default": "default",
"description": "%config.markdown-mermaid.lightModeTheme.description%"
},
"markdown-mermaid.darkModeTheme": {
"order": 1,
"type": "string",
"enum": [
"base",
"forest",
"dark",
"default",
"neutral"
],
"default": "dark",
"description": "%config.markdown-mermaid.darkModeTheme.description%"
},
"markdown-mermaid.languages": {
"order": 2,
"type": "array",
"default": [
"mermaid"
],
"description": "%config.markdown-mermaid.languages.description%"
},
"markdown-mermaid.maxTextSize": {
"order": 3,
"type": "number",
"default": 50000,
"description": "%config.markdown-mermaid.maxTextSize.description%"
},
"markdown-mermaid.mouseNavigation.enabled": {
"type": "string",
"description": "%config.markdown-mermaid.mouseNavigation.enabled.description%",
"enum": [
"always",
"alt",
"never"
],
"default": "alt",
"markdownEnumDescriptions": [
"%config.markdown-mermaid.mouseNavigation.enabled.always%",
"%config.markdown-mermaid.mouseNavigation.enabled.alt%",
"%config.markdown-mermaid.mouseNavigation.enabled.never%"
]
},
"markdown-mermaid.controls.show": {
"type": "string",
"description": "%config.markdown-mermaid.controls.show.description%",
"enum": [
"never",
"onHoverOrFocus",
"always"
],
"enumDescriptions": [
"%config.markdown-mermaid.controls.show.never%",
"%config.markdown-mermaid.controls.show.onHoverOrFocus%",
"%config.markdown-mermaid.controls.show.always%"
],
"default": "onHoverOrFocus"
},
"markdown-mermaid.resizable": {
"type": "boolean",
"default": true,
"description": "%config.markdown-mermaid.resizable.description%"
},
"markdown-mermaid.maxHeight": {
"type": "string",
"default": "",
"markdownDescription": "%config.markdown-mermaid.maxHeight.markdownDescription%"
}
}
},
"markdown.previewScripts": [
{
"path": "./markdown-preview-out/index.js",
"type": "module"
}
],
"notebookRenderer": [
{
"id": "vscode.markdown-it.mermaid-extension",
"displayName": "Markdown-It Mermaid Renderer",
"entrypoint": {
"extends": "vscode.markdown-it-renderer",
"path": "./notebook-out/index.js"
}
}
],
"markdown.markdownItPlugins": true,
"chatOutputRenderers": [
{
"viewType": "vscode.mermaid-markdown-features.chatOutputItem",
"mimeTypes": [
"text/vnd.mermaid"
],
"codeBlockLanguageIdentifiers": [
"mermaid"
]
}
],
"languageModelTools": [
{
"name": "renderMermaidDiagram",
"displayName": "Mermaid Renderer",
"toolReferenceName": "renderMermaidDiagram",
"legacyToolReferenceFullNames": [
"vscode.mermaid-chat-features/renderMermaidDiagram"
],
"canBeReferencedInPrompt": true,
"modelDescription": "Renders a Mermaid diagram from Mermaid.js markup.",
"userDescription": "Render a Mermaid.js diagrams from markup.",
"when": "config.mermaid-markdown.enabled",
"inputSchema": {
"type": "object",
"properties": {
"markup": {
"type": "string",
"description": "The mermaid diagram markup to render as a Mermaid diagram. This should only be the markup of the diagram. Do not include a wrapping code block."
},
"title": {
"type": "string",
"description": "A short title that describes the diagram."
}
}
}
}
]
},
"scripts": {
"compile": "gulp compile-extension:mermaid-markdown-features && npm run build-webview",
"watch": "npm run build-webview && gulp watch-extension:mermaid-markdown-features",
"vscode:prepublish": "npm run build-ext && npm run build-webview",
"build-ext": "gulp compile-extension:mermaid-markdown-features",
"build-webview": "node ./esbuild.webview.mts",
"compile-web": "npm-run-all2 -lp bundle-web typecheck-web",
"bundle-web": "node ./esbuild.browser.mts",
"typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit",
"watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web",
"watch-bundle-web": "node ./esbuild.browser.mts --watch",
"watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch"
},
"devDependencies": {
"@types/markdown-it": "^14.1.0",
"@types/node": "^22.18.10",
"@types/vscode-notebook-renderer": "^1.72.0",
"@vscode/codicons": "^0.0.36"
},
"dependencies": {
"@iconify-json/logos": "^1.2.0",
"@iconify-json/mdi": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.0",
"@mermaid-js/layout-tidy-tree": "^0.2.0",
"@mermaid-js/mermaid-zenuml": "^0.2.0",
"dompurify": "^3.4.1",
"mermaid": "^11.15.0"
},
"overrides": {
"lodash-es": "4.18.1"
}
}
@@ -0,0 +1,23 @@
{
"displayName": "Mermaid Markdown Features",
"description": "Adds Mermaid diagram support to built-in chats, Markdown previews, and notebooks.",
"command.resetPanZoom.title": "Reset Pan and Zoom",
"command.openInEditor.title": "Open Diagram in Editor",
"command.copySource.title": "Copy Diagram Source",
"config.title": "Mermaid",
"config.enabled.description": "Enable a tool for Mermaid diagram rendering in chat responses.",
"config.markdown-mermaid.lightModeTheme.description": "Default Mermaid theme for light mode.",
"config.markdown-mermaid.darkModeTheme.description": "Default Mermaid theme for dark mode.",
"config.markdown-mermaid.languages.description": "Default languages in Markdown.",
"config.markdown-mermaid.maxTextSize.description": "The maximum allowed size of the user's text diagram.",
"config.markdown-mermaid.mouseNavigation.enabled.description": "Controls when mouse-based navigation is enabled on Mermaid diagrams.",
"config.markdown-mermaid.mouseNavigation.enabled.always": "Always enable mouse navigation on Mermaid diagrams.",
"config.markdown-mermaid.mouseNavigation.enabled.alt": "Only enable mouse navigation when holding down Alt (Option on macOS). Gestures such as pinch-to-zoom will still work without Alt.",
"config.markdown-mermaid.mouseNavigation.enabled.never": "Disable mouse navigation.",
"config.markdown-mermaid.controls.show.description": "Controls showing UI controls on Mermaid diagrams.",
"config.markdown-mermaid.controls.show.never": "Never show controls.",
"config.markdown-mermaid.controls.show.onHoverOrFocus": "Show zoom controls when hovering over or focusing a diagram.",
"config.markdown-mermaid.controls.show.always": "Always show zoom controls.",
"config.markdown-mermaid.resizable.description": "Allow diagrams to be resized vertically by dragging the bottom edge.",
"config.markdown-mermaid.maxHeight.markdownDescription": "Maximum height for diagrams. Can be a number in pixels or a CSS value like `80vh` or `400px`. Leave empty to try to automatically size diagrams based on their content."
}
@@ -15,10 +15,16 @@ initializeMermaidWebview(vscode).then(panZoomHandler => {
}
// Wire up zoom controls
const panModeBtn = document.querySelector<HTMLButtonElement>('.pan-mode-btn');
const zoomInBtn = document.querySelector('.zoom-in-btn');
const zoomOutBtn = document.querySelector('.zoom-out-btn');
const zoomResetBtn = document.querySelector('.zoom-reset-btn');
panModeBtn?.addEventListener('click', () => {
const enabled = panZoomHandler.togglePanMode();
panModeBtn.classList.toggle('active', enabled);
panModeBtn.setAttribute('aria-pressed', String(enabled));
});
zoomInBtn?.addEventListener('click', () => panZoomHandler.zoomIn());
zoomOutBtn?.addEventListener('click', () => panZoomHandler.zoomOut());
zoomResetBtn?.addEventListener('click', () => panZoomHandler.reset());
@@ -19,6 +19,7 @@ export class PanZoomHandler {
private isPanning = false;
private hasDragged = false;
private hasInteracted = false;
private panModeEnabled = false;
private startX = 0;
private startY = 0;
@@ -76,13 +77,7 @@ export class PanZoomHandler {
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 = 'grab';
} else if (e.altKey && e.shiftKey) {
this.container.style.cursor = 'zoom-out';
} else {
this.container.style.cursor = 'default';
}
this.setCursor(e.altKey, e.shiftKey);
}
}
@@ -90,9 +85,18 @@ export class PanZoomHandler {
if (this.isPanning) {
return;
}
if (e.altKey && !e.shiftKey) {
this.setCursor(e.altKey, e.shiftKey);
}
private setCursor(altKey: boolean, shiftKey: boolean): void {
if (this.panModeEnabled) {
this.container.style.cursor = 'grab';
} else if (e.altKey && e.shiftKey) {
return;
}
if (altKey && !shiftKey) {
this.container.style.cursor = 'grab';
} else if (altKey && shiftKey) {
this.container.style.cursor = 'zoom-out';
} else {
this.container.style.cursor = 'default';
@@ -154,7 +158,7 @@ export class PanZoomHandler {
}
private handleMouseDown(e: MouseEvent): void {
if (e.button !== 0 || !e.altKey) {
if (e.button !== 0 || (!this.panModeEnabled && !e.altKey)) {
return;
}
e.preventDefault();
@@ -190,7 +194,7 @@ export class PanZoomHandler {
private handleMouseUp(): void {
if (this.isPanning) {
this.isPanning = false;
this.container.style.cursor = 'default';
this.setCursor(false, false);
this.saveState();
}
}
@@ -280,6 +284,12 @@ export class PanZoomHandler {
this.zoomAtPoint(0.8, rect.width / 2, rect.height / 2);
}
public togglePanMode(): boolean {
this.panModeEnabled = !this.panModeEnabled;
this.setCursor(false, false);
return this.panModeEnabled;
}
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;
@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist/",
@@ -13,7 +13,7 @@
"node"
],
"typeRoots": [
"../node_modules/@types"
"../../node_modules/@types"
]
}
}
@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import mermaid, { MermaidConfig } from 'mermaid';
import { loadExtensionConfig, registerMermaidAddons, renderMermaidBlocksInElement } from '../shared';
import { DiagramManager } from '../shared/diagramManager';
import { IDisposable } from '../shared/disposable';
let currentAbortController: AbortController | undefined;
let currentDisposables: IDisposable[] = [];
const diagramManager = new DiagramManager(loadExtensionConfig());
async function init() {
for (const disposable of currentDisposables) {
disposable.dispose();
}
currentDisposables = [];
// Abort any in-progress render
currentAbortController?.abort();
currentAbortController = new AbortController();
const signal = currentAbortController.signal;
const extConfig = loadExtensionConfig();
diagramManager.updateConfig(extConfig);
const config: MermaidConfig = {
startOnLoad: false,
maxTextSize: extConfig.maxTextSize,
theme: (document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast')
? extConfig.darkModeTheme
: extConfig.lightModeTheme) as MermaidConfig['theme'],
};
mermaid.initialize(config);
await registerMermaidAddons();
const activeIds = new Set<string>();
await renderMermaidBlocksInElement(document.body, (mermaidContainer, content) => {
mermaidContainer.innerHTML = content;
activeIds.add(mermaidContainer.id);
currentDisposables.push(diagramManager.setup(mermaidContainer.id, mermaidContainer));
}, signal);
// Clean up saved states for diagrams that no longer exist
diagramManager.retainStates(activeIds);
}
window.addEventListener('vscode.markdown.updateContent', init);
init();
@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type MarkdownIt from 'markdown-it';
import mermaid from 'mermaid';
import type { RendererContext } from 'vscode-notebook-renderer';
import { extendMarkdownItWithMermaid } from '../../src/markdownMermaid/markdownIt';
import { loadExtensionConfig, loadMermaidConfig, registerMermaidAddons, renderMermaidBlocksInElement } from '../shared';
import { DiagramManager } from '../shared/diagramManager';
interface MarkdownItRenderer {
extendMarkdownIt(fn: (md: MarkdownIt) => void): void;
}
export async function activate(ctx: RendererContext<void>) {
const markdownItRenderer = await ctx.getRenderer('vscode.markdown-it-renderer') as MarkdownItRenderer | undefined;
if (!markdownItRenderer) {
throw new Error(`Could not load 'vscode.markdown-it-renderer'`);
}
mermaid.initialize(loadMermaidConfig());
await registerMermaidAddons();
markdownItRenderer.extendMarkdownIt((md: MarkdownIt) => {
extendMarkdownItWithMermaid(md, { languageIds: () => ['mermaid'] });
const diagramManager = new DiagramManager(loadExtensionConfig());
const render = md.renderer.render;
md.renderer.render = function (tokens, options, env) {
const result = render.call(this, tokens, options, env);
const shadowRoot = document.getElementById(env?.outputItem.id)?.shadowRoot;
diagramManager.updateConfig(loadExtensionConfig());
const temp = document.createElement('div');
temp.innerHTML = result;
renderMermaidBlocksInElement(temp, (mermaidContainer, content) => {
const liveEl = shadowRoot?.getElementById(mermaidContainer.id);
if (liveEl) {
liveEl.dataset.vscodeContext = mermaidContainer.dataset.vscodeContext ?? '';
liveEl.innerHTML = content;
diagramManager.setup(liveEl.id, liveEl);
} else {
console.warn('Could not find live element to render mermaid to');
}
});
return temp.innerHTML;
};
return md;
});
}
@@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export interface MermaidExtensionConfig {
readonly darkModeTheme: string;
readonly lightModeTheme: string;
readonly maxTextSize: number;
readonly clickDrag: ClickDragMode;
readonly showControls: ShowControlsMode;
readonly resizable: boolean;
readonly maxHeight: string;
}
export const enum ShowControlsMode {
Never = 'never',
OnHoverOrFocus = 'onHoverOrFocus',
Always = 'always'
}
export const enum ClickDragMode {
Always = 'always',
Alt = 'alt',
Never = 'never'
}
@@ -0,0 +1,4 @@
declare module '*.css' {
const content: string;
export default content;
}
@@ -0,0 +1,614 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ClickDragMode, MermaidExtensionConfig, ShowControlsMode } from './config';
import diagramStyles from './diagramStyles.css';
import { IDisposable } from './disposable';
const minScale = 0.5;
const maxScale = 10;
const zoomFactor = 0.002;
interface Dimensions {
readonly width: number;
readonly height: number;
}
interface Point {
readonly x: number;
readonly y: number;
}
export interface PanZoomState {
readonly scale: number;
readonly translate: Point;
readonly hasInteracted: boolean;
readonly customHeight?: number;
}
/**
* Manages all DiagramElement instances within a window/document.
*/
export class DiagramManager {
private readonly instances = new Map<string, DiagramElement>();
private readonly savedStates = new Map<string, PanZoomState>();
private readonly diagramStyleSheet: HTMLStyleElement;
private config: MermaidExtensionConfig;
constructor(config: MermaidExtensionConfig) {
this.config = config;
this.diagramStyleSheet = document.createElement('style');
this.diagramStyleSheet.className = 'markdown-style mermaid-diagram-styles';
this.diagramStyleSheet.textContent = diagramStyles;
document.head.appendChild(this.diagramStyleSheet);
}
public updateConfig(config: MermaidExtensionConfig): void {
this.config = config;
}
/**
* Sets up pan-zoom support for a mermaid container.
*
* @param id Unique identifier for this instance (used for state preservation)
* @param mermaidContainer The container element with the rendered diagram
*
* @returns An IDisposable that cleans up pan zoom support for this instance.
*/
public setup(id: string, mermaidContainer: HTMLElement): IDisposable {
// Clean up existing instance (state is saved automatically in disposeInstance)
this.disposeInstance(id);
const parent = mermaidContainer.parentNode;
if (!parent) {
return { dispose: () => { } };
}
// Create wrapper structure
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-wrapper';
const content = document.createElement('div');
content.className = 'mermaid-content';
parent.insertBefore(wrapper, mermaidContainer);
content.appendChild(mermaidContainer);
wrapper.appendChild(content);
// Create and track instance
const state = this.savedStates.get(id);
const instance = new DiagramElement(wrapper, content, this.config, state);
this.instances.set(id, instance);
// Initialize after DOM update
requestAnimationFrame(() => {
instance.initialize();
});
return { dispose: () => this.disposeInstance(id) };
}
/**
* Disposes a specific instance by id, saving its state first.
*/
private disposeInstance(id: string): void {
const instance = this.instances.get(id);
if (instance) {
// Save state before disposing
this.savedStates.set(id, instance.getState());
instance.dispose();
this.instances.delete(id);
}
}
/**
* Removes saved states for IDs not in the given set.
* Call this after rendering to clean up states for removed diagrams.
*/
public retainStates(activeIds: Set<string>): void {
for (const id of this.savedStates.keys()) {
if (!activeIds.has(id)) {
this.savedStates.delete(id);
}
}
}
}
/**
* Implements pan and zoom for a given DOM node.
*
* Features: drag to pan, pinch/scroll to zoom, Alt+click zoom, zoom controls.
*/
export class DiagramElement {
private scale = 1;
private translate: Point = { x: 0, y: 0 };
/** Cached SVG intrinsic dimensions from last layout */
private lastSvgSize: Dimensions = { width: 0, height: 0 };
private isPanning = false;
private hasDragged = false;
private hasInteracted = false;
private panModeEnabled = false;
private startX = 0;
private startY = 0;
private isResizing = false;
private resizeStartY = 0;
private resizeStartHeight = 0;
private customHeight: number | undefined;
private panModeButton: HTMLButtonElement | null = null;
private readonly resizeHandle: HTMLElement | null = null;
private readonly resizeObserver: ResizeObserver;
private readonly showControls: ShowControlsMode;
private readonly clickDrag: ClickDragMode;
private readonly resizable: boolean;
private readonly maxHeight: string;
private readonly abortController = new AbortController();
constructor(
private readonly container: HTMLElement,
private readonly content: HTMLElement,
config: MermaidExtensionConfig,
initialState?: PanZoomState
) {
this.showControls = config.showControls;
this.clickDrag = config.clickDrag;
this.resizable = config.resizable;
this.maxHeight = config.maxHeight;
// Restore state if provided
if (initialState) {
this.scale = initialState.scale;
this.translate = { x: initialState.translate.x, y: initialState.translate.y };
this.hasInteracted = initialState.hasInteracted;
this.customHeight = initialState.customHeight;
}
this.content.style.transformOrigin = '0 0';
this.container.style.overflow = 'hidden';
this.container.tabIndex = 0;
this.container.setAttribute('aria-label', 'Mermaid Diagram');
// Apply max height if configured and valid
if (this.maxHeight) {
const sanitizedMaxHeight = sanitizeCssLength(this.maxHeight);
if (sanitizedMaxHeight) {
this.container.style.maxHeight = sanitizedMaxHeight;
}
}
// Set initial cursor based on click-drag mode
this.setCursor(false, false);
this.setupEventListeners();
if (this.showControls !== ShowControlsMode.Never) {
this.createZoomControls();
}
if (this.resizable) {
this.resizeHandle = this.createResizeHandle();
this.container.appendChild(this.resizeHandle);
}
// Watch for container size changes
this.resizeObserver = new ResizeObserver(() => this.handleResize());
this.resizeObserver.observe(this.container);
}
public initialize(): void {
if (this.hasInteracted) {
// Restore previous transform if user had interacted
// Also cache SVG dimensions for resize calculations
this.tryResizeContainerToFitSvg();
this.applyTransform();
} else {
this.centerContent();
}
}
public getState(): PanZoomState {
return {
scale: this.scale,
translate: { x: this.translate.x, y: this.translate.y },
hasInteracted: this.hasInteracted,
customHeight: this.customHeight,
};
}
public dispose(): void {
this.abortController.abort();
this.resizeObserver.disconnect();
}
private setupEventListeners(): void {
const signal = this.abortController.signal;
this.container.addEventListener('mousedown', e => this.handleMouseDown(e), { signal });
document.addEventListener('mousemove', e => this.handleMouseMove(e), { signal });
document.addEventListener('mouseup', () => this.handleMouseUp(), { signal });
this.container.addEventListener('click', e => this.handleClick(e), { signal });
this.container.addEventListener('wheel', e => this.handleWheel(e), { passive: false, signal });
this.container.addEventListener('mousemove', e => this.updateCursor(e), { signal });
this.container.addEventListener('mouseenter', e => this.updateCursor(e), { signal });
window.addEventListener('keydown', e => this.handleKeyChange(e), { signal });
window.addEventListener('keyup', e => this.handleKeyChange(e), { signal });
}
private createZoomControls(): void {
const signal = this.abortController.signal;
const controls = document.createElement('div');
controls.className = 'mermaid-zoom-controls';
if (this.showControls === ShowControlsMode.OnHoverOrFocus) {
controls.classList.add('mermaid-zoom-controls-auto-hide');
}
controls.innerHTML = `
<button class="pan-mode-btn" title="Toggle Pan Mode" aria-label="Toggle Pan Mode" aria-pressed="false"><span class="codicon codicon-move" aria-hidden="true"></span></button>
<button class="zoom-out-btn" title="Zoom Out" aria-label="Zoom Out"><span class="codicon codicon-zoom-out" aria-hidden="true"></span></button>
<button class="zoom-in-btn" title="Zoom In" aria-label="Zoom In"><span class="codicon codicon-zoom-in" aria-hidden="true"></span></button>
<button class="zoom-reset-btn" title="Reset Zoom" aria-label="Reset Zoom"><span class="codicon codicon-screen-normal" aria-hidden="true"></span></button>
`;
this.panModeButton = controls.querySelector('.pan-mode-btn');
this.panModeButton?.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.togglePanMode();
}, { signal });
controls.querySelector('.zoom-in-btn')?.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.zoomIn();
}, { signal });
controls.querySelector('.zoom-out-btn')?.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.zoomOut();
}, { signal });
controls.querySelector('.zoom-reset-btn')?.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.reset();
}, { signal });
this.container.appendChild(controls);
}
private createResizeHandle(): HTMLElement {
const signal = this.abortController.signal;
const resizeHandle = document.createElement('div');
resizeHandle.className = 'mermaid-resize-handle';
resizeHandle.title = 'Drag to resize';
resizeHandle.tabIndex = 0;
resizeHandle.setAttribute('role', 'separator');
resizeHandle.setAttribute('aria-label', 'Resize Mermaid Diagram');
resizeHandle.setAttribute('aria-orientation', 'horizontal');
resizeHandle.addEventListener('mousedown', e => {
e.preventDefault();
e.stopPropagation();
this.isResizing = true;
this.resizeStartY = e.clientY;
this.resizeStartHeight = this.container.getBoundingClientRect().height;
document.body.style.cursor = 'ns-resize';
}, { signal });
document.addEventListener('mousemove', e => {
if (!this.isResizing) {
return;
}
// Check if mouse button was released outside the window
if (e.buttons === 0) {
this.isResizing = false;
document.body.style.cursor = '';
return;
}
const deltaY = e.clientY - this.resizeStartY;
this.resizeToHeight(this.resizeStartHeight + deltaY);
}, { signal });
resizeHandle.addEventListener('keydown', e => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') {
return;
}
e.preventDefault();
e.stopPropagation();
const currentHeight = this.customHeight ?? this.container.getBoundingClientRect().height;
const direction = e.key === 'ArrowUp' ? -1 : 1;
const step = e.shiftKey ? 50 : 10;
this.resizeToHeight(currentHeight + direction * step);
}, { signal });
document.addEventListener('mouseup', () => {
if (this.isResizing) {
this.isResizing = false;
document.body.style.cursor = '';
}
}, { signal });
return resizeHandle;
}
private togglePanMode(): void {
this.panModeEnabled = !this.panModeEnabled;
this.panModeButton?.classList.toggle('active', this.panModeEnabled);
this.panModeButton?.setAttribute('aria-pressed', String(this.panModeEnabled));
this.setCursor(false, false);
}
private resizeToHeight(height: number): void {
const newHeight = Math.max(100, height);
this.container.style.height = `${newHeight}px`;
this.customHeight = newHeight;
}
private handleKeyChange(e: KeyboardEvent): void {
if ((e.key === 'Alt' || e.key === 'Shift') && !this.isPanning) {
e.preventDefault();
this.setCursor(e.altKey, e.shiftKey);
}
}
private updateCursor(e: MouseEvent): void {
if (!this.isPanning) {
this.setCursor(e.altKey, e.shiftKey);
}
}
private setCursor(altKey: boolean, shiftKey: boolean): void {
// Pan mode always shows grab cursor
if (this.panModeEnabled) {
this.container.style.cursor = 'grab';
return;
}
if (this.clickDrag === ClickDragMode.Alt) {
// In Alt mode: default cursor normally, grab when alt is pressed
if (altKey && shiftKey) {
this.container.style.cursor = 'zoom-out';
} else if (altKey) {
this.container.style.cursor = 'grab';
} else {
this.container.style.cursor = 'default';
}
} else {
// In Always/Never mode: use grab cursor with zoom modifiers
if (altKey && !shiftKey) {
this.container.style.cursor = 'zoom-in';
} else if (altKey && shiftKey) {
this.container.style.cursor = 'zoom-out';
} else {
this.container.style.cursor = 'grab';
}
}
}
private handleClick(e: MouseEvent): void {
if (!e.altKey || this.hasDragged) {
return;
}
e.preventDefault();
e.stopPropagation();
const rect = this.container.getBoundingClientRect();
const factor = e.shiftKey ? 0.8 : 1.25;
this.zoomAtPoint(factor, e.clientX - rect.left, e.clientY - rect.top);
}
private handleWheel(e: WheelEvent): void {
const isPinchZoom = e.ctrlKey;
if (isPinchZoom || e.altKey) {
e.preventDefault();
e.stopPropagation();
const rect = this.container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Pinch gestures report smaller deltaY values than scroll wheel,
// so we apply a multiplier to make them feel equally sensitive
const pinchMultiplier = isPinchZoom ? 10 : 1;
const delta = -e.deltaY * zoomFactor * pinchMultiplier;
const newScale = Math.min(maxScale, Math.max(minScale, this.scale * (1 + delta)));
const scaleFactor = newScale / this.scale;
this.translate = {
x: mouseX - (mouseX - this.translate.x) * scaleFactor,
y: mouseY - (mouseY - this.translate.y) * scaleFactor,
};
this.scale = newScale;
this.applyTransform();
this.hasInteracted = true;
}
}
private handleMouseDown(e: MouseEvent): void {
if (e.button !== 0) {
return;
}
// Check if panning is allowed based on clickDrag mode or pan mode
if (!this.panModeEnabled && this.clickDrag === ClickDragMode.Alt && !e.altKey) {
return;
}
e.preventDefault();
e.stopPropagation();
this.isPanning = true;
this.hasDragged = false;
this.startX = e.clientX - this.translate.x;
this.startY = e.clientY - this.translate.y;
this.container.style.cursor = 'grabbing';
}
private handleMouseMove(e: MouseEvent): void {
if (!this.isPanning) {
return;
}
if (e.buttons === 0) {
this.handleMouseUp();
return;
}
const dx = e.clientX - this.startX - this.translate.x;
const dy = e.clientY - this.startY - this.translate.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
this.hasDragged = true;
}
this.translate = {
x: e.clientX - this.startX,
y: e.clientY - this.startY,
};
this.applyTransform();
}
private handleMouseUp(): void {
if (this.isPanning) {
this.isPanning = false;
this.setCursor(false, false);
this.hasInteracted = true;
}
}
private applyTransform(): void {
this.content.style.transform = `translate(${this.translate.x}px, ${this.translate.y}px) scale(${this.scale})`;
}
private handleResize(): void {
if (this.hasInteracted) {
// Try to preserve the visible content as percentage of the SVG,
// then restore that same percentage after resize.
// Step 1: Calculate which SVG point is at the viewport's top-left (0, 0)
const svgAtOriginX = -this.translate.x / this.scale;
const svgAtOriginY = -this.translate.y / this.scale;
// Express as percentage of old SVG dimensions
const percentX = this.lastSvgSize.width > 0 ? svgAtOriginX / this.lastSvgSize.width : 0;
const percentY = this.lastSvgSize.height > 0 ? svgAtOriginY / this.lastSvgSize.height : 0;
// Step 2: Resize and update cached SVG dimensions
if (!this.tryResizeContainerToFitSvg()) {
return;
}
// Step 3: Calculate new translate to keep the same percentage at origin
const newSvgAtOriginX = percentX * this.lastSvgSize.width;
const newSvgAtOriginY = percentY * this.lastSvgSize.height;
this.translate = {
x: -newSvgAtOriginX * this.scale,
y: -newSvgAtOriginY * this.scale,
};
this.applyTransform();
} else {
this.centerContent();
}
}
/**
* Resizes the container's height and updates cached SVG dimensions.
*
* This will try to preserve the current height if the user has resized the diagram. Otherwise
* it will resize to fit the SVG's intrinsic height.
*
* Updates `lastSvgSize` with the SVG's intrinsic dimensions.
*
* @returns true if the SVG was found and dimensions were updated, false otherwise.
*/
private tryResizeContainerToFitSvg(): boolean {
const svg = this.content.querySelector('svg');
if (!svg) {
return false;
}
svg.removeAttribute('height');
// Get the intrinsic size
const oldTransform = this.content.style.transform;
this.content.style.transform = 'none';
const rect = svg.getBoundingClientRect();
this.content.style.transform = oldTransform;
// Update cache
this.lastSvgSize = { width: rect.width, height: rect.height };
// Use custom height if set, otherwise use the SVG's intrinsic height
const containerHeight = this.customHeight ?? this.lastSvgSize.height;
this.container.style.height = `${containerHeight}px`;
return true;
}
private centerContent(): void {
if (!this.tryResizeContainerToFitSvg()) {
return;
}
// Start at scale 1, centered
this.scale = 1;
const containerRect = this.container.getBoundingClientRect();
this.translate = {
x: (containerRect.width - this.lastSvgSize.width) / 2,
y: 0,
};
this.applyTransform();
}
public reset(): void {
this.scale = 1;
this.translate = { x: 0, y: 0 };
this.hasInteracted = false;
this.customHeight = undefined;
this.centerContent();
}
public zoomIn(): void {
const rect = this.container.getBoundingClientRect();
this.zoomAtPoint(1.25, rect.width / 2, rect.height / 2);
}
public zoomOut(): void {
const rect = this.container.getBoundingClientRect();
this.zoomAtPoint(0.8, rect.width / 2, rect.height / 2);
}
private zoomAtPoint(factor: number, x: number, y: number): void {
const newScale = Math.min(maxScale, Math.max(minScale, this.scale * factor));
const scaleFactor = newScale / this.scale;
this.translate = {
x: x - (x - this.translate.x) * scaleFactor,
y: y - (y - this.translate.y) * scaleFactor,
};
this.scale = newScale;
this.applyTransform();
this.hasInteracted = true;
}
}
function sanitizeCssLength(value: string): string {
try {
const parsed = CSSNumericValue.parse(value);
return parsed.toString();
} catch {
return '';
}
}
@@ -0,0 +1,104 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
@import '@vscode/codicons/dist/codicon.css';
.mermaid-zoom-controls {
position: absolute;
top: 4px;
right: 4px;
display: flex;
width: auto;
gap: 2px;
z-index: 100;
background: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border, transparent);
border-radius: 6px;
padding: 2px;
}
.mermaid-zoom-controls-auto-hide {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.mermaid-wrapper:hover .mermaid-zoom-controls-auto-hide,
.mermaid-wrapper:focus .mermaid-zoom-controls-auto-hide,
.mermaid-wrapper:focus-within .mermaid-zoom-controls-auto-hide {
opacity: 1;
}
.mermaid-zoom-controls button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
color: var(--vscode-icon-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
}
.mermaid-zoom-controls button:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.mermaid-zoom-controls button.active {
background: var(--vscode-toolbar-activeBackground);
color: var(--vscode-focusBorder);
}
.mermaid-wrapper {
position: relative;
width: 100%;
overflow: hidden;
border: 1px solid transparent;
border-radius: 4px;
transition: border-color 0.2s ease-in-out;
margin-bottom: 1em;
}
.mermaid-wrapper:hover,
.mermaid-wrapper:focus,
.mermaid-wrapper:focus-within {
border-color: var(--vscode-editorWidget-border);
}
.mermaid-content {
transform-origin: 0 0;
}
.mermaid-resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 10px;
cursor: ns-resize;
background: transparent;
z-index: 50;
}
.mermaid-resize-handle::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.4));
border-radius: 2px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
transition-delay: 0s;
}
.mermaid-resize-handle:hover::after {
opacity: 1;
transition-delay: 0.3s;
}
@@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export interface IDisposable {
dispose(): void;
}
@@ -0,0 +1,14 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const iconPacks = [
{
name: 'logos',
loader: () => import('@iconify-json/logos').then(m => m.icons),
},
{
name: 'mdi',
loader: () => import('@iconify-json/mdi').then(m => m.icons),
},
];
@@ -0,0 +1,172 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import elkLayouts from '@mermaid-js/layout-elk';
import tidyTreeLayouts from '@mermaid-js/layout-tidy-tree';
import zenuml from '@mermaid-js/mermaid-zenuml';
import mermaid, { MermaidConfig } from 'mermaid';
import { iconPacks } from './iconPackConfig';
import { ClickDragMode, MermaidExtensionConfig, ShowControlsMode } from './config';
function renderMermaidElement(
mermaidContainer: HTMLElement,
usedIds: Set<string>,
writeOut: (mermaidContainer: HTMLElement, content: string) => void,
signal?: AbortSignal,
): {
containerId: string;
contentHash: string;
p: Promise<void>;
} | undefined {
const source = (mermaidContainer.textContent ?? '').trim();
if (!source) {
return;
}
const contentHash = hashString(source);
const containerId = generateContentId(source, usedIds);
const diagramId = `d${containerId}`;
mermaidContainer.id = containerId;
mermaidContainer.dataset.vscodeContext = JSON.stringify({
webviewSection: 'mermaid',
mermaidSource: source,
});
mermaidContainer.innerHTML = '';
return {
containerId,
contentHash,
p: (async () => {
try {
// Catch any parsing errors
await mermaid.parse(source);
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
// Render the diagram
const renderResult = await mermaid.render(diagramId, source);
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
writeOut(mermaidContainer, renderResult.svg);
renderResult.bindFunctions?.(mermaidContainer);
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
const errorMessageNode = document.createElement('pre');
errorMessageNode.className = 'mermaid-error';
errorMessageNode.innerText = error.message;
writeOut(mermaidContainer, errorMessageNode.outerHTML);
}
throw error;
}
})()
};
}
export async function renderMermaidBlocksInElement(
root: HTMLElement,
writeOut: (mermaidContainer: HTMLElement, content: string, contentHash: string) => void,
signal?: AbortSignal
): Promise<void> {
// Track used IDs for this render pass
const usedIds = new Set<string>();
// Delete existing mermaid outputs
for (const el of root.querySelectorAll('.mermaid > svg')) {
el.remove();
}
for (const svg of root.querySelectorAll('svg')) {
if (svg.parentElement?.id.startsWith('dmermaid')) {
svg.parentElement.remove();
}
}
// We need to generate all the container ids sync, but then do the actual rendering async
const renderPromises: Array<Promise<void>> = [];
for (const mermaidContainer of root.querySelectorAll<HTMLElement>('.mermaid')) {
const result = renderMermaidElement(mermaidContainer, usedIds, (container, content) => {
writeOut(container, content, result!.contentHash);
}, signal);
if (result) {
renderPromises.push(result.p);
}
}
await Promise.all(renderPromises);
}
export async function registerMermaidAddons() {
mermaid.registerIconPacks(iconPacks);
mermaid.registerLayoutLoaders(elkLayouts);
mermaid.registerLayoutLoaders(tidyTreeLayouts);
await mermaid.registerExternalDiagrams([zenuml]);
}
const defaultConfig: MermaidExtensionConfig = {
darkModeTheme: 'dark',
lightModeTheme: 'default',
maxTextSize: 50000,
clickDrag: ClickDragMode.Alt,
showControls: ShowControlsMode.OnHoverOrFocus,
resizable: true,
maxHeight: '',
};
export function loadExtensionConfig(): MermaidExtensionConfig {
const configSpan = document.getElementById('markdown-mermaid');
const configAttr = configSpan?.dataset.config;
if (!configAttr) {
return defaultConfig;
}
try {
return { ...defaultConfig, ...JSON.parse(configAttr) };
} catch {
return defaultConfig;
}
}
export function loadMermaidConfig(): MermaidConfig {
const config = loadExtensionConfig();
return {
startOnLoad: false,
theme: (document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast')
? config.darkModeTheme
: config.lightModeTheme) as MermaidConfig['theme'],
};
}
/**
* Generate a simple hash from a string for content-based IDs.
* Uses a fast non-cryptographic hash suitable for deduplication.
*/
function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
// Convert to hex and ensure positive
return (hash >>> 0).toString(16).padStart(8, '0');
}
function generateContentId(source: string, usedIds: Set<string>): string {
const hash = hashString(source);
let id = `mermaid-${hash}`;
let counter = 0;
// Handle collisions by appending a counter
while (usedIds.has(id)) {
counter++;
id = `mermaid-${hash}-${counter}`;
}
usedIds.add(id);
return id;
}
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { MermaidEditorManager } from './editorManager';
import { MermaidWebviewManager } from './webviewManager';
import { MermaidCommandContext, MermaidWebviewManager } from './webviewManager';
import { escapeHtmlText } from './util/html';
import { generateUuid } from './util/uuid';
import { disposeAll } from './util/dispose';
@@ -17,7 +17,7 @@ const mime = 'text/vnd.mermaid';
/**
* View type that uniquely identifies the Mermaid chat output renderer.
*/
const viewType = 'vscode.chat-mermaid-features.chatOutputItem';
const viewType = 'vscode.mermaid-markdown-features.chatOutputItem';
class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer {
@@ -43,7 +43,7 @@ class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer {
// Listen for messages from the webview
disposables.push(webview.onDidReceiveMessage(message => {
if (message.type === 'openInEditor') {
vscode.commands.executeCommand('_mermaid-chat.openInEditor', { mermaidWebviewId: webviewId });
void vscode.commands.executeCommand('_mermaid-markdown.openInEditor', { mermaidWebviewId: webviewId });
}
}));
@@ -63,6 +63,7 @@ class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer {
const nonce = generateUuid();
const mermaidScript = vscode.Uri.joinPath(mediaRoot, 'index.js');
const codiconsUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'codicon.css'));
const openInEditorLabel = vscode.l10n.t('Open Diagram in Editor');
webview.html = `
<!DOCTYPE html>
@@ -103,7 +104,8 @@ class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer {
opacity: 0;
transition: opacity 0.2s;
}
body:hover .open-in-editor-btn {
body:hover .open-in-editor-btn,
.open-in-editor-btn:focus {
opacity: 1;
}
.open-in-editor-btn:hover {
@@ -114,7 +116,7 @@ class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer {
</head>
<body data-vscode-context='${JSON.stringify({ preventDefaultContextMenuItems: true, mermaidWebviewId: webviewId })}' data-vscode-mermaid-webview-id="${webviewId}">
<button class="open-in-editor-btn" title="${vscode.l10n.t('Open in Editor')}"><i class="codicon codicon-open-preview"></i></button>
<button class="open-in-editor-btn" title="${openInEditorLabel}" aria-label="${openInEditorLabel}"><i class="codicon codicon-open-preview" aria-hidden="true"></i></button>
<pre class="mermaid">
${escapeHtmlText(mermaidSource)}
</pre>
@@ -134,7 +136,12 @@ export function registerChatSupport(
const disposables: vscode.Disposable[] = [];
disposables.push(
vscode.commands.registerCommand('_mermaid-chat.openInEditor', (ctx?: { mermaidWebviewId?: string }) => {
vscode.commands.registerCommand('_mermaid-markdown.openInEditor', (ctx?: MermaidCommandContext) => {
if (typeof ctx?.mermaidSource === 'string') {
editorManager.openPreview(ctx.mermaidSource, typeof ctx.title === 'string' ? ctx.title : undefined);
return;
}
const webviewInfo = ctx?.mermaidWebviewId ? webviewManager.getWebview(ctx.mermaidWebviewId) : webviewManager.activeWebview;
if (webviewInfo) {
editorManager.openPreview(webviewInfo.mermaidSource, webviewInfo.title);
@@ -8,7 +8,7 @@ import { MermaidWebviewManager } from './webviewManager';
import { escapeHtmlText } from './util/html';
import { Disposable } from './util/dispose';
export const mermaidEditorViewType = 'vscode.chat-mermaid-features.preview';
export const mermaidEditorViewType = 'vscode.mermaid-markdown-features.preview';
interface MermaidPreviewState {
readonly webviewId: string;
@@ -211,6 +211,10 @@ class MermaidPreview extends Disposable {
const codiconsUri = this._webviewPanel.webview.asWebviewUri(
vscode.Uri.joinPath(mediaRoot, 'codicon.css')
);
const togglePanModeLabel = vscode.l10n.t('Toggle Pan Mode');
const zoomOutLabel = vscode.l10n.t('Zoom Out');
const zoomInLabel = vscode.l10n.t('Zoom In');
const resetPanZoomLabel = vscode.l10n.t('Reset Pan and Zoom');
return /* html */`<!DOCTYPE html>
<html lang="en">
@@ -265,13 +269,18 @@ class MermaidPreview extends Disposable {
.zoom-controls button:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.zoom-controls button.active {
background: var(--vscode-toolbar-activeBackground);
color: var(--vscode-focusBorder);
}
</style>
</head>
<body data-vscode-context='${JSON.stringify({ preventDefaultContextMenuItems: true, mermaidWebviewId: this.diagramId })}' data-vscode-mermaid-webview-id="${this.diagramId}">
<div class="zoom-controls">
<button class="zoom-out-btn" title="${vscode.l10n.t('Zoom Out')}"><i class="codicon codicon-zoom-out"></i></button>
<button class="zoom-in-btn" title="${vscode.l10n.t('Zoom In')}"><i class="codicon codicon-zoom-in"></i></button>
<button class="zoom-reset-btn" title="${vscode.l10n.t('Reset Zoom')}"><i class="codicon codicon-screen-normal"></i></button>
<button class="pan-mode-btn" title="${togglePanModeLabel}" aria-label="${togglePanModeLabel}" aria-pressed="false"><i class="codicon codicon-move" aria-hidden="true"></i></button>
<button class="zoom-out-btn" title="${zoomOutLabel}" aria-label="${zoomOutLabel}"><i class="codicon codicon-zoom-out" aria-hidden="true"></i></button>
<button class="zoom-in-btn" title="${zoomInLabel}" aria-label="${zoomInLabel}"><i class="codicon codicon-zoom-in" aria-hidden="true"></i></button>
<button class="zoom-reset-btn" title="${resetPanZoomLabel}" aria-label="${resetPanZoomLabel}"><i class="codicon codicon-screen-normal" aria-hidden="true"></i></button>
</div>
<pre class="mermaid">
${escapeHtmlText(this._mermaidSource)}
@@ -0,0 +1,61 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { registerChatSupport } from './chatOutputRenderer';
import { MermaidEditorManager } from './editorManager';
import { configSection, injectMermaidConfig } from './markdownMermaid/config';
import { extendMarkdownItWithMermaid } from './markdownMermaid/markdownIt';
import { MermaidCommandContext, MermaidWebviewManager } from './webviewManager';
import type MarkdownIt from 'markdown-it';
export function activate(context: vscode.ExtensionContext) {
const webviewManager = new MermaidWebviewManager();
const editorManager = new MermaidEditorManager(context.extensionUri, webviewManager);
context.subscriptions.push(editorManager);
// Register chat support
context.subscriptions.push(registerChatSupport(context, webviewManager, editorManager));
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand('_mermaid-markdown.resetPanZoom', (ctx?: { mermaidWebviewId?: string }) => {
webviewManager.resetPanZoom(ctx?.mermaidWebviewId);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('_mermaid-markdown.copySource', (ctx?: MermaidCommandContext) => {
if (typeof ctx?.mermaidSource === 'string') {
void vscode.env.clipboard.writeText(ctx.mermaidSource);
return;
}
const webviewInfo = ctx?.mermaidWebviewId ? webviewManager.getWebview(ctx.mermaidWebviewId) : webviewManager.activeWebview;
if (webviewInfo) {
void vscode.env.clipboard.writeText(webviewInfo.mermaidSource);
}
})
);
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(`${configSection}.languages`)) {
void vscode.commands.executeCommand('markdown.api.reloadPlugins');
}
if (e.affectsConfiguration(configSection) || e.affectsConfiguration('workbench.colorTheme')) {
void vscode.commands.executeCommand('markdown.preview.refresh');
}
}));
return {
extendMarkdownIt(md: MarkdownIt) {
extendMarkdownItWithMermaid(md, {
languageIds: () => vscode.workspace.getConfiguration(configSection).get<readonly string[]>('languages', ['mermaid'])
});
md.use(injectMermaidConfig);
return md;
}
};
}
@@ -0,0 +1,59 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import type MarkdownIt from 'markdown-it';
export const configSection = 'markdown-mermaid';
const enum ClickDragMode {
Alt = 'alt'
}
const enum ShowControlsMode {
OnHoverOrFocus = 'onHoverOrFocus'
}
const defaultMermaidTheme = 'default';
const validMermaidThemes = [
'base',
'forest',
'dark',
'default',
'neutral',
];
function sanitizeMermaidTheme(theme: string | undefined): string {
return typeof theme === 'string' && validMermaidThemes.includes(theme) ? theme : defaultMermaidTheme;
}
export function injectMermaidConfig(md: MarkdownIt): MarkdownIt {
const render = md.renderer.render;
md.renderer.render = function (...args) {
const config = vscode.workspace.getConfiguration(configSection);
const configData = {
darkModeTheme: sanitizeMermaidTheme(config.get('darkModeTheme')),
lightModeTheme: sanitizeMermaidTheme(config.get('lightModeTheme')),
maxTextSize: config.get('maxTextSize'),
clickDrag: config.get('mouseNavigation.enabled', ClickDragMode.Alt),
showControls: config.get('controls.show', ShowControlsMode.OnHoverOrFocus),
resizable: config.get('resizable', true),
maxHeight: config.get('maxHeight', ''),
};
const escapedConfig = escapeHtmlAttribute(JSON.stringify(configData));
return `<span id="${configSection}" aria-hidden="true" data-config="${escapedConfig}"></span>
${render.apply(md.renderer, args)}`;
};
return md;
}
function escapeHtmlAttribute(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
@@ -0,0 +1,152 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type MarkdownIt from 'markdown-it';
const mermaidLanguageId = 'mermaid';
const containerTokenName = 'mermaidContainer';
const minMarkers = 3;
const markerStr = ':';
const markerChar = markerStr.charCodeAt(0);
const markerLen = markerStr.length;
/**
* Extends markdown-it so that it can render mermaid diagrams.
*
* This does not actually implement rendering of mermaid diagrams. Instead we just make sure that mermaid
* block syntax is properly parsed by markdown-it. All actual mermaid rendering happens in the webview
* where the markdown is rendered.
*/
export function extendMarkdownItWithMermaid(md: MarkdownIt, config: { languageIds(): readonly string[] }): MarkdownIt {
md.use((md: MarkdownIt) => {
function container(state: MarkdownIt.StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
let pos: number;
let autoClosed = false;
let start = state.bMarks[startLine] + state.tShift[startLine];
let max = state.eMarks[startLine];
if (markerChar !== state.src.charCodeAt(start)) {
return false;
}
for (pos = start + 1; pos <= max; pos++) {
if (markerStr[(pos - start) % markerLen] !== state.src[pos]) {
break;
}
}
const markerCount = Math.floor((pos - start) / markerLen);
if (markerCount < minMarkers) {
return false;
}
pos -= (pos - start) % markerLen;
const markup = state.src.slice(start, pos);
const params = state.src.slice(pos, max);
if (params.trim().split(' ')[0].toLowerCase() !== mermaidLanguageId) {
return false;
}
if (silent) {
return true;
}
let nextLine = startLine;
for (; ;) {
nextLine++;
if (nextLine >= endLine) {
break;
}
start = state.bMarks[nextLine] + state.tShift[nextLine];
max = state.eMarks[nextLine];
if (start < max && state.sCount[nextLine] < state.blkIndent) {
break;
}
if (markerChar !== state.src.charCodeAt(start)) {
continue;
}
if (state.sCount[nextLine] - state.blkIndent >= 4) {
continue;
}
for (pos = start + 1; pos <= max; pos++) {
if (markerStr[(pos - start) % markerLen] !== state.src[pos]) {
break;
}
}
if (Math.floor((pos - start) / markerLen) < markerCount) {
continue;
}
pos -= (pos - start) % markerLen;
pos = state.skipSpaces(pos);
if (pos < max) {
continue;
}
autoClosed = true;
break;
}
const oldParent = state.parentType;
const oldLineMax = state.lineMax;
state.parentType = 'container' as MarkdownIt.StateBlock.ParentType;
state.lineMax = nextLine;
const containerToken = state.push(containerTokenName, 'div', 1);
containerToken.markup = markup;
containerToken.block = true;
containerToken.info = params;
containerToken.map = [startLine, nextLine];
containerToken.content = state.getLines(startLine + 1, nextLine, state.blkIndent, true);
state.parentType = oldParent;
state.lineMax = oldLineMax;
state.line = nextLine + (autoClosed ? 1 : 0);
return true;
}
md.block.ruler.before('fence', containerTokenName, container, {
alt: ['paragraph', 'reference', 'blockquote', 'list']
});
md.renderer.rules[containerTokenName] = (tokens: MarkdownIt.Token[], idx: number) => {
const token = tokens[idx];
const src = token.content;
return `<div class="${mermaidLanguageId}">${preProcess(src)}</div>`;
};
});
const highlight = md.options.highlight;
md.options.highlight = (code: string, lang: string, attrs: string) => {
const reg = new RegExp('\\b(' + config.languageIds().map(escapeRegExp).join('|') + ')\\b', 'i');
if (lang && reg.test(lang)) {
return `<pre class="${mermaidLanguageId}" style="all: unset;">${preProcess(code)}</pre>`;
}
return highlight?.(code, lang, attrs) ?? code;
};
return md;
}
function preProcess(source: string): string {
return source
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n+$/, '')
.trimStart();
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -12,6 +12,12 @@ export interface MermaidWebviewInfo {
readonly type: 'chat' | 'editor';
}
export interface MermaidCommandContext {
readonly mermaidWebviewId?: string;
readonly mermaidSource?: string;
readonly title?: string;
}
/**
* Manages all mermaid webviews (both chat output renderers and editor previews).
* Tracks the active webview and provides methods for interacting with webviews.