@incremark/core 0.1.0 → 0.1.2

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.
@@ -50,7 +50,7 @@ export class IncremarkParser {
50
50
  private context: BlockContext
51
51
  private options: ParserOptions
52
52
  /** 缓存的容器配置,避免重复计算 */
53
- private cachedContainerConfig: ContainerConfig | undefined | null = null
53
+ private readonly containerConfig: ContainerConfig | undefined
54
54
  /** 上次 append 返回的 pending blocks,用于 getAst 复用 */
55
55
  private lastPendingBlocks: ParsedBlock[] = []
56
56
 
@@ -60,8 +60,8 @@ export class IncremarkParser {
60
60
  ...options
61
61
  }
62
62
  this.context = createInitialContext()
63
- // 初始化容器配置缓存
64
- this.cachedContainerConfig = this.computeContainerConfig()
63
+ // 初始化容器配置(构造时计算一次)
64
+ this.containerConfig = this.computeContainerConfig()
65
65
  }
66
66
 
67
67
  private generateBlockId(): string {
@@ -74,10 +74,6 @@ export class IncremarkParser {
74
74
  return containers === true ? {} : containers
75
75
  }
76
76
 
77
- private getContainerConfig(): ContainerConfig | undefined {
78
- return this.cachedContainerConfig ?? undefined
79
- }
80
-
81
77
  private parse(text: string): Root {
82
78
  const extensions: MicromarkExtension[] = []
83
79
  const mdastExtensions: MdastExtension[] = []
@@ -148,7 +144,6 @@ export class IncremarkParser {
148
144
  let stableLine = -1
149
145
  let stableContext: BlockContext = this.context
150
146
  let tempContext = { ...this.context }
151
- const containerConfig = this.getContainerConfig()
152
147
 
153
148
  for (let i = this.pendingStartLine; i < this.lines.length; i++) {
154
149
  const line = this.lines[i]
@@ -156,7 +151,7 @@ export class IncremarkParser {
156
151
  const wasInContainer = tempContext.inContainer
157
152
  const wasContainerDepth = tempContext.containerDepth
158
153
 
159
- tempContext = updateContext(line, tempContext, containerConfig)
154
+ tempContext = updateContext(line, tempContext, this.containerConfig)
160
155
 
161
156
  if (wasInFencedCode && !tempContext.inFencedCode) {
162
157
  if (i < this.lines.length - 1) {
@@ -182,7 +177,7 @@ export class IncremarkParser {
182
177
  continue
183
178
  }
184
179
 
185
- const stablePoint = this.checkStability(i, containerConfig)
180
+ const stablePoint = this.checkStability(i)
186
181
  if (stablePoint >= 0) {
187
182
  stableLine = stablePoint
188
183
  stableContext = { ...tempContext }
@@ -192,10 +187,7 @@ export class IncremarkParser {
192
187
  return { line: stableLine, contextAtLine: stableContext }
193
188
  }
194
189
 
195
- private checkStability(
196
- lineIndex: number,
197
- containerConfig: ContainerConfig | undefined
198
- ): number {
190
+ private checkStability(lineIndex: number): number {
199
191
  // 第一行永远不稳定
200
192
  if (lineIndex === 0) {
201
193
  return -1
@@ -237,10 +229,10 @@ export class IncremarkParser {
237
229
  }
238
230
 
239
231
  // 新容器开始
240
- if (containerConfig !== undefined) {
241
- const container = detectContainer(line, containerConfig)
232
+ if (this.containerConfig !== undefined) {
233
+ const container = detectContainer(line, this.containerConfig)
242
234
  if (container && !container.isEnd) {
243
- const prevContainer = detectContainer(prevLine, containerConfig)
235
+ const prevContainer = detectContainer(prevLine, this.containerConfig)
244
236
  if (!prevContainer || prevContainer.isEnd) {
245
237
  return lineIndex - 1
246
238
  }
@@ -1,4 +1,5 @@
1
1
  import type { RootContent } from 'mdast'
2
+ import type { AstNode } from '../types'
2
3
  import type {
3
4
  SourceBlock,
4
5
  DisplayBlock,
@@ -7,7 +8,7 @@ import type {
7
8
  TransformerPlugin,
8
9
  AnimationEffect
9
10
  } from './types'
10
- import { countChars as defaultCountChars, sliceAst as defaultSliceAst, type TextChunk, type AccumulatedChunks } from './utils'
11
+ import { countChars as defaultCountChars, sliceAst as defaultSliceAst, appendToAst, type TextChunk, type AccumulatedChunks } from './utils'
11
12
 
12
13
  /**
13
14
  * Block Transformer
@@ -56,6 +57,14 @@ export class BlockTransformer<T = unknown> {
56
57
  private isPaused = false
57
58
  private chunks: TextChunk[] = [] // 累积的 chunks(用于 fade-in 动画)
58
59
  private visibilityHandler: (() => void) | null = null
60
+
61
+ // ============ 性能优化:缓存机制 ============
62
+ /** 缓存的已截断 displayNode(稳定的部分,避免重复遍历) */
63
+ private cachedDisplayNode: RootContent | null = null
64
+ /** 缓存的字符数(避免重复计算) */
65
+ private cachedTotalChars: number | null = null
66
+ /** 当前缓存的进度(对应 cachedDisplayNode) */
67
+ private cachedProgress: number = 0
59
68
 
60
69
  constructor(options: TransformerOptions = {}) {
61
70
  this.options = {
@@ -99,7 +108,10 @@ export class BlockTransformer<T = unknown> {
99
108
  if (this.state.currentBlock) {
100
109
  const updated = blocks.find((b) => b.id === this.state.currentBlock!.id)
101
110
  if (updated && updated.node !== this.state.currentBlock.node) {
102
- const oldTotal = this.countChars(this.state.currentBlock.node)
111
+ // 内容更新,清除缓存
112
+ this.clearCache()
113
+
114
+ const oldTotal = this.cachedTotalChars ?? this.countChars(this.state.currentBlock.node)
103
115
  const newTotal = this.countChars(updated.node)
104
116
 
105
117
  // 如果字符数减少了(AST 结构变化,如 **xxx 变成 **xxx**)
@@ -125,13 +137,15 @@ export class BlockTransformer<T = unknown> {
125
137
  */
126
138
  update(block: SourceBlock<T>): void {
127
139
  if (this.state.currentBlock?.id === block.id) {
128
- const oldTotal = this.countChars(this.state.currentBlock.node)
140
+ const oldTotal = this.cachedTotalChars ?? this.countChars(this.state.currentBlock.node)
129
141
  const newTotal = this.countChars(block.node)
130
142
 
131
143
  this.state.currentBlock = block
132
144
 
133
145
  // 如果内容增加了且之前暂停了,继续
134
146
  if (newTotal > oldTotal && !this.rafId && !this.isPaused && this.state.currentProgress >= oldTotal) {
147
+ // 内容变化,清除缓存
148
+ this.clearCache()
135
149
  this.startIfNeeded()
136
150
  }
137
151
  }
@@ -156,6 +170,7 @@ export class BlockTransformer<T = unknown> {
156
170
  pendingBlocks: []
157
171
  }
158
172
  this.chunks = []
173
+ this.clearCache()
159
174
 
160
175
  this.emit()
161
176
  }
@@ -172,6 +187,7 @@ export class BlockTransformer<T = unknown> {
172
187
  pendingBlocks: []
173
188
  }
174
189
  this.chunks = []
190
+ this.clearCache()
175
191
  this.emit()
176
192
  }
177
193
 
@@ -195,6 +211,7 @@ export class BlockTransformer<T = unknown> {
195
211
 
196
212
  /**
197
213
  * 获取用于渲染的 display blocks
214
+ * 优化:使用缓存的 displayNode,避免重复遍历已稳定的节点
198
215
  */
199
216
  getDisplayBlocks(): DisplayBlock<T>[] {
200
217
  const result: DisplayBlock<T>[] = []
@@ -211,22 +228,17 @@ export class BlockTransformer<T = unknown> {
211
228
 
212
229
  // 当前正在显示的 block
213
230
  if (this.state.currentBlock) {
214
- const total = this.countChars(this.state.currentBlock.node)
215
- // fade-in 效果:传入累积的 chunks
216
- const accumulatedChunks: AccumulatedChunks | undefined =
217
- this.options.effect === 'fade-in' && this.chunks.length > 0
218
- ? { stableChars: 0, chunks: this.chunks }
219
- : undefined
231
+ // 使用缓存的字符数
232
+ const total = this.getTotalChars()
220
233
 
221
- const displayNode = this.sliceNode(
222
- this.state.currentBlock.node,
223
- this.state.currentProgress,
224
- accumulatedChunks
225
- )
234
+ // 如果进度变化了或缓存无效,更新缓存的 displayNode
235
+ if (this.state.currentProgress !== this.cachedProgress || !this.cachedDisplayNode) {
236
+ this.updateCachedDisplayNode()
237
+ }
226
238
 
227
239
  result.push({
228
240
  ...this.state.currentBlock,
229
- displayNode: displayNode || { type: 'paragraph', children: [] },
241
+ displayNode: this.cachedDisplayNode || { type: 'paragraph', children: [] },
230
242
  progress: total > 0 ? this.state.currentProgress / total : 1,
231
243
  isDisplayComplete: false
232
244
  })
@@ -346,6 +358,7 @@ export class BlockTransformer<T = unknown> {
346
358
  if (!this.state.currentBlock && this.state.pendingBlocks.length > 0) {
347
359
  this.state.currentBlock = this.state.pendingBlocks.shift()!
348
360
  this.state.currentProgress = 0
361
+ this.clearCache() // 新 block,清除缓存
349
362
  }
350
363
 
351
364
  if (this.state.currentBlock) {
@@ -387,7 +400,8 @@ export class BlockTransformer<T = unknown> {
387
400
  return
388
401
  }
389
402
 
390
- const total = this.countChars(block.node)
403
+ // 使用缓存的字符数,避免重复计算
404
+ const total = this.getTotalChars()
391
405
  const step = this.getStep()
392
406
  const prevProgress = this.state.currentProgress
393
407
 
@@ -408,12 +422,13 @@ export class BlockTransformer<T = unknown> {
408
422
  this.emit()
409
423
 
410
424
  if (this.state.currentProgress >= total) {
411
- // 当前 block 完成,清空 chunks
425
+ // 当前 block 完成,清空 chunks 和缓存
412
426
  this.notifyComplete(block.node)
413
427
  this.state.completedBlocks.push(block)
414
428
  this.state.currentBlock = null
415
429
  this.state.currentProgress = 0
416
430
  this.chunks = []
431
+ this.clearCache()
417
432
  this.processNext()
418
433
  }
419
434
  }
@@ -425,12 +440,6 @@ export class BlockTransformer<T = unknown> {
425
440
  let result = ''
426
441
  let charIndex = 0
427
442
 
428
- interface AstNode {
429
- type: string
430
- value?: string
431
- children?: AstNode[]
432
- }
433
-
434
443
  function traverse(n: AstNode): boolean {
435
444
  if (charIndex >= end) return false
436
445
 
@@ -477,6 +486,7 @@ export class BlockTransformer<T = unknown> {
477
486
  this.state.currentBlock = this.state.pendingBlocks.shift()!
478
487
  this.state.currentProgress = 0
479
488
  this.chunks = []
489
+ this.clearCache() // 新 block,清除缓存
480
490
  this.emit()
481
491
  // 继续运行(rAF 已经在调度中)
482
492
  } else {
@@ -537,6 +547,84 @@ export class BlockTransformer<T = unknown> {
537
547
  }
538
548
  }
539
549
  }
550
+
551
+ // ============ 缓存管理方法 ============
552
+
553
+ /**
554
+ * 更新缓存的 displayNode
555
+ * 使用真正的增量追加模式:只处理新增部分,不重复遍历已稳定的节点
556
+ */
557
+ private updateCachedDisplayNode(): void {
558
+ const block = this.state.currentBlock
559
+ if (!block) {
560
+ this.cachedDisplayNode = null
561
+ this.cachedProgress = 0
562
+ return
563
+ }
564
+
565
+ const currentProgress = this.state.currentProgress
566
+
567
+ // 如果进度减少了(内容更新导致),需要重新截断
568
+ if (currentProgress < this.cachedProgress) {
569
+ this.cachedDisplayNode = this.sliceNode(
570
+ block.node,
571
+ currentProgress,
572
+ this.getAccumulatedChunks()
573
+ )
574
+ this.cachedProgress = currentProgress
575
+ return
576
+ }
577
+
578
+ // 如果进度增加了,使用增量追加模式
579
+ if (currentProgress > this.cachedProgress && this.cachedDisplayNode) {
580
+ // 真正的增量追加:只处理新增部分,不重复遍历已稳定的节点
581
+ this.cachedDisplayNode = appendToAst(
582
+ this.cachedDisplayNode,
583
+ block.node,
584
+ this.cachedProgress,
585
+ currentProgress,
586
+ this.getAccumulatedChunks()
587
+ )
588
+ this.cachedProgress = currentProgress
589
+ } else if (!this.cachedDisplayNode) {
590
+ // 首次截断
591
+ this.cachedDisplayNode = this.sliceNode(
592
+ block.node,
593
+ currentProgress,
594
+ this.getAccumulatedChunks()
595
+ )
596
+ this.cachedProgress = currentProgress
597
+ }
598
+ }
599
+
600
+ /**
601
+ * 获取总字符数(带缓存)
602
+ */
603
+ private getTotalChars(): number {
604
+ if (this.cachedTotalChars === null && this.state.currentBlock) {
605
+ this.cachedTotalChars = this.countChars(this.state.currentBlock.node)
606
+ }
607
+ return this.cachedTotalChars ?? 0
608
+ }
609
+
610
+ /**
611
+ * 清除缓存(当 block 切换或内容更新时)
612
+ */
613
+ private clearCache(): void {
614
+ this.cachedDisplayNode = null
615
+ this.cachedTotalChars = null
616
+ this.cachedProgress = 0
617
+ }
618
+
619
+ /**
620
+ * 获取累积的 chunks(用于 fade-in 效果)
621
+ */
622
+ private getAccumulatedChunks(): AccumulatedChunks | undefined {
623
+ if (this.options.effect === 'fade-in' && this.chunks.length > 0) {
624
+ return { stableChars: 0, chunks: this.chunks }
625
+ }
626
+ return undefined
627
+ }
540
628
  }
541
629
 
542
630
  /**
@@ -1,4 +1,5 @@
1
1
  import type { RootContent } from 'mdast'
2
+ import type { BlockStatus } from '../types'
2
3
 
3
4
  /**
4
5
  * 源 Block 类型(来自解析器)
@@ -9,7 +10,7 @@ export interface SourceBlock<T = unknown> {
9
10
  /** AST 节点 */
10
11
  node: RootContent
11
12
  /** 块状态 */
12
- status: 'pending' | 'stable' | 'completed'
13
+ status: BlockStatus
13
14
  /** 用户自定义元数据 */
14
15
  meta?: T
15
16
  }
@@ -1,4 +1,5 @@
1
- import type { RootContent, Text, Parent } from 'mdast'
1
+ import type { RootContent, Text } from 'mdast'
2
+ import type { AstNode } from '../types'
2
3
 
3
4
  /**
4
5
  * 文本块片段(用于渐入动画)
@@ -21,34 +22,28 @@ export interface TextNodeWithChunks extends Text {
21
22
  }
22
23
 
23
24
  /**
24
- * AST 节点的通用类型(文本节点或容器节点)
25
+ * 计算 AST 节点的总字符数
25
26
  */
26
- interface AstNode {
27
- type: string
28
- value?: string
29
- children?: AstNode[]
27
+ export function countChars(node: RootContent): number {
28
+ return countCharsInNode(node as AstNode)
30
29
  }
31
30
 
32
31
  /**
33
- * 计算 AST 节点的总字符数
32
+ * 计算单个 AST 节点的字符数(内部辅助函数)
34
33
  */
35
- export function countChars(node: RootContent): number {
36
- let count = 0
37
-
38
- function traverse(n: AstNode): void {
39
- if (n.value && typeof n.value === 'string') {
40
- count += n.value.length
41
- return
42
- }
43
- if (n.children && Array.isArray(n.children)) {
44
- for (const child of n.children) {
45
- traverse(child)
46
- }
34
+ function countCharsInNode(n: AstNode): number {
35
+ if (n.value && typeof n.value === 'string') {
36
+ return n.value.length
37
+ }
38
+ if (n.children && Array.isArray(n.children)) {
39
+ let count = 0
40
+ for (const child of n.children) {
41
+ count += countCharsInNode(child)
47
42
  }
43
+ return count
48
44
  }
49
-
50
- traverse(node as AstNode)
51
- return count
45
+ // 其他节点(如 thematicBreak, image)算作 1 个字符
46
+ return 1
52
47
  }
53
48
 
54
49
  /**
@@ -71,20 +66,24 @@ interface ChunkRange {
71
66
  /**
72
67
  * 截断 AST 节点,只保留前 maxChars 个字符
73
68
  * 支持 chunks(用于渐入动画)
69
+ * 支持增量模式:跳过已处理的字符,只处理新增部分
74
70
  *
75
71
  * @param node 原始节点
76
72
  * @param maxChars 最大字符数
77
73
  * @param accumulatedChunks 累积的 chunks 信息(用于渐入动画)
74
+ * @param skipChars 跳过前 N 个字符(已处理的部分,用于增量追加)
78
75
  * @returns 截断后的节点,如果 maxChars <= 0 返回 null
79
76
  */
80
77
  export function sliceAst(
81
78
  node: RootContent,
82
79
  maxChars: number,
83
- accumulatedChunks?: AccumulatedChunks
80
+ accumulatedChunks?: AccumulatedChunks,
81
+ skipChars: number = 0
84
82
  ): RootContent | null {
85
83
  if (maxChars <= 0) return null
84
+ if (skipChars >= maxChars) return null
86
85
 
87
- let remaining = maxChars
86
+ let remaining = maxChars - skipChars // 只处理新增部分
88
87
  let charIndex = 0
89
88
 
90
89
  // 计算 chunks 在文本中的范围
@@ -106,14 +105,23 @@ export function sliceAst(
106
105
 
107
106
  // 文本类节点:截断 value,可能添加 chunks
108
107
  if (n.value && typeof n.value === 'string') {
109
- const take = Math.min(n.value.length, remaining)
108
+ const nodeStart = charIndex
109
+ const nodeEnd = charIndex + n.value.length
110
+
111
+ // 如果整个节点都在 skipChars 之前,跳过
112
+ if (nodeEnd <= skipChars) {
113
+ charIndex = nodeEnd
114
+ return null
115
+ }
116
+
117
+ // 如果节点跨越 skipChars,需要从 skipChars 位置开始取
118
+ const skipInNode = Math.max(0, skipChars - nodeStart)
119
+ const take = Math.min(n.value.length - skipInNode, remaining)
110
120
  remaining -= take
111
121
  if (take === 0) return null
112
122
 
113
- const slicedValue = n.value.slice(0, take)
114
- const nodeStart = charIndex
115
- const nodeEnd = charIndex + take
116
- charIndex += take
123
+ const slicedValue = n.value.slice(skipInNode, skipInNode + take)
124
+ charIndex = nodeEnd
117
125
 
118
126
  const result: AstNode & { stableLength?: number; chunks?: TextChunk[] } = {
119
127
  ...n,
@@ -126,14 +134,14 @@ export function sliceAst(
126
134
  let firstChunkLocalStart = take // 第一个 chunk 在节点中的起始位置
127
135
 
128
136
  for (const range of chunkRanges) {
129
- // 计算 chunk 与当前节点的交集
130
- const overlapStart = Math.max(range.start, nodeStart)
131
- const overlapEnd = Math.min(range.end, nodeEnd)
137
+ // 计算 chunk 与当前节点的交集(考虑 skipChars)
138
+ const overlapStart = Math.max(range.start, nodeStart + skipInNode)
139
+ const overlapEnd = Math.min(range.end, nodeStart + skipInNode + take)
132
140
 
133
141
  if (overlapStart < overlapEnd) {
134
- // 有交集,提取对应的文本
135
- const localStart = overlapStart - nodeStart
136
- const localEnd = overlapEnd - nodeStart
142
+ // 有交集,提取对应的文本(相对于 slicedValue)
143
+ const localStart = overlapStart - (nodeStart + skipInNode)
144
+ const localEnd = overlapEnd - (nodeStart + skipInNode)
137
145
  const chunkText = slicedValue.slice(localStart, localEnd)
138
146
 
139
147
  if (chunkText.length > 0) {
@@ -161,13 +169,36 @@ export function sliceAst(
161
169
  // 容器节点:递归处理 children
162
170
  if (n.children && Array.isArray(n.children)) {
163
171
  const newChildren: AstNode[] = []
172
+ let childCharIndex = charIndex
173
+
164
174
  for (const child of n.children) {
165
175
  if (remaining <= 0) break
176
+
177
+ // 计算子节点的字符范围
178
+ const childChars = countCharsInNode(child as AstNode)
179
+ const childStart = childCharIndex
180
+ const childEnd = childCharIndex + childChars
181
+
182
+ // 如果子节点完全在 skipChars 之前,跳过
183
+ if (childEnd <= skipChars) {
184
+ childCharIndex = childEnd
185
+ continue
186
+ }
187
+
188
+ // 如果子节点跨越 skipChars,需要处理
189
+ // 临时更新 charIndex 以便子节点正确处理 skipChars
190
+ const savedCharIndex = charIndex
191
+ charIndex = childStart
166
192
  const processed = process(child)
193
+ charIndex = savedCharIndex
194
+
167
195
  if (processed) {
168
196
  newChildren.push(processed)
169
197
  }
198
+
199
+ childCharIndex = childEnd
170
200
  }
201
+
171
202
  if (newChildren.length === 0) {
172
203
  return null
173
204
  }
@@ -183,9 +214,151 @@ export function sliceAst(
183
214
  return process(node as AstNode) as RootContent | null
184
215
  }
185
216
 
217
+ /**
218
+ * 增量追加:将新增的字符范围追加到现有的 displayNode
219
+ * 这是真正的增量追加实现,只处理新增部分,不重复遍历已稳定的节点
220
+ *
221
+ * @param baseNode 已截断的基础节点(稳定的部分)
222
+ * @param sourceNode 原始完整节点
223
+ * @param startChars 起始字符位置(已处理的字符数)
224
+ * @param endChars 结束字符位置(新的进度)
225
+ * @param accumulatedChunks 累积的 chunks 信息(用于渐入动画)
226
+ * @returns 追加后的完整节点
227
+ */
228
+ export function appendToAst(
229
+ baseNode: RootContent,
230
+ sourceNode: RootContent,
231
+ startChars: number,
232
+ endChars: number,
233
+ accumulatedChunks?: AccumulatedChunks
234
+ ): RootContent {
235
+ // 如果新增字符数为 0,直接返回 baseNode
236
+ if (endChars <= startChars) {
237
+ return baseNode
238
+ }
239
+
240
+ // 从 sourceNode 中提取新增的字符范围(跳过已处理的部分)
241
+ const newChars = endChars - startChars
242
+ const newPart = sliceAst(sourceNode, endChars, accumulatedChunks, startChars)
243
+
244
+ // 如果提取失败,返回 baseNode
245
+ if (!newPart) {
246
+ return baseNode
247
+ }
248
+
249
+ // 将新增部分合并到 baseNode
250
+ return mergeAstNodes(baseNode, newPart)
251
+ }
252
+
253
+ /**
254
+ * 合并两个 AST 节点
255
+ * 将 newPart 追加到 baseNode 的最后一个可追加节点中
256
+ */
257
+ function mergeAstNodes(baseNode: RootContent, newPart: RootContent): RootContent {
258
+ // 如果两个节点类型不同,无法合并,返回 baseNode
259
+ if (baseNode.type !== newPart.type) {
260
+ return baseNode
261
+ }
262
+
263
+ const base = baseNode as AstNode
264
+ const part = newPart as AstNode
265
+
266
+ // 如果是文本节点,合并文本和 chunks
267
+ if (base.value && typeof base.value === 'string' && part.value && typeof part.value === 'string') {
268
+ const baseChunks = (base as TextNodeWithChunks).chunks || []
269
+ const partChunks = (part as TextNodeWithChunks).chunks || []
270
+
271
+ // 合并所有 chunks:累积所有读取的 chunks
272
+ // chunks 数组包含每次读取的新文本片段,它们 join 到一起就是 value
273
+ const mergedChunks = [...baseChunks, ...partChunks]
274
+
275
+ // 根据设计:value = stableText + chunks[0].text + chunks[1].text + ... + chunks[n].text
276
+ // base.value = baseStableText + baseChunks[0].text + ... + baseChunks[n].text
277
+ // part.value = partStableText + partChunks[0].text + ... + partChunks[m].text
278
+ // 合并后:value = base.value + part.value(完整文本)
279
+ const mergedValue = base.value + part.value
280
+
281
+ // stableLength 是稳定部分的长度(不需要动画的部分)
282
+ // base 的稳定部分保持不变,base 的 chunks 和 part 的 chunks 都需要动画
283
+ const baseStableLength = (base as TextNodeWithChunks).stableLength ?? 0
284
+
285
+ // 验证:mergedValue 应该等于 baseStableText + 所有 chunks 的文本
286
+ // baseStableText = base.value.slice(0, baseStableLength)
287
+ // 所有 chunks 的文本 = baseChunks + partChunks 的文本
288
+ const result = {
289
+ ...base,
290
+ value: mergedValue,
291
+ stableLength: mergedChunks.length > 0 ? baseStableLength : undefined,
292
+ chunks: mergedChunks.length > 0 ? mergedChunks : undefined
293
+ } as TextNodeWithChunks
294
+
295
+ return result as RootContent
296
+ }
297
+
298
+ // 如果是容器节点,合并 children
299
+ if (base.children && Array.isArray(base.children) && part.children && Array.isArray(part.children)) {
300
+ // 如果 base 的最后一个子节点和 part 的第一个子节点类型相同,尝试合并
301
+ if (base.children.length > 0 && part.children.length > 0) {
302
+ const lastBaseChild = base.children[base.children.length - 1]
303
+ const firstPartChild = part.children[0]
304
+
305
+ if (lastBaseChild.type === firstPartChild.type) {
306
+ // 尝试合并最后一个和第一个子节点
307
+ const merged = mergeAstNodes(lastBaseChild as RootContent, firstPartChild as RootContent)
308
+ return {
309
+ ...base,
310
+ children: [
311
+ ...base.children.slice(0, -1),
312
+ merged as AstNode,
313
+ ...part.children.slice(1)
314
+ ]
315
+ } as RootContent
316
+ }
317
+ }
318
+
319
+ // 否则直接追加所有子节点
320
+ return {
321
+ ...base,
322
+ children: [...base.children, ...part.children]
323
+ } as RootContent
324
+ }
325
+
326
+ // 其他情况,返回 baseNode(无法合并)
327
+ return baseNode
328
+ }
329
+
186
330
  /**
187
331
  * 深拷贝 AST 节点
332
+ * 使用递归浅拷贝实现,比 JSON.parse/stringify 更高效
333
+ * 且保持对象结构完整性
188
334
  */
189
335
  export function cloneNode<T extends RootContent>(node: T): T {
190
- return JSON.parse(JSON.stringify(node))
336
+ // 优先使用 structuredClone(Node 17+ / 现代浏览器)
337
+ if (typeof structuredClone === 'function') {
338
+ return structuredClone(node)
339
+ }
340
+
341
+ // 回退到递归拷贝
342
+ return deepClone(node) as T
343
+ }
344
+
345
+ /**
346
+ * 递归深拷贝对象
347
+ */
348
+ function deepClone<T>(obj: T): T {
349
+ if (obj === null || typeof obj !== 'object') {
350
+ return obj
351
+ }
352
+
353
+ if (Array.isArray(obj)) {
354
+ return obj.map(item => deepClone(item)) as T
355
+ }
356
+
357
+ const cloned = {} as T
358
+ for (const key in obj) {
359
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
360
+ cloned[key] = deepClone(obj[key])
361
+ }
362
+ }
363
+ return cloned
191
364
  }
@@ -10,6 +10,17 @@ export type BlockStatus =
10
10
  | 'stable' // 可能完整,但下一个 chunk 可能会改变它
11
11
  | 'completed' // 确认完成,不会再改变
12
12
 
13
+ /**
14
+ * AST 节点的通用接口(用于遍历)
15
+ * 统一定义,避免各模块重复声明
16
+ */
17
+ export interface AstNode {
18
+ type: string
19
+ value?: string
20
+ children?: AstNode[]
21
+ [key: string]: unknown
22
+ }
23
+
13
24
  /**
14
25
  * 解析出的块
15
26
  */