@linloir/limr 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.en.md +151 -0
  2. package/README.md +80 -127
  3. package/benchmarks/results/layout.json +2593 -0
  4. package/benchmarks/results/markstream-code.json +1823 -0
  5. package/benchmarks/results/markstream-reading-repeat.json +1152 -0
  6. package/benchmarks/results/markstream.json +12486 -0
  7. package/benchmarks/results/streaming-browser.json +77 -0
  8. package/benchmarks/results/streaming.json +357 -0
  9. package/dist/base.css +93 -10
  10. package/dist/{chunk-7JF5HHVF.js → chunk-BG5QUQFR.js} +340 -72
  11. package/dist/chunk-BG5QUQFR.js.map +1 -0
  12. package/dist/chunk-BVHHOVMT.js +179 -0
  13. package/dist/chunk-BVHHOVMT.js.map +1 -0
  14. package/dist/chunk-FJZCQY2S.js +155 -0
  15. package/dist/chunk-FJZCQY2S.js.map +1 -0
  16. package/dist/chunk-FSI5OTBW.js +1 -0
  17. package/dist/{chunk-ETHLPBKQ.js → chunk-GU2RPESF.js} +238 -131
  18. package/dist/chunk-GU2RPESF.js.map +1 -0
  19. package/dist/{chunk-65V5OZ4G.js → chunk-SUAAUGAX.js} +1350 -511
  20. package/dist/chunk-SUAAUGAX.js.map +1 -0
  21. package/dist/core/index.d.ts +6 -3
  22. package/dist/core/index.js +15 -5
  23. package/dist/extensions/index.d.ts +30 -5
  24. package/dist/extensions/index.js +15 -3
  25. package/dist/index.d.ts +4 -4
  26. package/dist/index.js +31 -8
  27. package/dist/{engine-43u8B4yt.d.ts → knuth-plass-C8mgFm7W.d.ts} +67 -1
  28. package/dist/knuth-plass-layout-CHQCWKB5.js +449 -0
  29. package/dist/knuth-plass-layout-CHQCWKB5.js.map +1 -0
  30. package/dist/react/index.d.ts +7 -5
  31. package/dist/react/index.js +7 -3
  32. package/dist/styles.css +93 -10
  33. package/dist/{translations-BliabrPd.d.ts → translations-BXzSwLBJ.d.ts} +49 -4
  34. package/docs/README.md +13 -0
  35. package/docs/api.md +5 -158
  36. package/docs/assets/rendered.svg +9 -0
  37. package/docs/assets/source.svg +9 -0
  38. package/docs/en/README.md +28 -0
  39. package/docs/en/ai-skills.md +57 -0
  40. package/docs/en/annotations.md +86 -0
  41. package/docs/en/api.md +115 -0
  42. package/docs/en/architecture.md +41 -0
  43. package/docs/en/extensions.md +208 -0
  44. package/docs/en/getting-started.md +69 -0
  45. package/docs/en/integration.md +154 -0
  46. package/docs/en/modules.md +84 -0
  47. package/docs/en/performance.md +93 -0
  48. package/docs/en/plugin-packages.md +68 -0
  49. package/docs/en/styles.md +99 -0
  50. package/docs/en/syntax.md +455 -0
  51. package/docs/extensions.md +5 -306
  52. package/docs/implementation.md +5 -76
  53. package/docs/performance.md +5 -81
  54. package/docs/screenshots/blocks.png +0 -0
  55. package/docs/screenshots/dark.png +0 -0
  56. package/docs/screenshots/desktop.png +0 -0
  57. package/docs/screenshots/plugin-mobile.png +0 -0
  58. package/docs/styles.md +5 -100
  59. package/docs/visual-upgrade.md +5 -145
  60. package/docs/zh-CN/README.md +28 -0
  61. package/docs/zh-CN/ai-skills.md +57 -0
  62. package/docs/zh-CN/annotations.md +86 -0
  63. package/docs/zh-CN/api.md +115 -0
  64. package/docs/zh-CN/architecture.md +41 -0
  65. package/docs/zh-CN/extensions.md +210 -0
  66. package/docs/zh-CN/getting-started.md +69 -0
  67. package/docs/zh-CN/integration.md +154 -0
  68. package/docs/zh-CN/modules.md +84 -0
  69. package/docs/zh-CN/performance.md +93 -0
  70. package/docs/zh-CN/plugin-packages.md +68 -0
  71. package/docs/zh-CN/styles.md +99 -0
  72. package/docs/zh-CN/syntax.md +457 -0
  73. package/examples/notes.tsx +18 -11
  74. package/examples/packages/paper-theme/index.css +25 -0
  75. package/examples/packages/paper-theme/package.json +7 -0
  76. package/examples/packages/reading-tools/index.d.ts +2 -0
  77. package/examples/packages/reading-tools/index.js +25 -0
  78. package/examples/packages/reading-tools/package.json +16 -0
  79. package/package.json +17 -5
  80. package/skills/limr-plugin-development/SKILL.md +22 -0
  81. package/skills/limr-plugin-development/agents/openai.yaml +4 -0
  82. package/skills/limr-plugin-development/assets/plugin-template/README.md +30 -0
  83. package/skills/limr-plugin-development/assets/plugin-template/package.json +36 -0
  84. package/skills/limr-plugin-development/assets/plugin-template/src/index.tsx +50 -0
  85. package/skills/limr-plugin-development/assets/plugin-template/src/styles.css +9 -0
  86. package/skills/limr-plugin-development/assets/plugin-template/tsconfig.json +20 -0
  87. package/skills/limr-plugin-development/assets/plugin-template/tsup.config.ts +8 -0
  88. package/skills/limr-plugin-development/references/contract.md +51 -0
  89. package/skills/limr-use/SKILL.md +23 -0
  90. package/skills/limr-use/agents/openai.yaml +4 -0
  91. package/skills/limr-use/references/integration.md +49 -0
  92. package/skills/limr-use/references/syntax.md +20 -0
  93. package/dist/chunk-65V5OZ4G.js.map +0 -1
  94. package/dist/chunk-7JF5HHVF.js.map +0 -1
  95. package/dist/chunk-ETHLPBKQ.js.map +0 -1
  96. package/dist/chunk-V2OG4Y3F.js +0 -1
  97. /package/dist/{chunk-V2OG4Y3F.js.map → chunk-FSI5OTBW.js.map} +0 -0
@@ -0,0 +1,210 @@
1
+ # 扩展开发
2
+
3
+ > 阅读提示:本文包含 LiMR 扩展语法和交互示例。部分内容在 GitHub 等普通 Markdown 阅读器中无法完整展示;前往 [GitHub Pages 文档](https://linloir.github.io/LiMR/#/docs/zh-CN/extensions) 可获得最佳阅读体验。
4
+
5
+ [文档目录](README.md) · [English](../en/extensions.md)
6
+
7
+ 第一次接触模块时,先阅读[内置模块](modules.md):那里解释模块对象、创建函数、模块名、Markdown 标签,以及 `extensions` 和 `plugins` 的区别。
8
+
9
+ ## 选择接口
10
+
11
+ | 需求 | 接口 |
12
+ | --- | --- |
13
+ | 一个带 Markdown 正文的新区块 | defineBlock |
14
+ | 一种独立标注类型 | defineAnnotation |
15
+ | 组合语法、标注、动作和参数 | defineExtension / RendererExtension |
16
+ | 改写已有节点外观或只替换满足条件的节点 | components / rendererOverrides |
17
+ | 新的解析规则 | micromark + mdast 扩展,必要时 transform |
18
+ | 仅修改视觉 | CSS 主题包 + appearance |
19
+
20
+ 所有接口从 `@linloir/limr` 公开导出。第三方包不要导入 src、dist 内部路径或 useRuntime 等未公开接口。完整独立模板见[插件模块包](plugin-packages.md)。
21
+
22
+ ## 追加、删除与替换默认模块
23
+
24
+ ```tsx
25
+ import { Reader, standard, replaceExtension, video, defineBlock } from '@linloir/limr';
26
+
27
+ const extensions = replaceExtension(standard(), video({ loading: 'click' }));
28
+ const plugins = [defineBlock({
29
+ name: 'reading-tip',
30
+ component: ({ children }) => <aside className="reading-tip">{children}</aside>,
31
+ })];
32
+
33
+ export function Article({ source }: { source: string }) {
34
+ return <Reader source={source} extensions={extensions} plugins={plugins} />;
35
+ }
36
+ ```
37
+
38
+ 只移除模块可 filter 它的 name。扩展名、注册标签名和指令名各自必须唯一,重复会报错;不要用 plugins 再追加一份 citation 或 video。`replaceExtension` 替换同名项,不存在时追加。保留配置引用,避免流式渲染期间反复重建解析器。
39
+
40
+ ## 自定义正文区块
41
+
42
+ ```tsx
43
+ import { defineBlock } from '@linloir/limr';
44
+
45
+ export const notice = defineBlock({
46
+ name: 'notice',
47
+ component: ({ children, renderAttribute }) => <aside className="notice">
48
+ <header>{renderAttribute('title')}</header>
49
+ {children}
50
+ </aside>,
51
+ });
52
+ ```
53
+
54
+ 支持 `<notice title="提示">…</notice>`、配对的 `::notice{title="提示"}` / `::`,以及 `:::` 容器。name 默认同时成为标签名和指令名,可通过 tags / directives 自定义。defineBlock 默认 display: block、selection: content、scope: local。
55
+
56
+ | NodeRendererProps | 用法 |
57
+ | --- | --- |
58
+ | node | 当前 AST 节点和静态属性 |
59
+ | children | 已经渲染好的子树;优先复用 |
60
+ | source | **当前节点**对应的源码片段,不是全文 |
61
+ | pending | 所属尾块是否仍在流式生成 |
62
+ | incomplete | 当前语法节点是否尚未闭合 |
63
+ | renderChildren(nodes, { trailing? }) | 重排已有子树,可在末尾注入纯操作角标 |
64
+ | renderValue(tokens?) | 渲染字面量及高亮 token,保留原文选择和标注插入 |
65
+ | renderAttribute(name) | 显示属性文本并保留原文坐标 |
66
+
67
+ 不要把 children 转字符串再解析,也不要仅输出 node.value 导致源码映射丢失。无法逐字对应的图表/媒体用 selection: atomic;需要指定可视边界时使用 data-limr-selection-surface。纯操作角标可以使用公开 InlineMarker,不必建立 Annotation。
68
+
69
+ ## 标注卡片由插件拥有
70
+
71
+ ```tsx
72
+ import { AnnotationCard, AnnotationMarkdown, defineAnnotation, ToolIcon } from '@linloir/limr';
73
+
74
+ export const note = defineAnnotation<{ text: string }>({
75
+ type: 'example:note',
76
+ label: 'Note',
77
+ icon: <ToolIcon name="note" />,
78
+ color: '#6B9BD2',
79
+ Content: ({ data, loading, error, retry }) => loading ? <p>Loading…</p>
80
+ : error ? <button onClick={retry}>Retry</button>
81
+ : <AnnotationMarkdown source={data?.text ?? ''} />,
82
+ sidebar: {
83
+ Preview: ({ data }) => data?.text ? <span>{data.text}</span> : null,
84
+ previewLines: 2,
85
+ },
86
+ overlay: {
87
+ Card: props => <AnnotationCard {...props} title="Note" />,
88
+ previewWidth: 320,
89
+ expand: { placement: 'center', width: 640, maxHeight: '80dvh' },
90
+ },
91
+ });
92
+ ```
93
+
94
+ Content 处理 presentation(preview / expanded / sidebar)、editing、data 和资源状态。overlay.Card 拥有标题、正文和操作,可复用 AnnotationCard 或完全自绘;children 是共享加载状态的内容。LiMR 管理定位、portal、焦点、动画和退出生命周期。不要用通用标题覆盖插件自己的语义。
95
+
96
+ expand 必须显式配置;默认不展开。placement 支持 anchor / center,尺寸为像素或 CSS 长度,也可按 annotation 返回策略。sidebar.Preview 只读本地 data;不要在摘要触发网络请求。Preview: false 或返回 null 隐藏摘要。同一 occurrence 的首个类型负责卡片外观和分组摘要,可通过 annotations 获取组内数据。
97
+
98
+ sidebar 支持 previewLines、previewColor、color、borderColor、titleColor、textColor、excerptColor 和 collapseOnContentClick。内容根节点无需重复添加卡片 padding;复用共享 `--limr-sidebar-padding-inline`,处理内部段落间距即可。
99
+
100
+ ## 选区动作
101
+
102
+ ```tsx
103
+ import { defineExtension, ToolIcon } from '@linloir/limr';
104
+
105
+ export const savePassage = defineExtension({
106
+ name: 'example:save-passage',
107
+ actions: [{
108
+ id: 'example:save-passage', label: 'Save passage', icon: <ToolIcon name="note" />,
109
+ visible: ({ selection }) => selection.settled && selection.quality !== 'unmapped',
110
+ run(context) {
111
+ const id = `example-note-${crypto.randomUUID()}`;
112
+ context.addAnnotation({
113
+ id, type: 'example:note', data: { text: context.selection.text },
114
+ anchors: context.selection.syntax.ranges.map(range => ({
115
+ range, documentId: context.document.id,
116
+ quote: context.document.source.slice(range.start, range.end),
117
+ })),
118
+ });
119
+ context.activateAnnotation(id);
120
+ context.clearSelection();
121
+ },
122
+ }],
123
+ });
124
+ ```
125
+
126
+ 与上面的 note 扩展一起安装。SelectionAction 必须有 id、label、icon,可提供 visible、disabled、run、onHover、renderExpanded 或 render。面板操作通过 context.run 使用工具栏的 busy/error 处理。上下文包含选区、完整文档快照、同片段 annotations/highlight、locale、取消信号与命令。动作 ID 冲突时后者覆盖;宿主显式 actions 的同 ID 项最终优先。
127
+
128
+ ## 参数
129
+
130
+ ```tsx
131
+ import { defineExtension, useRendererParameter } from '@linloir/limr';
132
+
133
+ declare module '@linloir/limr' {
134
+ interface RendererParameters { exampleDetail?: 'short' | 'full' }
135
+ }
136
+
137
+ export const glossary = defineExtension({
138
+ name: 'example:glossary', tags: ['glossary'], scope: 'local',
139
+ parameters: { exampleDetail: 'short' },
140
+ components: {
141
+ glossary: ({ children }) => <aside data-detail={useRendererParameter('exampleDetail')}>
142
+ {children}
143
+ </aside>,
144
+ },
145
+ });
146
+ ```
147
+
148
+ 宿主传 `parameters={{ exampleDetail: 'full' }}` 覆盖默认值。参数变更只更新视图,不解析源文;相同参数后注册默认值优先。独立包需要让声明合并出现在导出的 .d.ts 中。
149
+
150
+ ## 解析与流式约束
151
+
152
+ SyntaxExtension 支持 micromark、mdast、tags、directives、display、selection、literal、transform 和 scope。transform 默认全文作用域;仅依赖当前局部子树、不会回写前文或读取全局文档语义时才声明 scope: local。context.source 仍是原始全文,position 必须保持原始 UTF-16 坐标。插件不要先用正则替换 source 再解析。
153
+
154
+ 未知特殊标签安全显示整段原文,内部 Markdown 也作为原文显示。默认配置的未知指令则渲染内部 Markdown,丢弃指令外层标记;未安装任何指令扩展时按基础 Markdown 解析。已注册的未闭合标签和容器携带 node.incomplete,之后追加继续恢复;半截头部暂缓显示或交由模块展示 loading;无效 JSX 仍可能回退并记录 diagnostics;结束必须 finish 或 streaming=false。扩展 transform 的异常记录为 diagnostic;客户端渲染边界可回退源码,但 React 错误边界不负责捕获服务端渲染异常。
155
+
156
+ 验证应覆盖标签/指令、中文与 emoji、源坐标、流式结束与一次性解析等价、SSR,以及新增交互的浏览器行为。独立分发还需验证真实打包消费,见[插件模块包](plugin-packages.md)。
157
+
158
+ ## 插件本地化
159
+
160
+ 1.0.0 起,组件可用 useLocale() 读取合并后的 locale,以 locale.language 选择自己的文案。AnnotationType.label 接受字符串或 `(locale) => string`;选区动作使用 context.locale。Citation 默认标题随语言切换为 Citation / 引用。保持插件实例稳定即可更新文案,无需重写源文;用户保存的笔记不会被翻译。
161
+
162
+ ## 流式中间态与增量动画
163
+
164
+ `pending` 表示顶层块还在增长;`incomplete` 表示当前语法节点尚未闭合。注册的标签自动支持组件指令写法。`defineBlock` 可提供 `streaming.Loading`,不提供时由原组件渐进渲染,并通过 `incomplete` 判断状态。后续内容到来时保持组件身份和内部状态。
165
+
166
+ 默认的定义、题目、拓展阅读、视频、图片和 callout 指令使用小圆环等待完整内容;Mermaid 等待闭合后异步绘图;代码、公式、GFM 和翻译模块直接展示可用中间态。callout 的引用块写法可在头部完整后渐进展示正文。未闭合的引用定义不会提前注册不完整数据。第三方模块完全拥有自己的中间态样式。
167
+
168
+ ```tsx
169
+ import { defineBlock, defineExtension } from '@linloir/limr';
170
+
171
+ export const chart = defineBlock({
172
+ name: 'chart',
173
+ component: ({ children }) => <section>{children}</section>,
174
+ streaming: { Loading: () => <span role="status">正在准备图表…</span> },
175
+ });
176
+ export const streamMotion = defineExtension({
177
+ name: 'example:stream-motion',
178
+ streaming: {
179
+ effect: {
180
+ className: 'my-stream-increment',
181
+ keyframes: [{ opacity: 0.25 }, { opacity: 1 }],
182
+ timing: { duration: 320, easing: 'ease-out' },
183
+ },
184
+ },
185
+ });
186
+ ```
187
+
188
+ 后注册的 `streaming.effect` 优先,阅读器的 `streamEffect` 拥有最终覆盖权,`false` 关闭效果。关键帧和时间选项使用 Web Animations API;减少动态效果的系统偏好会阻止新动画。默认动画仅改变新增文字和特殊块的透明度,不添加背景色;文字动画结束后在原生选区离开时合并临时包装节点。使用 `children`、`renderChildren` 和 `renderValue` 可保留这些行为;插件自行替换 DOM 时,需要自行维护选区和动画。
189
+
190
+ `SyntaxExtension.literal` 让指令正文保持字面量,避免内部代码被解释为 Markdown 或 JSX。模块可读取原始 `source` 或变换组件节点;它不会执行属性表达式、YAML 或 JavaScript。
191
+
192
+ ## 按条件替换渲染组件
193
+
194
+ 模块可通过 `rendererOverrides` 限制替换的节点类型、语言、祖先或流式状态;规则只作用于使用该模块的 Reader。后注册且满足条件的规则优先;宿主 Reader 的规则优先于模块和 `components` 映射。未匹配的节点继续使用原组件。
195
+
196
+ ```tsx
197
+ import { defineExtension, type NodeRenderer } from '@linloir/limr';
198
+
199
+ const JavaScriptCode: NodeRenderer = ({ renderValue }) => <pre>{renderValue()}</pre>;
200
+ export const customCode = defineExtension({
201
+ name: 'example:javascript-code',
202
+ rendererOverrides: [{
203
+ type: 'code',
204
+ when: (node, context) => node.lang === 'js' && !context.incomplete,
205
+ component: JavaScriptCode,
206
+ }],
207
+ });
208
+ ```
209
+
210
+ `when` 也能读取 `pending` 和 `ancestors`。显式替换组件拥有自己的中间态渲染,仍处于 LiMR 的源范围、原子选择和异常边界之内。使用 `renderValue`、`renderAttribute`、`renderChildren` 维持精确映射。这里的限制是渲染规则作用域,不是对第三方 JavaScript 的安全沙箱。
@@ -0,0 +1,69 @@
1
+ # 快速开始
2
+
3
+ > 阅读提示:本文包含 LiMR 扩展语法和交互示例。部分内容在 GitHub 等普通 Markdown 阅读器中无法完整展示;前往 [GitHub Pages 文档](https://linloir.github.io/LiMR/#/docs/zh-CN/getting-started) 可获得最佳阅读体验。
4
+
5
+ [文档目录](README.md) · [English](../en/getting-started.md)
6
+
7
+ ## 安装
8
+
9
+ ```sh
10
+ npm install @linloir/limr
11
+ ```
12
+
13
+ 宿主项目需要 React 和 React DOM 18.3 或 19。包是 ESM,Node.js 要求 20+;支持 Vite 等 ESM 构建工具。CSS 必须由应用显式导入。
14
+
15
+ ## 完整阅读器
16
+
17
+ ```tsx
18
+ import { Reader, chinese } from '@linloir/limr';
19
+ import '@linloir/limr/styles.css';
20
+ import '@linloir/limr/math.css';
21
+
22
+ const source = `# LiMR
23
+
24
+ 正文支持 **强调**、表格、代码和 $x^2$。
25
+
26
+ ## 参考来源
27
+
28
+ 阅读这段话<ref id="guide" />。
29
+
30
+ <citation id="guide" title="Guide" url="https://example.com/guide" />`;
31
+
32
+ export function Article() {
33
+ return <Reader source={source} locale={chinese} theme="auto" />;
34
+ }
35
+ ```
36
+
37
+ 宽屏提供目录、正文和侧栏;容器宽度小于 `sidebarBreakpoint`(默认 1120px)时标注改用浮层。目录在窄屏以可折叠列表展示。
38
+
39
+ 默认排版为 `standard`;可选 `knuth-plass` 或 `justify`,见[项目集成](integration.md)。默认模块还提供 Mermaid、嵌套 callout、`==高亮==` 和脚注卡片,不需要再追加同名插件;完整列表见[内置模块](modules.md)。
40
+
41
+ ## 只需要正文
42
+
43
+ ```tsx
44
+ import { Markdown } from '@linloir/limr';
45
+ import '@linloir/limr/styles/base.css';
46
+
47
+ export function Excerpt() {
48
+ return <Markdown source="一段 **Markdown**。" />;
49
+ }
50
+ ```
51
+
52
+ `Markdown` 可以独立使用,保留解析和选区能力,但不自动挂载工具栏。静态内容可设 `interactive={false}` 关闭选区监听。已经有 `DocumentProvider` 时,用不带 `source` 的 `<Markdown />` 共享文档。
53
+
54
+ ## 选用样式
55
+
56
+ | 导入 | 内容 |
57
+ | --- | --- |
58
+ | `@linloir/limr/styles.css` | 默认主题、布局、交互和 Maple 代码字体 |
59
+ | `@linloir/limr/styles/base.css` | 相同样式,不引入 Maple 字体 CSS |
60
+ | `@linloir/limr/fonts.css` | 只引入 Maple,可与 base.css 分开使用 |
61
+ | `@linloir/limr/math.css` | KaTeX 样式与本地公式字体 |
62
+
63
+ `styles.css` 与 `styles/base.css` 选一个。选择 base 不会减少 npm 安装时包含的字体文件。中文正文使用宿主/系统字体,Playground 的 Noto Serif SC 并非组件默认加载项。
64
+
65
+ ## 下一步
66
+
67
+ - 查看[默认语法](syntax.md),区分内置正文模块与应用标注插件。
68
+ - 查看[项目集成](integration.md),接入流式输出、滚动容器和 SSR。
69
+ - 需要 AI 协助接入时安装 [limr-use Skill](ai-skills.md)。
@@ -0,0 +1,154 @@
1
+ # 项目集成
2
+
3
+ > 阅读提示:本文包含 LiMR 扩展语法和交互示例。部分内容在 GitHub 等普通 Markdown 阅读器中无法完整展示;前往 [GitHub Pages 文档](https://linloir.github.io/LiMR/#/docs/zh-CN/integration) 可获得最佳阅读体验。
4
+
5
+ [文档目录](README.md) · [English](../en/integration.md)
6
+
7
+ ## 选择组件
8
+
9
+ 用 `Reader` 获取完整阅读体验;用 `Markdown` 渲染独立正文;自己设计布局时让一个 `DocumentProvider` 管理一个主 `Markdown` 及其目录、卡片和工具栏。Provider 内再次传入 `source` 的 Markdown 会创建独立文档,不再共享父文档状态。
10
+
11
+ ## 流式内容
12
+
13
+ ```tsx
14
+ import { Markdown } from '@linloir/limr';
15
+
16
+ export function StreamingAnswer({ answer, generating }: {
17
+ answer: string;
18
+ generating: boolean;
19
+ }) {
20
+ return <Markdown source={answer} streaming={generating} followOutput />;
21
+ }
22
+ ```
23
+
24
+ 应用负责传输层,把每个片段追加到完整 `answer`;不要只把最后一个片段传给 source。最后一次更新必须传 `streaming={false}`,完成尾部语法与语义校正。收尾可以复用稳定前缀;引用定义变化等情况仍可能触发全文解析。followOutput 只在仍靠近底部时跟随,不会强行拉回正在阅读上文的用户。纯核心支持 `append(chunk)` / `finish()`,React props 使用累计字符串。
25
+
26
+ 默认新增文字和特殊块使用透明度淡入,段落结束时不重播动画。`streamEffect={false}` 可关闭,传入 `className`、`keyframes` 和 `timing` 可自定义;系统的减少动态效果偏好优先。未闭合模块可显示 loading,代码/公式渐进显示;复用 `children`、`renderChildren`、`renderValue` 有助于保留控件状态与选区。插件接口见[扩展开发](extensions.md)。
27
+
28
+ ## 段落排版
29
+
30
+ ```tsx
31
+ import { useState } from 'react';
32
+ import { Reader, type TypographyMode } from '@linloir/limr';
33
+
34
+ export function TypographyReader({ source }: { source: string }) {
35
+ const [mode, setMode] = useState<TypographyMode>('standard');
36
+ return <>
37
+ <select aria-label="排版模式" value={mode}
38
+ onChange={event => setMode(event.target.value as TypographyMode)}>
39
+ <option value="standard">标准</option>
40
+ <option value="knuth-plass">Knuth–Plass</option>
41
+ <option value="justify">两端对齐</option>
42
+ </select>
43
+ <Reader source={source} typography={mode} />
44
+ </>;
45
+ }
46
+ ```
47
+
48
+ `standard` 为默认值;`justify` 使用浏览器两端对齐;`knuth-plass` 启用真正的段落优化算法。`knuthPlass` 可调整 TeX 参数与断词;流式尾段稳定后再参与 KP 排版。详细行为及性能边界见[样式](styles.md)和[性能](performance.md)。
49
+
50
+ ## 内部滚动容器
51
+
52
+ ```tsx
53
+ import { useState } from 'react';
54
+ import { Reader } from '@linloir/limr';
55
+
56
+ export function ScrollPanel({ source }: { source: string }) {
57
+ const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null);
58
+ return <div ref={setScrollRoot} style={{ height: '80vh', overflowY: 'auto' }}>
59
+ <Reader source={source} scrollRoot={scrollRoot} scrollOffset={32} />
60
+ </div>;
61
+ }
62
+ ```
63
+
64
+ 窗口滚动无需指定 scrollRoot。固定顶栏通过 scrollOffset 留出距离;目录布局可用 `--limr-sticky-top` 调整。切换整篇独立文档时,给 Reader 新的 React key,并提供稳定、页面内唯一的 documentId,以重新建立任务与交互状态。
65
+
66
+ ## 自定义组合
67
+
68
+ 下面是固定三栏的桌面组合。窄屏响应式切换、目录折叠和自动高度优先使用 Reader;需要自己的断点策略时,由应用决定挂载 sidebar 还是 overlay。
69
+
70
+ ```tsx
71
+ import { DocumentProvider, Markdown, TableOfContents, AnnotationSidebar,
72
+ SelectionToolbar } from '@linloir/limr';
73
+
74
+ export function DesktopReader({ source }: { source: string }) {
75
+ return <DocumentProvider source={source}>
76
+ <div style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr) 280px', gap: 24 }}>
77
+ <TableOfContents style={{ position: 'sticky', top: 24, alignSelf: 'start', maxHeight: '80vh' }} />
78
+ <main className="limr-reader-content" style={{ minHeight: '70vh', overflow: 'clip' }}>
79
+ <Markdown />
80
+ </main>
81
+ <AnnotationSidebar />
82
+ </div>
83
+ <SelectionToolbar />
84
+ </DocumentProvider>;
85
+ }
86
+ ```
87
+
88
+ 正文与同步侧栏应处于同一滚动容器、同一未缩放坐标系;侧栏由网格行或宿主给定高度,不按所有卡片总高度撑大页面,也不要给同步列表增加独立滚动。卡片内的长内容可以自行滚动。抽屉等普通列表用 `<AnnotationSidebar sync="none" />`,宿主提供高度与 overflow。
89
+
90
+ 浮层组合是在同一个 Provider 中放置 `<Markdown />`、`<AnnotationOverlay />`、`<SelectionToolbar />`。两个浮层组件支持 portalRoot,Reader 的组合 props 不直接暴露 portalRoot;需要定制时拆开组合。
91
+
92
+ ## 受控数据与文档切换
93
+
94
+ ```tsx
95
+ import { useState } from 'react';
96
+ import { Reader, type Annotation } from '@linloir/limr';
97
+
98
+ export function SavedArticle({ id, source }: { id: string; source: string }) {
99
+ const [annotations, setAnnotations] = useState<readonly Annotation[]>([]);
100
+ return <Reader key={id} documentId={id} source={source}
101
+ annotations={annotations} onAnnotationsChange={setAnnotations} />;
102
+ }
103
+ ```
104
+
105
+ 该片段展示单篇文章的状态。真实应用按文章 ID 加载/保存 annotations,并在切换文章时更换这一数组;React key 不会自动清空宿主状态。不传 annotations 时使用组件内部状态;传入后必须通过回调接收并回传编辑,否则编辑操作会报错。不可原地修改数组或 data。持久化范围与迁移规则见[标注与选区](annotations.md)。
106
+
107
+ ## 全局译文模式
108
+
109
+ ```tsx
110
+ import { useState } from 'react';
111
+ import { Reader, type TranslationMode } from '@linloir/limr';
112
+
113
+ export function BilingualArticle({ source }: { source: string }) {
114
+ const [mode, setMode] = useState<TranslationMode | undefined>();
115
+ return <Reader source={source}
116
+ parameters={{ translationMode: mode, onTranslationModeChange: setMode }} />;
117
+ }
118
+ ```
119
+
120
+ 参数仅影响视图,不触发重解析。其他插件也可以注册自己的参数,见[扩展开发](extensions.md)。
121
+
122
+ ## SSR 与客户端边界
123
+
124
+ 交互组件放在宿主框架的客户端组件边界内(例如需要时在文件顶部使用 `'use client'`),由框架安排 SSR 和 hydration。不要在模块初始化时访问 window/document;保持服务端与客户端的 source、documentId 和初始配置一致。
125
+
126
+ 同步 SSR 中,未加载公式输出源码回退。需要首次 HTML 包含完整 KaTeX 内容时,在服务器渲染前调用:
127
+
128
+ ```tsx
129
+ import { renderToString } from 'react-dom/server';
130
+ import { Markdown, preloadMath } from '@linloir/limr';
131
+
132
+ export async function renderArticle(source: string) {
133
+ await preloadMath();
134
+ return renderToString(<Markdown source={source} documentId="server-article" />);
135
+ }
136
+ ```
137
+
138
+ 也可以使用 React 流式 SSR 处理 Suspense。Shiki 在客户端补充高亮,SSR HTML 仍包含代码文本。Mermaid 在服务器输出 loading,客户端完成绘图后淡入;KP 在客户端测量字体并排版,SSR 保留普通文本布局。`load` 标注请求在客户端显示卡片时发起;如需服务端已有内容,可预先放进 annotation.data。宿主仍需把 CSS 和字体资源放到页面能访问的位置。
139
+
140
+ ## 已有插件与主题
141
+
142
+ 从真实插件包导入工厂,在模块外或 useMemo 中创建稳定 plugins 数组,再传给 Reader。CSS 需要按插件说明显式导入;主题通过 appearance 选择,不是通过 plugins 传入。具体安装与本地 tgz 流程见[插件模块包](plugin-packages.md)。
143
+
144
+ ## 常见问题
145
+
146
+ | 现象 | 检查 |
147
+ | --- | --- |
148
+ | 布局没有样式 / 公式错位 | 是否导入 styles 或 base,以及 math.css |
149
+ | 加入插件后默认模块消失 | 是否把追加插件误传给 extensions |
150
+ | 编辑标注后数据不更新 | 是否在受控回调中回传新数组 |
151
+ | 正文、目录不同步 | Markdown 是否意外带 source 创建了第二个 Provider |
152
+ | 内部滚动定位错误 | scrollRoot 是否是真正滚动的 DOM 元素 |
153
+ | 浮层主题不一致 | 是否使用 data-limr-appearance 作用域覆盖 portal |
154
+ | 流式渲染反复全文解析 | 是否每次 render 重建 extensions/plugins,或存在全文 transform |
@@ -0,0 +1,84 @@
1
+ # 内置模块
2
+
3
+ > 阅读提示:本文包含 LiMR 扩展语法和交互示例。部分内容在 GitHub 等普通 Markdown 阅读器中无法完整展示;前往 [GitHub Pages 文档](https://linloir.github.io/LiMR/#/docs/zh-CN/modules) 可获得最佳阅读体验。
4
+
5
+ [文档目录](README.md) · [English](../en/modules.md)
6
+
7
+ ## 模块是什么
8
+
9
+ LiMR 的基础解析器认识 CommonMark:标题、段落、列表、链接、图片和代码围栏等。**模块**是在这个基础上增加一组能力的配置对象,例如识别公式、渲染视频、显示引用卡片。开发时这个对象的公开类型叫 `RendererExtension`,所以 API 中也称它为“扩展”。
10
+
11
+ 模块可以同时包含语法规则、React 渲染组件、标注类型、工具栏动作和默认参数;并非每个模块都必须有这些部分。例如 `math` 增加公式语法和渲染,`code-highlight` 为现有代码围栏添加高亮,`citation` 同时提供引用语法和标注卡片。
12
+
13
+ **插件**通常指应用自行添加的模块,或第三方包提供的模块。它与内置模块使用相同的接口;npm 包只是分发代码的方式,一个包可以导出多个模块。把模块放进 `plugins` 后,就会追加到已有配置中。
14
+
15
+ ## 函数、模块名和标签名的区别
16
+
17
+ 以视频为例:
18
+
19
+ ```tsx
20
+ import { Reader, standard, replaceExtension, video } from '@linloir/limr';
21
+
22
+ const videoModule = video({ loading: 'click' });
23
+ // videoModule.name === 'video'
24
+ const extensions = replaceExtension(standard(), videoModule);
25
+
26
+ export function Article() {
27
+ return <Reader extensions={extensions} source={'<video src="/lesson.mp4" />'} />;
28
+ }
29
+ ```
30
+
31
+ | 写法 | 在哪里使用 | 含义 |
32
+ | --- | --- | --- |
33
+ | `video` / `video(...)` | 应用的 JavaScript / TypeScript | 导入的函数 / 调用这个函数,创建一个模块配置对象 |
34
+ | `videoModule` | 应用代码 | 自己给对象起的变量名,可以改成其他名字 |
35
+ | `'video'` | 模块对象的 `name` 字段 | LiMR 区分、替换和移除模块时使用的稳定标识 |
36
+ | `<video … />` / `<md-video … />` | Markdown 正文 | 模块注册的两种标签,触发同一个视频渲染器 |
37
+ | `::video{…}` | Markdown 正文 | 模块注册的指令写法,也可以使用 `:::video` 容器 |
38
+
39
+ 这些名称不要求一致。例如创建代码高亮模块的函数是 `codeHighlight()`,对象的模块名却是 `'code-highlight'`,正文只需要普通代码围栏,不需要 `<code-highlight>` 标签。不要把 `video()` 或字符串 `'video'` 直接写进 Markdown 来启用模块,也不要把字符串放进 `plugins`;传入的是函数返回的对象。
40
+
41
+ `video({ loading: 'click' })` 的参数设置这个 Reader 中视频模块的默认行为;正文的 `<video loading="eager" … />` 则可以覆盖某一个视频的设置。不同模块支持不同参数,见[默认语法](syntax.md)。
42
+
43
+ ## 随 LiMR 提供的默认模块
44
+
45
+ 这些模块都随 `@linloir/limr` 安装,不需要分别安装 npm 包。`standard()` 返回下面的模块对象数组,使用各自的默认配置。
46
+
47
+ | 创建模块的函数 | 对象的 `name` | 增加的能力与正文写法 |
48
+ | --- | --- | --- |
49
+ | `gfm()` | `gfm` | 表格、`~~删除线~~`、任务列表、自动链接、`[^id]` 脚注解析 |
50
+ | `math()` | `math` | `$…$` 行内公式、`$$…$$` 块级公式,需要额外导入 math.css |
51
+ | `codeHighlight()` | `code-highlight` | 使用 Shiki 为带语言标记的代码围栏着色 |
52
+ | `definition()` | `definition` | `<definition>` 释义卡片,可带标题与 Markdown 正文 |
53
+ | `question()` | `question` | `<question>` 练习题,选项、提示、答案与本地判分 |
54
+ | `extras()` | `extras` | `<extras>` / `<extra>` 拓展阅读链接 |
55
+ | `video()` | `video` | `<video>` / `<md-video>` 原生视频、YouTube、Bilibili 嵌入 |
56
+ | `figures()` | `figures` | `<figures>` / `<figure>` 响应式图组、单图和图注 |
57
+ | `citation()` | `citation` | `<citation>` 定义来源,`<ref>` 插入引用角标和卡片 |
58
+ | `translations()` | `translations` | `<translations>` 包含原文与译文,切换原文、译文、对照 |
59
+ | `mermaid()` | `mermaid` | Mermaid 围栏 / `::mermaid`,图像、源码、缩放和复制 |
60
+ | `callout()` | `callout` | Obsidian callout 类型、别名和嵌套注释 / `::callout` |
61
+ | `inlineHighlights()` | `inline-highlights` | `==文字==` 和 `::highlight`,默认蓝色只读高亮 |
62
+ | `footnotes()` | `footnotes` | `[^id]`、`^[正文]`,评论角标与脚注卡片;`::footnote` / `::footnote-ref` |
63
+
64
+ 所有函数都可以从 `@linloir/limr` 或 `@linloir/limr/extensions` 导入。`standard()` 是便捷组合函数,自身不是额外的模块;它的返回值是数组。
65
+
66
+ `Reader`、独立使用的 `Markdown`、`DocumentProvider` 在未传 `extensions` 时自动使用 `standard()`,因此最初的 `<Reader source={source} />` 就支持这些能力。共享 Provider 内的组件使用 Provider 的配置。纯核心 `createDocument()` 默认只启用 CommonMark;需要时显式传入扩展,它不负责显示 React UI。
67
+
68
+ 高亮、复制、删除选区标注是工具栏自带动作。`==文字==` 和 Markdown 脚注由默认模块自动识别;源码中的高亮只可复制,不能通过工具栏编辑。Playground 的可编辑旁注、笔记、提问、脚注和模拟 AI 问答来自示例插件,**没有自动装进每个 Reader**。`question()` 的练习题与“提问”标注也不是同一功能。
69
+
70
+ ## 按需要组合
71
+
72
+ 默认配置足够时不必写 `standard()`。需要定制时,记住这三个操作:
73
+
74
+ | 目的 | 写法 | 结果 |
75
+ | --- | --- | --- |
76
+ | 加自己的功能 | `plugins={[myPlugin]}` | 保留默认模块,追加插件对象 |
77
+ | 只保留指定能力 | `extensions={[gfm(), math()]}` | 替换完整默认模块清单;CommonMark 仍可用 |
78
+ | 调整一个内置模块 | `extensions={replaceExtension(standard(), video({ loading: 'click' }))}` | 按模块 `name` 替换视频,保留其他模块 |
79
+
80
+ 移除一个模块也可以过滤数组:`standard().filter(module => module.name !== 'video')`。不需要扩展语法时传 `extensions={[]}`。
81
+
82
+ 不要通过 `plugins={[video()]}` 再次追加已默认存在的视频模块:重复模块名、标签或指令会报错。模块没有启用时,未知标签按文本显示;未知指令可能仅留下内部 Markdown;已有的未知标注数据保留并显示通用回退。完整边界见[架构与行为](architecture.md)。
83
+
84
+ 接下来可以直接试读[语法示例](syntax.md),或参考[扩展开发](extensions.md)创建模块、[插件模块包](plugin-packages.md)打包分发。
@@ -0,0 +1,93 @@
1
+ # 性能与限制
2
+
3
+ > 阅读提示:本文包含 LiMR 扩展语法和交互示例。部分内容在 GitHub 等普通 Markdown 阅读器中无法完整展示;前往 [GitHub Pages 文档](https://linloir.github.io/LiMR/#/docs/zh-CN/performance) 可获得最佳阅读体验。
4
+
5
+ [文档目录](README.md) · [English](../en/performance.md)
6
+
7
+ 前几节保留仓库 **2026-10-12** 的历史基准,便于对照。文末 **2026-10-13** 一节记录本次优化实测。两组数据都不是性能保证。
8
+
9
+ 环境:Intel Core i7-13700、WSL2/Linux、Node 20.19.5、Chromium 156.0.8078.4、React 19 production。对照为 react-markdown 10.1.0 + remark-gfm 4.0.1。
10
+
11
+ ## 浏览器
12
+
13
+ 双方渲染同一份 GFM,使用相同排版,只比较正文,不含 Reader 目录/侧栏。每组取三个独立页面样本的中位数,预热并等待字体,交替测量;计时包括同步 React 提交和强制布局,不包括网络、异步重资源和帧间空闲时间。
14
+
15
+ | 场景 | LiMR | react-markdown |
16
+ | --- | ---: | ---: |
17
+ | 87,381 字符,200 节,首次挂载 | 126.1 ms | 134.0 ms |
18
+ | 877,782 字符,2,000 节,首次挂载 | 1,345.4 ms | 1,437.6 ms |
19
+ | 34,860 字符,138 次流式提交,累计同步时间 | 306.1 ms | 1,864.1 ms |
20
+ | 同一流式场景,单次提交 P95 | 2.9 ms | 23.7 ms |
21
+
22
+ 最后一次完整解析包含在统计中;P95 不代表每帧都低于 16ms。原始数据:[browser.json](../../benchmarks/results/browser.json)。
23
+
24
+ ## 选区与 SSR
25
+
26
+ 历史长文拖选绘制延迟 P95 为 25.2–28.5ms,代码为 18.9–19.4ms。这是 selectionchange 到覆盖层变更的延迟,不是硬件输入到屏幕的延迟。同一自动化拖动手势总耗时 LiMR 3.23s,对照 1.52s;不能因为 rAF 接近就声称成本相同。数据:[selection.json](../../benchmarks/results/selection.json)。
27
+
28
+ | Node 场景 | LiMR | 对照 |
29
+ | --- | ---: | ---: |
30
+ | 87,381 字符,SSR | 123.85 ms | 135.80 ms |
31
+ | 877,782 字符,SSR | 5,508.29 ms | 2,720.70 ms |
32
+ | 34,860 字符,137 次核心追加及收尾 | 164.04 ms | 2,673.74 ms |
33
+
34
+ 大文档 SSR 波动明显,LiMR 的样本为 2.64–9.10s,对照为 2.70–5.03s;这组数据不显示 SSR 优势。核心追加对照每次重新解析完整前缀。数据:[node.json](../../benchmarks/results/node.json)。
35
+
36
+ ## 使用限制
37
+
38
+ 保留全部 DOM 支持搜索、选择和打印,因此首次完整长文渲染仍有成本。增量解析复用稳定前缀;长尾块、全文 transform、定义变化、中间编辑和收尾仍可昂贵。保持 extensions/plugins 引用稳定,按需要限制高亮语言和长度。
39
+
40
+ 上面的历史测量未覆盖完整 Reader 布局;下方新增的 KP 对照包含一个完整 Reader 场景。两组数据都未覆盖大量公式、Shiki 冷启动、任意密集标注或插件组合、实体移动设备。字体安装体积与浏览器请求量也不同,见[样式与字体](styles.md)。
41
+
42
+ ## 在仓库复现
43
+
44
+ ```sh
45
+ NODE_ENV=production pnpm bench
46
+ pnpm exec vite build
47
+ pnpm exec vite preview --host 127.0.0.1 --port 4174
48
+ # 在另一个终端执行:
49
+ pnpm bench:browser
50
+ pnpm bench:selection
51
+ ```
52
+
53
+ 运行时避免同时构建或跑其他基准;脚本写入 benchmarks/results。每次使用自己的硬件、内容和已构建版本重新判断,不把历史测量当作最新包验收结果。
54
+
55
+ ## 2026-10-13:流式与排版优化实测
56
+
57
+ 以下为本次工作区实现与修改前提交 `ebab991` 的同进程对比,使用同一依赖、原始 UTF-16 输入、分块大小和五次交错顺序采样,按总耗时取中位样本。数据只包含核心追加和收尾,不包含 React、浏览器布局、异步高亮或网络。机器和运行时见 [streaming.json](../../benchmarks/results/streaming.json)。
58
+
59
+ | 场景 | 字符数 | 修改前总耗时 | 修改后总耗时 |
60
+ | --- | ---: | ---: | ---: |
61
+ | GFM | 34,860 | 139.22ms | 89.07ms |
62
+ | 默认模块 | 34,860 | 141.40ms | 94.63ms |
63
+ | 增长的代码围栏 | 43,209 | 1335.87ms | 29.05ms |
64
+ | 单个长段落 | 7,840 | 72.43ms | 74.32ms |
65
+
66
+ 优化包含:追加时增量维护行号、缓存已完成节点的语义索引、局部收尾、持续增长代码的词法快速路径,以及按需创建代码字符映射。仍在增长的复杂段落要重新解析;单个长段落的差异较小且受运行波动影响,不能据此声称所有内容都同比加速。
67
+
68
+ 运行 `pnpm bench:streaming` 测量当前核心。需要交错对照时,将 `LIMR_BENCH_BASELINE` 指向未修改的 `ebab991` 独立检出目录,并确保那里可访问相同依赖。脚本写入 `benchmarks/results/streaming.json`;复跑不意味着能复现历史硬件与耗时。
69
+
70
+ 生产 Chromium 156 的完整同步更新(React 提交加布局、默认排版和增量动画)中,默认模块的 34,860 字符流式文档 P95 为 **3.9ms**、最大 7.3ms;43,211 字符代码 P95 为 **2.6ms**、最大 12.4ms。每种场景三次隔离页面采样、字体预热、256 字符一块,rAF 等待和异步高亮不计入。这里没有浏览器旧版本对照,也不代表物理设备上的端到端帧率。原始数据:[streaming-browser.json](../../benchmarks/results/streaming-browser.json)。
71
+
72
+ KP 提供独立生产基准:在完整 scikit-learn Playground 中保留目录、侧栏和标注,每种模式使用三个独立页面,各进行三轮向下/向上滚动,再进行 32 次 1100–1420px 的窗口宽度变化。第一轮包含新进入视口的段落排版,后续轮次复用结果。以下合并每种模式的 1,080 个滚动样本和 96 个缩放样本,没有 CPU 降速。
73
+
74
+ | 测量 | 标准模式 | Knuth–Plass |
75
+ | --- | ---: | ---: |
76
+ | 滚动中位数 / P95 / 最大值 | 16.6 / 17.0 / 18.5ms | 16.6 / 19.8 / 46.5ms |
77
+ | 缩放中位数 / P95 / 最大值 | 11.2 / 24.1 / 31.9ms | 17.7 / 41.6 / 49.9ms |
78
+ | 超过 50ms 的滚动样本 | 0 / 1,080 | 0 / 1,080 |
79
+ | 超过 50ms 的缩放样本 | 0 / 96 | 0 / 96 |
80
+
81
+ 侧栏起点定位按源码范围跳过无关绑定,找到首个可见字符即停止,不再为每条批注遍历全部 KP 行节点。
82
+
83
+ 滚动计时从 `scrollTo` 到下一次 rAF;缩放计时包含窗口宽度更新、协议开销和下一次 rAF。KP 在缩放时仍有额外成本,但本轮未复现此前的长时间卡顿。这是本机 Chromium 的结果,不代表物理设备输入延迟或任意插件下的性能保证。原始样本和精确测量边界见 [layout.json](../../benchmarks/results/layout.json)。
84
+
85
+ ```sh
86
+ pnpm build:playground
87
+ pnpm preview:playground --port 4174
88
+ # 在另一个终端执行,避免同时构建或测试:
89
+ pnpm exec tsx benchmarks/layout.ts
90
+ BENCH_URL=http://127.0.0.1:4174/LiMR pnpm exec tsx benchmarks/streaming-browser.ts
91
+ ```
92
+
93
+ 准备阶段将字形测量、断词、合法断点与前缀和独立于行宽缓存。渲染使用每行的词间距片段,减少逐空格 DOM;宽度变化时,断点不变则原位调整间距,断点变化则复用文本容器。同步测量共用一个 Range,避免后续 DOM 修改维护大量尚未回收的活动 Range。高亮几何缓存为段落局部坐标,KP 的相邻绘制片段按行合并,集中读写避免每个矩形强制布局。视口蒙层坐标不向正文继承。滚动保留已排版节点,不重复求解段落。