
简介在线排版工具源代码是一套基于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天然支持叠加和部分选区操作这就是后续格式刷能实现的基础。我把这套约定叫“样式协议”后续每一个新样式进来都先问一句能不能归纳成mark2.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插件CtrlZ能用就完事了。我这边的场景是大量行内样式操作比如把一段文字的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只给占位块滚动进入视口附近再加载真实资源这个懒加载策略完全复用了浏览器原生的loadinglazy能力改动量很小。历史栈的优化是把每个事务对文档的改动用位置映射描述而不是存两份完整文档对比这样栈里数据量能压缩一个量级。做完这三项优化大文档的编辑流畅度基本上可以保持在每秒六十帧的水平。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/exporterHTML、Markdown、PDF、DOCX四类导出器独立成目录新增一种格式不影响其他逻辑src/ui界面组件只负责交互不直接操作文档结构。在这套结构下周边工作也顺手了很多。比如做软件著作权登记的时候需要提交整个项目的源代码PDF我从Git仓库按commit直接自动生成归档文件不再手工复制代码上线前也定期跑静态代码分析重点检查像innerHTML、href属性拼接这类容易被注入的位置。源码工程化这件事前期的结构收益是复利的越到后期越能感受到值回票价。我自己的经验是排版工具这类项目里的绝大多数“疑难杂症”最终都能追溯到文档结构定义不清或插件边界模糊上。先把schema和序列化协议定稳再写UI交互后续才会越改越顺。就算只做一个内部使用的工具也值得用长期维护的心态去对待。如果你正打算动手写一个类似的在线排版工具不妨先从这套骨架逻辑试起把最核心的文档模型和导出链路打通再去堆特效和交互这样踩坑的概率会小很多。本文还有配套的精品资源点击获取