TipTap 排版问题调研:Pretext 与测量层

返回 🧭 富文本排版与布局 · 🗂 富文本编辑专题

阅读位置:这是一份跨 CSS、编辑器模型、虚拟化和测量架构的调研,不是第一次配置 TipTap 的起点。

  • CJK 换行、长词、代码块:先完成一个最小 TipTap 编辑器,再直接读第 5、6 节。
  • mention、chip、inline atom:先理解 Schema NodeView,再读第 7 节。
  • 分页、虚拟化、测量或 Pretext:先读 性能与可访问性 的性能部分,再从第 8 节继续。

任何编辑态方案都必须保留浏览器对 selection、caret 和 IME composition 的主导权;Pretext 是预测与辅助层,不是可编辑 DOM 的替代品。

0. 一句话结论

使用 TipTap 时的排版问题,不能只靠 TipTap 解决,也不应让 @chenglou/pretext 替代 TipTap / ProseMirror 的编辑视图。更稳的架构是:

TipTap / ProseMirror 管编辑模型、事务、选区、IME 与 DOM 编辑;浏览器 CSS 管编辑态真实排版;Pretext / Canvas 测量层只做预测、虚拟化、分页草算和只读渲染辅助;最终边界用真实 DOM reconciliation 修正。

如果把 Pretext 当成「编辑器布局引擎」,会撞上 selection、caret hit testing、IME composition、copy / paste、screen reader 与浏览器真实 shaping 的复杂边界。如果把它当成「测量与预排版服务」,它正好能补 TipTap 在大文档、高度预测、只读卡片、分页预估上的短板。

1. 问题定义:「排版问题」其实分成四类

1.1 编辑态的文本排版

典型问题:

  • 中文、日文、韩文换行不自然。
  • 英文长词、URL、token 撑破容器。
  • 段落内 inline code、link、mention、chip 影响行高或换行。
  • 代码块到底横向滚动还是自动换行。
  • 输入法合成期间光标跳动、选区错位。

这类问题的约束是:用户正在 contentEditable 里编辑。任何排版策略都必须尊重浏览器编辑模型与 ProseMirror 的 DOM 同步机制。这里优先级最高的是正确性,不是视觉上最漂亮。

1.2 展示态 / 预览态排版

典型问题:

  • 富文本卡片需要先知道高度,避免 masonry / feed 闪动。
  • 聊天气泡需要算自然换行后的尺寸。
  • 只读详情页希望做更精细的排版,比如避让图片、做多列、做摘要省略。

这类问题不需要处理光标、输入法和实时 selection,因此可以使用更主动的测量和自绘策略。Pretext 在这里更有空间。

1.3 长文档性能与虚拟化

典型问题:

  • 文档很长时,全部挂载 DOM 导致输入延迟。
  • 每次输入都读 getBoundingClientRect() / offsetHeight,触发 forced synchronous layout。
  • NodeView 太多,React / Vue 同步 mount 和 update 成本高。
  • 需要虚拟滚动,但滚动条高度又依赖未知内容高度。

这类问题的核心不是「文字好不好看」,而是能否在不读大量 DOM 的情况下得到足够准确的高度估计

1.4 分页 / 打印 / 类文档布局

典型问题:

  • 要在编辑器里显示 A4 / Letter 页面。
  • 标题、段落、图片、表格不能随便跨页。
  • 编辑过程中页边界要跟着变化。
  • 导出 PDF 时需要稳定分页。

这类问题最容易被误解。浏览器有 print / paged media 相关能力,但交互式编辑态不能简单依赖打印布局。实时精确分页会把文本测量、块级测量、图片加载、表格高度、脚注、浮动内容、字体加载都拖进按键关键路径。

2. 职责边界:TipTap、ProseMirror、CSS、Pretext 分别管什么

2.1 ProseMirror / TipTap 的职责

ProseMirror 的核心是:

  • 不可变文档树。
  • Schema 约束。
  • Transaction / Step / Mapping。
  • Selection。
  • EditorView。
  • Plugin / Decoration。
  • NodeView。

TipTap 是 ProseMirror 之上的声明式封装:用 Extension / Node / Mark 把 schema、commands、shortcuts、input rules、paste rules、NodeView 聚合到一个扩展单元里。

因此 TipTap 适合管:

  • 文档结构。
  • 编辑行为。
  • 命令与快捷键。
  • 粘贴 / 序列化。
  • 自定义节点和标记。
  • 协同、评论、装饰、菜单等编辑器特性。

TipTap 不适合管:

  • 每一行最终在哪里换行。
  • 浏览器字体 shaping 的细节。
  • 实时精确分页。
  • 大量 DOM 几何读取后的布局调度。

这些不是 TipTap 抽象层的强项。

2.2 CSS 的职责

编辑态里,浏览器 CSS Text 是最可信的最终排版来源。尤其是:

  • CJK 换行。
  • Unicode line breaking。
  • OpenType shaping。
  • hyphenation。
  • bidi。
  • 字体 fallback。
  • IME 合成文本的临时渲染。

实际工程里,排版第一步不应该是引入测量库,而是把 CSS 策略收敛清楚:

.ProseMirror {
  font: 16px/1.6 system-ui, sans-serif;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  word-break: normal;
  line-break: auto;
  hyphens: auto;
  tab-size: 2;
}
 
.ProseMirror pre,
.ProseMirror code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
 
.ProseMirror pre {
  white-space: pre;
  overflow-x: auto;
}
 
.ProseMirror pre[data-soft-wrap="true"] {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

这里的原则是:编辑态尽量让浏览器做真实排版,测量层只用于辅助决策。

2.3 Pretext 的职责

@chenglou/pretext 是纯 JS / TS 的多行文本测量与布局库。它的核心价值是:不通过 DOM 几何读取来预估文本换行、行数和高度。它通过预处理文本、分段、测量 segment 宽度,让后续在不同宽度下的 layout 变成相对便宜的计算。

Pretext 适合:

  • paragraph / heading / list item 的高度预测。
  • 聊天气泡、卡片、masonry 的只读布局。
  • 长文档虚拟化的估算高度。
  • 分页草算。
  • canvas / SVG / WebGL / 服务端近似排版。
  • @chenglou/pretext/rich-inline 覆盖简单 inline rich flow:code span、link、mention、chip。

Pretext 不适合:

  • 接管 contentEditable 内部编辑态换行。
  • 替代 ProseMirror 的 coordsAtPos / posAtCoords
  • 决定真实 selection、caret 和 IME 合成文本位置。
  • 作为导出 PDF 的唯一分页依据。

一句话:Pretext 是测量与预测层,不是 TipTap 的 View 层替代品。

3. 推荐总体架构

3.1 四层模型

┌──────────────────────────────────────────────┐
│ 应用层:页面、工具栏、业务状态、保存、协同       │
├──────────────────────────────────────────────┤
│ 编辑层:TipTap / ProseMirror                   │
│ Schema / Transaction / Selection / NodeView   │
├──────────────────────────────────────────────┤
│ 排版策略层:CSS + NodeView discipline           │
│ line-break / hyphens / code wrap / decorations│
├──────────────────────────────────────────────┤
│ 测量层:Pretext + ResizeObserver + DOM fallback│
│ height cache / pagination estimate / reconcile│
└──────────────────────────────────────────────┘

关键点:

  • 编辑层永远以 ProseMirror state 为真相。
  • 编辑态最终视觉布局以浏览器 DOM / CSS 为真相。
  • 测量层可以预测,但必须允许被 DOM 测量修正。
  • 分页和虚拟化用 decoration / overlay 表达,不要频繁改正文档结构。

3.2 数据流

TipTap JSON / PM doc
  ↓ extract block metrics input
block text + marks + attrs + font config + container width

Pretext estimate

height cache

virtualizer / pagination planner

render visible DOM

ResizeObserver / selective DOM measurement

cache reconciliation

这条链路的核心是「预测先行,真实 DOM 回填」。不要指望预测层永远准确,也不要把真实 DOM 测量放到每次按键的同步关键路径。

4. TipTap 内部哪些机制会影响排版

4.1 NodeView 是编辑体验扩展点,不是通用排版容器

NodeView 适合:

  • 嵌入卡片。
  • 图片 / 视频 / 文件。
  • 表格或复杂 block。
  • 交互式 widget。
  • 不适合用普通 DOM 表达的块。

NodeView 不适合:

  • 大量普通段落。
  • 每个 mention 都挂 React 组件。
  • 每个行内 code span 都挂复杂组件。
  • 用 NodeView 模拟每一行。

原因很直接:每个 NodeView 都是 ProseMirror View 层要维护的一个边界。React / Vue NodeView 还会把框架渲染同步塞进 ProseMirror 更新流程。文档一大,它们会变成输入延迟来源。

建议:

  • 普通 paragraph / heading / list 走 renderHTML + CSS。
  • 简单 inline 样式用 Mark。
  • mention / chip 如果可接受「整体删除、整体选择」,用 inline atom node,但 DOM 保持轻。
  • 只有复杂交互才上 NodeView。
  • React NodeView 必须 memo、少传 props、传 getPos 而不是固定 pos

4.2 Decoration 适合表达「排版辅助 UI」

页边界、当前行高亮、搜索命中、评论锚点、协作者光标、临时测量标记,都不应该污染文档模型。优先使用 Decoration:

  • Decoration.inline:纯样式区间,最轻。
  • Decoration.node:给节点加 class / attrs。
  • Decoration.widget:插入不属于文档的 DOM,最贵,但适合评论锚点、页边界 marker。

分页场景尤其要避免把 page break 频繁写进真实文档。编辑时每次输入都插入/删除 page break node,会让 Step、Mapping、collab、undo、selection 都变复杂。更稳的做法是:

  • 编辑态页边界 = Decoration / overlay。
  • 导出态页边界 = 离线 pass 计算后生成。

4.3 Selection / IME 是不能牺牲的底线

任何排版方案只要影响以下能力,都要降级或放弃:

  • 中文输入法 composition。
  • Android 输入法。
  • 鼠标拖选。
  • Shift + Arrow 选择。
  • copy / paste。
  • screen reader 浏览。
  • posAtCoords / coordsAtPos

这也是为什么不建议在编辑态用 Pretext 自绘行布局。你不仅要画出文本,还要重新实现浏览器和 ProseMirror 已经处理的大量编辑边界。

5. CSS 排版策略:先把浏览器能力用对

5.1 white-space

常见选择:

  • normal:普通段落,折叠空白。
  • pre-wrap:保留换行和连续空格,同时允许自动换行。
  • pre:代码块默认选择,保留空白且不自动换行。

TipTap 编辑器正文常见是 white-space: pre-wrap,因为用户输入的换行需要被保留。代码块默认 pre 更符合开发者预期。

5.2 overflow-wrapword-break

建议默认:

.ProseMirror {
  overflow-wrap: break-word;
  word-break: normal;
}

需要强行处理 URL / token 的容器,可以局部用:

.ProseMirror .break-anywhere {
  overflow-wrap: anywhere;
}

谨慎使用:

word-break: break-all;

它会让英文单词随意断开,中文场景看似解决撑破,但会损害英文可读性。通常只适合特别窄的 badge / table cell / token 容器。

5.3 CJK: line-break

对中文、日文、韩文,需要关注:

line-break: auto;

更细的策略可以按产品风格试:

  • loose:更宽松,适合窄屏或移动端。
  • normal:普通。
  • strict:更严格,可能更符合排版规范,但更容易产生空隙。

这类策略不应只在英文样本文档上验证。必须准备中文、日文、韩文、混排数字、全角标点、英文长词、emoji 的 corpus。

5.4 hyphens

英文或欧洲语言可使用:

.ProseMirror {
  hyphens: auto;
}

但它依赖 lang:

<p lang="en">...</p>
<p lang="de">...</p>

没有正确 lang,浏览器很可能无法启用对应断词字典。不同浏览器的 hyphenation 字典和效果也会不同,所以它更适合改善视觉,不适合作为跨浏览器完全一致的布局依据。

5.5 text-wrap: pretty / balance

Chrome 等浏览器提供 text-wrap: pretty / balance 等更高级的换行优化。它们可能让标题或短段落更美观,但不能默认用于超长可编辑正文:

  • 支持度不一。
  • 可能有额外计算成本。
  • 结果可能影响高度预测。

建议仅用于只读标题、卡片摘要,并用 CSS.supports 做 feature detection。

6. 代码块排版

代码块要和普通段落分开处理,因为它有三组冲突目标:

  • 保留缩进与空白。
  • 可复制。
  • 不撑破布局。
  • 不让 DOM 过重。

推荐默认:

.ProseMirror pre {
  white-space: pre;
  overflow-x: auto;
}

提供用户可选 soft wrap:

.ProseMirror pre[data-wrap="true"] {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

对于高亮:

  • 小代码块可以用 Shiki / highlight.js 生成 span。
  • 大代码块避免每字符 span。
  • 语言包和 theme 懒加载。
  • 如果要虚拟化代码行,优先在只读 code viewer 做,不要把编辑态变成复杂行级 NodeView。

TipTap 的 CodeBlock 扩展本身解决的是 schema / command / input rule 层的问题,不是性能高亮或超大代码块渲染问题。

7. inline atom、mention、chip 与 rich inline

7.1 三种建模方案

方案适合风险
Marklink、bold、inline code、highlight无法表达整体不可编辑 chip
Inline nodemention、emoji、variable、tokenselection / copy / 删除边界要测试
Inline NodeView复杂交互 chip、popover triggerDOM 与 selection 边界更脆,性能更重

建议默认顺序:

  1. 能用 Mark 就用 Mark。
  2. 需要原子整体行为时用 inline atom node。
  3. 只有需要复杂交互时才使用 inline NodeView。

7.2 Pretext rich-inline 的位置

@chenglou/pretext/rich-inline 可以作为「只读 inline flow helper」:让 code span、link、mention、chip 参与手动布局,并保持 chip 不被拆开。

适合:

  • 消息气泡预览。
  • 富文本卡片摘要。
  • 只读评论渲染。
  • 高度预测时把 mention/chip 当作额外宽度的不可断 segment。

不适合:

  • 编辑态真实 selection。
  • 可编辑 NodeView 内部行布局。
  • 直接替代 ProseMirror 的 inline DOM。

7.3 测试清单

inline atom 必测:

  • 光标从左右方向进入。
  • Backspace / Delete 删除。
  • Shift + Arrow 选择。
  • 鼠标拖选跨 atom。
  • 双击词选择。
  • 复制粘贴 round-trip。
  • 中文输入法在 atom 前后输入。
  • Android / iOS 输入。

这类测试比视觉截图更重要,因为视觉正确不代表编辑语义正确。

8. 分页:最容易过度承诺的领域

8.1 为什么实时精确分页难

分页需要知道每个 block 的高度,但 block 高度可能取决于:

  • 容器宽度。
  • 字体加载状态。
  • CJK / hyphenation。
  • 图片加载结果。
  • 表格列宽。
  • code block wrap。
  • NodeView 内部异步内容。
  • decorations 是否影响布局。
  • 浏览器 zoom。

如果每次按键都同步测量所有后续 block,会触发大量 layout read。长文档中,这会让输入延迟失控。

8.2 推荐分页分层

编辑态:

  • 使用 page overlay / decoration 显示页边界。
  • 根据缓存高度增量更新受影响页之后的少量范围。
  • 暂时允许页边界轻微漂移。
  • 用户停顿后做更准确 reconciliation。

导出态:

  • 进入导出流程时锁定内容。
  • 等字体、图片、异步 NodeView 内容稳定。
  • 离线测量真实 DOM。
  • 生成最终 page breaks / PDF。

8.3 不建议的做法

  • 每次输入后从头扫描整篇文档。
  • 把 page break node 写入正文并频繁移动。
  • 用 Pretext 的预测结果直接作为最终分页。
  • 为每一页创建一个独立 TipTap 实例,除非产品能接受跨页 selection / undo / collab 的复杂代价。

9. 虚拟化与高度预测

9.1 基本策略

长文档虚拟化的关键问题是:不可见内容没有 DOM,但滚动容器需要知道总高度。

可行策略:

  1. 为每个 block 建立估算高度。
  2. 可见 block 挂载真实 DOM。
  3. 用 ResizeObserver 回填真实高度。
  4. 更新 cache 和 offset map。
  5. 滚动时只挂载 viewport 附近内容。

Pretext 可用于第 1 步:对纯文本块快速估算行数和高度。

9.2 cache key

高度缓存不能只按文本内容做 key,至少要包含:

  • node type:paragraph / heading / list item / blockquote。
  • text hash。
  • marks summary:link / code / bold 是否影响字体或 spacing。
  • attrs:heading level、indent、alignment。
  • container width。
  • font family / font size / font weight / line-height。
  • letter-spacing。
  • white-space。
  • locale / lang。
  • code block wrap mode。
  • inline atom metrics。

如果漏掉 font 或 width,缓存会在响应式布局、字号切换、主题切换时失效。

9.3 Pretext 估算与 DOM 修正

推荐策略:

estimateHeight(block):
  if block is simple text:
    return pretextLayout(block.text, width, fontConfig).height
 
  if block is rich inline:
    return richInlineEstimate(block.inlineRuns, width, fontConfig).height
 
  if block is image/table/custom NodeView:
    return previousMeasuredHeight ?? typeDefaultHeight

然后:

onVisibleBlockResize(blockId, measuredHeight):
  cache[blockId] = measuredHeight
  offsetMap.updateFrom(blockId)
  virtualizer.reconcileScrollPosition()

这里的核心是接受「先估计,再修正」。不要追求首次估计 100% 准确。

10. DOM measurement 与 reflow discipline

10.1 避免 forced synchronous layout

典型反模式:

el.style.width = nextWidth + "px";
const height = el.offsetHeight;
el.style.transform = `translateY(${height}px)`;

这会在写 DOM 后立刻读 layout,浏览器被迫同步 flush。

更稳的纪律:

  • 同一帧先批量读,再批量写。
  • 使用 requestAnimationFrame 分离读写。
  • 用 ResizeObserver 观察尺寸变化。
  • 对不影响编辑的重算做 debounce。
  • 不在 ProseMirror plugin apply 里读 DOM。

10.2 读写分离

let pending = false;
 
function scheduleMeasure() {
  if (pending) return;
  pending = true;
 
  requestAnimationFrame(() => {
    const measurements = visibleBlocks.map((block) => ({
      id: block.id,
      height: block.dom.getBoundingClientRect().height,
    }));
 
    requestAnimationFrame(() => {
      pending = false;
      applyMeasurements(measurements);
    });
  });
}

这个模式不是银弹,但它表达了基本纪律:不要在同一个同步调用栈里交错 DOM write 和 layout read。

11. Pretext 集成草案

11.1 作为服务而不是组件

把 Pretext 包成一个 measurement service:

type TextMeasureInput = {
  id: string;
  text: string;
  width: number;
  font: string;
  lineHeight: number;
  whiteSpace: "normal" | "pre-wrap";
  wordBreak?: "normal" | "keep-all";
  letterSpacing?: number;
};
 
type TextMeasureResult = {
  lineCount: number;
  height: number;
  naturalWidth?: number;
};

TipTap 插件或外部 virtualizer 不直接依赖 Pretext 细节,只依赖这个接口。这样未来可以替换成 DOM measurement、Canvas fallback、worker 方案或服务端预估。

11.2 从 TipTap doc 提取测量输入

基本方法:

  • 遍历 top-level block。
  • 对 paragraph / heading / list item 提取 textBetween
  • 对 inline atom 提供 leaf text 或宽度占位。
  • 对 code block 保留换行和空格,走 pre-wrappre 策略。
  • 对 table / image / custom NodeView 标记为 requiresDomMeasurement

注意:如果 marks 改变字体宽度,不能简单丢弃 marks。例如 inline code 通常是 monospace 且有 padding / background;link 可能不改变宽度;bold 可能改变字重和实际宽度。

11.3 rich inline 的 run 模型

可以把 inline 内容转成 runs:

type InlineRun =
  | { type: "text"; text: string; marks: string[] }
  | { type: "code"; text: string }
  | { type: "mention"; label: string; width: number; break: "never" }
  | { type: "chip"; label: string; width: number; break: "never" };

Pretext rich-inline 可以在只读或预测层处理这些 run,但 TipTap 编辑态仍保留真实 DOM。

11.4 校准策略

引入 Pretext 后必须做校准:

  • 同一批样本文档,记录 Pretext 估算高度与真实 DOM 高度。
  • 按语言拆分:中文、英文、日文、韩文、emoji、RTL、混排。
  • 按节点类型拆分:paragraph、heading、list、blockquote、code。
  • 按字体拆分:system-ui、serif、monospace、业务字体。
  • 统计误差:平均误差、P95、最大误差。

如果某类内容误差过大,不要硬调全局参数;应把该类型标记为 DOM-only measurement 或增加类型专用估算。

12. 推荐落地路线

阶段 1:先修 CSS 与 schema

目标:解决 60% 的排版问题,不引入测量复杂度。

  • 明确编辑器全局 font / line-height
  • 设置正文 white-space
  • 设置 overflow-wrap / word-break / line-break
  • 为多语言内容补 lang
  • 代码块区分 horizontal scroll 与 soft wrap。
  • 检查 inline atom 的 schema 和 DOM 是否过重。

验收:

  • 中文、英文长词、URL、emoji、代码块样本视觉正常。
  • IME 输入不跳光标。
  • copy / paste 正常。

阶段 2:建立测量缓存

目标:为虚拟化、卡片预览、分页预估准备基础设施。

  • 设计 block id。
  • 建立 block height cache。
  • 简单文本块接入 Pretext。
  • 复杂块用 ResizeObserver 回填。
  • cache key 包含 width / font / line-height。

验收:

  • 首屏无需全量 DOM 测量即可获得总高度估计。
  • 可见块真实测量后滚动位置稳定修正。
  • typing 时没有明显 forced layout。

阶段 3:虚拟化或分页草算

目标:解决长文档和类页面体验。

  • 用 offset map 驱动虚拟滚动。
  • 页边界用 overlay / Decoration。
  • 用户输入时只重算受影响区域。
  • idle 时做更大范围 reconciliation。

验收:

  • 长文档输入延迟可量化下降。
  • 页边界可以延迟收敛,但不阻塞输入。
  • undo / redo / collab 不受 page overlay 污染。

阶段 4:导出与高保真分页

目标:把编辑态的近似体验和导出态的确定性分开。

  • 导出前等待 fonts / images / async node 稳定。
  • 使用真实 DOM 或专门排版环境测量。
  • 生成最终 PDF / HTML page breaks。
  • 记录导出与编辑预览的差异。

验收:

  • 导出结果稳定。
  • 编辑态不为导出精度牺牲输入性能。

13. 监控与 benchmark

需要记录的指标:

  • keydown 到下一帧 paint 的延迟。
  • ProseMirror transaction 时间。
  • NodeView mount / update 次数。
  • DecorationSet 重建次数。
  • forced synchronous layout 次数。
  • ResizeObserver 回调数量。
  • 可见 DOM 节点数。
  • 长任务数量和时长。
  • Pretext 估算误差。

Chrome DevTools Performance 里重点看:

  • Scripting。
  • Rendering。
  • Layout。
  • Recalculate Style。
  • Long Task。
  • forced reflow 相关调用栈。

工程上可打点:

performance.mark("editor-input-start");
// dispatch transaction
performance.mark("editor-dispatch-end");
 
requestAnimationFrame(() => {
  performance.mark("editor-next-paint");
  performance.measure(
    "input-to-next-paint",
    "editor-input-start",
    "editor-next-paint"
  );
});

14. 决策矩阵

需求优先方案不建议
普通编辑换行CSS Text + TipTap DOMPretext 接管编辑行布局
CJK 换行line-break + 样本测试word-break: break-all 全局兜底
长 URL 撑破overflow-wrap 局部策略全局破坏单词可读性
mention / chipinline node / 轻 DOM大量 React inline NodeView
代码块默认横向滚动 + 可选 soft wrap每字符复杂 DOM
卡片高度Pretext 估算 + DOM 回填首屏全量 DOM 测量
长文档block height cache + virtualizer全文挂载 + 每次读高度
编辑态分页overlay / decoration + 增量测量每次输入改正文档 page break
导出分页离线真实测量用编辑态预测结果直接导出

15. 对后续调研的启发

这套调研后续可以拆成几条独立路线:

  1. 排版标准路线:CSS Text、Unicode UAX #14、OpenType shaping、hyphenation、CJK line breaking。
  2. 编辑器内核路线:ProseMirror selection、coords mapping、NodeView、Decoration 与 IME。
  3. 性能工程路线:forced layout、ResizeObserver、virtualizer、height cache、React NodeView 成本。
  4. Pretext 路线:Canvas measurement、Intl.Segmenter、rich-inline、accuracy corpus。
  5. 分页路线:CSS Paged Media、浏览器打印、编辑态页面模拟、导出 pipeline。

更重要的是,这几条路线不能混成一个问题。每次遇到「TipTap 排版不对」,先判断它属于:

  • CSS 策略问题。
  • Schema / NodeView 建模问题。
  • 浏览器编辑态问题。
  • 性能与测量问题。
  • 只读渲染问题。
  • 导出分页问题。

分类正确之后,方案才会自然收敛。

参考