@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 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
- * const { markdown, blocks, append, finalize, reset } = useIncremark()
60
+ * // 基础用法
61
+ * const { blocks, append, finalize } = useIncremark()
21
62
  *
22
- * async function handleStream(stream) {
23
- * for await (const chunk of stream) {
24
- * append(chunk)
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
- * finalize()
27
- * }
72
+ * })
28
73
  *
29
- * return <div>{markdown.length} 字符</div>
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
- /** 所有块(完成 + 待处理),带稳定 ID */
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: any;
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
- export { Incremark, type IncremarkProps, IncremarkRenderer, type IncremarkRendererProps, type UseDevToolsOptions, type UseIncremarkOptions, type UseIncremarkReturn, useDevTools, useIncremark };
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 blocks = useMemo(() => {
17
- const result = [];
18
- for (const block of completedBlocks) {
19
- result.push({ ...block, stableId: block.id });
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
- for (let i = 0; i < pendingBlocks.length; i++) {
22
- result.push({
23
- ...pendingBlocks[i],
24
- stableId: `pending-${i}`
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
- return result;
28
- }, [completedBlocks, pendingBlocks]);
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
- }, [parser]);
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
- /** 所有块(完成 + 待处理),带稳定 ID */
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
- useEffect(() => {
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
- return /* @__PURE__ */ jsx(React.Fragment, { children: child.value }, i);
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) }) : renderInlineChildren(child.children || []) }, i)) });
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) => /* @__PURE__ */ jsx2(
214
- "div",
215
- {
216
- className: `incremark-block ${showBlockStatus && block.status === "pending" ? "pending" : ""}`,
217
- children: /* @__PURE__ */ jsx2(IncremarkRenderer, { node: block.node, components })
218
- },
219
- block.stableId
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.4",
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.4"
22
+ "@incremark/core": "0.1.0"
23
23
  },
24
24
  "dependencies": {
25
- "@incremark/devtools": "0.0.4"
25
+ "@incremark/devtools": "0.1.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/react": "^18.2.0",