@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.
- package/README.md +82 -1
- package/dist/index.d.ts +326 -2
- package/dist/index.js +552 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +43 -0
- package/src/transformer/BlockTransformer.ts +552 -0
- package/src/transformer/index.ts +36 -0
- package/src/transformer/plugins.ts +113 -0
- package/src/transformer/styles.css +33 -0
- package/src/transformer/types.ts +114 -0
- package/src/transformer/utils.ts +191 -0
|
@@ -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
|
+
}
|