简介:这是一款专为 VS Code 用户打造的 Markdown 增强插件,面向前端开发者、技术文档撰写者及轻量级内容创作者,旨在解决原生编辑器在可视化编辑、实时预览与富媒体支持方面的短板。插件提供 Typora 级别的流畅体验:支持表格可视化编辑、图片拖拽自动存入 assets 文件夹、KaTeX/Mermaid/Graphviz/ECharts/abc.js 等多格式图形渲染,并具备即时渲染、所见即所得(WYSIWYG)与分屏三模式切换,辅以多主题、快捷键与 HTML/Markdown 双向复制功能。资源包为 3.03MB 的 ZIP 文件,共含 30 个文件,涵盖核心逻辑(7 个 TypeScript、3 个 JavaScript)、配置管理(8 个 JSON、2 个 .gitignore、2 个 lock)、样式与构建(2 个 CSS、1 个 HTML、1 个 .babelrc、1 个 .map)、以及演示素材(1 个 PNG、1 个 GIF)和开源协议(LICENSE)。目前已有 2110 人学习下载,可直接导入 VS Code 使用,完整保留源码结构与开发配置,便于二次定制与深度理解插件工作机制。
1. 不装 Typora,也能在 VS Code 里获得「所见即所得」的 Markdown 编辑体验
你是否经历过这样的场景:写技术文档时反复切窗口预览、手动拼接路径、为对齐表格列数反复数空格、拖一张图进编辑器后发现路径错乱、想插入一个 ✅ 或 📊 图标却要查 Unicode 码表?Typora 的流畅感确实让人上瘾——但它不免费、Mac 版更新慢、Windows 下偶发闪退、激活机制也常被误判为异常行为。而 VS Code 本身是开源、可定制、插件生态成熟、且天然支持 Git、调试、多语言共存的开发环境。问题从来不是「VS Code 不够好」,而是「默认 Markdown 支持太原始」:它只渲染,不编辑;只解析,不交互;只显示,不组织。真正需要的,不是一个「模仿 Typora UI」的壳,而是一套可嵌入、可组合、可调试、可版本化的 Markdown 工作流增强方案——它必须原生兼容 VS Code 的编辑器架构(如 TextEditor、Webview、Custom Editor API),利用其底层能力实现表格可视化编辑、图片拖拽注入、图标快捷插入等高频操作,同时不破坏.md文件的纯文本本质和跨平台可读性。本文聚焦于当前最稳定、可复现、无依赖冲突的落地路径:以官方推荐的Custom Editor模式为核心,配合markdown-it渲染链与vscode-webview-ui-toolkit构建轻量级交互层,所有功能均基于标准 Markdown 语法扩展,导出 PDF/HTML 时无需额外工具链,也不引入任何非标准标记。
2. 用 Custom Editor API 实现真正的「可视化表格编辑」,而非简单渲染
VS Code 默认的 Markdown 预览仅是只读渲染,无法双击编辑单元格、拖动调整列宽、或实时同步修改到源码。要突破这一限制,必须绕过markdown.preview命令,转而注册一个Custom Editor——这是 VS Code 1.60+ 提供的正式 API,允许插件完全接管某类文件(如.md)的编辑视图,同时保留左侧源码编辑器与右侧可视化面板的并存能力。这种模式下,表格不再只是<table>标签的静态输出,而是可交互的 DOM 组件,其变更会自动反向生成符合 CommonMark 规范的 Markdown 表格语法。
2.1 注册 Custom Editor 并绑定 Markdown 文件类型
在插件package.json中声明 editor contribution:
{ "contributes": { "customEditors": [ { "viewType": "markdown-visual-editor", "displayName": "Markdown Visual Editor", "selector": [ { "filenamePattern": "*.md" } ], "priority": "default" } ] } }提示:
priority: "default"表示当用户右键.md文件选择「Open with...」时,默认启用该编辑器;若设为"option",则需手动选择。不要覆盖default编辑器,否则将影响其他插件(如Markdown All in One)的快捷键绑定。
2.2 在 Webview 中构建可编辑表格组件
核心逻辑在src/extension.ts中注册 editor provider:
import * as vscode from 'vscode'; export class MarkdownVisualEditorProvider implements vscode.CustomTextEditorProvider { public static register(context: vscode.ExtensionContext): vscode.Disposable { const provider = new MarkdownVisualEditorProvider(context); const providerRegistration = vscode.window.registerCustomEditorProvider( 'markdown-visual-editor', provider, { webviewOptions: { retainContextWhenHidden: true }, supportsMultipleEditorsPerDocument: false } ); return providerRegistration; } async resolveCustomTextEditor( document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): Promise<void> { webviewPanel.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'media')] }; // 注入初始 Markdown 内容(含表格) const content = document.getText(); webviewPanel.webview.html = this.getWebviewContent(webviewPanel.webview, content); } private getWebviewContent(webview: vscode.Webview, content: string): string { const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.context.extensionUri, 'media', 'editor.js') ); return ` <!DOCTYPE html> <html> <body> <div id="editor-root"></div> <script src="${scriptUri}"></script> </body> </html>`; } }2.2.1 表格解析与双向同步逻辑(media/editor.js)
使用markdown-it解析源码中的表格,并将其转换为可编辑的<table>结构:
// 使用 markdown-it-tables 插件解析表格块 const md = require('markdown-it')({ html: true, breaks: true, linkify: true }).use(require('markdown-it-tables')); // 将 Markdown 表格字符串转为二维数组 function parseTable(mdStr) { const tokens = md.parse(mdStr, {}); for (const token of tokens) { if (token.type === 'table_open') { const tableTokens = []; let row = []; for (let i = tokens.indexOf(token) + 1; i < tokens.length; i++) { const t = tokens[i]; if (t.type === 'tr_open') continue; if (t.type === 'th_close' || t.type === 'td_close') { row.push(t.content || ''); } if (t.type === 'tr_close') { tableTokens.push([...row]); row = []; } } return tableTokens; } } return []; } // 反向生成 Markdown 表格(严格对齐列数,补空格) function generateTableMarkdown(tableData) { if (!tableData.length) return ''; const cols = Math.max(...tableData.map(r => r.length)); const header = tableData[0].map((cell, i) => cell || '').join(' | '); const separator = '|'.repeat(cols).split('').map(() => '---').join('|'); const body = tableData.slice(1).map(row => row.map((cell, i) => cell || '').join(' | ') ).join('\n'); return `${header}\n${separator}\n${body}`; }注意:
markdown-it-tables是轻量级插件,仅处理标准表格语法(| A | B |),不依赖remark或unified生态,避免与 VS Code 内置 Markdown 解析器冲突。生成的 Markdown 字符串必须满足 CommonMark 对齐要求——每列分隔符|两侧需有空格,否则 VS Code 原生预览将无法识别。
2.3 表格编辑事件绑定与实时保存
监听<td>的contenteditable变更,并触发 VS Code 文档更新:
document.addEventListener('input', (e) => { if (e.target.tagName === 'TD' || e.target.tagName === 'TH') { const tableEl = e.target.closest('table'); const tableData = Array.from(tableEl.querySelectorAll('tr')).map(tr => Array.from(tr.querySelectorAll('td, th')).map(td => td.innerText.trim()) ); // 生成新 Markdown 表格 const newTableMd = generateTableMarkdown(tableData); // 替换原文档中对应表格块(通过正则定位,非 AST) const docText = editor.document.getText(); const tableRegex = /(\|[^\n]+\|\n\|[-:| ]+\|\n(?:\|[^\n]+\|\n?)*)/g; const match = tableRegex.exec(docText); if (match && match.index !== -1) { const newText = docText.substring(0, match.index) + newTableMd + docText.substring(match.index + match[0].length); // 执行编辑操作(非直接 setText,避免覆盖用户其他修改) const edit = new vscode.WorkspaceEdit(); edit.replace(editor.document.uri, new vscode.Range( editor.document.positionAt(match.index), editor.document.positionAt(match.index + match[0].length) ), newTableMd ); vscode.workspace.applyEdit(edit); } } });提示:此处使用正则匹配表格块而非 AST 解析,因 VS Code 当前不暴露完整的 Markdown AST 接口。正则
/(\|[^\n]+\|\n\|[-:| ]+\|\n(?:\|[^\n]+\|\n?)*)/g能准确捕获标准表格(含表头、分隔行、数据行),但要求用户输入时遵守基本格式——这是与 Typora 的关键差异:VS Code 插件不隐藏语法约束,而是强化规范意识。
3. 拖拽图片注入:从文件系统到相对路径的全自动转换
Typora 的拖拽图片功能之所以「丝滑」,在于它自动完成三件事:监听drop事件、将文件复制到指定目录(如./assets/)、将路径写入。VS Code 插件需复现这一闭环,但必须尊重工作区结构——不能硬编码assets/,而应读取用户配置的markdown.imageDir,并确保路径为相对于当前.md文件的路径,而非工作区根目录。
3.1 注册全局拖拽监听并拦截默认行为
在 Webview 的editor.js中添加:
document.addEventListener('dragover', (e) => { e.preventDefault(); // 必须阻止默认行为,否则触发浏览器下载 }); document.addEventListener('drop', async (e) => { e.preventDefault(); const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; // 获取当前文档 URI(用于计算相对路径) const docUri = await vscode.postMessage({ type: 'getDocumentUri' }); for (const file of files) { if (!file.type.startsWith('image/')) continue; // 读取文件二进制内容 const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); // 调用 VS Code API 将文件写入工作区 const targetPath = await vscode.postMessage({ type: 'saveImage', fileName: file.name, content: uint8Array, docUri: docUri }); // 插入 Markdown 图片语法 const relativePath = getRelativePath(docUri, targetPath); const insertText = ``; vscode.postMessage({ type: 'insertText', text: insertText }); } });3.2 在 Extension Host 中处理文件写入
extension.ts中响应saveImage消息:
webviewPanel.webview.onDidReceiveMessage( async (message) => { switch (message.type) { case 'saveImage': try { const docUri = vscode.Uri.parse(message.docUri); const workspaceFolder = vscode.workspace.getWorkspaceFolder(docUri); if (!workspaceFolder) throw new Error('No workspace folder'); // 读取用户配置的图片目录(默认 ./images) const imageDir = vscode.workspace.getConfiguration('markdown').get('imageDir', 'images'); const targetDirUri = vscode.Uri.joinPath(workspaceFolder.uri, imageDir); // 创建目录(递归) try { await vscode.workspace.fs.createDirectory(targetDirUri); } catch (e) { // 目录已存在,忽略 } const ext = path.extname(message.fileName); const safeName = `${Date.now()}-${message.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`; const targetUri = vscode.Uri.joinPath(targetDirUri, safeName); await vscode.workspace.fs.writeFile(targetUri, message.content); // 返回目标 URI 供 Webview 计算相对路径 webviewPanel.webview.postMessage({ type: 'imageSaved', uri: targetUri.toString() }); } catch (err) { vscode.window.showErrorMessage(`Failed to save image: ${err.message}`); } break; } }, undefined, context.subscriptions );3.2.1 计算相对于当前文件的路径(关键!)
getRelativePath函数必须精确计算:
function getRelativePath(docUri, targetUri) { const docPath = docUri.fsPath; const targetPath = targetUri.fsPath; // 使用 Node.js path.relative(注意:在 Webview 中不可用,需在 Extension Host 计算) // 因此实际逻辑应移至 extension.ts 中,在 writeFile 后立即计算并返回 // 此处仅为示意 return path.relative(path.dirname(docPath), targetPath).replace(/\\/g, '/'); }提示:VS Code 的
vscode.workspace.fsAPI 在 Webview 中不可用,所有文件系统操作必须在 Extension Host(即extension.ts)中执行。因此「拖拽 → 读取 → 写入 → 计算路径 → 插入」整个流程需跨进程通信,不能在前端直接调用fs.writeFileSync。这是与 Typora 本地应用的本质区别,也是保证安全性的必要设计。
3.3 用户可配置的图片目录与命名策略
在package.json中声明配置项:
"contributes": { "configuration": { "properties": { "markdown.imageDir": { "type": "string", "default": "images", "description": "Directory relative to the markdown file where dragged images are saved.", "scope": "resource" }, "markdown.imageNaming": { "type": "string", "enum": ["timestamp", "original", "sequential"], "default": "timestamp", "description": "How to name saved images.", "scope": "resource" } } } }用户可在工作区设置中修改:
// .vscode/settings.json { "markdown.imageDir": "static/img", "markdown.imageNaming": "original" }注意:
"scope": "resource"表示该配置可按文件夹单独设置,适合多项目混合工作区(如 docs/ 和 src/ 分开管理图片目录)。
4. 图标快捷插入:基于 Unicode 与 SVG 的双模支持
Typora 内置图标库(如:smile:)本质是 Emoji 替换,但 VS Code 插件需兼顾纯文本兼容性与视觉丰富性。最佳实践是提供两套机制:基础层用 Unicode Emoji(零依赖、全平台显示),增强层用内联 SVG(可缩放、可着色、支持图标字体),由用户按需切换。
4.1 构建可搜索的 Emoji 列表(Unicode 模式)
使用node-emoji库(轻量,仅 200KB)提供完整 Emoji 映射:
npm install node-emoji在 Webview 中加载 Emoji 数据:
import emoji from 'node-emoji'; // 生成搜索索引(按关键词分组) const emojiIndex = {}; Object.entries(emoji.emojilib).forEach(([code, data]) => { const keywords = data.k.split(' '); keywords.forEach(k => { if (!emojiIndex[k]) emojiIndex[k] = []; emojiIndex[k].push({ code, char: emoji.emojify(`:${code}:`) }); }); }); // 搜索函数 function searchEmoji(query) { const terms = query.toLowerCase().split(/\s+/); return terms.reduce((acc, term) => { const matches = emojiIndex[term] || []; return [...acc, ...matches]; }, []).filter((item, i, arr) => arr.findIndex(t => t.code === item.code) === i); }UI 层提供搜索框与网格展示:
<div class="emoji-search"> <input type="text" placeholder="Search emoji (e.g. 'smile', 'arrow')" id="emoji-search" /> <div id="emoji-results" class="emoji-grid"></div> </div>点击插入时直接写入 Unicode 字符:
document.getElementById('emoji-search').addEventListener('input', (e) => { const results = searchEmoji(e.target.value); const grid = document.getElementById('emoji-results'); grid.innerHTML = results.slice(0, 20).map(item => `<span class="emoji-item">// 内置图标定义(精简版) const svgIcons = { 'check': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>', 'alert': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>', 'code': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 13 16 8"></polyline><polyline points="8 18 2 13 8 8"></polyline></svg>' }; // 插入 SVG(自动包裹为 div,避免被 Markdown 解析器误处理) function insertSvgIcon(name) { const svg = svgIcons[name]; if (!svg) return; const wrapped = `<div class="inline-svg">${svg}</div>`; vscode.postMessage({ type: 'insertText', text: wrapped }); }提示:SVG 必须包裹在
<div>中并添加class="inline-svg",否则 VS Code 的 Markdown 渲染器可能将其当作 HTML 块处理,导致换行或样式错乱。CSS 中需定义.inline-svg { display: inline-block; vertical-align: middle; }。
5. 验证与调试:三步确认你的插件是否真正生效
安装插件后,不能仅凭 UI 外观判断功能完整性。以下验证步骤缺一不可,每一步失败都指向不同层级的问题:
5.1 检查 Custom Editor 是否被正确激活
打开任意.md文件,观察右下角状态栏:
- ✅ 正确状态:显示
Markdown Visual Editor(或你设定的displayName) - ❌ 错误状态:显示
Plain Text或Markdown Preview - 排查命令:在命令面板(Ctrl+Shift+P)输入
Developer: Toggle Developer Tools,查看 Console 是否报错Cannot find module 'markdown-it'(未安装依赖)或registerCustomEditorProvider failed(package.json配置错误)
5.2 表格编辑的双向同步验证表
| 操作 | 预期结果 | 检查点 |
|---|---|---|
| 在 Webview 表格中修改单元格内容 | 源码.md文件对应位置实时更新 | 查看文件未保存时的「脏标记」★ 是否出现 |
| 手动在源码中修改表格(如增删列) | Webview 表格自动重绘,列数/内容同步 | 切换回源码视图再切回 Webview,观察是否刷新 |
| 删除整行表格(源码中删掉 ` | --- | ` 行) |
5.3 拖拽图片路径的可靠性测试
创建测试目录结构:
my-project/ ├── README.md └── docs/ └── guide.md- ✅ 在
README.md中拖拽图片 → 应保存至my-project/images/xxx.png,插入 - ✅ 在
docs/guide.md中拖拽图片 → 应保存至my-project/docs/images/xxx.png,插入(注意:images/是相对于guide.md的路径) - ❌ 若插入
,说明getRelativePath计算错误,需检查path.relative()的参数顺序
5.4 图标插入的渲染兼容性检查
- Unicode Emoji:在 GitHub、GitLab、Obsidian 中打开同一
.md文件,确认 Emoji 正常显示(无需额外渲染) - SVG 图标:在 VS Code 内置预览中查看,确认
<div class="inline-svg">未被解析为文本,且图标居中对齐;导出为 HTML 时,检查<style>标签中是否注入了.inline-svg规则
提示:VS Code 的 Markdown 导出 PDF 功能(需
mdpdf插件)默认不渲染内联 SVG。若需 PDF 支持,应在导出前将 SVG 转为 Base64 图片,或改用pandoc工具链——这属于工作流延伸,不在本插件职责范围内。
本文还有配套的精品资源,点击获取