@incremark/core 0.0.5 → 0.1.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.
@@ -1,85 +1,364 @@
1
- import type { RootContent } from 'mdast'
1
+ import type { RootContent, Text } from 'mdast'
2
+ import type { AstNode } from '../types'
3
+
4
+ /**
5
+ * 文本块片段(用于渐入动画)
6
+ */
7
+ export interface TextChunk {
8
+ /** 文本内容 */
9
+ text: string
10
+ /** 创建时间戳 */
11
+ createdAt: number
12
+ }
13
+
14
+ /**
15
+ * 扩展的文本节点(支持 chunks)
16
+ */
17
+ export interface TextNodeWithChunks extends Text {
18
+ /** 稳定部分的长度(不需要动画) */
19
+ stableLength?: number
20
+ /** 临时的文本片段,用于渐入动画 */
21
+ chunks?: TextChunk[]
22
+ }
2
23
 
3
24
  /**
4
25
  * 计算 AST 节点的总字符数
5
26
  */
6
27
  export function countChars(node: RootContent): number {
7
- let count = 0
8
-
9
- function traverse(n: any): void {
10
- // 文本类节点
11
- if (n.value && typeof n.value === 'string') {
12
- count += n.value.length
13
- return
14
- }
28
+ return countCharsInNode(node as AstNode)
29
+ }
15
30
 
16
- // 容器节点,递归处理子节点
17
- if (n.children && Array.isArray(n.children)) {
18
- for (const child of n.children) {
19
- traverse(child)
20
- }
31
+ /**
32
+ * 计算单个 AST 节点的字符数(内部辅助函数)
33
+ */
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)
21
42
  }
43
+ return count
22
44
  }
45
+ // 其他节点(如 thematicBreak, image)算作 1 个字符
46
+ return 1
47
+ }
48
+
49
+ /**
50
+ * 累积的 chunks 信息
51
+ */
52
+ export interface AccumulatedChunks {
53
+ /** 已经稳定显示的字符数(不需要动画) */
54
+ stableChars: number
55
+ /** 累积的 chunk 列表 */
56
+ chunks: TextChunk[]
57
+ }
23
58
 
24
- traverse(node)
25
- return count
59
+ /** chunk 范围信息 */
60
+ interface ChunkRange {
61
+ start: number
62
+ end: number
63
+ chunk: TextChunk
26
64
  }
27
65
 
28
66
  /**
29
67
  * 截断 AST 节点,只保留前 maxChars 个字符
68
+ * 支持 chunks(用于渐入动画)
69
+ * 支持增量模式:跳过已处理的字符,只处理新增部分
30
70
  *
31
71
  * @param node 原始节点
32
72
  * @param maxChars 最大字符数
73
+ * @param accumulatedChunks 累积的 chunks 信息(用于渐入动画)
74
+ * @param skipChars 跳过前 N 个字符(已处理的部分,用于增量追加)
33
75
  * @returns 截断后的节点,如果 maxChars <= 0 返回 null
34
76
  */
35
- export function sliceAst(node: RootContent, maxChars: number): RootContent | null {
77
+ export function sliceAst(
78
+ node: RootContent,
79
+ maxChars: number,
80
+ accumulatedChunks?: AccumulatedChunks,
81
+ skipChars: number = 0
82
+ ): RootContent | null {
36
83
  if (maxChars <= 0) return null
84
+ if (skipChars >= maxChars) return null
37
85
 
38
- let remaining = maxChars
86
+ let remaining = maxChars - skipChars // 只处理新增部分
87
+ let charIndex = 0
88
+
89
+ // 计算 chunks 在文本中的范围
90
+ const chunkRanges: ChunkRange[] = []
91
+ if (accumulatedChunks && accumulatedChunks.chunks.length > 0) {
92
+ let chunkStart = accumulatedChunks.stableChars
93
+ for (const chunk of accumulatedChunks.chunks) {
94
+ chunkRanges.push({
95
+ start: chunkStart,
96
+ end: chunkStart + chunk.text.length,
97
+ chunk
98
+ })
99
+ chunkStart += chunk.text.length
100
+ }
101
+ }
39
102
 
40
- function process(n: any): any {
103
+ function process(n: AstNode): AstNode | null {
41
104
  if (remaining <= 0) return null
42
105
 
43
- // 文本类节点:截断 value
106
+ // 文本类节点:截断 value,可能添加 chunks
44
107
  if (n.value && typeof n.value === 'string') {
45
- 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)
46
120
  remaining -= take
47
121
  if (take === 0) return null
48
- return { ...n, value: n.value.slice(0, take) }
122
+
123
+ const slicedValue = n.value.slice(skipInNode, skipInNode + take)
124
+ charIndex = nodeEnd
125
+
126
+ const result: AstNode & { stableLength?: number; chunks?: TextChunk[] } = {
127
+ ...n,
128
+ value: slicedValue
129
+ }
130
+
131
+ // 检查是否有 chunks 落在这个节点范围内
132
+ if (chunkRanges.length > 0 && accumulatedChunks) {
133
+ const nodeChunks: TextChunk[] = []
134
+ let firstChunkLocalStart = take // 第一个 chunk 在节点中的起始位置
135
+
136
+ for (const range of chunkRanges) {
137
+ // 计算 chunk 与当前节点的交集(考虑 skipChars)
138
+ const overlapStart = Math.max(range.start, nodeStart + skipInNode)
139
+ const overlapEnd = Math.min(range.end, nodeStart + skipInNode + take)
140
+
141
+ if (overlapStart < overlapEnd) {
142
+ // 有交集,提取对应的文本(相对于 slicedValue)
143
+ const localStart = overlapStart - (nodeStart + skipInNode)
144
+ const localEnd = overlapEnd - (nodeStart + skipInNode)
145
+ const chunkText = slicedValue.slice(localStart, localEnd)
146
+
147
+ if (chunkText.length > 0) {
148
+ // 记录第一个 chunk 的起始位置
149
+ if (nodeChunks.length === 0) {
150
+ firstChunkLocalStart = localStart
151
+ }
152
+ nodeChunks.push({
153
+ text: chunkText,
154
+ createdAt: range.chunk.createdAt
155
+ })
156
+ }
157
+ }
158
+ }
159
+
160
+ if (nodeChunks.length > 0) {
161
+ result.stableLength = firstChunkLocalStart
162
+ result.chunks = nodeChunks
163
+ }
164
+ }
165
+
166
+ return result
49
167
  }
50
168
 
51
169
  // 容器节点:递归处理 children
52
170
  if (n.children && Array.isArray(n.children)) {
53
- const newChildren: any[] = []
171
+ const newChildren: AstNode[] = []
172
+ let childCharIndex = charIndex
173
+
54
174
  for (const child of n.children) {
55
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
56
192
  const processed = process(child)
193
+ charIndex = savedCharIndex
194
+
57
195
  if (processed) {
58
196
  newChildren.push(processed)
59
197
  }
198
+
199
+ childCharIndex = childEnd
60
200
  }
61
- // 如果没有 children 了,根据节点类型决定是否保留
201
+
62
202
  if (newChildren.length === 0) {
63
- // 对于某些容器节点,即使没有内容也应该保留结构
64
- // 例如 list 节点如果没有 children 就不应该渲染
65
203
  return null
66
204
  }
67
205
  return { ...n, children: newChildren }
68
206
  }
69
207
 
70
- // 其他节点(如 thematicBreak, image):整体处理
71
- // 算作 1 个字符的消耗
208
+ // 其他节点(如 thematicBreak, image
72
209
  remaining -= 1
210
+ charIndex += 1
73
211
  return { ...n }
74
212
  }
75
213
 
76
- return process(node)
214
+ return process(node as AstNode) as RootContent | null
215
+ }
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
77
328
  }
78
329
 
79
330
  /**
80
331
  * 深拷贝 AST 节点
332
+ * 使用递归浅拷贝实现,比 JSON.parse/stringify 更高效
333
+ * 且保持对象结构完整性
81
334
  */
82
335
  export function cloneNode<T extends RootContent>(node: T): T {
83
- 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
84
343
  }
85
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
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
  */