mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-19 14:49:48 +01:00
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:
+3
-1
@@ -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
@@ -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/**',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
-1184
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
@@ -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),
|
||||
]);
|
||||
+3226
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."
|
||||
}
|
||||
+6
@@ -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());
|
||||
+21
-11
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
+13
-6
@@ -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);
|
||||
+13
-4
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n+$/, '')
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
+6
@@ -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.
|
||||
Reference in New Issue
Block a user