火芯のMD Editor(Vue3+Tailwind 重构版)

火芯のMD Editor 是一款纯前端实现的在线 Markdown 编辑器:无需后端、跨平台访问、支持实时预览与多种格式导出。

定位:写作 + 预览 + 导出一站式工具(MD / HTML / PNG / PDF)

技术栈:Vue 3 + Vite + Tailwind CSS

核心依赖:marked(解析)+ DOMPurify(净化)+ highlight.js(高亮)+ html2canvas(截图)+ jsPDF(PDF)+ Font Awesome(图标)


? 目录


核心功能

  • ? 实时预览:左侧编辑、右侧预览,输入即渲染
  • ? 同步滚动:编辑区与预览区滚动联动(避免“看着看着迷路”)
  • ? 语法高亮:使用 highlight.js 对代码块自动着色
  • ? 富工具栏:标题、列表、引用、代码块、链接、锚点、表格、图标/emoji、预格式化文本等快速插入
  • ?️ 本地图片:支持选择文件与拖拽上传,并在渲染与导出时正确嵌入
  • ? 多格式导出:MD / HTML / PNG / PDF
  • ?️ 全屏写作:一键进入/退出全屏

从旧版到重构版:变化点

旧文 article-static-homepage-2026-typecho.md 解析的是“静态页面 + 多 JS 脚本 + 多 CSS 文件”的实现方式;而当前工程在保持“纯前端、可导出”的目标不变的前提下,做了几件更偏工程化的调整:

  1. 界面与交互组件化:用 Vue 3 统一状态与交互(菜单、弹窗、表单、预览区等),避免全局变量散落。
  2. 构建与样式体系升级:Vite 提供开发/构建能力,Tailwind 让组件样式收敛在同一套约束内。
  3. 安全渲染变为默认路径:渲染链路内置 DOMPurify,减少插入 HTML 时的 XSS 风险。
  4. “本地图片”成为一等公民:引入 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 ? `![${altText}](${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”稳定、可控地渲染到预览区。

当前工程的渲染链路可以理解为三步:

  1. marked.lexer():把 Markdown 转成 tokens
  2. 可选增强:对 tokens 做二次处理(比如把 localimg:// 图片替换成 dataURL)
  3. 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 时还得能“带着图片走”

当前工程采用了一个很实用的折中方案:

  1. 插入图片时,把文件读成 dataURL(Base64)
  2. 把 dataURL 存入 localStorage 的一张表里(key 是 token)
  3. 在 Markdown 源码中插入 localimg://<token> 作为引用
  4. 渲染/导出时,再把 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

当前样式主要分两块:

  1. 编辑器 UI(工具栏/弹窗/按钮/布局):用 Tailwind 的 @layer components 抽成一组语义化 class(例如 toolbar-btneditor-dialog 等),保持复用与一致性。
  2. 预览排版(markdown-body):对标题、段落、列表、引用、表格、代码、图片等做统一的“文档风格”约束。

这样做的效果是:

  • UI 组件样式不会和 Markdown 内容样式互相污染
  • 预览区排版能稳定接近“文章阅读模式”,更适合直接截图/PDF 导出

如果你也在做纯前端写作工具,希望这篇“重构版解析”能让你更快看懂当前工程的关键取舍与实现路径。

评论区:

还没有评论,快来抢沙发吧!