@incremark/core 0.2.2 → 0.2.4

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,113 +0,0 @@
1
- import type { RootContent, Code } from 'mdast'
2
- import type { TransformerPlugin } from './types'
3
-
4
- /**
5
- * 代码块插件:整体出现,不逐字符显示
6
- *
7
- * 注意:默认不启用,代码块默认参与打字机效果
8
- * 如需整体显示代码块,可手动添加此插件
9
- */
10
- export const codeBlockPlugin: TransformerPlugin = {
11
- name: 'code-block',
12
- match: (node: RootContent) => node.type === 'code',
13
- countChars: () => 1, // 算作 1 个字符,整体出现
14
- sliceNode: (node, displayedChars, totalChars) => {
15
- // 要么全部显示,要么不显示
16
- return displayedChars >= totalChars ? node : null
17
- }
18
- }
19
-
20
- /**
21
- * Mermaid 图表插件:整体出现
22
- *
23
- * 注意:默认不启用,mermaid 默认参与打字机效果
24
- * 如需整体显示 mermaid,可手动添加此插件
25
- */
26
- export const mermaidPlugin: TransformerPlugin = {
27
- name: 'mermaid',
28
- match: (node: RootContent) => {
29
- if (node.type !== 'code') return false
30
- const codeNode = node as Code
31
- return codeNode.lang === 'mermaid'
32
- },
33
- countChars: () => 1,
34
- sliceNode: (node, displayedChars) => (displayedChars > 0 ? node : null)
35
- }
36
-
37
- /**
38
- * 图片插件:立即显示(不参与打字机效果)
39
- * 图片没有文本内容,应立即显示
40
- */
41
- export const imagePlugin: TransformerPlugin = {
42
- name: 'image',
43
- match: (node: RootContent) => node.type === 'image',
44
- countChars: () => 0 // 0 字符,立即显示
45
- }
46
-
47
- /**
48
- * 数学公式插件:整体出现
49
- *
50
- * 注意:默认不启用,数学公式默认参与打字机效果
51
- * 如需整体显示公式,可手动添加此插件
52
- */
53
- export const mathPlugin: TransformerPlugin = {
54
- name: 'math',
55
- match: (node: RootContent) => {
56
- const type = node.type as string
57
- return type === 'math' || type === 'inlineMath'
58
- },
59
- countChars: () => 1,
60
- sliceNode: (node, displayedChars) => (displayedChars > 0 ? node : null)
61
- }
62
-
63
- /**
64
- * 分割线插件:立即显示
65
- * 分隔线没有文本内容,应立即显示
66
- */
67
- export const thematicBreakPlugin: TransformerPlugin = {
68
- name: 'thematic-break',
69
- match: (node: RootContent) => node.type === 'thematicBreak',
70
- countChars: () => 0
71
- }
72
-
73
- /**
74
- * 默认插件集合
75
- *
76
- * 只包含确实需要特殊处理的节点:
77
- * - 图片:无文本内容,立即显示
78
- * - 分隔线:无文本内容,立即显示
79
- *
80
- * 代码块、mermaid、数学公式默认参与打字机效果
81
- * 如需整体显示,可手动添加对应插件
82
- */
83
- export const defaultPlugins: TransformerPlugin[] = [
84
- imagePlugin,
85
- thematicBreakPlugin
86
- ]
87
-
88
- /**
89
- * 完整插件集合(所有特殊节点整体显示)
90
- * 包含代码块、mermaid、数学公式等的整体显示
91
- */
92
- export const allPlugins: TransformerPlugin[] = [
93
- mermaidPlugin, // mermaid 优先于普通 code block
94
- codeBlockPlugin,
95
- imagePlugin,
96
- mathPlugin,
97
- thematicBreakPlugin
98
- ]
99
-
100
- /**
101
- * 创建自定义插件的辅助函数
102
- */
103
- export function createPlugin(
104
- name: string,
105
- matcher: (node: RootContent) => boolean,
106
- options: Partial<Omit<TransformerPlugin, 'name' | 'match'>> = {}
107
- ): TransformerPlugin {
108
- return {
109
- name,
110
- match: matcher,
111
- ...options
112
- }
113
- }
@@ -1,115 +0,0 @@
1
- import type { RootContent } from 'mdast'
2
- import type { BlockStatus } from '../types'
3
-
4
- /**
5
- * 源 Block 类型(来自解析器)
6
- */
7
- export interface SourceBlock<T = unknown> {
8
- /** 唯一标识 */
9
- id: string
10
- /** AST 节点 */
11
- node: RootContent
12
- /** 块状态 */
13
- status: BlockStatus
14
- /** 用户自定义元数据 */
15
- meta?: T
16
- }
17
-
18
- /**
19
- * 显示用的 Block(转换后)
20
- */
21
- export interface DisplayBlock<T = unknown> extends SourceBlock<T> {
22
- /** 用于显示的 AST 节点(可能是截断的) */
23
- displayNode: RootContent
24
- /** 显示进度 0-1 */
25
- progress: number
26
- /** 是否已完成显示 */
27
- isDisplayComplete: boolean
28
- }
29
-
30
- /**
31
- * 动画效果类型
32
- * - 'none': 无动画效果
33
- * - 'fade-in': 新增字符渐入效果
34
- * - 'typing': 打字机光标效果
35
- */
36
- export type AnimationEffect = 'none' | 'fade-in' | 'typing'
37
-
38
- /**
39
- * Transformer 插件
40
- */
41
- export interface TransformerPlugin {
42
- /** 插件名称 */
43
- name: string
44
-
45
- /**
46
- * 判断是否处理此节点
47
- * 返回 true 表示这个插件要处理此节点
48
- */
49
- match?: (node: RootContent) => boolean
50
-
51
- /**
52
- * 自定义字符数计算
53
- * 返回 undefined 则使用默认逻辑
54
- * 返回 0 表示立即显示(不参与逐字符效果)
55
- */
56
- countChars?: (node: RootContent) => number | undefined
57
-
58
- /**
59
- * 自定义截断逻辑
60
- * @param node 原始节点
61
- * @param displayedChars 当前应显示的字符数
62
- * @param totalChars 该节点的总字符数
63
- * @returns 截断后的节点,null 表示不显示
64
- */
65
- sliceNode?: (
66
- node: RootContent,
67
- displayedChars: number,
68
- totalChars: number
69
- ) => RootContent | null
70
-
71
- /**
72
- * 节点显示完成时的回调
73
- */
74
- onComplete?: (node: RootContent) => void
75
- }
76
-
77
- /**
78
- * Transformer 配置选项
79
- */
80
- export interface TransformerOptions {
81
- /**
82
- * 每 tick 增加的字符数
83
- * - number: 固定步长(默认 1)
84
- * - [min, max]: 随机步长区间(更自然的打字效果)
85
- */
86
- charsPerTick?: number | [number, number]
87
- /** tick 间隔 (ms),默认 20 */
88
- tickInterval?: number
89
- /** 动画效果,默认 'none' */
90
- effect?: AnimationEffect
91
- /** 插件列表 */
92
- plugins?: TransformerPlugin[]
93
- /** 状态变化回调 */
94
- onChange?: (displayBlocks: DisplayBlock[]) => void
95
- /**
96
- * 是否在页面不可见时自动暂停
97
- * 默认 true,节省资源
98
- */
99
- pauseOnHidden?: boolean
100
- }
101
-
102
- /**
103
- * Transformer 内部状态
104
- */
105
- export interface TransformerState<T = unknown> {
106
- /** 已完成显示的 blocks */
107
- completedBlocks: SourceBlock<T>[]
108
- /** 当前正在显示的 block */
109
- currentBlock: SourceBlock<T> | null
110
- /** 当前 block 已显示的字符数 */
111
- currentProgress: number
112
- /** 等待显示的 blocks */
113
- pendingBlocks: SourceBlock<T>[]
114
- }
115
-
@@ -1,364 +0,0 @@
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
- }
23
-
24
- /**
25
- * 计算 AST 节点的总字符数
26
- */
27
- export function countChars(node: RootContent): number {
28
- return countCharsInNode(node as AstNode)
29
- }
30
-
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)
42
- }
43
- return count
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
- }
58
-
59
- /** chunk 范围信息 */
60
- interface ChunkRange {
61
- start: number
62
- end: number
63
- chunk: TextChunk
64
- }
65
-
66
- /**
67
- * 截断 AST 节点,只保留前 maxChars 个字符
68
- * 支持 chunks(用于渐入动画)
69
- * 支持增量模式:跳过已处理的字符,只处理新增部分
70
- *
71
- * @param node 原始节点
72
- * @param maxChars 最大字符数
73
- * @param accumulatedChunks 累积的 chunks 信息(用于渐入动画)
74
- * @param skipChars 跳过前 N 个字符(已处理的部分,用于增量追加)
75
- * @returns 截断后的节点,如果 maxChars <= 0 返回 null
76
- */
77
- export function sliceAst(
78
- node: RootContent,
79
- maxChars: number,
80
- accumulatedChunks?: AccumulatedChunks,
81
- skipChars: number = 0
82
- ): RootContent | null {
83
- if (maxChars <= 0) return null
84
- if (skipChars >= maxChars) return null
85
-
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
- }
102
-
103
- function process(n: AstNode): AstNode | null {
104
- if (remaining <= 0) return null
105
-
106
- // 文本类节点:截断 value,可能添加 chunks
107
- if (n.value && typeof n.value === 'string') {
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)
120
- remaining -= take
121
- if (take === 0) return null
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
167
- }
168
-
169
- // 容器节点:递归处理 children
170
- if (n.children && Array.isArray(n.children)) {
171
- const newChildren: AstNode[] = []
172
- let childCharIndex = charIndex
173
-
174
- for (const child of n.children) {
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
192
- const processed = process(child)
193
- charIndex = savedCharIndex
194
-
195
- if (processed) {
196
- newChildren.push(processed)
197
- }
198
-
199
- childCharIndex = childEnd
200
- }
201
-
202
- if (newChildren.length === 0) {
203
- return null
204
- }
205
- return { ...n, children: newChildren }
206
- }
207
-
208
- // 其他节点(如 thematicBreak, image)
209
- remaining -= 1
210
- charIndex += 1
211
- return { ...n }
212
- }
213
-
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
328
- }
329
-
330
- /**
331
- * 深拷贝 AST 节点
332
- * 使用递归浅拷贝实现,比 JSON.parse/stringify 更高效
333
- * 且保持对象结构完整性
334
- */
335
- export function cloneNode<T extends RootContent>(node: T): T {
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
364
- }