@incremark/react 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 +188 -1
- package/dist/index.d.ts +181 -14
- package/dist/index.js +428 -30
- package/dist/styles.css +15 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Incremark 的 React 18+ 集成库。
|
|
|
7
7
|
## 特性
|
|
8
8
|
|
|
9
9
|
- 📦 **开箱即用** - 提供 `useIncremark` hook 和 `<Incremark>` 组件
|
|
10
|
+
- ⌨️ **打字机效果** - 内置 `useBlockTransformer` 实现逐字符显示
|
|
10
11
|
- 🎨 **可定制** - 支持自定义渲染组件
|
|
11
12
|
- ⚡ **高性能** - 利用 React 的 reconciliation 机制
|
|
12
13
|
- 🔧 **DevTools** - 内置开发者工具
|
|
@@ -71,11 +72,96 @@ function App() {
|
|
|
71
72
|
| `blocks` | `Block[]` | 所有块 |
|
|
72
73
|
| `completedBlocks` | `Block[]` | 已完成块 |
|
|
73
74
|
| `pendingBlocks` | `Block[]` | 待处理块 |
|
|
75
|
+
| `isLoading` | `boolean` | 是否正在加载 |
|
|
74
76
|
| `append` | `Function` | 追加内容 |
|
|
75
77
|
| `finalize` | `Function` | 完成解析 |
|
|
76
78
|
| `reset` | `Function` | 重置状态 |
|
|
77
79
|
| `render` | `Function` | 一次性渲染(reset + append + finalize) |
|
|
78
80
|
|
|
81
|
+
### useBlockTransformer(sourceBlocks, options)
|
|
82
|
+
|
|
83
|
+
打字机效果 hook。作为解析器和渲染器之间的中间层,控制内容的逐步显示。
|
|
84
|
+
|
|
85
|
+
**参数:**
|
|
86
|
+
|
|
87
|
+
| 参数 | 类型 | 说明 |
|
|
88
|
+
|------|------|------|
|
|
89
|
+
| `sourceBlocks` | `SourceBlock[]` | 源 blocks(通常来自 `completedBlocks`) |
|
|
90
|
+
| `options.charsPerTick` | `number` | 每次显示的字符数(默认:2) |
|
|
91
|
+
| `options.tickInterval` | `number` | 每次显示的间隔时间 ms(默认:50) |
|
|
92
|
+
| `options.plugins` | `TransformerPlugin[]` | 插件列表(用于特殊块的处理) |
|
|
93
|
+
|
|
94
|
+
**返回值:**
|
|
95
|
+
|
|
96
|
+
| 属性 | 类型 | 说明 |
|
|
97
|
+
|------|------|------|
|
|
98
|
+
| `displayBlocks` | `DisplayBlock[]` | 用于渲染的 blocks |
|
|
99
|
+
| `isProcessing` | `boolean` | 是否正在处理中 |
|
|
100
|
+
| `skip` | `Function` | 跳过动画,立即显示全部内容 |
|
|
101
|
+
| `reset` | `Function` | 重置状态 |
|
|
102
|
+
| `setOptions` | `Function` | 动态更新配置 |
|
|
103
|
+
|
|
104
|
+
**使用示例:**
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { useMemo, useState, useEffect } from 'react'
|
|
108
|
+
import { useIncremark, useBlockTransformer, Incremark, defaultPlugins } from '@incremark/react'
|
|
109
|
+
|
|
110
|
+
function App() {
|
|
111
|
+
const { completedBlocks, append, finalize, reset: resetParser } = useIncremark()
|
|
112
|
+
|
|
113
|
+
// 配置打字机速度
|
|
114
|
+
const [typewriterSpeed, setTypewriterSpeed] = useState(2)
|
|
115
|
+
const [typewriterInterval, setTypewriterInterval] = useState(50)
|
|
116
|
+
|
|
117
|
+
// 转换为 SourceBlock 格式
|
|
118
|
+
const sourceBlocks = useMemo(() =>
|
|
119
|
+
completedBlocks.map(block => ({
|
|
120
|
+
id: block.id,
|
|
121
|
+
node: block.node,
|
|
122
|
+
status: block.status
|
|
123
|
+
})),
|
|
124
|
+
[completedBlocks]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// 使用 BlockTransformer
|
|
128
|
+
const {
|
|
129
|
+
displayBlocks,
|
|
130
|
+
isProcessing,
|
|
131
|
+
skip,
|
|
132
|
+
reset: resetTransformer,
|
|
133
|
+
setOptions
|
|
134
|
+
} = useBlockTransformer(sourceBlocks, {
|
|
135
|
+
charsPerTick: typewriterSpeed,
|
|
136
|
+
tickInterval: typewriterInterval,
|
|
137
|
+
plugins: defaultPlugins
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// 监听配置变化
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setOptions({ charsPerTick: typewriterSpeed, tickInterval: typewriterInterval })
|
|
143
|
+
}, [typewriterSpeed, typewriterInterval, setOptions])
|
|
144
|
+
|
|
145
|
+
// 转换为渲染格式
|
|
146
|
+
const renderBlocks = useMemo(() =>
|
|
147
|
+
displayBlocks.map(db => ({
|
|
148
|
+
...db,
|
|
149
|
+
stableId: db.id,
|
|
150
|
+
node: db.displayNode,
|
|
151
|
+
status: db.isDisplayComplete ? 'completed' : 'pending'
|
|
152
|
+
})),
|
|
153
|
+
[displayBlocks]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div>
|
|
158
|
+
<Incremark blocks={renderBlocks} />
|
|
159
|
+
{isProcessing && <button onClick={skip}>跳过</button>}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
79
165
|
### useDevTools(incremark)
|
|
80
166
|
|
|
81
167
|
启用 DevTools。
|
|
@@ -93,9 +179,18 @@ useDevTools(incremark)
|
|
|
93
179
|
<Incremark
|
|
94
180
|
blocks={blocks}
|
|
95
181
|
components={{ heading: MyHeading }}
|
|
182
|
+
showBlockStatus={true}
|
|
96
183
|
/>
|
|
97
184
|
```
|
|
98
185
|
|
|
186
|
+
**Props:**
|
|
187
|
+
|
|
188
|
+
| Prop | 类型 | 说明 |
|
|
189
|
+
|------|------|------|
|
|
190
|
+
| `blocks` | `Block[]` | 要渲染的 blocks |
|
|
191
|
+
| `components` | `object` | 自定义组件映射 |
|
|
192
|
+
| `showBlockStatus` | `boolean` | 是否显示块状态(pending/completed) |
|
|
193
|
+
|
|
99
194
|
## 自定义组件
|
|
100
195
|
|
|
101
196
|
```tsx
|
|
@@ -114,6 +209,44 @@ function App() {
|
|
|
114
209
|
}
|
|
115
210
|
```
|
|
116
211
|
|
|
212
|
+
## 插件系统
|
|
213
|
+
|
|
214
|
+
BlockTransformer 支持插件来处理特殊类型的块:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import {
|
|
218
|
+
defaultPlugins,
|
|
219
|
+
codeBlockPlugin,
|
|
220
|
+
imagePlugin,
|
|
221
|
+
mermaidPlugin,
|
|
222
|
+
mathPlugin,
|
|
223
|
+
thematicBreakPlugin,
|
|
224
|
+
createPlugin
|
|
225
|
+
} from '@incremark/react'
|
|
226
|
+
|
|
227
|
+
// 使用默认插件集
|
|
228
|
+
const { displayBlocks } = useBlockTransformer(sourceBlocks, {
|
|
229
|
+
plugins: defaultPlugins
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// 或自定义插件
|
|
233
|
+
const myPlugin = createPlugin({
|
|
234
|
+
name: 'my-plugin',
|
|
235
|
+
match: (node) => node.type === 'myType',
|
|
236
|
+
transform: (node) => ({ displayNode: node, isComplete: true })
|
|
237
|
+
})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**内置插件:**
|
|
241
|
+
|
|
242
|
+
| 插件 | 说明 |
|
|
243
|
+
|------|------|
|
|
244
|
+
| `codeBlockPlugin` | 代码块整体显示 |
|
|
245
|
+
| `imagePlugin` | 图片整体显示 |
|
|
246
|
+
| `mermaidPlugin` | Mermaid 图表整体显示 |
|
|
247
|
+
| `mathPlugin` | 数学公式整体显示 |
|
|
248
|
+
| `thematicBreakPlugin` | 分隔线整体显示 |
|
|
249
|
+
|
|
117
250
|
## 与 React Query 集成
|
|
118
251
|
|
|
119
252
|
```tsx
|
|
@@ -143,7 +276,61 @@ function StreamingContent() {
|
|
|
143
276
|
}
|
|
144
277
|
```
|
|
145
278
|
|
|
279
|
+
## AutoScrollContainer
|
|
280
|
+
|
|
281
|
+
自动滚动容器组件,适用于流式内容场景:
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
import { useRef, useState } from 'react'
|
|
285
|
+
import { AutoScrollContainer, Incremark, type AutoScrollContainerRef } from '@incremark/react'
|
|
286
|
+
|
|
287
|
+
function App() {
|
|
288
|
+
const scrollRef = useRef<AutoScrollContainerRef>(null)
|
|
289
|
+
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div>
|
|
293
|
+
<AutoScrollContainer
|
|
294
|
+
ref={scrollRef}
|
|
295
|
+
enabled={autoScrollEnabled}
|
|
296
|
+
threshold={50}
|
|
297
|
+
behavior="smooth"
|
|
298
|
+
>
|
|
299
|
+
<Incremark blocks={blocks} />
|
|
300
|
+
</AutoScrollContainer>
|
|
301
|
+
|
|
302
|
+
{/* 显示滚动状态 */}
|
|
303
|
+
{scrollRef.current?.isUserScrolledUp() && (
|
|
304
|
+
<span>用户已暂停自动滚动</span>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{/* 强制滚动到底部 */}
|
|
308
|
+
<button onClick={() => scrollRef.current?.scrollToBottom()}>
|
|
309
|
+
滚动到底部
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Props:**
|
|
317
|
+
|
|
318
|
+
| Prop | 类型 | 默认值 | 说明 |
|
|
319
|
+
|------|------|--------|------|
|
|
320
|
+
| `enabled` | `boolean` | `true` | 是否启用自动滚动 |
|
|
321
|
+
| `threshold` | `number` | `50` | 触发自动滚动的底部阈值(像素) |
|
|
322
|
+
| `behavior` | `ScrollBehavior` | `'instant'` | 滚动行为 |
|
|
323
|
+
| `className` | `string` | - | 容器类名 |
|
|
324
|
+
| `style` | `CSSProperties` | - | 容器样式 |
|
|
325
|
+
|
|
326
|
+
**Ref 方法(通过 useRef):**
|
|
327
|
+
|
|
328
|
+
| 方法 | 说明 |
|
|
329
|
+
|------|------|
|
|
330
|
+
| `scrollToBottom()` | 强制滚动到底部 |
|
|
331
|
+
| `isUserScrolledUp()` | 返回用户是否手动向上滚动了 |
|
|
332
|
+
| `container` | 容器 DOM 元素引用 |
|
|
333
|
+
|
|
146
334
|
## License
|
|
147
335
|
|
|
148
336
|
MIT
|
|
149
|
-
|
package/dist/index.d.ts
CHANGED
|
@@ -1,32 +1,85 @@
|
|
|
1
|
-
import { ParserOptions, ParsedBlock, Root, IncrementalUpdate, IncremarkParser, RootContent } from '@incremark/core';
|
|
2
|
-
export { IncrementalUpdate, ParsedBlock, ParserOptions, Root, RootContent } from '@incremark/core';
|
|
1
|
+
import { ParserOptions, AnimationEffect, TransformerPlugin, ParsedBlock, Root, IncrementalUpdate, IncremarkParser, SourceBlock, TransformerOptions, DisplayBlock, BlockTransformer, RootContent } from '@incremark/core';
|
|
2
|
+
export { AnimationEffect, BlockTransformer, DisplayBlock, IncrementalUpdate, ParsedBlock, ParserOptions, Root, RootContent, SourceBlock, TransformerOptions, TransformerPlugin, TransformerState, allPlugins, cloneNode, codeBlockPlugin, countChars, createBlockTransformer, createPlugin, defaultPlugins, imagePlugin, mathPlugin, mermaidPlugin, sliceAst, thematicBreakPlugin } from '@incremark/core';
|
|
3
3
|
import * as _incremark_devtools from '@incremark/devtools';
|
|
4
4
|
import { DevToolsOptions } from '@incremark/devtools';
|
|
5
5
|
import React from 'react';
|
|
6
6
|
|
|
7
|
+
/** 打字机效果配置 */
|
|
8
|
+
interface TypewriterOptions {
|
|
9
|
+
/** 是否启用打字机效果(可动态切换) */
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
/** 每次显示的字符数,可以是固定值或范围 [min, max] */
|
|
12
|
+
charsPerTick?: number | [number, number];
|
|
13
|
+
/** 更新间隔 (ms) */
|
|
14
|
+
tickInterval?: number;
|
|
15
|
+
/** 动画效果: 'none' | 'fade-in' | 'typing' */
|
|
16
|
+
effect?: AnimationEffect;
|
|
17
|
+
/** 光标字符(仅 typing 效果使用) */
|
|
18
|
+
cursor?: string;
|
|
19
|
+
/** 页面不可见时暂停 */
|
|
20
|
+
pauseOnHidden?: boolean;
|
|
21
|
+
/** 自定义插件 */
|
|
22
|
+
plugins?: TransformerPlugin[];
|
|
23
|
+
}
|
|
7
24
|
interface UseIncremarkOptions extends ParserOptions {
|
|
25
|
+
/** 打字机配置,传入即创建 transformer(可通过 enabled 控制是否启用) */
|
|
26
|
+
typewriter?: TypewriterOptions;
|
|
8
27
|
}
|
|
9
28
|
interface BlockWithStableId$1 extends ParsedBlock {
|
|
10
29
|
stableId: string;
|
|
11
30
|
}
|
|
31
|
+
/** 打字机控制对象 */
|
|
32
|
+
interface TypewriterControls {
|
|
33
|
+
/** 是否启用 */
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
/** 设置启用状态 */
|
|
36
|
+
setEnabled: (enabled: boolean) => void;
|
|
37
|
+
/** 是否正在处理中 */
|
|
38
|
+
isProcessing: boolean;
|
|
39
|
+
/** 是否已暂停 */
|
|
40
|
+
isPaused: boolean;
|
|
41
|
+
/** 当前动画效果 */
|
|
42
|
+
effect: AnimationEffect;
|
|
43
|
+
/** 跳过动画,直接显示全部 */
|
|
44
|
+
skip: () => void;
|
|
45
|
+
/** 暂停动画 */
|
|
46
|
+
pause: () => void;
|
|
47
|
+
/** 恢复动画 */
|
|
48
|
+
resume: () => void;
|
|
49
|
+
/** 动态更新配置 */
|
|
50
|
+
setOptions: (options: Partial<TypewriterOptions>) => void;
|
|
51
|
+
}
|
|
12
52
|
/**
|
|
13
53
|
* React Hook: Incremark 流式 Markdown 解析器
|
|
14
54
|
*
|
|
15
55
|
* @example
|
|
16
56
|
* ```tsx
|
|
17
|
-
* import { useIncremark } from '@incremark/react'
|
|
57
|
+
* import { useIncremark, Incremark } from '@incremark/react'
|
|
18
58
|
*
|
|
19
59
|
* function App() {
|
|
20
|
-
*
|
|
60
|
+
* // 基础用法
|
|
61
|
+
* const { blocks, append, finalize } = useIncremark()
|
|
21
62
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
63
|
+
* // 启用打字机效果
|
|
64
|
+
* const { blocks, append, finalize, typewriter } = useIncremark({
|
|
65
|
+
* typewriter: {
|
|
66
|
+
* enabled: true, // 可动态切换
|
|
67
|
+
* charsPerTick: [1, 3],
|
|
68
|
+
* tickInterval: 30,
|
|
69
|
+
* effect: 'typing',
|
|
70
|
+
* cursor: '|'
|
|
25
71
|
* }
|
|
26
|
-
*
|
|
27
|
-
* }
|
|
72
|
+
* })
|
|
28
73
|
*
|
|
29
|
-
*
|
|
74
|
+
* // 动态切换打字机效果
|
|
75
|
+
* typewriter.setEnabled(false)
|
|
76
|
+
*
|
|
77
|
+
* return (
|
|
78
|
+
* <>
|
|
79
|
+
* <Incremark blocks={blocks} />
|
|
80
|
+
* {typewriter.isProcessing && <button onClick={typewriter.skip}>跳过</button>}
|
|
81
|
+
* </>
|
|
82
|
+
* )
|
|
30
83
|
* }
|
|
31
84
|
* ```
|
|
32
85
|
*/
|
|
@@ -39,7 +92,7 @@ declare function useIncremark(options?: UseIncremarkOptions): {
|
|
|
39
92
|
pendingBlocks: ParsedBlock[];
|
|
40
93
|
/** 当前完整的 AST */
|
|
41
94
|
ast: Root;
|
|
42
|
-
/**
|
|
95
|
+
/** 用于渲染的 blocks(根据打字机设置自动处理) */
|
|
43
96
|
blocks: BlockWithStableId$1[];
|
|
44
97
|
/** 是否正在加载 */
|
|
45
98
|
isLoading: boolean;
|
|
@@ -49,12 +102,14 @@ declare function useIncremark(options?: UseIncremarkOptions): {
|
|
|
49
102
|
finalize: () => IncrementalUpdate;
|
|
50
103
|
/** 强制中断 */
|
|
51
104
|
abort: () => IncrementalUpdate;
|
|
52
|
-
/**
|
|
105
|
+
/** 重置解析器和打字机 */
|
|
53
106
|
reset: () => void;
|
|
54
107
|
/** 一次性渲染(reset + append + finalize) */
|
|
55
108
|
render: (content: string) => IncrementalUpdate;
|
|
56
109
|
/** 解析器实例 */
|
|
57
110
|
parser: IncremarkParser;
|
|
111
|
+
/** 打字机控制 */
|
|
112
|
+
typewriter: TypewriterControls;
|
|
58
113
|
};
|
|
59
114
|
type UseIncremarkReturn = ReturnType<typeof useIncremark>;
|
|
60
115
|
|
|
@@ -77,8 +132,78 @@ interface UseDevToolsOptions extends DevToolsOptions {
|
|
|
77
132
|
*/
|
|
78
133
|
declare function useDevTools(incremark: UseIncremarkReturn, options?: UseDevToolsOptions): _incremark_devtools.IncremarkDevTools | null;
|
|
79
134
|
|
|
135
|
+
interface UseBlockTransformerOptions extends Omit<TransformerOptions, 'onChange'> {
|
|
136
|
+
}
|
|
137
|
+
interface UseBlockTransformerReturn<T = unknown> {
|
|
138
|
+
/** 用于渲染的 display blocks */
|
|
139
|
+
displayBlocks: DisplayBlock<T>[];
|
|
140
|
+
/** 是否正在处理中 */
|
|
141
|
+
isProcessing: boolean;
|
|
142
|
+
/** 是否已暂停 */
|
|
143
|
+
isPaused: boolean;
|
|
144
|
+
/** 当前动画效果 */
|
|
145
|
+
effect: AnimationEffect;
|
|
146
|
+
/** 跳过所有动画 */
|
|
147
|
+
skip: () => void;
|
|
148
|
+
/** 重置状态 */
|
|
149
|
+
reset: () => void;
|
|
150
|
+
/** 暂停动画 */
|
|
151
|
+
pause: () => void;
|
|
152
|
+
/** 恢复动画 */
|
|
153
|
+
resume: () => void;
|
|
154
|
+
/** 动态更新配置 */
|
|
155
|
+
setOptions: (options: Partial<Pick<TransformerOptions, 'charsPerTick' | 'tickInterval' | 'effect' | 'pauseOnHidden'>>) => void;
|
|
156
|
+
/** transformer 实例(用于高级用法) */
|
|
157
|
+
transformer: BlockTransformer<T>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* React Hook: Block Transformer
|
|
161
|
+
*
|
|
162
|
+
* 用于控制 blocks 的逐步显示(打字机效果)
|
|
163
|
+
* 作为解析器和渲染器之间的中间层
|
|
164
|
+
*
|
|
165
|
+
* 特性:
|
|
166
|
+
* - 使用 requestAnimationFrame 实现流畅动画
|
|
167
|
+
* - 支持随机步长 `charsPerTick: [1, 3]`
|
|
168
|
+
* - 支持动画效果 `effect: 'typing'`
|
|
169
|
+
* - 页面不可见时自动暂停
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```tsx
|
|
173
|
+
* import { useIncremark, useBlockTransformer, defaultPlugins } from '@incremark/react'
|
|
174
|
+
*
|
|
175
|
+
* function App() {
|
|
176
|
+
* const { completedBlocks, append, finalize } = useIncremark()
|
|
177
|
+
*
|
|
178
|
+
* // 转换为 SourceBlock 格式
|
|
179
|
+
* const sourceBlocks = useMemo(() => completedBlocks.map(block => ({
|
|
180
|
+
* id: block.id,
|
|
181
|
+
* node: block.node,
|
|
182
|
+
* status: block.status
|
|
183
|
+
* })), [completedBlocks])
|
|
184
|
+
*
|
|
185
|
+
* // 添加打字机效果
|
|
186
|
+
* const { displayBlocks, isProcessing, skip, effect } = useBlockTransformer(sourceBlocks, {
|
|
187
|
+
* charsPerTick: [1, 3], // 随机步长
|
|
188
|
+
* tickInterval: 30,
|
|
189
|
+
* effect: 'typing', // 光标效果
|
|
190
|
+
* plugins: defaultPlugins
|
|
191
|
+
* })
|
|
192
|
+
*
|
|
193
|
+
* return (
|
|
194
|
+
* <div className={effect === 'typing' ? 'typing' : ''}>
|
|
195
|
+
* <Incremark blocks={displayBlocks} />
|
|
196
|
+
* {isProcessing && <button onClick={skip}>跳过</button>}
|
|
197
|
+
* </div>
|
|
198
|
+
* )
|
|
199
|
+
* }
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
declare function useBlockTransformer<T = unknown>(sourceBlocks: SourceBlock<T>[], options?: UseBlockTransformerOptions): UseBlockTransformerReturn<T>;
|
|
203
|
+
|
|
80
204
|
interface BlockWithStableId extends ParsedBlock {
|
|
81
205
|
stableId: string;
|
|
206
|
+
isLastPending?: boolean;
|
|
82
207
|
}
|
|
83
208
|
interface IncremarkProps {
|
|
84
209
|
/** 要渲染的块列表 */
|
|
@@ -110,7 +235,7 @@ declare const Incremark: React.FC<IncremarkProps>;
|
|
|
110
235
|
interface IncremarkRendererProps {
|
|
111
236
|
node: RootContent;
|
|
112
237
|
components?: Partial<Record<string, React.ComponentType<{
|
|
113
|
-
node:
|
|
238
|
+
node: RootContent;
|
|
114
239
|
}>>>;
|
|
115
240
|
}
|
|
116
241
|
/**
|
|
@@ -118,4 +243,46 @@ interface IncremarkRendererProps {
|
|
|
118
243
|
*/
|
|
119
244
|
declare const IncremarkRenderer: React.FC<IncremarkRendererProps>;
|
|
120
245
|
|
|
121
|
-
|
|
246
|
+
interface AutoScrollContainerProps {
|
|
247
|
+
/** 子元素 */
|
|
248
|
+
children: React.ReactNode;
|
|
249
|
+
/** 是否启用自动滚动 */
|
|
250
|
+
enabled?: boolean;
|
|
251
|
+
/** 触发自动滚动的底部阈值(像素) */
|
|
252
|
+
threshold?: number;
|
|
253
|
+
/** 滚动行为 */
|
|
254
|
+
behavior?: ScrollBehavior;
|
|
255
|
+
/** 容器样式 */
|
|
256
|
+
style?: React.CSSProperties;
|
|
257
|
+
/** 容器类名 */
|
|
258
|
+
className?: string;
|
|
259
|
+
}
|
|
260
|
+
interface AutoScrollContainerRef {
|
|
261
|
+
/** 强制滚动到底部 */
|
|
262
|
+
scrollToBottom: () => void;
|
|
263
|
+
/** 是否用户手动向上滚动了 */
|
|
264
|
+
isUserScrolledUp: () => boolean;
|
|
265
|
+
/** 容器元素引用 */
|
|
266
|
+
container: HTMLDivElement | null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 自动滚动容器
|
|
270
|
+
*
|
|
271
|
+
* 当内容更新时自动滚动到底部。
|
|
272
|
+
* 如果用户手动向上滚动,则暂停自动滚动,直到用户再次滚动到底部。
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* ```tsx
|
|
276
|
+
* const scrollRef = useRef<AutoScrollContainerRef>(null)
|
|
277
|
+
*
|
|
278
|
+
* <AutoScrollContainer ref={scrollRef} enabled={true}>
|
|
279
|
+
* <Incremark blocks={blocks} />
|
|
280
|
+
* </AutoScrollContainer>
|
|
281
|
+
*
|
|
282
|
+
* // 强制滚动到底部
|
|
283
|
+
* scrollRef.current?.scrollToBottom()
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
declare const AutoScrollContainer: React.ForwardRefExoticComponent<AutoScrollContainerProps & React.RefAttributes<AutoScrollContainerRef>>;
|
|
287
|
+
|
|
288
|
+
export { AutoScrollContainer, type AutoScrollContainerProps, type AutoScrollContainerRef, Incremark, type IncremarkProps, IncremarkRenderer, type IncremarkRendererProps, type TypewriterControls, type TypewriterOptions, type UseBlockTransformerOptions, type UseBlockTransformerReturn, type UseDevToolsOptions, type UseIncremarkOptions, type UseIncremarkReturn, useBlockTransformer, useDevTools, useIncremark };
|
package/dist/index.js
CHANGED
|
@@ -1,31 +1,125 @@
|
|
|
1
1
|
// src/hooks/useIncremark.ts
|
|
2
|
-
import { useState, useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
3
3
|
import {
|
|
4
|
-
createIncremarkParser
|
|
4
|
+
createIncremarkParser,
|
|
5
|
+
createBlockTransformer,
|
|
6
|
+
defaultPlugins
|
|
5
7
|
} from "@incremark/core";
|
|
6
8
|
function useIncremark(options = {}) {
|
|
7
9
|
const parserRef = useRef(null);
|
|
10
|
+
const transformerRef = useRef(null);
|
|
11
|
+
const hasTypewriterConfig = !!options.typewriter;
|
|
12
|
+
const cursorRef = useRef(options.typewriter?.cursor ?? "|");
|
|
8
13
|
if (!parserRef.current) {
|
|
9
14
|
parserRef.current = createIncremarkParser(options);
|
|
10
15
|
}
|
|
16
|
+
if (hasTypewriterConfig && !transformerRef.current) {
|
|
17
|
+
const twOptions = options.typewriter;
|
|
18
|
+
transformerRef.current = createBlockTransformer({
|
|
19
|
+
charsPerTick: twOptions.charsPerTick ?? [1, 3],
|
|
20
|
+
tickInterval: twOptions.tickInterval ?? 30,
|
|
21
|
+
effect: twOptions.effect ?? "none",
|
|
22
|
+
pauseOnHidden: twOptions.pauseOnHidden ?? true,
|
|
23
|
+
plugins: twOptions.plugins ?? defaultPlugins,
|
|
24
|
+
onChange: () => {
|
|
25
|
+
setForceUpdateCount((c) => c + 1);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
11
29
|
const parser = parserRef.current;
|
|
30
|
+
const transformer = transformerRef.current;
|
|
12
31
|
const [markdown, setMarkdown] = useState("");
|
|
13
32
|
const [completedBlocks, setCompletedBlocks] = useState([]);
|
|
14
33
|
const [pendingBlocks, setPendingBlocks] = useState([]);
|
|
15
34
|
const [isLoading, setIsLoading] = useState(false);
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
const [forceUpdateCount, setForceUpdateCount] = useState(0);
|
|
36
|
+
const [typewriterEnabled, setTypewriterEnabled] = useState(options.typewriter?.enabled ?? hasTypewriterConfig);
|
|
37
|
+
const [isTypewriterProcessing, setIsTypewriterProcessing] = useState(false);
|
|
38
|
+
const [isTypewriterPaused, setIsTypewriterPaused] = useState(false);
|
|
39
|
+
const [typewriterEffect, setTypewriterEffect] = useState(
|
|
40
|
+
options.typewriter?.effect ?? "none"
|
|
41
|
+
);
|
|
42
|
+
const sourceBlocks = useMemo(
|
|
43
|
+
() => completedBlocks.map((block) => ({
|
|
44
|
+
id: block.id,
|
|
45
|
+
node: block.node,
|
|
46
|
+
status: block.status
|
|
47
|
+
})),
|
|
48
|
+
[completedBlocks]
|
|
49
|
+
);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!transformer) return;
|
|
52
|
+
transformer.push(sourceBlocks);
|
|
53
|
+
const displayBlocks = transformer.getDisplayBlocks();
|
|
54
|
+
const currentDisplaying = displayBlocks.find((b) => !b.isDisplayComplete);
|
|
55
|
+
if (currentDisplaying) {
|
|
56
|
+
const updated = sourceBlocks.find((b) => b.id === currentDisplaying.id);
|
|
57
|
+
if (updated) {
|
|
58
|
+
transformer.update(updated);
|
|
59
|
+
}
|
|
20
60
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
setIsTypewriterProcessing(transformer.isProcessing());
|
|
62
|
+
setIsTypewriterPaused(transformer.isPausedState());
|
|
63
|
+
}, [sourceBlocks, transformer]);
|
|
64
|
+
const addCursorToNode = useCallback((node, cursor) => {
|
|
65
|
+
const cloned = JSON.parse(JSON.stringify(node));
|
|
66
|
+
function addToLast(n) {
|
|
67
|
+
if (n.children && n.children.length > 0) {
|
|
68
|
+
for (let i = n.children.length - 1; i >= 0; i--) {
|
|
69
|
+
if (addToLast(n.children[i])) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
n.children.push({ type: "text", value: cursor });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (n.type === "text" && typeof n.value === "string") {
|
|
77
|
+
n.value += cursor;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (typeof n.value === "string") {
|
|
81
|
+
n.value += cursor;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
addToLast(cloned);
|
|
87
|
+
return cloned;
|
|
88
|
+
}, []);
|
|
89
|
+
const blocks = useMemo(() => {
|
|
90
|
+
if (!typewriterEnabled || !transformer) {
|
|
91
|
+
const result = [];
|
|
92
|
+
for (const block of completedBlocks) {
|
|
93
|
+
result.push({ ...block, stableId: block.id });
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0; i < pendingBlocks.length; i++) {
|
|
96
|
+
result.push({
|
|
97
|
+
...pendingBlocks[i],
|
|
98
|
+
stableId: `pending-${i}`
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
26
102
|
}
|
|
27
|
-
|
|
28
|
-
|
|
103
|
+
const displayBlocks = transformer.getDisplayBlocks();
|
|
104
|
+
return displayBlocks.map((db, index) => {
|
|
105
|
+
const isPending = !db.isDisplayComplete;
|
|
106
|
+
const isLastPending = isPending && index === displayBlocks.length - 1;
|
|
107
|
+
let node = db.displayNode;
|
|
108
|
+
if (typewriterEffect === "typing" && isLastPending) {
|
|
109
|
+
node = addCursorToNode(db.displayNode, cursorRef.current);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: db.id,
|
|
113
|
+
stableId: db.id,
|
|
114
|
+
status: db.isDisplayComplete ? "completed" : "pending",
|
|
115
|
+
isLastPending,
|
|
116
|
+
node,
|
|
117
|
+
startOffset: 0,
|
|
118
|
+
endOffset: 0,
|
|
119
|
+
rawText: ""
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}, [completedBlocks, pendingBlocks, typewriterEnabled, typewriterEffect, addCursorToNode, forceUpdateCount]);
|
|
29
123
|
const ast = useMemo(
|
|
30
124
|
() => ({
|
|
31
125
|
type: "root",
|
|
@@ -65,7 +159,8 @@ function useIncremark(options = {}) {
|
|
|
65
159
|
setPendingBlocks([]);
|
|
66
160
|
setMarkdown("");
|
|
67
161
|
setIsLoading(false);
|
|
68
|
-
|
|
162
|
+
transformer?.reset();
|
|
163
|
+
}, [parser, transformer]);
|
|
69
164
|
const render = useCallback(
|
|
70
165
|
(content) => {
|
|
71
166
|
const update = parser.render(content);
|
|
@@ -77,6 +172,59 @@ function useIncremark(options = {}) {
|
|
|
77
172
|
},
|
|
78
173
|
[parser]
|
|
79
174
|
);
|
|
175
|
+
const skip = useCallback(() => {
|
|
176
|
+
transformer?.skip();
|
|
177
|
+
setIsTypewriterProcessing(false);
|
|
178
|
+
}, [transformer]);
|
|
179
|
+
const pause = useCallback(() => {
|
|
180
|
+
transformer?.pause();
|
|
181
|
+
setIsTypewriterPaused(true);
|
|
182
|
+
}, [transformer]);
|
|
183
|
+
const resume = useCallback(() => {
|
|
184
|
+
transformer?.resume();
|
|
185
|
+
setIsTypewriterPaused(false);
|
|
186
|
+
}, [transformer]);
|
|
187
|
+
const setTypewriterOptions = useCallback(
|
|
188
|
+
(opts) => {
|
|
189
|
+
if (opts.enabled !== void 0) {
|
|
190
|
+
setTypewriterEnabled(opts.enabled);
|
|
191
|
+
}
|
|
192
|
+
if (opts.charsPerTick !== void 0 || opts.tickInterval !== void 0 || opts.effect !== void 0 || opts.pauseOnHidden !== void 0) {
|
|
193
|
+
transformer?.setOptions({
|
|
194
|
+
charsPerTick: opts.charsPerTick,
|
|
195
|
+
tickInterval: opts.tickInterval,
|
|
196
|
+
effect: opts.effect,
|
|
197
|
+
pauseOnHidden: opts.pauseOnHidden
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (opts.effect !== void 0) {
|
|
201
|
+
setTypewriterEffect(opts.effect);
|
|
202
|
+
}
|
|
203
|
+
if (opts.cursor !== void 0) {
|
|
204
|
+
cursorRef.current = opts.cursor;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[transformer]
|
|
208
|
+
);
|
|
209
|
+
const typewriter = useMemo(
|
|
210
|
+
() => ({
|
|
211
|
+
enabled: typewriterEnabled,
|
|
212
|
+
setEnabled: setTypewriterEnabled,
|
|
213
|
+
isProcessing: isTypewriterProcessing,
|
|
214
|
+
isPaused: isTypewriterPaused,
|
|
215
|
+
effect: typewriterEffect,
|
|
216
|
+
skip,
|
|
217
|
+
pause,
|
|
218
|
+
resume,
|
|
219
|
+
setOptions: setTypewriterOptions
|
|
220
|
+
}),
|
|
221
|
+
[typewriterEnabled, isTypewriterProcessing, isTypewriterPaused, typewriterEffect, skip, pause, resume, setTypewriterOptions]
|
|
222
|
+
);
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
return () => {
|
|
225
|
+
transformer?.destroy();
|
|
226
|
+
};
|
|
227
|
+
}, [transformer]);
|
|
80
228
|
return {
|
|
81
229
|
/** 已收集的完整 Markdown 字符串 */
|
|
82
230
|
markdown,
|
|
@@ -86,7 +234,7 @@ function useIncremark(options = {}) {
|
|
|
86
234
|
pendingBlocks,
|
|
87
235
|
/** 当前完整的 AST */
|
|
88
236
|
ast,
|
|
89
|
-
/**
|
|
237
|
+
/** 用于渲染的 blocks(根据打字机设置自动处理) */
|
|
90
238
|
blocks,
|
|
91
239
|
/** 是否正在加载 */
|
|
92
240
|
isLoading,
|
|
@@ -96,22 +244,24 @@ function useIncremark(options = {}) {
|
|
|
96
244
|
finalize,
|
|
97
245
|
/** 强制中断 */
|
|
98
246
|
abort,
|
|
99
|
-
/**
|
|
247
|
+
/** 重置解析器和打字机 */
|
|
100
248
|
reset,
|
|
101
249
|
/** 一次性渲染(reset + append + finalize) */
|
|
102
250
|
render,
|
|
103
251
|
/** 解析器实例 */
|
|
104
|
-
parser
|
|
252
|
+
parser,
|
|
253
|
+
/** 打字机控制 */
|
|
254
|
+
typewriter
|
|
105
255
|
};
|
|
106
256
|
}
|
|
107
257
|
|
|
108
258
|
// src/hooks/useDevTools.ts
|
|
109
|
-
import { useEffect, useRef as useRef2 } from "react";
|
|
259
|
+
import { useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
110
260
|
import { createDevTools } from "@incremark/devtools";
|
|
111
261
|
function useDevTools(incremark, options = {}) {
|
|
112
262
|
const devtoolsRef = useRef2(null);
|
|
113
263
|
const optionsRef = useRef2(options);
|
|
114
|
-
|
|
264
|
+
useEffect2(() => {
|
|
115
265
|
const devtools = createDevTools(optionsRef.current);
|
|
116
266
|
devtoolsRef.current = devtools;
|
|
117
267
|
incremark.parser.setOnChange((state) => {
|
|
@@ -137,15 +287,103 @@ function useDevTools(incremark, options = {}) {
|
|
|
137
287
|
return devtoolsRef.current;
|
|
138
288
|
}
|
|
139
289
|
|
|
290
|
+
// src/hooks/useBlockTransformer.ts
|
|
291
|
+
import { useState as useState2, useCallback as useCallback2, useRef as useRef3, useEffect as useEffect3 } from "react";
|
|
292
|
+
import {
|
|
293
|
+
createBlockTransformer as createBlockTransformer2
|
|
294
|
+
} from "@incremark/core";
|
|
295
|
+
function useBlockTransformer(sourceBlocks, options = {}) {
|
|
296
|
+
const [displayBlocks, setDisplayBlocks] = useState2([]);
|
|
297
|
+
const [isProcessing, setIsProcessing] = useState2(false);
|
|
298
|
+
const [isPaused, setIsPaused] = useState2(false);
|
|
299
|
+
const [effect, setEffect] = useState2(options.effect ?? "none");
|
|
300
|
+
const transformerRef = useRef3(null);
|
|
301
|
+
if (!transformerRef.current) {
|
|
302
|
+
transformerRef.current = createBlockTransformer2({
|
|
303
|
+
...options,
|
|
304
|
+
onChange: (blocks) => {
|
|
305
|
+
setDisplayBlocks(blocks);
|
|
306
|
+
setIsProcessing(transformerRef.current?.isProcessing() ?? false);
|
|
307
|
+
setIsPaused(transformerRef.current?.isPausedState() ?? false);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const transformer = transformerRef.current;
|
|
312
|
+
useEffect3(() => {
|
|
313
|
+
transformer.push(sourceBlocks);
|
|
314
|
+
const currentDisplaying = displayBlocks.find((b) => !b.isDisplayComplete);
|
|
315
|
+
if (currentDisplaying) {
|
|
316
|
+
const updated = sourceBlocks.find((b) => b.id === currentDisplaying.id);
|
|
317
|
+
if (updated) {
|
|
318
|
+
transformer.update(updated);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}, [sourceBlocks, transformer]);
|
|
322
|
+
useEffect3(() => {
|
|
323
|
+
return () => {
|
|
324
|
+
transformer.destroy();
|
|
325
|
+
};
|
|
326
|
+
}, [transformer]);
|
|
327
|
+
const skip = useCallback2(() => {
|
|
328
|
+
transformer.skip();
|
|
329
|
+
}, [transformer]);
|
|
330
|
+
const reset = useCallback2(() => {
|
|
331
|
+
transformer.reset();
|
|
332
|
+
}, [transformer]);
|
|
333
|
+
const pause = useCallback2(() => {
|
|
334
|
+
transformer.pause();
|
|
335
|
+
setIsPaused(true);
|
|
336
|
+
}, [transformer]);
|
|
337
|
+
const resume = useCallback2(() => {
|
|
338
|
+
transformer.resume();
|
|
339
|
+
setIsPaused(false);
|
|
340
|
+
}, [transformer]);
|
|
341
|
+
const setOptionsCallback = useCallback2(
|
|
342
|
+
(opts) => {
|
|
343
|
+
transformer.setOptions(opts);
|
|
344
|
+
if (opts.effect !== void 0) {
|
|
345
|
+
setEffect(opts.effect);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
[transformer]
|
|
349
|
+
);
|
|
350
|
+
return {
|
|
351
|
+
displayBlocks,
|
|
352
|
+
isProcessing,
|
|
353
|
+
isPaused,
|
|
354
|
+
effect,
|
|
355
|
+
skip,
|
|
356
|
+
reset,
|
|
357
|
+
pause,
|
|
358
|
+
resume,
|
|
359
|
+
setOptions: setOptionsCallback,
|
|
360
|
+
transformer
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
140
364
|
// src/components/IncremarkRenderer.tsx
|
|
141
365
|
import React from "react";
|
|
142
366
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
367
|
+
function getStableText(node) {
|
|
368
|
+
if (!node.chunks || node.chunks.length === 0) {
|
|
369
|
+
return node.value;
|
|
370
|
+
}
|
|
371
|
+
return node.value.slice(0, node.stableLength ?? 0);
|
|
372
|
+
}
|
|
143
373
|
function renderInlineChildren(children) {
|
|
144
374
|
if (!children) return null;
|
|
145
375
|
return children.map((child, i) => {
|
|
146
376
|
switch (child.type) {
|
|
147
|
-
case "text":
|
|
148
|
-
|
|
377
|
+
case "text": {
|
|
378
|
+
const textNode = child;
|
|
379
|
+
if (textNode.chunks && textNode.chunks.length > 0) {
|
|
380
|
+
return /* @__PURE__ */ jsxs(React.Fragment, { children: [
|
|
381
|
+
getStableText(textNode),
|
|
382
|
+
textNode.chunks.map((chunk) => /* @__PURE__ */ jsx("span", { className: "incremark-fade-in", children: chunk.text }, chunk.createdAt))
|
|
383
|
+
] }, i);
|
|
384
|
+
}
|
|
385
|
+
return /* @__PURE__ */ jsx(React.Fragment, { children: textNode.value }, i);
|
|
386
|
+
}
|
|
149
387
|
case "strong":
|
|
150
388
|
return /* @__PURE__ */ jsx("strong", { children: renderInlineChildren(child.children) }, i);
|
|
151
389
|
case "emphasis":
|
|
@@ -162,6 +400,8 @@ function renderInlineChildren(children) {
|
|
|
162
400
|
return /* @__PURE__ */ jsx("del", { children: renderInlineChildren(child.children) }, i);
|
|
163
401
|
case "paragraph":
|
|
164
402
|
return /* @__PURE__ */ jsx(React.Fragment, { children: renderInlineChildren(child.children) }, i);
|
|
403
|
+
case "html":
|
|
404
|
+
return /* @__PURE__ */ jsx("span", { dangerouslySetInnerHTML: { __html: child.value } }, i);
|
|
165
405
|
default:
|
|
166
406
|
return /* @__PURE__ */ jsx("span", { children: child.value || "" }, i);
|
|
167
407
|
}
|
|
@@ -180,7 +420,7 @@ var DefaultList = ({ node }) => {
|
|
|
180
420
|
const Tag = node.ordered ? "ol" : "ul";
|
|
181
421
|
return /* @__PURE__ */ jsx(Tag, { className: "incremark-list", children: node.children?.map((item, i) => /* @__PURE__ */ jsx("li", { children: renderInlineChildren(item.children) }, i)) });
|
|
182
422
|
};
|
|
183
|
-
var DefaultBlockquote = ({ node }) => /* @__PURE__ */ jsx("blockquote", { className: "incremark-blockquote", children: node.children?.map((child, i) => /* @__PURE__ */ jsx(React.Fragment, { children: child.type === "paragraph" ? /* @__PURE__ */ jsx("p", { children: renderInlineChildren(child.children) }) :
|
|
423
|
+
var DefaultBlockquote = ({ node }) => /* @__PURE__ */ jsx("blockquote", { className: "incremark-blockquote", children: node.children?.map((child, i) => /* @__PURE__ */ jsx(React.Fragment, { children: child.type === "paragraph" ? /* @__PURE__ */ jsx("p", { children: renderInlineChildren(child.children) }) : "children" in child && Array.isArray(child.children) ? renderInlineChildren(child.children) : null }, i)) });
|
|
184
424
|
var DefaultTable = ({ node }) => /* @__PURE__ */ jsx("div", { className: "incremark-table-wrapper", children: /* @__PURE__ */ jsxs("table", { className: "incremark-table", children: [
|
|
185
425
|
/* @__PURE__ */ jsx("thead", { children: node.children?.[0] && /* @__PURE__ */ jsx("tr", { children: node.children[0].children?.map((cell, i) => /* @__PURE__ */ jsx("th", { children: renderInlineChildren(cell.children) }, i)) }) }),
|
|
186
426
|
/* @__PURE__ */ jsx("tbody", { children: node.children?.slice(1).map((row, i) => /* @__PURE__ */ jsx("tr", { children: row.children?.map((cell, j) => /* @__PURE__ */ jsx("td", { children: renderInlineChildren(cell.children) }, j)) }, i)) })
|
|
@@ -210,18 +450,176 @@ var Incremark = ({
|
|
|
210
450
|
showBlockStatus = true,
|
|
211
451
|
className = ""
|
|
212
452
|
}) => {
|
|
213
|
-
return /* @__PURE__ */ jsx2("div", { className: `incremark ${className}`, children: blocks.map((block) =>
|
|
214
|
-
"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
453
|
+
return /* @__PURE__ */ jsx2("div", { className: `incremark ${className}`, children: blocks.map((block) => {
|
|
454
|
+
const isPending = block.status === "pending";
|
|
455
|
+
const classes = [
|
|
456
|
+
"incremark-block",
|
|
457
|
+
isPending ? "incremark-pending" : "incremark-completed",
|
|
458
|
+
block.isLastPending ? "incremark-last-pending" : ""
|
|
459
|
+
].filter(Boolean).join(" ");
|
|
460
|
+
return /* @__PURE__ */ jsx2("div", { className: classes, children: /* @__PURE__ */ jsx2(IncremarkRenderer, { node: block.node, components }) }, block.stableId);
|
|
461
|
+
}) });
|
|
221
462
|
};
|
|
463
|
+
|
|
464
|
+
// src/components/AutoScrollContainer.tsx
|
|
465
|
+
import {
|
|
466
|
+
useRef as useRef4,
|
|
467
|
+
useEffect as useEffect4,
|
|
468
|
+
useCallback as useCallback3,
|
|
469
|
+
useState as useState3,
|
|
470
|
+
forwardRef,
|
|
471
|
+
useImperativeHandle
|
|
472
|
+
} from "react";
|
|
473
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
474
|
+
var AutoScrollContainer = forwardRef(
|
|
475
|
+
({
|
|
476
|
+
children,
|
|
477
|
+
enabled = true,
|
|
478
|
+
threshold = 50,
|
|
479
|
+
behavior = "instant",
|
|
480
|
+
style,
|
|
481
|
+
className
|
|
482
|
+
}, ref) => {
|
|
483
|
+
const containerRef = useRef4(null);
|
|
484
|
+
const [isUserScrolledUp, setIsUserScrolledUp] = useState3(false);
|
|
485
|
+
const lastScrollTopRef = useRef4(0);
|
|
486
|
+
const lastScrollHeightRef = useRef4(0);
|
|
487
|
+
const isNearBottom = useCallback3(() => {
|
|
488
|
+
const container = containerRef.current;
|
|
489
|
+
if (!container) return true;
|
|
490
|
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
491
|
+
return scrollHeight - scrollTop - clientHeight <= threshold;
|
|
492
|
+
}, [threshold]);
|
|
493
|
+
const scrollToBottom = useCallback3(
|
|
494
|
+
(force = false) => {
|
|
495
|
+
const container = containerRef.current;
|
|
496
|
+
if (!container) return;
|
|
497
|
+
if (isUserScrolledUp && !force) return;
|
|
498
|
+
container.scrollTo({
|
|
499
|
+
top: container.scrollHeight,
|
|
500
|
+
behavior
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
[isUserScrolledUp, behavior]
|
|
504
|
+
);
|
|
505
|
+
const hasScrollbar = useCallback3(() => {
|
|
506
|
+
const container = containerRef.current;
|
|
507
|
+
if (!container) return false;
|
|
508
|
+
return container.scrollHeight > container.clientHeight;
|
|
509
|
+
}, []);
|
|
510
|
+
const handleScroll = useCallback3(() => {
|
|
511
|
+
const container = containerRef.current;
|
|
512
|
+
if (!container) return;
|
|
513
|
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
514
|
+
if (scrollHeight <= clientHeight) {
|
|
515
|
+
setIsUserScrolledUp(false);
|
|
516
|
+
lastScrollTopRef.current = 0;
|
|
517
|
+
lastScrollHeightRef.current = scrollHeight;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (isNearBottom()) {
|
|
521
|
+
setIsUserScrolledUp(false);
|
|
522
|
+
} else {
|
|
523
|
+
const isScrollingUp = scrollTop < lastScrollTopRef.current;
|
|
524
|
+
const isContentUnchanged = scrollHeight === lastScrollHeightRef.current;
|
|
525
|
+
if (isScrollingUp && isContentUnchanged) {
|
|
526
|
+
setIsUserScrolledUp(true);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
lastScrollTopRef.current = scrollTop;
|
|
530
|
+
lastScrollHeightRef.current = scrollHeight;
|
|
531
|
+
}, [isNearBottom]);
|
|
532
|
+
useEffect4(() => {
|
|
533
|
+
const container = containerRef.current;
|
|
534
|
+
if (container) {
|
|
535
|
+
lastScrollTopRef.current = container.scrollTop;
|
|
536
|
+
lastScrollHeightRef.current = container.scrollHeight;
|
|
537
|
+
}
|
|
538
|
+
}, []);
|
|
539
|
+
useEffect4(() => {
|
|
540
|
+
const container = containerRef.current;
|
|
541
|
+
if (!container || !enabled) return;
|
|
542
|
+
const observer = new MutationObserver(() => {
|
|
543
|
+
requestAnimationFrame(() => {
|
|
544
|
+
if (!containerRef.current) return;
|
|
545
|
+
const { scrollHeight, clientHeight } = containerRef.current;
|
|
546
|
+
if (scrollHeight <= clientHeight) {
|
|
547
|
+
setIsUserScrolledUp(false);
|
|
548
|
+
}
|
|
549
|
+
lastScrollHeightRef.current = scrollHeight;
|
|
550
|
+
if (!isUserScrolledUp) {
|
|
551
|
+
scrollToBottom();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
observer.observe(container, {
|
|
556
|
+
childList: true,
|
|
557
|
+
subtree: true,
|
|
558
|
+
characterData: true
|
|
559
|
+
});
|
|
560
|
+
return () => observer.disconnect();
|
|
561
|
+
}, [enabled, isUserScrolledUp, scrollToBottom]);
|
|
562
|
+
useImperativeHandle(
|
|
563
|
+
ref,
|
|
564
|
+
() => ({
|
|
565
|
+
scrollToBottom: () => scrollToBottom(true),
|
|
566
|
+
isUserScrolledUp: () => isUserScrolledUp,
|
|
567
|
+
container: containerRef.current
|
|
568
|
+
}),
|
|
569
|
+
[scrollToBottom, isUserScrolledUp]
|
|
570
|
+
);
|
|
571
|
+
return /* @__PURE__ */ jsx3(
|
|
572
|
+
"div",
|
|
573
|
+
{
|
|
574
|
+
ref: containerRef,
|
|
575
|
+
className,
|
|
576
|
+
style: {
|
|
577
|
+
overflowY: "auto",
|
|
578
|
+
height: "100%",
|
|
579
|
+
...style
|
|
580
|
+
},
|
|
581
|
+
onScroll: handleScroll,
|
|
582
|
+
children
|
|
583
|
+
}
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
AutoScrollContainer.displayName = "AutoScrollContainer";
|
|
588
|
+
|
|
589
|
+
// src/index.ts
|
|
590
|
+
import {
|
|
591
|
+
BlockTransformer as BlockTransformer2,
|
|
592
|
+
createBlockTransformer as createBlockTransformer3,
|
|
593
|
+
countChars,
|
|
594
|
+
sliceAst,
|
|
595
|
+
cloneNode,
|
|
596
|
+
codeBlockPlugin,
|
|
597
|
+
mermaidPlugin,
|
|
598
|
+
imagePlugin,
|
|
599
|
+
mathPlugin,
|
|
600
|
+
thematicBreakPlugin,
|
|
601
|
+
defaultPlugins as defaultPlugins2,
|
|
602
|
+
allPlugins,
|
|
603
|
+
createPlugin
|
|
604
|
+
} from "@incremark/core";
|
|
222
605
|
export {
|
|
606
|
+
AutoScrollContainer,
|
|
607
|
+
BlockTransformer2 as BlockTransformer,
|
|
223
608
|
Incremark,
|
|
224
609
|
IncremarkRenderer,
|
|
610
|
+
allPlugins,
|
|
611
|
+
cloneNode,
|
|
612
|
+
codeBlockPlugin,
|
|
613
|
+
countChars,
|
|
614
|
+
createBlockTransformer3 as createBlockTransformer,
|
|
615
|
+
createPlugin,
|
|
616
|
+
defaultPlugins2 as defaultPlugins,
|
|
617
|
+
imagePlugin,
|
|
618
|
+
mathPlugin,
|
|
619
|
+
mermaidPlugin,
|
|
620
|
+
sliceAst,
|
|
621
|
+
thematicBreakPlugin,
|
|
622
|
+
useBlockTransformer,
|
|
225
623
|
useDevTools,
|
|
226
624
|
useIncremark
|
|
227
625
|
};
|
package/dist/styles.css
CHANGED
|
@@ -189,3 +189,18 @@
|
|
|
189
189
|
font-size: 11px;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/* ============ 渐入动画效果 ============ */
|
|
193
|
+
|
|
194
|
+
.incremark-fade-in {
|
|
195
|
+
animation: incremark-fade-in 0.4s ease-out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@keyframes incremark-fade-in {
|
|
199
|
+
from {
|
|
200
|
+
opacity: 0;
|
|
201
|
+
}
|
|
202
|
+
to {
|
|
203
|
+
opacity: 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@incremark/react",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Incremark React integration - Incremental Markdown parser for AI streaming",
|
|
6
6
|
"type": "module",
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
],
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": ">=18.0.0",
|
|
22
|
-
"@incremark/core": "0.0
|
|
22
|
+
"@incremark/core": "0.1.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@incremark/devtools": "0.0
|
|
25
|
+
"@incremark/devtools": "0.1.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/react": "^18.2.0",
|