@incremark/core 0.0.4 → 0.1.0

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.
@@ -0,0 +1,552 @@
1
+ import type { RootContent } from 'mdast'
2
+ import type {
3
+ SourceBlock,
4
+ DisplayBlock,
5
+ TransformerOptions,
6
+ TransformerState,
7
+ TransformerPlugin,
8
+ AnimationEffect
9
+ } from './types'
10
+ import { countChars as defaultCountChars, sliceAst as defaultSliceAst, type TextChunk, type AccumulatedChunks } from './utils'
11
+
12
+ /**
13
+ * Block Transformer
14
+ *
15
+ * 用于控制 blocks 的逐步显示(打字机效果)
16
+ * 作为解析器和渲染器之间的中间层
17
+ *
18
+ * 特性:
19
+ * - 使用 requestAnimationFrame 实现流畅动画
20
+ * - 支持随机步长,模拟真实打字效果
21
+ * - 支持 typing 动画效果
22
+ * - 页面不可见时自动暂停,节省资源
23
+ * - 插件系统支持自定义节点处理
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const transformer = new BlockTransformer({
28
+ * charsPerTick: [1, 3], // 随机 1-3 个字符
29
+ * tickInterval: 30,
30
+ * effect: 'typing',
31
+ * onChange: (displayBlocks) => {
32
+ * // 更新 UI
33
+ * }
34
+ * })
35
+ *
36
+ * // 推入新 blocks
37
+ * transformer.push(blocks)
38
+ *
39
+ * // 获取当前显示状态
40
+ * const displayBlocks = transformer.getDisplayBlocks()
41
+ * ```
42
+ */
43
+ export class BlockTransformer<T = unknown> {
44
+ private state: TransformerState<T>
45
+ private options: {
46
+ charsPerTick: number | [number, number]
47
+ tickInterval: number
48
+ effect: AnimationEffect
49
+ plugins: TransformerPlugin[]
50
+ onChange: (displayBlocks: DisplayBlock<T>[]) => void
51
+ pauseOnHidden: boolean
52
+ }
53
+ private rafId: number | null = null
54
+ private lastTickTime = 0
55
+ private isRunning = false
56
+ private isPaused = false
57
+ private chunks: TextChunk[] = [] // 累积的 chunks(用于 fade-in 动画)
58
+ private visibilityHandler: (() => void) | null = null
59
+
60
+ constructor(options: TransformerOptions = {}) {
61
+ this.options = {
62
+ charsPerTick: options.charsPerTick ?? 1,
63
+ tickInterval: options.tickInterval ?? 20,
64
+ effect: options.effect ?? 'none',
65
+ plugins: options.plugins ?? [],
66
+ onChange: options.onChange ?? (() => {}),
67
+ pauseOnHidden: options.pauseOnHidden ?? true
68
+ }
69
+
70
+ this.state = {
71
+ completedBlocks: [],
72
+ currentBlock: null,
73
+ currentProgress: 0,
74
+ pendingBlocks: []
75
+ }
76
+
77
+ // 设置页面可见性监听
78
+ if (this.options.pauseOnHidden && typeof document !== 'undefined') {
79
+ this.setupVisibilityHandler()
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 推入新的 blocks
85
+ * 会自动过滤已存在的 blocks
86
+ */
87
+ push(blocks: SourceBlock<T>[]): void {
88
+ const existingIds = this.getAllBlockIds()
89
+
90
+ // 找出新增的 blocks
91
+ const newBlocks = blocks.filter((b) => !existingIds.has(b.id))
92
+
93
+ if (newBlocks.length > 0) {
94
+ this.state.pendingBlocks.push(...newBlocks)
95
+ this.startIfNeeded()
96
+ }
97
+
98
+ // 如果当前正在显示的 block 内容更新了(pending block 变化)
99
+ if (this.state.currentBlock) {
100
+ const updated = blocks.find((b) => b.id === this.state.currentBlock!.id)
101
+ if (updated && updated.node !== this.state.currentBlock.node) {
102
+ const oldTotal = this.countChars(this.state.currentBlock.node)
103
+ const newTotal = this.countChars(updated.node)
104
+
105
+ // 如果字符数减少了(AST 结构变化,如 **xxx 变成 **xxx**)
106
+ // 重新计算进度,保持相对位置
107
+ if (newTotal < oldTotal || newTotal < this.state.currentProgress) {
108
+ this.state.currentProgress = Math.min(this.state.currentProgress, newTotal)
109
+ }
110
+
111
+ // 内容更新,更新引用
112
+ this.state.currentBlock = updated
113
+ // 如果之前暂停了(因为到达末尾),重新开始
114
+ if (!this.rafId && !this.isPaused) {
115
+ if (this.state.currentProgress < newTotal) {
116
+ this.startIfNeeded()
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 更新指定 block(用于 pending block 内容增加时)
125
+ */
126
+ update(block: SourceBlock<T>): void {
127
+ if (this.state.currentBlock?.id === block.id) {
128
+ const oldTotal = this.countChars(this.state.currentBlock.node)
129
+ const newTotal = this.countChars(block.node)
130
+
131
+ this.state.currentBlock = block
132
+
133
+ // 如果内容增加了且之前暂停了,继续
134
+ if (newTotal > oldTotal && !this.rafId && !this.isPaused && this.state.currentProgress >= oldTotal) {
135
+ this.startIfNeeded()
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 跳过所有动画,直接显示全部内容
142
+ */
143
+ skip(): void {
144
+ this.stop()
145
+
146
+ const allBlocks = [
147
+ ...this.state.completedBlocks,
148
+ ...(this.state.currentBlock ? [this.state.currentBlock] : []),
149
+ ...this.state.pendingBlocks
150
+ ]
151
+
152
+ this.state = {
153
+ completedBlocks: allBlocks,
154
+ currentBlock: null,
155
+ currentProgress: 0,
156
+ pendingBlocks: []
157
+ }
158
+ this.chunks = []
159
+
160
+ this.emit()
161
+ }
162
+
163
+ /**
164
+ * 重置状态
165
+ */
166
+ reset(): void {
167
+ this.stop()
168
+ this.state = {
169
+ completedBlocks: [],
170
+ currentBlock: null,
171
+ currentProgress: 0,
172
+ pendingBlocks: []
173
+ }
174
+ this.chunks = []
175
+ this.emit()
176
+ }
177
+
178
+ /**
179
+ * 暂停动画
180
+ */
181
+ pause(): void {
182
+ this.isPaused = true
183
+ this.cancelRaf()
184
+ }
185
+
186
+ /**
187
+ * 恢复动画
188
+ */
189
+ resume(): void {
190
+ if (this.isPaused) {
191
+ this.isPaused = false
192
+ this.startIfNeeded()
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 获取用于渲染的 display blocks
198
+ */
199
+ getDisplayBlocks(): DisplayBlock<T>[] {
200
+ const result: DisplayBlock<T>[] = []
201
+
202
+ // 已完成的 blocks
203
+ for (const block of this.state.completedBlocks) {
204
+ result.push({
205
+ ...block,
206
+ displayNode: block.node,
207
+ progress: 1,
208
+ isDisplayComplete: true
209
+ })
210
+ }
211
+
212
+ // 当前正在显示的 block
213
+ 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
220
+
221
+ const displayNode = this.sliceNode(
222
+ this.state.currentBlock.node,
223
+ this.state.currentProgress,
224
+ accumulatedChunks
225
+ )
226
+
227
+ result.push({
228
+ ...this.state.currentBlock,
229
+ displayNode: displayNode || { type: 'paragraph', children: [] },
230
+ progress: total > 0 ? this.state.currentProgress / total : 1,
231
+ isDisplayComplete: false
232
+ })
233
+ }
234
+
235
+ return result
236
+ }
237
+
238
+ /**
239
+ * 是否正在处理中
240
+ */
241
+ isProcessing(): boolean {
242
+ return this.isRunning || this.state.currentBlock !== null || this.state.pendingBlocks.length > 0
243
+ }
244
+
245
+ /**
246
+ * 是否已暂停
247
+ */
248
+ isPausedState(): boolean {
249
+ return this.isPaused
250
+ }
251
+
252
+ /**
253
+ * 获取内部状态(用于调试)
254
+ */
255
+ getState(): Readonly<TransformerState<T>> {
256
+ return { ...this.state }
257
+ }
258
+
259
+ /**
260
+ * 动态更新配置
261
+ */
262
+ setOptions(options: Partial<Pick<TransformerOptions, 'charsPerTick' | 'tickInterval' | 'effect' | 'pauseOnHidden'>>): void {
263
+ if (options.charsPerTick !== undefined) {
264
+ this.options.charsPerTick = options.charsPerTick
265
+ }
266
+ if (options.tickInterval !== undefined) {
267
+ this.options.tickInterval = options.tickInterval
268
+ }
269
+ if (options.effect !== undefined) {
270
+ this.options.effect = options.effect
271
+ }
272
+ if (options.pauseOnHidden !== undefined) {
273
+ this.options.pauseOnHidden = options.pauseOnHidden
274
+ if (options.pauseOnHidden && typeof document !== 'undefined') {
275
+ this.setupVisibilityHandler()
276
+ } else {
277
+ this.removeVisibilityHandler()
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 获取当前配置
284
+ */
285
+ getOptions(): {
286
+ charsPerTick: number | [number, number]
287
+ tickInterval: number
288
+ effect: AnimationEffect
289
+ } {
290
+ return {
291
+ charsPerTick: this.options.charsPerTick,
292
+ tickInterval: this.options.tickInterval,
293
+ effect: this.options.effect
294
+ }
295
+ }
296
+
297
+ /**
298
+ * 获取当前动画效果
299
+ */
300
+ getEffect(): AnimationEffect {
301
+ return this.options.effect
302
+ }
303
+
304
+ /**
305
+ * 销毁,清理资源
306
+ */
307
+ destroy(): void {
308
+ this.stop()
309
+ this.removeVisibilityHandler()
310
+ }
311
+
312
+ // ============ 私有方法 ============
313
+
314
+ private getAllBlockIds(): Set<string> {
315
+ return new Set([
316
+ ...this.state.completedBlocks.map((b) => b.id),
317
+ this.state.currentBlock?.id,
318
+ ...this.state.pendingBlocks.map((b) => b.id)
319
+ ].filter((id): id is string => id !== undefined))
320
+ }
321
+
322
+ private setupVisibilityHandler(): void {
323
+ if (this.visibilityHandler) return
324
+
325
+ this.visibilityHandler = () => {
326
+ if (document.hidden) {
327
+ this.pause()
328
+ } else {
329
+ this.resume()
330
+ }
331
+ }
332
+
333
+ document.addEventListener('visibilitychange', this.visibilityHandler)
334
+ }
335
+
336
+ private removeVisibilityHandler(): void {
337
+ if (this.visibilityHandler) {
338
+ document.removeEventListener('visibilitychange', this.visibilityHandler)
339
+ this.visibilityHandler = null
340
+ }
341
+ }
342
+
343
+ private startIfNeeded(): void {
344
+ if (this.rafId || this.isPaused) return
345
+
346
+ if (!this.state.currentBlock && this.state.pendingBlocks.length > 0) {
347
+ this.state.currentBlock = this.state.pendingBlocks.shift()!
348
+ this.state.currentProgress = 0
349
+ }
350
+
351
+ if (this.state.currentBlock) {
352
+ this.isRunning = true
353
+ this.lastTickTime = 0
354
+ this.scheduleNextFrame()
355
+ }
356
+ }
357
+
358
+ private scheduleNextFrame(): void {
359
+ this.rafId = requestAnimationFrame((time) => this.animationFrame(time))
360
+ }
361
+
362
+ private animationFrame(time: number): void {
363
+ this.rafId = null
364
+
365
+ // 计算是否应该执行 tick
366
+ if (this.lastTickTime === 0) {
367
+ this.lastTickTime = time
368
+ }
369
+
370
+ const elapsed = time - this.lastTickTime
371
+
372
+ if (elapsed >= this.options.tickInterval) {
373
+ this.lastTickTime = time
374
+ this.tick()
375
+ }
376
+
377
+ // 如果还在运行,继续调度
378
+ if (this.isRunning && !this.isPaused) {
379
+ this.scheduleNextFrame()
380
+ }
381
+ }
382
+
383
+ private tick(): void {
384
+ const block = this.state.currentBlock
385
+ if (!block) {
386
+ this.processNext()
387
+ return
388
+ }
389
+
390
+ const total = this.countChars(block.node)
391
+ const step = this.getStep()
392
+ const prevProgress = this.state.currentProgress
393
+
394
+ this.state.currentProgress = Math.min(prevProgress + step, total)
395
+
396
+ // 如果是 fade-in 效果,添加新的 chunk
397
+ if (this.options.effect === 'fade-in' && this.state.currentProgress > prevProgress) {
398
+ // 从 block.node 中提取新增的字符
399
+ const newText = this.extractText(block.node, prevProgress, this.state.currentProgress)
400
+ if (newText.length > 0) {
401
+ this.chunks.push({
402
+ text: newText,
403
+ createdAt: Date.now()
404
+ })
405
+ }
406
+ }
407
+
408
+ this.emit()
409
+
410
+ if (this.state.currentProgress >= total) {
411
+ // 当前 block 完成,清空 chunks
412
+ this.notifyComplete(block.node)
413
+ this.state.completedBlocks.push(block)
414
+ this.state.currentBlock = null
415
+ this.state.currentProgress = 0
416
+ this.chunks = []
417
+ this.processNext()
418
+ }
419
+ }
420
+
421
+ /**
422
+ * 从 AST 节点中提取指定范围的文本
423
+ */
424
+ private extractText(node: RootContent, start: number, end: number): string {
425
+ let result = ''
426
+ let charIndex = 0
427
+
428
+ interface AstNode {
429
+ type: string
430
+ value?: string
431
+ children?: AstNode[]
432
+ }
433
+
434
+ function traverse(n: AstNode): boolean {
435
+ if (charIndex >= end) return false
436
+
437
+ if (n.value && typeof n.value === 'string') {
438
+ const nodeStart = charIndex
439
+ const nodeEnd = charIndex + n.value.length
440
+ charIndex = nodeEnd
441
+
442
+ // 计算交集
443
+ const overlapStart = Math.max(start, nodeStart)
444
+ const overlapEnd = Math.min(end, nodeEnd)
445
+
446
+ if (overlapStart < overlapEnd) {
447
+ result += n.value.slice(overlapStart - nodeStart, overlapEnd - nodeStart)
448
+ }
449
+ return charIndex < end
450
+ }
451
+
452
+ if (n.children && Array.isArray(n.children)) {
453
+ for (const child of n.children) {
454
+ if (!traverse(child)) return false
455
+ }
456
+ }
457
+
458
+ return true
459
+ }
460
+
461
+ traverse(node as AstNode)
462
+ return result
463
+ }
464
+
465
+ private getStep(): number {
466
+ const { charsPerTick } = this.options
467
+ if (typeof charsPerTick === 'number') {
468
+ return charsPerTick
469
+ }
470
+ // 随机步长
471
+ const [min, max] = charsPerTick
472
+ return Math.floor(Math.random() * (max - min + 1)) + min
473
+ }
474
+
475
+ private processNext(): void {
476
+ if (this.state.pendingBlocks.length > 0) {
477
+ this.state.currentBlock = this.state.pendingBlocks.shift()!
478
+ this.state.currentProgress = 0
479
+ this.chunks = []
480
+ this.emit()
481
+ // 继续运行(rAF 已经在调度中)
482
+ } else {
483
+ this.isRunning = false
484
+ this.cancelRaf()
485
+ this.emit()
486
+ }
487
+ }
488
+
489
+ private cancelRaf(): void {
490
+ if (this.rafId) {
491
+ cancelAnimationFrame(this.rafId)
492
+ this.rafId = null
493
+ }
494
+ }
495
+
496
+ private stop(): void {
497
+ this.cancelRaf()
498
+ this.isRunning = false
499
+ this.isPaused = false
500
+ }
501
+
502
+ private emit(): void {
503
+ this.options.onChange(this.getDisplayBlocks())
504
+ }
505
+
506
+ // ============ 插件调用 ============
507
+
508
+ private countChars(node: RootContent): number {
509
+ // 先找匹配的插件
510
+ for (const plugin of this.options.plugins) {
511
+ if (plugin.match?.(node) && plugin.countChars) {
512
+ const result = plugin.countChars(node)
513
+ if (result !== undefined) return result
514
+ }
515
+ }
516
+ // 默认计算
517
+ return defaultCountChars(node)
518
+ }
519
+
520
+ private sliceNode(node: RootContent, chars: number, accumulatedChunks?: AccumulatedChunks): RootContent | null {
521
+ // 先找匹配的插件
522
+ for (const plugin of this.options.plugins) {
523
+ if (plugin.match?.(node) && plugin.sliceNode) {
524
+ const total = this.countChars(node)
525
+ const result = plugin.sliceNode(node, chars, total)
526
+ if (result !== null) return result
527
+ }
528
+ }
529
+ // 默认截断,传入累积的 chunks
530
+ return defaultSliceAst(node, chars, accumulatedChunks)
531
+ }
532
+
533
+ private notifyComplete(node: RootContent): void {
534
+ for (const plugin of this.options.plugins) {
535
+ if (plugin.match?.(node) && plugin.onComplete) {
536
+ plugin.onComplete(node)
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ /**
543
+ * 创建 BlockTransformer 实例的工厂函数
544
+ */
545
+ export function createBlockTransformer<T = unknown>(
546
+ options?: TransformerOptions
547
+ ): BlockTransformer<T> {
548
+ return new BlockTransformer<T>(options)
549
+ }
550
+
551
+
552
+
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Block Transformer
3
+ *
4
+ * 用于控制 blocks 的逐步显示(打字机效果)
5
+ * 作为解析器和渲染器之间的中间层
6
+ */
7
+
8
+ // 核心类
9
+ export { BlockTransformer, createBlockTransformer } from './BlockTransformer'
10
+
11
+ // 类型
12
+ export type {
13
+ SourceBlock,
14
+ DisplayBlock,
15
+ TransformerPlugin,
16
+ TransformerOptions,
17
+ TransformerState,
18
+ AnimationEffect
19
+ } from './types'
20
+
21
+ // 工具函数
22
+ export { countChars, sliceAst, cloneNode } from './utils'
23
+ export type { TextChunk, TextNodeWithChunks, AccumulatedChunks } from './utils'
24
+
25
+ // 内置插件
26
+ export {
27
+ codeBlockPlugin,
28
+ mermaidPlugin,
29
+ imagePlugin,
30
+ mathPlugin,
31
+ thematicBreakPlugin,
32
+ defaultPlugins,
33
+ allPlugins,
34
+ createPlugin
35
+ } from './plugins'
36
+
@@ -0,0 +1,113 @@
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
+ }