我做了一个排版器,也踩了一遍富文本复制的坑

欢迎访问我的个人网站 浮生·迹 使用排版功能!!!

维护微信公众号的人,大概都经历过这样的过程:文章内容已经写完,接下来还要调整标题字号、段落间距、引用样式、代码块和分隔线。单独看每一步都不难,但一篇文章从头调到尾,时间很快就过去了。

第三方排版工具提供了很多模板,不过不少主题和功能需要会员。我的公众号更新频率不算固定,为偶尔使用的模板长期付费并不合适,于是最初只想给自己做一个简单的排版页面:左边写 Markdown,右边实时预览,选好主题后复制到微信公众号编辑器。

真正开始实现以后,我发现这件事并不只是"给 HTML 加一份 CSS"。微信公众号最终接收的是从剪贴板粘贴进去的富文本,普通网页中依赖的样式表、类名和交互效果,不一定能跟着内容一起过去。

因此,这个模块真正需要解决的是三个问题:

  1. 怎样把 Markdown 转换成结构可控的 HTML;
  2. 怎样让不同主题共享渲染能力,又保留各自的视觉差异;
  3. 怎样把带样式的富文本可靠地写入剪贴板。

下面记录一下「趣排版」目前的实现思路。

整体流程

项目使用 Vue 3 和 Vite。编辑区通过 v-model 保存 Markdown 内容,右侧预览则由一个计算属性实时生成:

js 复制代码
const previewHtml = computed(() => {
  return renderWechatMarkdown(content.value, themeKey.value)
})

完整的数据流可以概括为:

text 复制代码
Markdown 原文
    ↓
markdown-it 解析
    ↓
自定义 Renderer 注入内联样式
    ↓
拼接文章外层和作者尾部
    ↓
以 text/html 写入剪贴板
    ↓
粘贴到微信公众号编辑器

这里没有直接把预览区域的 DOM 整块复制,而是先生成一份专门面向公众号的 HTML。这样可以把页面预览效果和最终复制内容分开处理,避免把按钮、动画或者编辑器自身的样式一并带过去。

使用 markdown-it 接管元素渲染

Markdown 解析使用的是 markdown-it。初始化时关闭原生 HTML,开启自动换行和链接识别:

js 复制代码
const markdown = new MarkdownIt({
  html: false,
  breaks: true,
  linkify: true,
  highlight: renderCode
})

关闭原生 HTML,是为了避免用户输入的任意标签直接进入预览区域。文章需要的样式统一由渲染器产生,这样输出结构更容易控制。

markdown-it 默认会把标题渲染成普通的 <h1><h2>。如果只在页面中给它们添加 class,浏览器预览没有问题,但复制到其他编辑器后,这些 class 背后的样式未必还在。

我的处理方式是重写对应的 renderer rule,在生成标签时直接写入内联样式:

js 复制代码
markdown.renderer.rules.heading_open = (tokens, index) => {
  const tag = tokens[index].tag

  if (tag === 'h2') {
    return `<h2 style="
      margin:32px 0 20px;
      padding-left:16px;
      border-left:4px solid ${theme.primaryColor};
      color:${theme.h2Color};
      font-size:${theme.h2FontSize};
    ">`
  }

  return `<${tag} style="color:${theme.h3Color};">`
}

目前标题、段落、引用、列表、链接、图片、表格、分隔线、行内代码和代码块都使用了自定义规则。这样做比维护一份外部 CSS 繁琐一些,但输出结果是相对独立的富文本,不依赖当前网站的样式环境。

列表的处理比标题更麻烦。有序列表、无序列表、嵌套列表和任务列表的结构不同,而四套主题对标记符号也有不同设计。因此渲染过程中会在 env 中记录当前列表类型、层级和序号,再由段落渲染规则决定插入圆点、数字或者其他标记。

用配置驱动四套主题

最开始做主题时,很容易走向复制四份渲染代码:每个主题各写一套标题、列表和引用样式。这种方式短期很快,但修改一个公共问题时,需要在四个地方重复处理。

现在的实现把主题拆成两部分:

  • 设计变量:颜色、字号、间距、圆角、代码背景等;
  • 行为标记:标题、列表、链接和表格采用哪一种渲染结构。

下面是经过简化的主题配置:

js 复制代码
const formatterThemes = {
  'polaris-code': {
    name: '微光代码',
    headingStyle: 'dev',
    listStyle: 'dev',
    tableStyle: 'dev',
    primaryColor: '#7C3AED',
    accentColor: '#0D9488',
    fontSize: '16px',
    lineHeight: '1.9',
    codeBg: '#EEECF5'
  }
}

渲染器读取这些配置,再决定标题使用缎带结构、左侧强调线、暖色标记还是编辑排版风格。这样既能复用 Markdown 解析和复制逻辑,又不会把主题限制成简单的"换颜色"。

目前有四套主题:

  • 霁蓝流光:标题层级明显,适合知识分享和经验总结;
  • 微光代码:突出代码块、列表和技术内容;
  • 纸间絮语:颜色和间距更柔和,适合随笔与生活记录;
  • 素纸铅字:减少装饰,适合长文和观点内容。

为了避免每次输入都重新创建 MarkdownIt 实例,渲染器会按主题进行缓存:

js 复制代码
function getRenderer(themeKey) {
  rendererCache[themeKey] ||= createRenderer(
    getFormatterTheme(themeKey)
  )
  return rendererCache[themeKey]
}

切换主题时只需要取出对应渲染器重新渲染内容,主题定义和业务组件之间也保持了相对清晰的边界。

代码高亮为什么也要内联化

技术文章离不开代码块,因此项目接入了 highlight.js。为了避免引入所有语言,只从 core 版本中注册当前常用的 Bash、CSS、Java、JavaScript、JSON、Python、TypeScript 和 XML。

highlight.js 默认输出类似下面的结构:

html 复制代码
<span class="hljs-keyword">const</span>

问题还是一样:如果 hljs-keyword 对应的样式没有一起复制,代码就只剩下结构,没有高亮颜色。

因此,高亮完成后还会把语法 class 转成内联颜色:

js 复制代码
function inlineHighlightStyles(html) {
  return html.replace(
    /<span class="hljs-([^"]+)">/g,
    (_match, className) => {
      const color = syntaxColors[className]
      return color
        ? `<span style="color:${color}">`
        : '<span>'
    }
  )
}

如果指定的语言不存在或者高亮失败,则回退为经过转义的普通代码文本,至少保证内容能正常显示,不让一次高亮异常影响整篇文章。

把 HTML 和纯文本同时写入剪贴板

完成渲染以后,最关键的一步是复制。

现代浏览器支持通过 ClipboardItem 同时写入多种格式。我会把渲染后的结果作为 text/html 写入,同时保留 Markdown 原文作为 text/plain

js 复制代码
const clipboardItem = new ClipboardItem({
  'text/html': new Blob([html], { type: 'text/html' }),
  'text/plain': new Blob([plainText], { type: 'text/plain' })
})

await navigator.clipboard.write([clipboardItem])

当目标编辑器支持富文本时,它会优先读取 text/html,标题、颜色和间距就可以跟随内容一起粘贴;如果目标位置只接收纯文本,仍然能够拿到原始 Markdown。

考虑到部分浏览器对 Clipboard API 的支持和权限限制,项目还保留了一套降级逻辑:

  1. 创建一个移出屏幕的临时容器;
  2. 将生成的 HTML 放入容器;
  3. 使用 RangeSelection 选中内容;
  4. 调用 document.execCommand('copy')
  5. 复制完成后清除选区并移除临时节点。

execCommand 已经属于旧接口,但作为兼容兜底仍然有实际作用。主流程优先使用 Clipboard API,只有失败时才进入降级分支。

草稿保存和一些细节

排版工具最怕页面刷新后内容全部消失,因此编辑内容、主题和文章尾部发生变化时,会在 500 毫秒后自动保存草稿。连续输入会不断重置计时器,避免每敲一个字就执行一次保存。

当前默认使用 localStorage,同时保留了通过环境变量切换后端接口的能力。页面重新打开时,会恢复文章内容、选中主题以及尾部设置。

此外,还有几个实现中的小取舍:

  • 图片不做本地拖拽上传,只接受线上地址,避免处理公众号素材权限和临时地址失效问题;
  • 文章尾部独立解析,支持普通文本和 Markdown 图片语法;
  • 移动端将编辑和预览拆成两个视图,避免双栏被压缩得无法使用;
  • 表格外层增加横向滚动容器,尽量降低窄屏下的布局溢出;
  • 预览区可以有动画效果,但复制输出只保留静态内联样式。

这些功能不复杂,却直接影响一个排版工具是否真的能用于日常写作。

最后

「趣排版」最初只是为了减少自己维护公众号时的重复排版工作,后来发现 Markdown 渲染、主题组织和富文本复制之间,还有不少值得整理的实现细节,于是把这次开发过程记录了下来。

目前四套主题已经可以直接使用,但微信公众号编辑器、浏览器和不同内容结构之间仍可能出现细节差异。如果你也做过类似的编辑器,或者在使用时遇到样式丢失、列表错位、代码块显示异常等问题,欢迎提出建议。

排版工具能做的事情其实很有限:让结构更清晰,让阅读更舒服,再把原本重复的格式调整尽量自动化。省下来的时间,最终还是应该回到内容本身。

相关推荐
布朗克1681 小时前
Go 入门到精通-33-unsafe 与 CGO
开发语言·后端·golang·unsafe·cgo
铁皮饭盒1 小时前
面试官:如何用 Bun + JS 实现安全的文件 MCP 工具集
前端·javascript·后端
小Ti客栈2 小时前
Spring Boot 整合 Swagger2 和 Knife4j实现接口文档与可视化调试
java·spring boot·后端
明月_清风2 小时前
💰 DeFi 入门完全指南:从 Uniswap 到 Aave,一文读懂去中心化金融
后端·web3
Conan在掘金2 小时前
鸿蒙报错速查:struct 里嵌套 @Component struct 就炸,Unexpected keyword 编译报错,根因 + 真解法
后端
探索前端2 小时前
Cesium图层加载及影像服务添加
前端·cesium
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十六):多驱动上传接口
后端·go·ai编程
明月_清风2 小时前
🎨 NFT 全景解析:从 JPEG 到数字所有权革命
后端·web3
阿懂在掘金2 小时前
Vue 弹窗新范式——代码减少、复用翻倍与 AI 时代的前端基建
前端·设计模式·前端框架