- 从 Word 粘过来的文档开始散架的地方
- 并不是没人做
- Web 里没有页
- 把内容宽度当作纸的这个选择
- 拆成核心与适配器的结构
- 打印一插进来,纸就又出现了
- 真做起来才会绊到的琐碎问题
- 如实写下这个项目的现状
- 小结与出处
从 Word 粘过来的文档开始散架的地方
把公司内部文档工具搬到 Web 上的项目里,几乎必然会冒出一个需求:像 Word 那样加一把标尺。就是那个能按段落拖三角形调左边距、只缩进首行、在刻度上抓表格列边界的东西。
从开发这一侧看,这个需求显得很小,因为好像画一条尺形刻度、再放上拖拽手柄就完事了。可一动手,第一天就卡住了。要在刻度上写 3cm,就得知道屏幕上多少像素是 3cm,而这个答案并不存在。
并不是没人做
这不是因为没人做才空着的位置。最近公开的 editor-ruler 的 README 把这个局面总结成这样:经典的 Web 所见即所得编辑器,也就是 Froala、TinyMCE、CKEditor 5、Quill 这一类,出厂都不带标尺;带标尺的只有 Syncfusion、DevExpress、ONLYOFFICE 这种整套抱着文档模型的重型组件。
这个分类就是线索。把有标尺的一侧和没有标尺的一侧分开的,不是开发人力也不是产品成熟度,而是有没有握着一套文档模型。
Web 里没有页
标尺在 Word 里能成立,是因为纸先定下来了。选了 A4,宽度就锁定为 210mm,左右页边距各 25mm 的话正文宽就是 160mm。标尺就是画这 160mm 的那把尺,把三角形拖到 3cm 的位置,意思就是距纸张左端 3cm。屏幕尺寸再怎么改,这个数字都不变,因为纸的存在与屏幕无关。
Web 里没有那张纸。编辑区的宽度会随浏览器窗口大小、侧边栏状态和缩放倍率不断变化。而且浏览器里的物理单位并不是真实的物理长度。CSS 中 1 英寸被定义为 96 像素,而这 96 像素在屏幕上实际是多少毫米,每台显示器都不一样。
所以在 Web 编辑器里画标尺,等于是假设了一张并不存在的纸。问题不在画尺的技术,而在决定以什么为基准来量。
把内容宽度当作纸的这个选择
editor-ruler 选的路子,直接体现在核心 API 上。调用方需要传进去一个用来汇报当前状态的函数。
import { createRuler } from '@devslab/editor-ruler';
const ruler = createRuler(mountElement, {
unit: 'cm',
getMetrics: () => ({ contentWidth, leftMargin, rightMargin, firstLineIndent }),
onChange(change, phase) {
// 把像素值应用到当前段落。松手的那一刻 phase 变成 commit
},
});
ruler.refresh(); // 选区或内容变化时都要调用
这里该看的不是第一个参数,而是 getMetrics。标尺自己并不知道纸有多宽,它每次都去问宿主。也就是说,扮演纸这个角色的是编辑区当前的内容宽度,宽度一变,标尺上的数字也会重算。
这个选择是有代价的。把屏幕缩窄,标尺上 3cm 的位置实际指向的就是另一个地方了。所以这把尺和 Word 的尺只是名字相同,量的东西不一样。Word 的尺量的是将被打印出来的纸,这把尺量的是此刻屏幕上的编辑区。在团队里引入时,这是必须告诉用户的差别。
拆成核心与适配器的结构
这个项目把只负责刻度、拖拽处理和单位换算的无依赖核心单独放着,把各编辑器之间的差异分离到薄薄的适配器里。Froala、Tiptap、CKEditor 5、Summernote 的适配器各自作为独立包发布。
需要这个结构,是因为每个编辑器保存缩进的位置都不一样。Tiptap 适配器把缩进存成节点属性,用内联 CSS 渲染。CKEditor 5 适配器存成模型属性,在下行转换阶段变成内联 CSS。Froala 和 Summernote 的适配器直接动 DOM。标尺做的计算是一样的,但结果写到哪儿,四家各不相同。
前面的论点在这里又出现了。加一把标尺,意味着决定段落的边距存在哪里。是节点属性还是内联样式,会让粘贴、导出和协同编辑的行为全部改变。这不是往上加一个 UI 部件的决定。
打印一插进来,纸就又出现了
这里出现了决策分岔的第二个点。如果这份文档只在屏幕上读,那纸从头到尾都不存在。可大多数内部文档工具最终都要打印,或者导出成 PDF。那一刻,纸就又出现了。
只要存在打印路径,标尺该量的就不是编辑区的宽度,而是将被打印的纸的正文宽度。在 Web 上声明那张纸的地方是 CSS 的 @page 规则,纸张尺寸和页边距在那里确定。也就是说,确定标尺的基准,就变成了让它去看和打印样式表相同的那组值。
把两者混在一起会得到最糟的结果。用户照着屏幕把它调成 3cm,一打印,纸上并不是 3cm。于是标尺反倒因为存在而失去了信任。所以选项实际上只有两个:把编辑区做成固定宽度的假纸,让屏幕与打印一致;或者在 UI 上明确说清楚标尺量的不是打印结果。两样都不做,也正是今天很多工具干脆不放标尺的原因。
真做起来才会绊到的琐碎问题
这个项目的 README 里有几条只有真正动手做过的人才写得出来。其中两条特别贴近实务。
第一,撤销的粒度。一次拖拽会产生几十次数值变更。放着不管的话,得按二十次撤销才能回到原来的位置。这个项目明确写着把一次拖拽合并成一个撤销步骤。前面 API 里 phase 之所以要区分是不是 commit,原因就在这儿。
第二,纵向标尺开关时的布局抖动。纵向的尺一出现,正文就被挤开那么多。这个项目提供了一个提前预留位置的选项,并把这个行为类比成 CSS 的 scrollbar-gutter: stable 来解释。这个类比准确地点出了问题的性质:可能稍后才出现的 UI,得在出现之前就把位置占好。
如实写下这个项目的现状
不能夸大的部分要说清楚。这个仓库建起来才几天,星标数是个位数,是个人或者极小的团队做的。许可证是 Apache-2.0,语言是 TypeScript,npm 包已经发布。README 里有一句说从 1.0.0 起就是稳定的,但这是作者本人的声明,不是第三方的评价。
所以我在这篇文章里并不建议你去用这个库。要不要上生产,是需要各自验证的问题。这个项目在当下值得一看的理由在别处:它附带着一份把问题描述准确了的文档。作者另外撰文整理了在没有页模型的前提下标尺可能意味着什么、做的过程中被什么绊住过。不管你打不打算自己做一个,那份问题定义都值得一读。
小结与出处
Web 编辑器没有标尺,不是少了一个功能,而是缺了一个前提。不把纸定下来就没法量,而把纸定下来的那一刻,这就不再是 UI 的决定,而是文档模型的决定。如果内部工具里有人向你要这个功能,请在给实现估工之前,先就「拿什么当纸」达成一致。
- devslab-kr/editor-ruler 仓库 —— 核心 API、各适配器的保存方式、撤销处理、纵向尺的位置预留选项。正文中关于这个项目的叙述全部在 README 中确认过。
- Every web rich-text editor is missing a ruler —— 仓库作为背景文章链接的作者原文
- 通过仓库元数据确认的事项:许可证 Apache-2.0、语言 TypeScript、仓库创建时间与星标数。关于成熟度的叙述基于这些数字,并不是对代码质量的评价。
- CSS 中 1 英寸被定义为 96 像素这一点,依据的是 CSS Values and Units 规范中绝对长度单位的定义。
현재 단락 (1/34)
把公司内部文档工具搬到 Web 上的项目里,几乎必然会冒出一个需求:像 Word 那样加一把标尺。就是那个能按段落拖三角形调左边距、只缩进首行、在刻度上抓表格列边界的东西。