@incremark/core 0.0.3 → 0.0.5

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,472 @@
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 } 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 visibilityHandler: (() => void) | null = null
58
+
59
+ constructor(options: TransformerOptions = {}) {
60
+ this.options = {
61
+ charsPerTick: options.charsPerTick ?? 1,
62
+ tickInterval: options.tickInterval ?? 20,
63
+ effect: options.effect ?? 'none',
64
+ plugins: options.plugins ?? [],
65
+ onChange: options.onChange ?? (() => {}),
66
+ pauseOnHidden: options.pauseOnHidden ?? true
67
+ }
68
+
69
+ this.state = {
70
+ completedBlocks: [],
71
+ currentBlock: null,
72
+ currentProgress: 0,
73
+ pendingBlocks: []
74
+ }
75
+
76
+ // 设置页面可见性监听
77
+ if (this.options.pauseOnHidden && typeof document !== 'undefined') {
78
+ this.setupVisibilityHandler()
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 推入新的 blocks
84
+ * 会自动过滤已存在的 blocks
85
+ */
86
+ push(blocks: SourceBlock<T>[]): void {
87
+ const existingIds = this.getAllBlockIds()
88
+
89
+ // 找出新增的 blocks
90
+ const newBlocks = blocks.filter((b) => !existingIds.has(b.id))
91
+
92
+ if (newBlocks.length > 0) {
93
+ this.state.pendingBlocks.push(...newBlocks)
94
+ this.startIfNeeded()
95
+ }
96
+
97
+ // 如果当前正在显示的 block 内容更新了(pending block 变化)
98
+ if (this.state.currentBlock) {
99
+ const updated = blocks.find((b) => b.id === this.state.currentBlock!.id)
100
+ if (updated && updated.node !== this.state.currentBlock.node) {
101
+ // 内容增加了,更新引用
102
+ this.state.currentBlock = updated
103
+ // 如果之前暂停了(因为到达末尾),重新开始
104
+ if (!this.rafId && !this.isPaused) {
105
+ const total = this.countChars(updated.node)
106
+ if (this.state.currentProgress < total) {
107
+ this.startIfNeeded()
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 更新指定 block(用于 pending block 内容增加时)
116
+ */
117
+ update(block: SourceBlock<T>): void {
118
+ if (this.state.currentBlock?.id === block.id) {
119
+ const oldTotal = this.countChars(this.state.currentBlock.node)
120
+ const newTotal = this.countChars(block.node)
121
+
122
+ this.state.currentBlock = block
123
+
124
+ // 如果内容增加了且之前暂停了,继续
125
+ if (newTotal > oldTotal && !this.rafId && !this.isPaused && this.state.currentProgress >= oldTotal) {
126
+ this.startIfNeeded()
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 跳过所有动画,直接显示全部内容
133
+ */
134
+ skip(): void {
135
+ this.stop()
136
+
137
+ const allBlocks = [
138
+ ...this.state.completedBlocks,
139
+ ...(this.state.currentBlock ? [this.state.currentBlock] : []),
140
+ ...this.state.pendingBlocks
141
+ ]
142
+
143
+ this.state = {
144
+ completedBlocks: allBlocks,
145
+ currentBlock: null,
146
+ currentProgress: 0,
147
+ pendingBlocks: []
148
+ }
149
+
150
+ this.emit()
151
+ }
152
+
153
+ /**
154
+ * 重置状态
155
+ */
156
+ reset(): void {
157
+ this.stop()
158
+ this.state = {
159
+ completedBlocks: [],
160
+ currentBlock: null,
161
+ currentProgress: 0,
162
+ pendingBlocks: []
163
+ }
164
+ this.emit()
165
+ }
166
+
167
+ /**
168
+ * 暂停动画
169
+ */
170
+ pause(): void {
171
+ this.isPaused = true
172
+ this.cancelRaf()
173
+ }
174
+
175
+ /**
176
+ * 恢复动画
177
+ */
178
+ resume(): void {
179
+ if (this.isPaused) {
180
+ this.isPaused = false
181
+ this.startIfNeeded()
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 获取用于渲染的 display blocks
187
+ */
188
+ getDisplayBlocks(): DisplayBlock<T>[] {
189
+ const result: DisplayBlock<T>[] = []
190
+
191
+ // 已完成的 blocks
192
+ for (const block of this.state.completedBlocks) {
193
+ result.push({
194
+ ...block,
195
+ displayNode: block.node,
196
+ progress: 1,
197
+ isDisplayComplete: true
198
+ })
199
+ }
200
+
201
+ // 当前正在显示的 block
202
+ if (this.state.currentBlock) {
203
+ const total = this.countChars(this.state.currentBlock.node)
204
+ const displayNode = this.sliceNode(this.state.currentBlock.node, this.state.currentProgress)
205
+
206
+ result.push({
207
+ ...this.state.currentBlock,
208
+ displayNode: displayNode || { type: 'paragraph', children: [] },
209
+ progress: total > 0 ? this.state.currentProgress / total : 1,
210
+ isDisplayComplete: false
211
+ })
212
+ }
213
+
214
+ return result
215
+ }
216
+
217
+ /**
218
+ * 是否正在处理中
219
+ */
220
+ isProcessing(): boolean {
221
+ return this.isRunning || this.state.currentBlock !== null || this.state.pendingBlocks.length > 0
222
+ }
223
+
224
+ /**
225
+ * 是否已暂停
226
+ */
227
+ isPausedState(): boolean {
228
+ return this.isPaused
229
+ }
230
+
231
+ /**
232
+ * 获取内部状态(用于调试)
233
+ */
234
+ getState(): Readonly<TransformerState<T>> {
235
+ return { ...this.state }
236
+ }
237
+
238
+ /**
239
+ * 动态更新配置
240
+ */
241
+ setOptions(options: Partial<Pick<TransformerOptions, 'charsPerTick' | 'tickInterval' | 'effect' | 'pauseOnHidden'>>): void {
242
+ if (options.charsPerTick !== undefined) {
243
+ this.options.charsPerTick = options.charsPerTick
244
+ }
245
+ if (options.tickInterval !== undefined) {
246
+ this.options.tickInterval = options.tickInterval
247
+ }
248
+ if (options.effect !== undefined) {
249
+ this.options.effect = options.effect
250
+ }
251
+ if (options.pauseOnHidden !== undefined) {
252
+ this.options.pauseOnHidden = options.pauseOnHidden
253
+ if (options.pauseOnHidden && typeof document !== 'undefined') {
254
+ this.setupVisibilityHandler()
255
+ } else {
256
+ this.removeVisibilityHandler()
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 获取当前配置
263
+ */
264
+ getOptions(): {
265
+ charsPerTick: number | [number, number]
266
+ tickInterval: number
267
+ effect: AnimationEffect
268
+ } {
269
+ return {
270
+ charsPerTick: this.options.charsPerTick,
271
+ tickInterval: this.options.tickInterval,
272
+ effect: this.options.effect
273
+ }
274
+ }
275
+
276
+ /**
277
+ * 获取当前动画效果
278
+ */
279
+ getEffect(): AnimationEffect {
280
+ return this.options.effect
281
+ }
282
+
283
+ /**
284
+ * 销毁,清理资源
285
+ */
286
+ destroy(): void {
287
+ this.stop()
288
+ this.removeVisibilityHandler()
289
+ }
290
+
291
+ // ============ 私有方法 ============
292
+
293
+ private getAllBlockIds(): Set<string> {
294
+ return new Set([
295
+ ...this.state.completedBlocks.map((b) => b.id),
296
+ this.state.currentBlock?.id,
297
+ ...this.state.pendingBlocks.map((b) => b.id)
298
+ ].filter((id): id is string => id !== undefined))
299
+ }
300
+
301
+ private setupVisibilityHandler(): void {
302
+ if (this.visibilityHandler) return
303
+
304
+ this.visibilityHandler = () => {
305
+ if (document.hidden) {
306
+ this.pause()
307
+ } else {
308
+ this.resume()
309
+ }
310
+ }
311
+
312
+ document.addEventListener('visibilitychange', this.visibilityHandler)
313
+ }
314
+
315
+ private removeVisibilityHandler(): void {
316
+ if (this.visibilityHandler) {
317
+ document.removeEventListener('visibilitychange', this.visibilityHandler)
318
+ this.visibilityHandler = null
319
+ }
320
+ }
321
+
322
+ private startIfNeeded(): void {
323
+ if (this.rafId || this.isPaused) return
324
+
325
+ if (!this.state.currentBlock && this.state.pendingBlocks.length > 0) {
326
+ this.state.currentBlock = this.state.pendingBlocks.shift()!
327
+ this.state.currentProgress = 0
328
+ }
329
+
330
+ if (this.state.currentBlock) {
331
+ this.isRunning = true
332
+ this.lastTickTime = 0
333
+ this.scheduleNextFrame()
334
+ }
335
+ }
336
+
337
+ private scheduleNextFrame(): void {
338
+ this.rafId = requestAnimationFrame((time) => this.animationFrame(time))
339
+ }
340
+
341
+ private animationFrame(time: number): void {
342
+ this.rafId = null
343
+
344
+ // 计算是否应该执行 tick
345
+ if (this.lastTickTime === 0) {
346
+ this.lastTickTime = time
347
+ }
348
+
349
+ const elapsed = time - this.lastTickTime
350
+
351
+ if (elapsed >= this.options.tickInterval) {
352
+ this.lastTickTime = time
353
+ this.tick()
354
+ }
355
+
356
+ // 如果还在运行,继续调度
357
+ if (this.isRunning && !this.isPaused) {
358
+ this.scheduleNextFrame()
359
+ }
360
+ }
361
+
362
+ private tick(): void {
363
+ const block = this.state.currentBlock
364
+ if (!block) {
365
+ this.processNext()
366
+ return
367
+ }
368
+
369
+ const total = this.countChars(block.node)
370
+ const step = this.getStep()
371
+
372
+ this.state.currentProgress = Math.min(this.state.currentProgress + step, total)
373
+
374
+ this.emit()
375
+
376
+ if (this.state.currentProgress >= total) {
377
+ // 当前 block 完成
378
+ this.notifyComplete(block.node)
379
+ this.state.completedBlocks.push(block)
380
+ this.state.currentBlock = null
381
+ this.state.currentProgress = 0
382
+ this.processNext()
383
+ }
384
+ }
385
+
386
+ private getStep(): number {
387
+ const { charsPerTick } = this.options
388
+ if (typeof charsPerTick === 'number') {
389
+ return charsPerTick
390
+ }
391
+ // 随机步长
392
+ const [min, max] = charsPerTick
393
+ return Math.floor(Math.random() * (max - min + 1)) + min
394
+ }
395
+
396
+ private processNext(): void {
397
+ if (this.state.pendingBlocks.length > 0) {
398
+ this.state.currentBlock = this.state.pendingBlocks.shift()!
399
+ this.state.currentProgress = 0
400
+ this.emit()
401
+ // 继续运行(rAF 已经在调度中)
402
+ } else {
403
+ this.isRunning = false
404
+ this.cancelRaf()
405
+ this.emit()
406
+ }
407
+ }
408
+
409
+ private cancelRaf(): void {
410
+ if (this.rafId) {
411
+ cancelAnimationFrame(this.rafId)
412
+ this.rafId = null
413
+ }
414
+ }
415
+
416
+ private stop(): void {
417
+ this.cancelRaf()
418
+ this.isRunning = false
419
+ this.isPaused = false
420
+ }
421
+
422
+ private emit(): void {
423
+ this.options.onChange(this.getDisplayBlocks())
424
+ }
425
+
426
+ // ============ 插件调用 ============
427
+
428
+ private countChars(node: RootContent): number {
429
+ // 先找匹配的插件
430
+ for (const plugin of this.options.plugins) {
431
+ if (plugin.match?.(node) && plugin.countChars) {
432
+ const result = plugin.countChars(node)
433
+ if (result !== undefined) return result
434
+ }
435
+ }
436
+ // 默认计算
437
+ return defaultCountChars(node)
438
+ }
439
+
440
+ private sliceNode(node: RootContent, chars: number): RootContent | null {
441
+ // 先找匹配的插件
442
+ for (const plugin of this.options.plugins) {
443
+ if (plugin.match?.(node) && plugin.sliceNode) {
444
+ const total = this.countChars(node)
445
+ const result = plugin.sliceNode(node, chars, total)
446
+ if (result !== null) return result
447
+ }
448
+ }
449
+ // 默认截断
450
+ return defaultSliceAst(node, chars)
451
+ }
452
+
453
+ private notifyComplete(node: RootContent): void {
454
+ for (const plugin of this.options.plugins) {
455
+ if (plugin.match?.(node) && plugin.onComplete) {
456
+ plugin.onComplete(node)
457
+ }
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * 创建 BlockTransformer 实例的工厂函数
464
+ */
465
+ export function createBlockTransformer<T = unknown>(
466
+ options?: TransformerOptions
467
+ ): BlockTransformer<T> {
468
+ return new BlockTransformer<T>(options)
469
+ }
470
+
471
+
472
+
@@ -0,0 +1,35 @@
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
+
24
+ // 内置插件
25
+ export {
26
+ codeBlockPlugin,
27
+ mermaidPlugin,
28
+ imagePlugin,
29
+ mathPlugin,
30
+ thematicBreakPlugin,
31
+ defaultPlugins,
32
+ allPlugins,
33
+ createPlugin
34
+ } from './plugins'
35
+
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Incremark BlockTransformer Animation Styles
3
+ *
4
+ * 这些样式用于配合 BlockTransformer 的 effect 选项
5
+ * 可以直接导入使用,或复制到你的项目中自定义
6
+ *
7
+ * 注意:光标字符已直接内嵌到内容中,以下样式仅供参考
8
+ */
9
+
10
+ /* ============ Typing 打字机光标效果 ============ */
11
+
12
+ /**
13
+ * 容器需要添加 .incremark-typing 类
14
+ * 光标字符已直接添加到正在输入的块内容末尾
15
+ */
16
+ .incremark-typing .incremark-pending {
17
+ /* 光标字符已内嵌在内容中,默认为 | */
18
+ }
19
+
20
+ /* ============ 代码块特殊处理 ============ */
21
+
22
+ /* 代码块内使用等宽字体 */
23
+ .incremark-typing pre,
24
+ .incremark-typing code {
25
+ font-family: monospace;
26
+ }
27
+
28
+ /* ============ 工具类 ============ */
29
+
30
+ /* 平滑过渡 */
31
+ .incremark-smooth * {
32
+ transition: opacity 0.1s ease-out;
33
+ }