火芯のMD Editor 是一款纯前端实现的在线 Markdown 编辑器:无需后端、跨平台访问、支持实时预览与多种格式导出。
定位:写作 + 预览 + 导出一站式工具(MD / HTML / PNG / PDF)
技术栈:Vue 3 + Vite + Tailwind CSS
核心依赖:marked(解析)+ DOMPurify(净化)+ highlight.js(高亮)+ html2canvas(截图)+ jsPDF(PDF)+ Font Awesome(图标)
? 目录
- 核心功能
- 从旧版到重构版:变化点
- 工程结构
- 核心代码(App.vue 关键片段)
- 渲染链路:Markdown → 安全 HTML
- 代码高亮:在 DOM 更新后执行
- 本地图片:localimg:// + localStorage
- 同步滚动:按比例映射 scrollTop
- 导出能力:MD / HTML / PNG / PDF
- 样式体系:Tailwind 组件层 + markdown-body
核心功能
- ? 实时预览:左侧编辑、右侧预览,输入即渲染
- ? 同步滚动:编辑区与预览区滚动联动(避免“看着看着迷路”)
- ? 语法高亮:使用 highlight.js 对代码块自动着色
- ? 富工具栏:标题、列表、引用、代码块、链接、锚点、表格、图标/emoji、预格式化文本等快速插入
- ?️ 本地图片:支持选择文件与拖拽上传,并在渲染与导出时正确嵌入
- ? 多格式导出:MD / HTML / PNG / PDF
- ?️ 全屏写作:一键进入/退出全屏
从旧版到重构版:变化点
旧文 article-static-homepage-2026-typecho.md 解析的是“静态页面 + 多 JS 脚本 + 多 CSS 文件”的实现方式;而当前工程在保持“纯前端、可导出”的目标不变的前提下,做了几件更偏工程化的调整:
- 界面与交互组件化:用 Vue 3 统一状态与交互(菜单、弹窗、表单、预览区等),避免全局变量散落。
- 构建与样式体系升级:Vite 提供开发/构建能力,Tailwind 让组件样式收敛在同一套约束内。
- 安全渲染变为默认路径:渲染链路内置 DOMPurify,减少插入 HTML 时的 XSS 风险。
- “本地图片”成为一等公民:引入
localimg://协议 token + localStorage 存储,解决纯前端环境下的图片持久与导出问题。
工程结构
当前工程更接近一个“单页应用(SPA)”:
markdown-editor/
├── index.html
├── package.json
├── vite.config.js
├── tailwind.config.js
├── postcss.config.js
├── src/
│ ├── main.js # Vue 入口:挂载 App、引入全局样式/高亮/图标
│ ├── App.vue # 主要功能基本都在这里(编辑/预览/工具栏/导出/弹窗)
│ └── style.css # Tailwind + 自定义组件类 + markdown-body 排版
└── dist/ # 构建产物(vite build)其中 src/main.js 做的事非常集中:加载样式与依赖样式,然后挂载 App.vue。
核心代码(App.vue 关键片段)
下面代码均来自 src/App.vue(为便于阅读做了少量截取与分段),基本覆盖了该项目“渲染 / 高亮 / 本地图片 / 同步滚动 / 导出”的核心链路。
1) 核心依赖与状态
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
const LOCAL_IMAGE_PREFIX = 'localimg://';
const LOCAL_IMAGE_STORE_KEY = 'markdown_editor_local_images_v1';
const editor = ref(null);
const preview = ref(null);
const text = ref('');
const showPreview = ref(true);
const syncing = ref(false);
const isExportingPreview = ref(false);
const localImageStore = ref({});
marked.setOptions({ gfm: true, breaks: true });2) Markdown 渲染 + 安全净化(含本地图片解析)
const extractLocalImageToken = (href = '') => {
if (typeof href !== 'string') return '';
return href.startsWith(LOCAL_IMAGE_PREFIX) ? href.slice(LOCAL_IMAGE_PREFIX.length) : '';
};
const walkMarkedTokens = (tokens, visitor) => {
if (!Array.isArray(tokens)) return;
for (const token of tokens) {
if (!token || typeof token !== 'object') continue;
visitor(token);
for (const value of Object.values(token)) {
if (Array.isArray(value)) walkMarkedTokens(value, visitor);
else if (value && typeof value === 'object' && value.type) walkMarkedTokens([value], visitor);
}
}
};
const resolveLocalImagesInTokens = (tokens) => {
walkMarkedTokens(tokens, (token) => {
if (token.type !== 'image' || typeof token.href !== 'string') return;
const localToken = extractLocalImageToken(token.href);
if (!localToken) return;
const dataUrl = localImageStore.value[localToken];
if (dataUrl) token.href = dataUrl;
});
if (tokens?.links && typeof tokens.links === 'object') {
Object.values(tokens.links).forEach((linkDef) => {
if (!linkDef || typeof linkDef.href !== 'string') return;
const localToken = extractLocalImageToken(linkDef.href);
if (!localToken) return;
const dataUrl = localImageStore.value[localToken];
if (dataUrl) linkDef.href = dataUrl;
});
}
};
const markdownToSanitizedHtml = (markdownText) => {
const tokens = marked.lexer(markdownText || '');
resolveLocalImagesInTokens(tokens);
return DOMPurify.sanitize(marked.parser(tokens));
};3) 本地图片:持久化 + 导出时还原
const loadLocalImageStore = () => {
try {
const raw = localStorage.getItem(LOCAL_IMAGE_STORE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
};
const saveLocalImageStore = () => {
try {
localStorage.setItem(LOCAL_IMAGE_STORE_KEY, JSON.stringify(localImageStore.value));
} catch {
// Ignore quota and storage errors to avoid interrupting editing.
}
};
const resolveLocalImagesInMarkdownSource = (markdownText) => {
const lines = (markdownText || '').split('\n');
let inFence = false;
const imagePattern = /!\[([^\]]*)\]\((localimg:\/\/[a-zA-Z0-9_-]+)\)/g;
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
return line.replace(imagePattern, (full, altText, localHref) => {
const localToken = extractLocalImageToken(localHref);
const dataUrl = localImageStore.value[localToken];
return dataUrl ? `` : full;
});
})
.join('\n');
};
const collectUsedLocalImageTokens = (markdownText) => {
const used = new Set();
const tokens = marked.lexer(markdownText || '');
walkMarkedTokens(tokens, (token) => {
if (token.type !== 'image' || typeof token.href !== 'string') return;
const localToken = extractLocalImageToken(token.href);
if (localToken) used.add(localToken);
});
if (tokens?.links && typeof tokens.links === 'object') {
Object.values(tokens.links).forEach((linkDef) => {
if (!linkDef || typeof linkDef.href !== 'string') return;
const localToken = extractLocalImageToken(linkDef.href);
if (localToken) used.add(localToken);
});
}
return used;
};
const cleanupUnusedLocalImages = () => {
const used = collectUsedLocalImageTokens(text.value);
const nextStore = {};
let changed = false;
Object.entries(localImageStore.value).forEach(([token, dataUrl]) => {
if (used.has(token)) nextStore[token] = dataUrl;
else changed = true;
});
if (changed) localImageStore.value = nextStore;
};
watch(
localImageStore,
() => {
saveLocalImageStore();
},
{ deep: true }
);
watch(text, () => {
if (localImageCleanupTimer) clearTimeout(localImageCleanupTimer);
localImageCleanupTimer = setTimeout(() => {
cleanupUnusedLocalImages();
}, 500);
});4) 语法高亮:DOM 更新后执行
const runHighlight = () => {
if (!preview.value) return;
preview.value.querySelectorAll('pre code').forEach((block) => hljs.highlightElement(block));
};
watch(previewHtml, async () => {
await nextTick();
runHighlight();
});5) 同步滚动:按比例映射(避免循环触发)
const onEditorScroll = () => {
if (!editor.value || !preview.value || syncing.value) return;
syncing.value = true;
const ratio =
editor.value.scrollHeight > editor.value.clientHeight
? editor.value.scrollTop / (editor.value.scrollHeight - editor.value.clientHeight)
: 0;
preview.value.scrollTop = ratio * (preview.value.scrollHeight - preview.value.clientHeight);
syncing.value = false;
};
const onPreviewScroll = () => {
if (!editor.value || !preview.value || syncing.value) return;
syncing.value = true;
const ratio =
preview.value.scrollHeight > preview.value.clientHeight
? preview.value.scrollTop / (preview.value.scrollHeight - preview.value.clientHeight)
: 0;
editor.value.scrollTop = ratio * (editor.value.scrollHeight - editor.value.clientHeight);
syncing.value = false;
};6) 导出:MD / HTML / PNG / PDF
const getDocTitle = () => {
const match = text.value.match(/^#+\s+(.+)$/m);
return match?.[1] ? match[1].trim().slice(0, 50) : 'document';
};
const makeFilename = (ext) => {
const raw = getDocTitle().replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_') || 'document';
const day = new Date().toISOString().slice(0, 10);
return `${raw}_${day}.${ext}`;
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportMd = () => {
const markdown = resolveLocalImagesInMarkdownSource(text.value);
downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), makeFilename('md'));
closeMenus();
};
const exportHtml = () => {
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${getDocTitle()}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px; padding: 36px 18px; line-height: 1.7; color: #1f2937; }
pre { background: #f1f5f9; padding: 14px; border-radius: 10px; overflow-x: auto; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #e2e8f0; padding: 8px 10px; }
</style>
</head>
<body>${renderedHtml.value}</body>
</html>`;
downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), makeFilename('html'));
closeMenus();
};
const ensurePreview = async () => {
if (!showPreview.value) {
showPreview.value = true;
await nextTick();
}
};
const capturePreviewCanvas = async () => {
if (!preview.value) return null;
const source = preview.value;
const clone = source.cloneNode(true);
clone.style.position = 'fixed';
clone.style.left = '-100000px';
clone.style.top = '0';
clone.style.width = `${source.clientWidth}px`;
clone.style.height = 'auto';
clone.style.maxHeight = 'none';
clone.style.minHeight = '0';
clone.style.overflow = 'visible';
clone.style.flex = 'none';
clone.style.backgroundColor = '#ffffff';
clone.style.zIndex = '-1';
document.body.appendChild(clone);
try {
await nextTick();
const width = Math.ceil(clone.scrollWidth || clone.clientWidth || source.clientWidth);
const height = Math.ceil(clone.scrollHeight || clone.clientHeight || source.clientHeight);
return await html2canvas(clone, {
backgroundColor: '#fff',
useCORS: true,
scale: 2,
width,
height,
windowWidth: width,
windowHeight: height,
scrollX: 0,
scrollY: 0
});
} finally {
clone.remove();
}
};
const exportPng = async () => {
isExportingPreview.value = true;
try {
await ensurePreview();
await nextTick();
const canvas = await capturePreviewCanvas();
if (!canvas) return;
canvas.toBlob((blob) => blob && downloadBlob(blob, makeFilename('png')));
} finally {
isExportingPreview.value = false;
closeMenus();
}
};
const exportPdf = async () => {
isExportingPreview.value = true;
try {
await ensurePreview();
await nextTick();
const canvas = await capturePreviewCanvas();
if (!canvas) return;
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = 210;
const pageHeight = 297;
const margin = 12;
const imgWidth = pageWidth - margin * 2;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = margin;
const imgData = canvas.toDataURL('image/png');
pdf.addImage(imgData, 'PNG', margin, position, imgWidth, imgHeight);
heightLeft -= pageHeight - margin * 2;
while (heightLeft > 0) {
position = margin - (imgHeight - heightLeft);
pdf.addPage();
pdf.addImage(imgData, 'PNG', margin, position, imgWidth, imgHeight);
heightLeft -= pageHeight - margin * 2;
}
pdf.save(makeFilename('pdf'));
} finally {
isExportingPreview.value = false;
closeMenus();
}
};渲染链路:Markdown → 安全 HTML
编辑器最重要的事情只有一件:把“用户输入的 Markdown”稳定、可控地渲染到预览区。
当前工程的渲染链路可以理解为三步:
marked.lexer():把 Markdown 转成 tokens- 可选增强:对 tokens 做二次处理(比如把
localimg://图片替换成 dataURL) marked.parser():把 tokens 转成 HTML,然后用DOMPurify.sanitize()清洗
代码逻辑(节选):
const markdownToSanitizedHtml = (markdownText) => {
const tokens = marked.lexer(markdownText || '');
resolveLocalImagesInTokens(tokens);
return DOMPurify.sanitize(marked.parser(tokens));
};为什么要 sanitize?
纯 Markdown 渲染里,用户仍可能通过某些方式产生可执行内容(尤其当你允许 HTML 片段时)。把 “sanitize” 固化在渲染函数中,相当于把安全策略做成默认能力,而不是“可选开关”。
小提示:当前工程里dompurify需要在依赖中可用;如果你打算把仓库当模板复用,建议确认已显式安装:npm i dompurify。
代码高亮:在 DOM 更新后执行
highlight.js 的高亮是“对现有 DOM 做增强”,因此正确时机应该是 预览 HTML 更新并渲染到页面之后。
当前实现思路是:
previewHtml变化时watch()触发await nextTick()等 Vue 把v-html更新到 DOM- 遍历
pre code执行hljs.highlightElement()
这种方式简单、稳定,并且不会把高亮逻辑掺进 Markdown 渲染本身。
本地图片:localimg:// + localStorage
纯前端编辑器里,本地图片是一个“绕不开”的难点:
- 你不能直接把本地文件路径写进 Markdown(浏览器安全限制)
- 你又希望图片能在刷新后仍可用(写作过程不能丢)
- 导出 MD/HTML/PNG/PDF 时还得能“带着图片走”
当前工程采用了一个很实用的折中方案:
- 插入图片时,把文件读成 dataURL(Base64)
- 把 dataURL 存入
localStorage的一张表里(key 是 token) - 在 Markdown 源码中插入
localimg://<token>作为引用 - 渲染/导出时,再把
localimg://替换回 dataURL
这套方案的两个关键点:
- 对渲染:用 token 替换 tokens 里的 image href,预览就能显示
- 对导出:导出 MD/HTML 时,把源码里的
localimg://统一替换成 dataURL,保证可移植性
同时工程还做了“垃圾回收”:
- 每次文本变化会延迟触发清理
- 扫描当前 Markdown 中出现过的 token,仅保留仍被引用的图片
这能显著降低 localStorage 被图片撑爆的风险。
同步滚动:按比例映射 scrollTop
同步滚动的核心是“比例映射”:
- 先算出 A 容器滚动进度
ratio - 再把这个
ratio映射到 B 容器的可滚动高度上
并且要避免互相触发导致的抖动/死循环,因此会引入一个 syncing 标志位。
这种实现相比“逐行定位”更通用:内容结构不同(渲染后高度变化)时也能保持基本一致的阅读位置。
导出能力:MD / HTML / PNG / PDF
1) 导出 MD:把本地图片还原成 dataURL
导出 MD 的关键不是写文件,而是“让导出的 Markdown 自包含”:
- 把
localimg://token替换为data:image/...;base64,... - 再下载为
.md
这样导出的文章丢到 Typecho / GitHub / 任何 Markdown 工具里,图片也不会失效(代价是文件会变大)。
2) 导出 HTML:生成一份可独立打开的页面
导出 HTML 会拼一份最简模板(meta、title、基础排版样式),把当前渲染后的 HTML 填进 <body>。
这类 HTML 的目标是“能双击打开就能看”,而不是完整复刻编辑器样式。
3) 导出 PNG:对预览区截图
PNG/PDF 的共同前置步骤是:保证预览区可见,并生成一张 canvas:
- 若预览区被隐藏,先打开预览并
nextTick() - 克隆预览 DOM 到屏幕外(避免影响当前布局)
- 用 html2canvas 渲染成 canvas(
scale: 2提升清晰度)
PNG 导出就是:canvas.toBlob() 然后下载。
4) 导出 PDF:同一张大图分页写入
PDF 的做法是:把 canvas 转成图片,然后用 jsPDF 按 A4 尺寸循环分页写入。
这种“整页截图 → 分页写入”的好处是简单,且能最大程度保持预览样式一致;缺点是 PDF 内文本不可选中(它本质是一张张图片)。
样式体系:Tailwind 组件层 + markdown-body
当前样式主要分两块:
- 编辑器 UI(工具栏/弹窗/按钮/布局):用 Tailwind 的
@layer components抽成一组语义化 class(例如toolbar-btn、editor-dialog等),保持复用与一致性。 - 预览排版(markdown-body):对标题、段落、列表、引用、表格、代码、图片等做统一的“文档风格”约束。
这样做的效果是:
- UI 组件样式不会和 Markdown 内容样式互相污染
- 预览区排版能稳定接近“文章阅读模式”,更适合直接截图/PDF 导出
如果你也在做纯前端写作工具,希望这篇“重构版解析”能让你更快看懂当前工程的关键取舍与实现路径。
评论区:
还没有评论,快来抢沙发吧!