简介:在线排版工具源代码是一套基于Web的轻量级实用程序,面向PHP开发者及有文本排版需求的用户,主要解决文章快速格式化、字数统计、空行统一等场景问题,界面简洁、上手门槛低。资源共17个文件,压缩包仅184KB,以HTML、JavaScript、CSS等前端资源为主,附带字体图标文件和简洁的使用帮助文档,目录结构清爽,便于定位与二次开发。已有1294人浏览学习,说明该工具在实际应用中具备一定关注度。源码充分展示了如何结合字符串处理与正则表达式实现字数计算、段落调整及空行增删,同时涉及前端交互与基础样式设计,适合想要理解在线排版原理、扩展自定义功能或嵌入到内容管理系统的开发者参考。整体是一个实用且可修改的完整源码包。 在线排版工具源代码,这个项目标题放出来,不少人的第一反应是:排版工具不是遍地都是吗?随便找一款富文本编辑器套个壳就能交差了。可真到自己从零开始搞,光是“输入法组词时光标突然蹦到行首”“从Word粘贴进来一堆乱码样式”“文档一长编辑区直接卡死”这几件事,就足够把人磨到怀疑人生。这背后的原因,是排版工具本质上不是在管理“字”,而是在管理“结构”——标题层级、列表嵌套、行内样式、块级属性,每一项都要有明确的数据模型,才能保证可视化界面、导出文件和数据库三者之间不变形。
我把自己做过的一个在线排版工具源码项目完整拆一遍,覆盖编辑器内核选型、文档模型设计、模板主题系统、导出链路、撤销重做、粘贴清洗,以及一堆在真实使用中踩过的坑。如果你正打算搭建写作平台、内容发布后台,或者自带排版能力的重型表单编辑器,这篇文章应该能让你少踩至少一半的坑。
1. 项目定位与整体设计:先把“排版”这件事想清楚
1.1 为什么还要再做一款在线排版工具
市面上现成的编辑器不少,但真拿来做“排版”这件事,痛点其实挺明显的。有的产品数据全部锁在自家账号体系里,想迁移出来非常费劲;有的编辑器做可视化录入还可以,但排版能力和输出控制很弱,复制到公众号或者技术文档平台时,样式直接打回原形;还有一类编辑器把重心放在协同上,格式和导出功能反而做得粗糙。
我这个项目的定位很朴素:做一个“排版优先”的在线编辑器,既要保留富文本可视化编辑的体验,又必须在导出时保持结构稳定。目标用户主要三类人:写公众号和技术博客的创作者、维护产品文档的内容运营、以及需要在后台频繁排版审核表单的管理员。对他们来说,排版工具不是写完就完,而是“写完之后导出去还能看”,这个要求听起来简单,实际做起来涉及的东西比想象中多得多。
1.2 编辑器内核选型:不是随便拿个开源编辑器就能顶事
先回答一个最基础的问题:为什么不能直接用现成的开源富文本编辑器?我也不是没有对比过,这里把几个主流方案摆出来聊一聊。
| 方案 | 文档模型 | 协作扩展 | 复杂排版能力 | 上手难度 |
|---|---|---|---|---|
| ProseMirror | 真正的树形结构 | 官方支持 | 强,schema约束分明 | 偏高 |
| Draft.js | 嵌套块结构 | 需要额外方案 | 中等,行内样式扩展麻烦 | 中等 |
| Slate.js | 灵活的节点树 | 方案成熟 | 强,但需要自己搭很多模块 | 偏高 |
| Quill | 扁平的Delta结构 | 需自研 | 一般,嵌套列表和复杂块级属性吃力 | 低 |
我最开始试过Quill,录入普通文字确实快,但一旦涉及“多级列表嵌套”“代码块里带行号”“图片和文字混排”这种场景,就感觉很别扭。后来换到ProseMirror,虽然学习曲线陡一点,但扎实的树形文档模型、事务驱动机制和schema强约束,让后续做模板换肤、导出转换、协同扩展都有了基础。这个选型决策是整个项目里最值的一件事。
前端我用Vue 3搭的界面,配合TypeScript,组件调度比JavaScript顺手太多;构建工具选的Vite,启动速度快,热更新也稳定。后端只承担文件上传、导出转换和自动保存接口,用的Node.js + Express,轻量够用。之所以前端框架选Vue而不是React,纯粹是我自己团队的技术栈习惯,编辑器内核本身和框架没有强绑定。
2. 核心模块拆解与实现思路
2.1 文档模型与样式协议:一切排版都源于结构
排版工具最怕的一件事,就是数据模型和视图各说各话。我在项目里把所有排版规则都收敛到ProseMirror的schema里,相当于先画好一张“什么是合法文档”的蓝图。举个例子,一段普通文本里可以加粗、加斜体、改颜色,但一张图片内部不能再嵌套另一张图片,代码块里也不能塞粗体标记,这些约束都要在schema里定义清楚。
我定义的核心节点和标记简化下来是这样:
import { Schema } from 'prosemirror-model'; export const schema = new Schema({ nodes: { doc: { content: 'block+' }, paragraph: { content: 'inline*', group: 'block' }, heading: { attrs: { level: { default: 1 } }, content: 'inline*', group: 'block' }, bullet_list: { content: 'list_item+', group: 'block' }, ordered_list: { attrs: { order: { default: 1 } }, content: 'list_item+', group: 'block' }, list_item: { content: 'paragraph block*' }, code_block: { content: 'text*', group: 'block', marks: '' }, image: { attrs: { src: {}, alt: { default: '' }, title: { default: '' } }, group: 'inline' }, text: { group: 'inline' }, hard_break: { inline: true, group: 'inline' } }, marks: { strong: {}, em: {}, strike: {}, underline: {}, link: { attrs: { href: {} } }, inline_code: {}, color: { attrs: { color: {} } } } });这里最关键的一个设计决策:行内样式全部用mark来表达,而不是直接往DOM元素的style属性里塞CSS。原因有两个。第一,mark是一种结构化数据,导出Markdown、生成HTML的时候都可以找到一一对应的转换规则;如果你直接在DOM上随手写style,导出器根本不知道该把这些样式映射成什么样的标签。第二,mark天然支持叠加和部分选区操作,这就是后续格式刷能实现的基础。我把这套约定叫“样式协议”,后续每一个新样式进来,都先问一句:能不能归纳成mark?
2.2 模板主题系统:换肤不碰内容层
排版工具还有一个很常见的需求:同一篇文章,换一套模板,整体视觉风格就要跟着变。我见过很多项目在这里翻车,原因是模板样式直接写在编辑器渲染层,内容和样式混在一起,换个模板就要动内容结构。我的做法是把模板做成独立的主题包,每个主题包含一份CSS变量定义和一套内容区作用域样式。
内容区统一用.pg-content这个类名包裹,所有主题的CSS选择器都限制在.pg-content前缀之下:
.pg-content h2 { font-size: 26px; border-bottom: 2px solid var(--accent); padding-bottom: 8px; } .pg-content blockquote { border-left: 4px solid var(--accent); background: var(--quote-bg); padding: 8px 16px; margin: 16px 0; } .pg-content code { font-family: 'JetBrains Mono', monospace; background: var(--code-bg); border-radius: 4px; padding: 2px 6px; }模板数据就是一个JSON描述,里面声明名字、预览图、依赖的CSS文件地址,以及一组主题变量。切换到新模板时,实际上只做两件事:替换CSS文件,更新CSS变量值。内容层的数据结构不做任何改动。这样做的好处是内容始终具备“平台无关性”,以后就算要输出成电子书或者PDF,拿同一份数据套不同的样式外壳就行。
2.3 导出链路:HTML、Markdown、PDF、DOCX一个都不能少
在线排版工具如果只能编辑不能导出,实用性会大打折扣。导出这块我拆成了四条链路,每一条都有各自的注意点。
HTML导出相对容易,从ProseMirror拿到HTML片段后,再套一层模板外壳,生成一个完整页面。这里有个细节:导出的HTML里,能用class的地方就不要堆inline style,否则后期换主题会很痛苦。Markdown导出我用了turndown这个库,但它的默认规则覆盖不了自定义的mark,比如文字颜色和行内代码,需要自己写规则映射。我的经验是,Markdown本来就不擅长表达复杂排版,所以导出Markdown时只需要保留标题、列表、引用、代码块、链接这些核心结构,颜色字号之类的内容直接丢弃,否则导出的Markdown会变得极其啰嗦。
PDF我用的是浏览器原生打印方案,通过@page规则控制纸张和页边距,配合print媒体类型下的CSS布局:
@page { size: A4; margin: 20mm 15mm; } .page-break { break-before: page; } .print-only { display: none; }打印方案的好处是不需要额外服务,坏处是不同浏览器的打印引擎多少有点差异,所以正式发布前一定要在目标浏览器里做回归测试。DOCX导出最折腾,因为Word的文件格式里包含大量排版参数,字体、行距、页边距、分页符这些都要逐个设置。我在服务端用docx.js拼装文档结构,再转成二进制,过程中踩得最狠的坑是中文字体配置,如果缺了w:eastAsia字体声明,Word打开文档很容易出现中文乱码。
3. 关键功能实现与实操冷知识
3.1 自定义撤销重做与历史管理:被默认方案坑过一次
很多人以为撤销重做拿来即用就行,ProseMirror自带history插件,Ctrl+Z能用就完事了。我这边的场景是大量行内样式操作,比如把一段文字的500个字符全部换色,默认历史会把这次操作拆成几十上百步,撤销的时候要紧按快捷键或连续点重做按钮,体验非常割裂。为了解决这个问题,我基于ProseMirror的事务元数据自定义了一套历史合并机制。
核心策略是:每个事务都携带一个actionType标记,在记录历史之前判断新事务能否合并到上一条记录。合并条件有三个,缺一不可:
- 操作类型相同,比如同样是
setTextColor,而其他类型的事务不合并; - 两次操作的时间间隔小于800毫秒;
- 操作影响的选区范围基本重叠或首尾相接。
满足条件的两个事务被合并成一条历史记录,撤销时一次就能回到批量修改之前。实现时要注意保存快照的方式,千万别每次doc变化都全量序列化存储,大文档的JSON可能会膨胀到几十MB,本地存储根本扛不住。我改用差分快照,只保存两个版本之间的差异片段,再配合LZString压缩,本地存储压力小了很多。还有一个必须处理的点:中文输入法组词期间,compositionstart到compositionend之间的事务绝对不能写入历史栈,否则我后面会讲到——撤销会把用户的拼音组合过程也当作一次操作。
3.2 格式刷、字数统计、自动保存:高频小功能也有技术含量
格式刷这个功能看起来简单,实现起来有不少细节:用户选中一段带样式的文字,点击格式刷,再选中目标文字,目标文字就被刷成源样式。我的实现思路是先用插件读取当前选中文本的所有marks,把每个mark的type和attrs记录下来,后续用户重新拖选文本时,再把记录下来的marks应用到新选区。
这里最容易翻车的地方是,点击格式刷按钮的瞬间,编辑器的选区可能会被按钮抢走,导致读取不到源样式。我的处理方式:在mousedown事件阶段就读取并缓存marks,阻止按钮的默认focus行为,等用户再去编辑区拖选时,缓存里已经有完整的样式快照了。另外我加了双击格式刷可以连续应用的设计,做完一段后按Esc退出,这属于交互细节,但实际用下来频率很高。
字数统计的坑主要有两个。第一,如果直接用text().length做统计,英文单词会被拆成一个个字母,数字和标点也全部算进去,数据失真严重。我的方案是正则分语言统计:中文字符按单个字计数,英文按连续字母片段计词,数字串单独计数。第二,字数统计要实时响应输入,但也不能每个按键都全量遍历文档,我把它放在ProseMirror的插件里监听事务变更,用增量方式更新统计数值,避免大文档卡顿。
自动保存我做了两层:一层是本地localStorage草稿,另一层是服务端接口防抖保存。防抖时间设在2秒,既能减少请求频率,又能保证断网或崩溃时损失最多不超过2秒的内容。存草稿的时候我会把光标位置也一起存下来,下次打开还能定位到上次编辑的位置,这个体验细节很多编辑器都没留意到。存储前先把文档JSON压缩再写进本地,避免大文档撑爆浏览器配额。
3.3 粘贴清洗与文档导入:格式污染的重灾区
排版工具里最脏的活,绝对是从外部粘贴内容。用户从Word、网页、公众号后台复制内容再粘贴进来,什么奇奇怪怪的标签和样式都会带进来。Word粘贴尤其夸张,会带出大量mso-开头的私有样式和冗余嵌套。我的处理流程很固定:先捕获粘贴事件里的HTML片段,用DOMParser解析成DOM树,然后递归遍历所有节点,按照白名单机制做筛选。白名单只保留p、h1-h6、ul、ol、li、blockquote、pre、code、strong、em、a、img这些必要标签,其余标签一概剪掉,行内样式也基本清空,只保留文本本身。
图片是另一个大坑。从外站复制过来的图片有两种来源:一种是纯外链URL,很可能会被外站防盗链,导致最终读者打开文章发现图片裂掉;另一种是浏览器已经转成Base64格式,直接塞进文档会让整个文档体积瞬间暴增。我的做法是:粘贴事件触发后,对外链图片做异步转存到自己的图床,对超大Base64图片做压缩后再转存,压缩参数设得比较保守,一般质量80%,长边限制在2000像素以内,既保证清晰度又控制体积。
Markdown导入走的是另一条路线,我直接用unified加remark-parse解析成AST,再映射到ProseMirror节点。但这里有几个细节容易漏:Markdown代码块的语言标记要转成代码块的language属性,后续高亮插件才认;任务列表里的复选框要转成自定义节点而不是普通列表项;还有Markdown里的容器块和脚注这类扩展语法,导入时必须明确决定是支持还是主动过滤,否则会出现解析失败。
4. 常见问题与排查技巧实录
4.1 输入法组词时光标乱跳怎么办
这个问题出现频率特别高,症状是用户在中文输入法里敲拼音,编辑器光标突然跳到行首,或者刚拼完的字直接消失了。排查来排查去,根子在于编辑器在输入法composition事件期间做了事务分发。浏览器在处理拼音组词的过程中,DOM会被输入法临时接管,这时候如果编辑器收到某些触发条件就dispatch事务,强制重建视图,就会把输入法组件打断了。
我的解决方案分三步。第一步,监听compositionstart事件,进入composition状态标记,期间暂停历史记录写入和自动保存。第二步,在compositionend触发之前,拒绝对文档做批量替换类的操作。第三步,也是很多人容易漏掉的,compositionend之后不能立刻flush缓冲操作,要给输入法留一点时间把最终字符提交到DOM,再执行后续逻辑。这套方案上线后,输入法导致的乱跳问题基本绝迹了。
4.2 大文档卡顿的实测分析
我压了一篇十万字加几十张图片的文档进去,实测编辑时明显卡顿,输入延迟能到三四百毫秒。定位之后发现瓶颈不在编辑器本身,而在三个地方:全量序列化、图片DOM渲染、历史栈快照太大。
全量序列化的问题前面说过,解决方式是差分快照加压缩存储。图片DOM渲染的优化更直接:编辑器初始化时,视口外的图片先不给真实src,只给占位块,滚动进入视口附近再加载真实资源,这个懒加载策略完全复用了浏览器原生的loading="lazy"能力,改动量很小。历史栈的优化是把每个事务对文档的改动用位置映射描述,而不是存两份完整文档对比,这样栈里数据量能压缩一个量级。做完这三项优化,大文档的编辑流畅度基本上可以保持在每秒六十帧的水平。
4.3 模板CSS和平台样式互相污染
这个问题是在接入后台管理系统之后爆发的:平台全局样式里自带h2 { margin: 30px 0 },编辑器里的标题就会跟着多出一大截margin;反过来,编辑器模板里的blockquote样式是带背景色的,外溢到同页面其他区域,整个后台界面瞬间变得很“村”。
解决办法就是前面提到的作用域隔离。我在编辑器内容区包了一个固定类名的容器,并在模板编译阶段把所有模板样式都加上.pg-content前缀,等于在样式层面做了一层命名空间隔离。如果项目对隔离要求更高,也可以用Shadow DOM把编辑器内容包进去,彻底物理隔离。不过Shadow DOM会带来组件通信和可访问性方面的一些额外成本,现阶段用作用域类名方案更省事。这里还有一个容易被忽略的问题:不同浏览器对h1、p、pre这些标签的默认样式本身就有差异,如果你的模板没有做reset,你会发现同一份文档在Chrome和Safari里打开,标题间距肉眼可见地不一样。所以模板里一定要先做节点级reset,再来处理主题变量。
4.4 源码工程化与归档的几点心得
最后聊点工程层面的体会。在线排版工具这种项目,代码结构如果从一开始不从“文档模型、插件、导出器、UI层”四个维度去切分,后面加功能是灾难。我的目录基本是这样的:
src/schema:所有节点、mark定义统一放这里,是全局唯一的数据协议入口;src/plugins:格式刷、字数统计、自动保存都做成ProseMirror插件,尽量不侵入编辑器主类;src/exporter:HTML、Markdown、PDF、DOCX四类导出器独立成目录,新增一种格式不影响其他逻辑;src/ui:界面组件,只负责交互,不直接操作文档结构。
在这套结构下,周边工作也顺手了很多。比如做软件著作权登记的时候需要提交整个项目的源代码PDF,我从Git仓库按commit直接自动生成归档文件,不再手工复制;代码上线前也定期跑静态代码分析,重点检查像innerHTML、href属性拼接这类容易被注入的位置。源码工程化这件事,前期的结构收益是复利的,越到后期越能感受到值回票价。
我自己的经验是,排版工具这类项目里的绝大多数“疑难杂症”,最终都能追溯到文档结构定义不清或插件边界模糊上。先把schema和序列化协议定稳,再写UI交互,后续才会越改越顺。就算只做一个内部使用的工具,也值得用长期维护的心态去对待。如果你正打算动手写一个类似的在线排版工具,不妨先从这套骨架逻辑试起,把最核心的文档模型和导出链路打通,再去堆特效和交互,这样踩坑的概率会小很多。
本文还有配套的精品资源,点击获取