@dxos/react-ui-markdown 0.8.4-main.c85a9c8dae → 0.8.4-main.cb12b3f963
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/dist/lib/browser/index.mjs +541 -10
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/{MarkdownViewer/MarkdownViewer.d.ts → MarkdownBlock/MarkdownBlock.d.ts} +3 -3
- package/dist/types/src/MarkdownBlock/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/{MarkdownViewer/MarkdownViewer.stories.d.ts → MarkdownBlock/MarkdownBlock.stories.d.ts} +4 -4
- package/dist/types/src/MarkdownBlock/MarkdownBlock.stories.d.ts.map +1 -0
- package/dist/types/src/MarkdownBlock/index.d.ts +2 -0
- package/dist/types/src/MarkdownBlock/index.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/MarkdownStream.d.ts +101 -0
- package/dist/types/src/MarkdownStream/MarkdownStream.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/MarkdownStream.stories.d.ts +23 -0
- package/dist/types/src/MarkdownStream/MarkdownStream.stories.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/footer.d.ts +23 -0
- package/dist/types/src/MarkdownStream/footer.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/index.d.ts +4 -0
- package/dist/types/src/MarkdownStream/index.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/stream.d.ts +39 -0
- package/dist/types/src/MarkdownStream/stream.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/stream.test.d.ts +2 -0
- package/dist/types/src/MarkdownStream/stream.test.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/testing/index.d.ts +2 -0
- package/dist/types/src/MarkdownStream/testing/index.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/testing/testing.d.ts +16 -0
- package/dist/types/src/MarkdownStream/testing/testing.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +2 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +28 -24
- package/src/{MarkdownViewer/MarkdownViewer.stories.tsx → MarkdownBlock/MarkdownBlock.stories.tsx} +10 -10
- package/src/{MarkdownViewer/MarkdownViewer.tsx → MarkdownBlock/MarkdownBlock.tsx} +14 -9
- package/src/{MarkdownViewer → MarkdownBlock}/index.ts +1 -1
- package/src/MarkdownStream/MarkdownStream.stories.tsx +215 -0
- package/src/MarkdownStream/MarkdownStream.tsx +446 -0
- package/src/MarkdownStream/footer.ts +119 -0
- package/src/MarkdownStream/index.ts +8 -0
- package/src/MarkdownStream/stream.test.ts +126 -0
- package/src/MarkdownStream/stream.ts +229 -0
- package/src/MarkdownStream/testing/index.ts +5 -0
- package/src/MarkdownStream/testing/testing.ts +56 -0
- package/src/MarkdownStream/testing/text.md +67 -0
- package/src/index.ts +2 -1
- package/dist/types/src/MarkdownViewer/MarkdownViewer.d.ts.map +0 -1
- package/dist/types/src/MarkdownViewer/MarkdownViewer.stories.d.ts.map +0 -1
- package/dist/types/src/MarkdownViewer/index.d.ts +0 -2
- package/dist/types/src/MarkdownViewer/index.d.ts.map +0 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorSelection, type Extension, Transaction } from '@codemirror/state';
|
|
6
|
+
import { type EditorView } from '@codemirror/view';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Fiber from 'effect/Fiber';
|
|
9
|
+
import * as Queue from 'effect/Queue';
|
|
10
|
+
import * as Stream from 'effect/Stream';
|
|
11
|
+
import React, {
|
|
12
|
+
forwardRef,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
useCallback,
|
|
15
|
+
useEffect,
|
|
16
|
+
useImperativeHandle,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
type RefObject,
|
|
20
|
+
} from 'react';
|
|
21
|
+
import { createPortal } from 'react-dom';
|
|
22
|
+
|
|
23
|
+
import { addEventListener } from '@dxos/async';
|
|
24
|
+
import { runAndForwardErrors } from '@dxos/effect';
|
|
25
|
+
import { ErrorBoundary, type ThemedClassName, useDynamicRef, useStateWithRef, useThemeContext } from '@dxos/react-ui';
|
|
26
|
+
import { useTextEditor, type UseTextEditor } from '@dxos/react-ui-editor';
|
|
27
|
+
import {
|
|
28
|
+
type AutoScrollProps,
|
|
29
|
+
type XmlTagsOptions,
|
|
30
|
+
type XmlWidgetState,
|
|
31
|
+
type XmlWidgetStateManager,
|
|
32
|
+
createBasicExtensions,
|
|
33
|
+
createThemeExtensions,
|
|
34
|
+
decorateMarkdown,
|
|
35
|
+
extendedMarkdown,
|
|
36
|
+
navigateNextEffect,
|
|
37
|
+
navigatePreviousEffect,
|
|
38
|
+
preview,
|
|
39
|
+
scroller,
|
|
40
|
+
scrollerLineEffect,
|
|
41
|
+
fader,
|
|
42
|
+
typewriter,
|
|
43
|
+
typewriterBypass,
|
|
44
|
+
xmlTagContextEffect,
|
|
45
|
+
xmlTagResetEffect,
|
|
46
|
+
xmlTagUpdateEffect,
|
|
47
|
+
xmlTags,
|
|
48
|
+
autoScroll,
|
|
49
|
+
documentSlots,
|
|
50
|
+
xmlFormatting,
|
|
51
|
+
xmlBlockDecoration,
|
|
52
|
+
} from '@dxos/ui-editor';
|
|
53
|
+
import { mx } from '@dxos/ui-theme';
|
|
54
|
+
import { isTruthy } from '@dxos/util';
|
|
55
|
+
|
|
56
|
+
import { setFooterVisibleEffect, footer } from './footer';
|
|
57
|
+
import { type StreamerOptions, createStreamer } from './stream';
|
|
58
|
+
export interface MarkdownStreamController extends XmlWidgetStateManager {
|
|
59
|
+
get length(): number | undefined;
|
|
60
|
+
focus: () => void;
|
|
61
|
+
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
62
|
+
navigateNext: () => void;
|
|
63
|
+
navigatePrevious: () => void;
|
|
64
|
+
setContext: (context: any) => void;
|
|
65
|
+
setContent: (text: string) => Promise<void>;
|
|
66
|
+
append: (text: string) => Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type MarkdownStreamEvent = {
|
|
70
|
+
type: 'submit';
|
|
71
|
+
value: string | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type MarkdownStreamProps = ThemedClassName<
|
|
75
|
+
{
|
|
76
|
+
debug?: boolean;
|
|
77
|
+
|
|
78
|
+
/** Initial content. */
|
|
79
|
+
content?: string;
|
|
80
|
+
|
|
81
|
+
/** View options. */
|
|
82
|
+
options?: {
|
|
83
|
+
autoScroll?: boolean;
|
|
84
|
+
typewriter?: boolean;
|
|
85
|
+
cursor?: boolean;
|
|
86
|
+
fader?: boolean;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Streaming cadence. See {@link StreamerOptions}.
|
|
90
|
+
* Use `'word'` or `'character'` to break large source chunks into smaller CM dispatches —
|
|
91
|
+
* useful when the AI service emits big partial blocks but you want a smoother typewriter
|
|
92
|
+
* effect. Combine with `streamDelayMs` to add a per-token sleep.
|
|
93
|
+
* Default: `'span'` (one CM dispatch per source chunk; current behaviour).
|
|
94
|
+
*/
|
|
95
|
+
streamCadence?: StreamerOptions['chunkSize'];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Per-token delay (ms) for the streaming queue. Default `0`.
|
|
99
|
+
*/
|
|
100
|
+
streamDelayMs?: number;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Optional React subtree rendered as a CodeMirror block-widget decoration anchored at
|
|
105
|
+
* `doc.length`. Scrolls with the document content (lives inside `cm-content`'s flow).
|
|
106
|
+
* Visibility tracks the truthiness of the prop.
|
|
107
|
+
*/
|
|
108
|
+
footer?: ReactNode;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extra CodeMirror extensions appended after the built-in stack — use for keymaps or
|
|
112
|
+
* other host-level behaviour (e.g. `Mod-d` to toggle debug) that should apply when the
|
|
113
|
+
* document is focused.
|
|
114
|
+
*/
|
|
115
|
+
extensions?: Extension;
|
|
116
|
+
|
|
117
|
+
/** Event handler. */
|
|
118
|
+
onEvent?: (event: MarkdownStreamEvent) => void;
|
|
119
|
+
} & (XmlTagsOptions & AutoScrollProps)
|
|
120
|
+
>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Codemirror-based markdown editor with xml tag widtgets and streaming support.
|
|
124
|
+
*/
|
|
125
|
+
export const MarkdownStream = forwardRef<MarkdownStreamController | null, MarkdownStreamProps>(
|
|
126
|
+
({ classNames, debug, content, options, registry, extensions, footer, onEvent }, forwardedRef) => {
|
|
127
|
+
// Store current content so that we can toggle debug mode. Default to '' so the
|
|
128
|
+
// `append()` path (which does `contentRef.current += text`) doesn't concatenate
|
|
129
|
+
// against `undefined` and stamp `"undefined"` into the transcript snapshot.
|
|
130
|
+
const contentRef = useRef(content ?? '');
|
|
131
|
+
|
|
132
|
+
// DOM node for the footer block widget — populated when its decoration mounts.
|
|
133
|
+
const [footerRoot, setFooterRoot] = useState<HTMLElement | null>(null);
|
|
134
|
+
|
|
135
|
+
// Codemirror editor.
|
|
136
|
+
const { parentRef, view, viewRef, widgets } = useMarkdownStreamTextEditor(contentRef, {
|
|
137
|
+
debug,
|
|
138
|
+
registry,
|
|
139
|
+
options,
|
|
140
|
+
extensions,
|
|
141
|
+
setFooterRoot: footer ? setFooterRoot : undefined,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Show the status footer.
|
|
145
|
+
const footerVisible = !!footer;
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
view?.dispatch({ effects: setFooterVisibleEffect.of(footerVisible) });
|
|
148
|
+
}, [view, footerVisible]);
|
|
149
|
+
|
|
150
|
+
// Streaming text queue.
|
|
151
|
+
const [queue, setQueue, queueRef] = useStateWithRef(Effect.runSync(Queue.unbounded<string>()));
|
|
152
|
+
|
|
153
|
+
// Reset document.
|
|
154
|
+
const onReset = useCallback(
|
|
155
|
+
async (text: string) => {
|
|
156
|
+
contentRef.current = text;
|
|
157
|
+
if (!viewRef.current) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Set content and scroll to bottom.
|
|
162
|
+
viewRef.current.dispatch({
|
|
163
|
+
effects: [xmlTagContextEffect.of(null), xmlTagResetEffect.of(null)],
|
|
164
|
+
changes: [{ from: 0, to: viewRef.current.state.doc.length, insert: text }],
|
|
165
|
+
annotations: typewriterBypass.of(true),
|
|
166
|
+
selection: EditorSelection.cursor(text.length),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// New queue.
|
|
170
|
+
setQueue(Effect.runSync(Queue.unbounded<string>()));
|
|
171
|
+
},
|
|
172
|
+
[contentRef, viewRef, setQueue],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Controller API.
|
|
176
|
+
useImperativeHandle(
|
|
177
|
+
forwardedRef,
|
|
178
|
+
() => createMarkdownStreamController({ contentRef, viewRef, queueRef, onReset }),
|
|
179
|
+
[onReset],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Widget events.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!parentRef.current) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// TODO(burdon): Replace this hack with a custom event listener from widgets.
|
|
189
|
+
return addEventListener(parentRef.current, 'click', (event) => {
|
|
190
|
+
const button = (event.target as HTMLElement).closest('[data-action="submit"]');
|
|
191
|
+
if (button?.getAttribute('data-action') === 'submit') {
|
|
192
|
+
onEvent?.({
|
|
193
|
+
type: 'submit',
|
|
194
|
+
value: button.getAttribute('data-value'),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}, [view, parentRef, onEvent]);
|
|
199
|
+
|
|
200
|
+
// Consume queue and update document.
|
|
201
|
+
useMarkdownStreamQueue(view, queue, {
|
|
202
|
+
chunkSize: options?.streamCadence,
|
|
203
|
+
delayMs: options?.streamDelayMs,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Cleanup.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
return () => {
|
|
209
|
+
view?.destroy();
|
|
210
|
+
};
|
|
211
|
+
}, [view]);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<>
|
|
215
|
+
{/* Markdown editor. */}
|
|
216
|
+
<div role='none' className={mx('dx-container', classNames)} ref={parentRef} />
|
|
217
|
+
|
|
218
|
+
{/* React widgets are rendered in portals outside of the editor. */}
|
|
219
|
+
<ErrorBoundary name='markdown-stream'>
|
|
220
|
+
{widgets.map(({ Component, root, id, props }) => (
|
|
221
|
+
<div key={id} role='none'>
|
|
222
|
+
{createPortal(<Component view={view} {...props} />, root)}
|
|
223
|
+
</div>
|
|
224
|
+
))}
|
|
225
|
+
{footerRoot && footerVisible && createPortal(footer, footerRoot)}
|
|
226
|
+
</ErrorBoundary>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
type MarkdownStreamTextEditorParams = Pick<MarkdownStreamProps, 'debug' | 'registry' | 'options' | 'extensions'> & {
|
|
233
|
+
setFooterRoot?: (el: HTMLElement | null) => void;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
type MarkdownStreamTextEditorResult = UseTextEditor & {
|
|
237
|
+
viewRef: RefObject<EditorView | null>;
|
|
238
|
+
widgets: XmlWidgetState[];
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Read-only markdown editor configured for streaming (theme, markdown extensions, XML widgets).
|
|
243
|
+
*/
|
|
244
|
+
const useMarkdownStreamTextEditor = (
|
|
245
|
+
currentContent: RefObject<string | undefined>,
|
|
246
|
+
{ debug, registry, options, extensions: extraExtensions, setFooterRoot }: MarkdownStreamTextEditorParams,
|
|
247
|
+
): MarkdownStreamTextEditorResult => {
|
|
248
|
+
const { themeMode } = useThemeContext();
|
|
249
|
+
|
|
250
|
+
// Active widgets.
|
|
251
|
+
const [widgets, setWidgets] = useState<XmlWidgetState[]>([]);
|
|
252
|
+
|
|
253
|
+
// Editor.
|
|
254
|
+
const { view, parentRef } = useTextEditor(() => {
|
|
255
|
+
const content = currentContent.current;
|
|
256
|
+
return {
|
|
257
|
+
initialValue: content,
|
|
258
|
+
selection: EditorSelection.cursor(content?.length ?? 0),
|
|
259
|
+
extensions: [
|
|
260
|
+
createBasicExtensions({
|
|
261
|
+
lineWrapping: true,
|
|
262
|
+
readOnly: true,
|
|
263
|
+
}),
|
|
264
|
+
createThemeExtensions({
|
|
265
|
+
slots: documentSlots,
|
|
266
|
+
scrollbarThin: true,
|
|
267
|
+
syntaxHighlighting: true,
|
|
268
|
+
themeMode,
|
|
269
|
+
}),
|
|
270
|
+
xmlFormatting({ skip: debug ? [] : ['prompt'] }),
|
|
271
|
+
!debug &&
|
|
272
|
+
[
|
|
273
|
+
extendedMarkdown({ registry }),
|
|
274
|
+
decorateMarkdown({
|
|
275
|
+
// `dxn:` links/images are reference widgets owned by `preview()` (PreviewInlineWidget /
|
|
276
|
+
// PreviewBlockWidget). Skipping them here avoids `decorateMarkdown` adding a
|
|
277
|
+
// non-functional `LinkButton` anchor on top of the same node — e.g. for
|
|
278
|
+
// `[DXOS](dxn:echo:BNPMIBEDJLRIILYUYZVM6GT64VWI6WPPZ:01KQ889PZBRNHAEECV0ANFAYX7)`.
|
|
279
|
+
skip: (node) => (node.name === 'Link' || node.name === 'Image') && node.url.startsWith('dxn:'),
|
|
280
|
+
}),
|
|
281
|
+
preview(),
|
|
282
|
+
// NOTE: An ancestor element must set `data-hue` so `.dx-panel` resolves to the user's
|
|
283
|
+
// hue tokens (see `packages/ui/ui-theme/src/css/components/panel.css`). Tailwind picks
|
|
284
|
+
// up these utility classes from this source file.
|
|
285
|
+
xmlBlockDecoration({
|
|
286
|
+
tag: 'prompt',
|
|
287
|
+
lineClass: 'cm-prompt-line my-8',
|
|
288
|
+
contentClass: 'cm-prompt-bubble dx-panel px-2 py-1.5 rounded-sm [&_*]:text-inherit!',
|
|
289
|
+
hideTags: true,
|
|
290
|
+
}),
|
|
291
|
+
xmlTags({ registry, setWidgets, bookmarks: ['prompt'] }),
|
|
292
|
+
scroller({ overScroll: 80 }),
|
|
293
|
+
options?.autoScroll && autoScroll(),
|
|
294
|
+
options?.typewriter &&
|
|
295
|
+
typewriter({
|
|
296
|
+
cursor: options?.cursor,
|
|
297
|
+
streamingTags: new Set(
|
|
298
|
+
Object.entries(registry ?? {})
|
|
299
|
+
.filter(([, def]) => def.streaming)
|
|
300
|
+
.map(([tag]) => tag),
|
|
301
|
+
),
|
|
302
|
+
}),
|
|
303
|
+
options?.fader && fader(),
|
|
304
|
+
setFooterRoot && footer(setFooterRoot),
|
|
305
|
+
].filter(isTruthy),
|
|
306
|
+
extraExtensions,
|
|
307
|
+
].filter(isTruthy),
|
|
308
|
+
};
|
|
309
|
+
}, [
|
|
310
|
+
themeMode,
|
|
311
|
+
registry,
|
|
312
|
+
debug,
|
|
313
|
+
options?.autoScroll,
|
|
314
|
+
options?.typewriter,
|
|
315
|
+
options?.cursor,
|
|
316
|
+
options?.fader,
|
|
317
|
+
extraExtensions,
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const viewRef = useDynamicRef(view);
|
|
321
|
+
return { view, viewRef, parentRef, widgets };
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Consumes streaming text from the queue and appends it to the editor document.
|
|
326
|
+
*/
|
|
327
|
+
const useMarkdownStreamQueue = (
|
|
328
|
+
view: EditorView | null,
|
|
329
|
+
queue: Queue.Queue<string>,
|
|
330
|
+
streamerOptions?: StreamerOptions,
|
|
331
|
+
) => {
|
|
332
|
+
const chunkSize = streamerOptions?.chunkSize;
|
|
333
|
+
const delayMs = streamerOptions?.delayMs;
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (!view) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Consume queue and update document.
|
|
340
|
+
const fork = Stream.fromQueue(queue).pipe(
|
|
341
|
+
(source) => createStreamer(source, { chunkSize, delayMs }),
|
|
342
|
+
Stream.runForEach((text) =>
|
|
343
|
+
Effect.sync(() => {
|
|
344
|
+
const scrollTop = view.scrollDOM.scrollTop;
|
|
345
|
+
view.dispatch({
|
|
346
|
+
changes: [{ from: view.state.doc.length, insert: text }],
|
|
347
|
+
annotations: Transaction.remote.of(true),
|
|
348
|
+
scrollIntoView: false,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Prevent autoscrolling.
|
|
352
|
+
requestAnimationFrame(() => {
|
|
353
|
+
view.scrollDOM.scrollTop = scrollTop;
|
|
354
|
+
});
|
|
355
|
+
}),
|
|
356
|
+
),
|
|
357
|
+
Effect.runFork,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
return () => {
|
|
361
|
+
void runAndForwardErrors(Fiber.interrupt(fork));
|
|
362
|
+
};
|
|
363
|
+
}, [view, queue, chunkSize, delayMs]);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
type MarkdownStreamControllerDeps = {
|
|
367
|
+
contentRef: RefObject<string | undefined>;
|
|
368
|
+
viewRef: RefObject<EditorView | null>;
|
|
369
|
+
queueRef: RefObject<Queue.Queue<string>>;
|
|
370
|
+
onReset: (text: string) => Promise<void>;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* External controller API.
|
|
375
|
+
*/
|
|
376
|
+
const createMarkdownStreamController = ({
|
|
377
|
+
contentRef,
|
|
378
|
+
viewRef,
|
|
379
|
+
queueRef,
|
|
380
|
+
onReset,
|
|
381
|
+
}: MarkdownStreamControllerDeps): MarkdownStreamController => {
|
|
382
|
+
return {
|
|
383
|
+
get length() {
|
|
384
|
+
return viewRef.current?.state.doc.length;
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
/** Focus the editor. */
|
|
388
|
+
focus: () => {
|
|
389
|
+
viewRef.current?.focus();
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/** Scroll to bottom. */
|
|
393
|
+
scrollToBottom: (behavior?: ScrollBehavior) => {
|
|
394
|
+
viewRef.current?.dispatch({
|
|
395
|
+
effects: scrollerLineEffect.of({ line: -1, behavior }),
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
/** Navigate previous prompt. */
|
|
400
|
+
navigatePrevious: () => {
|
|
401
|
+
viewRef.current?.dispatch({
|
|
402
|
+
effects: navigatePreviousEffect.of(),
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/** Navigate next prompt. */
|
|
407
|
+
navigateNext: () => {
|
|
408
|
+
viewRef.current?.dispatch({
|
|
409
|
+
effects: navigateNextEffect.of(),
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
/** Set the context for widgets (XML tags). */
|
|
414
|
+
setContext: (context: any) => {
|
|
415
|
+
viewRef.current?.dispatch({
|
|
416
|
+
effects: xmlTagContextEffect.of(context),
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/** Reset document. */
|
|
421
|
+
setContent: onReset,
|
|
422
|
+
|
|
423
|
+
/** Append to queue (and stream). */
|
|
424
|
+
append: async (text: string) => {
|
|
425
|
+
contentRef.current += text;
|
|
426
|
+
if (text.length) {
|
|
427
|
+
// Always go through the streaming queue, even when the doc starts empty. Skipping the
|
|
428
|
+
// queue in that case (via `onReset`) bypasses the `typewriter` extension's transaction filter
|
|
429
|
+
// and the first chunk lands in one CM dispatch — defeating the typewriter for any
|
|
430
|
+
// consumer (e.g. ChatThread) where the first delta is large because upstream batching
|
|
431
|
+
// collected several streaming partials before React rendered.
|
|
432
|
+
const queue = queueRef.current;
|
|
433
|
+
if (queue) {
|
|
434
|
+
await runAndForwardErrors(Queue.offer(queue, text));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
/** Update widget state. */
|
|
440
|
+
updateWidget: (id: string, value: any) => {
|
|
441
|
+
viewRef.current?.dispatch({
|
|
442
|
+
effects: xmlTagUpdateEffect.of({ id, value }),
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
} satisfies MarkdownStreamController;
|
|
446
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
import { Domino } from '@dxos/ui';
|
|
9
|
+
import { typewriterDrainingEffect } from '@dxos/ui-editor';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Host-controlled visibility intent. The footer block widget is rendered only when this is
|
|
13
|
+
* `true` AND typewriter is not actively draining its typewriter buffer — the latter is observed
|
|
14
|
+
* through {@link typewriterDrainingEffect}. Removing the decoration during a drip avoids the
|
|
15
|
+
* scroll-measure conflict between CM's view-line model and the floating absolute child.
|
|
16
|
+
*/
|
|
17
|
+
export const setFooterVisibleEffect = StateEffect.define<boolean>();
|
|
18
|
+
|
|
19
|
+
type FooterState = {
|
|
20
|
+
/** Host wants the footer rendered. */
|
|
21
|
+
wanted: boolean;
|
|
22
|
+
/** Typewriter is actively dripping into the document. */
|
|
23
|
+
draining: boolean;
|
|
24
|
+
decorations: DecorationSet;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Renders a host-supplied React subtree as a CodeMirror block widget anchored at
|
|
29
|
+
* `doc.length`. The widget lives inside the document, so it scrolls with content.
|
|
30
|
+
*
|
|
31
|
+
* Toggle the host-intent visibility via {@link setFooterVisibleEffect}. The widget is also
|
|
32
|
+
* automatically removed while {@link typewriterDrainingEffect} reports `true` and re-mounted
|
|
33
|
+
* once the typewriter's buffer drains — this prevents the block widget from interfering with CM's
|
|
34
|
+
* view-line measurement during the typewriter drip.
|
|
35
|
+
*
|
|
36
|
+
* For pure doc edits (no visibility transition), the decoration's position is mapped
|
|
37
|
+
* through the change set so insertions at the end translate the anchor without destroying
|
|
38
|
+
* the widget's DOM.
|
|
39
|
+
*/
|
|
40
|
+
export const footer = (setRoot: (el: HTMLElement | null) => void): Extension => {
|
|
41
|
+
const widget = new FooterWidget(setRoot);
|
|
42
|
+
const buildSet = (length: number): DecorationSet =>
|
|
43
|
+
Decoration.set([Decoration.widget({ widget, block: true, side: 1 }).range(length)]);
|
|
44
|
+
|
|
45
|
+
const field = StateField.define<FooterState>({
|
|
46
|
+
create: () => ({ wanted: false, draining: false, decorations: Decoration.none }),
|
|
47
|
+
update: (state, tr) => {
|
|
48
|
+
let { wanted, draining, decorations } = state;
|
|
49
|
+
for (const effect of tr.effects) {
|
|
50
|
+
if (effect.is(setFooterVisibleEffect)) {
|
|
51
|
+
wanted = effect.value;
|
|
52
|
+
}
|
|
53
|
+
if (effect.is(typewriterDrainingEffect)) {
|
|
54
|
+
draining = effect.value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Also gate on the document being non-empty: there's nothing for the footer to anchor
|
|
59
|
+
// below, and rendering it on a blank doc looks like detached chrome.
|
|
60
|
+
const docLength = tr.state.doc.length;
|
|
61
|
+
const visible = wanted && !draining && docLength > 0;
|
|
62
|
+
const wasVisible = decorations.size > 0;
|
|
63
|
+
if (visible !== wasVisible) {
|
|
64
|
+
decorations = visible ? buildSet(docLength) : Decoration.none;
|
|
65
|
+
} else if (tr.docChanged && decorations.size > 0) {
|
|
66
|
+
// Position-map the existing decoration so insertions at the end translate the
|
|
67
|
+
// widget anchor without destroying the DOM (`widget.eq` is identity-true).
|
|
68
|
+
decorations = decorations.map(tr.changes);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { wanted, draining, decorations };
|
|
72
|
+
},
|
|
73
|
+
provide: (f) => EditorView.decorations.from(f, (state) => state.decorations),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return [field];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Block widget rendered at the end of the document. The DOM element is reported via
|
|
81
|
+
* `setRoot` so the host React component can `createPortal` arbitrary content into it.
|
|
82
|
+
*/
|
|
83
|
+
class FooterWidget extends WidgetType {
|
|
84
|
+
constructor(private readonly _setRoot: (el: HTMLElement | null) => void) {
|
|
85
|
+
super();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Singleton equality so CM keeps the same DOM element across decoration rebuilds —
|
|
89
|
+
// the React subtree portaled into it is not unmounted on every doc change.
|
|
90
|
+
override eq(_other: this): boolean {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override ignoreEvent(): boolean {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override toDOM(): HTMLElement {
|
|
99
|
+
// The outer block-widget element is `position: relative` with zero flow height so it
|
|
100
|
+
// does not push the document layout (autoScroll, line measurement, etc. ignore it).
|
|
101
|
+
// The inner element is `position: absolute`, taking the React subtree out of flow — it
|
|
102
|
+
// renders as a floating layer anchored to the doc tail without consuming space.
|
|
103
|
+
const inner = Domino.of('div')
|
|
104
|
+
.classNames('cm-stream-footer-content')
|
|
105
|
+
.style({ position: 'absolute', left: '0', top: '0' });
|
|
106
|
+
|
|
107
|
+
const el = Domino.of('div')
|
|
108
|
+
.classNames('cm-stream-footer')
|
|
109
|
+
.style({ position: 'relative', height: '0' })
|
|
110
|
+
.append(inner);
|
|
111
|
+
|
|
112
|
+
this._setRoot(inner.root);
|
|
113
|
+
return el.root;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override destroy(): void {
|
|
117
|
+
this._setRoot(null);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, it } from '@effect/vitest';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Stream from 'effect/Stream';
|
|
8
|
+
import * as TestContext from 'effect/TestContext';
|
|
9
|
+
|
|
10
|
+
import { type StreamerOptions, createStreamer, splitFragments, splitSentences, splitSpans } from './stream';
|
|
11
|
+
|
|
12
|
+
describe('stream', () => {
|
|
13
|
+
it.effect('tokenize tags', ({ expect }) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
{
|
|
16
|
+
expect(splitSpans('A\n<test />\nB')).toEqual(['A\n', '<test />', '\nB']);
|
|
17
|
+
expect(splitSpans('A\n<test>hello</test>\nB')).toEqual(['A\n', '<test>', 'hello', '</test>', '\nB']);
|
|
18
|
+
}
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
it.effect('tokenize fragments', ({ expect }) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
{
|
|
25
|
+
expect(splitFragments('A\n<toolkit />\nB')).toEqual(['A\n', '<toolkit />', '\nB']);
|
|
26
|
+
expect(splitFragments('A\n<suggestion>Test</suggestion>\nB')).toEqual([
|
|
27
|
+
'A\n',
|
|
28
|
+
'<suggestion>Test</suggestion>',
|
|
29
|
+
'\nB',
|
|
30
|
+
]);
|
|
31
|
+
expect(splitFragments('A\n<select><option /><option>Test</option></select>\nB')).toEqual([
|
|
32
|
+
'A\n',
|
|
33
|
+
'<select><option /><option>Test</option></select>',
|
|
34
|
+
'\nB',
|
|
35
|
+
]);
|
|
36
|
+
// Hyphenated custom element names must resolve to the correct closing tag.
|
|
37
|
+
expect(splitFragments('A\n<dom-widget>Hello</dom-widget>\nB')).toEqual([
|
|
38
|
+
'A\n',
|
|
39
|
+
'<dom-widget>Hello</dom-widget>',
|
|
40
|
+
'\nB',
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it.effect('split sentences', ({ expect }) =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
expect(splitSentences('Hello world. What a nice day!\nLooking great.')).toEqual([
|
|
49
|
+
'Hello world. ',
|
|
50
|
+
'What a nice day!\n',
|
|
51
|
+
'Looking great.',
|
|
52
|
+
]);
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
it.effect('stream char-by-char with tags', ({ expect }) =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const text = 'Hello <b>World</b>!';
|
|
59
|
+
const result = yield* testStreamer(text);
|
|
60
|
+
expect(result).toEqual(['Hello ', '<b>World</b>', '!']);
|
|
61
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it.effect('stream keeps hyphenated custom elements intact', ({ expect }) =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const text = 'Before <dom-widget>Hello</dom-widget> after';
|
|
67
|
+
const result = yield* testStreamer(text);
|
|
68
|
+
expect(result).toEqual(['Before ', '<dom-widget>Hello</dom-widget>', ' after']);
|
|
69
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
it.effect('stream with self-closing tags', ({ expect }) =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const text = 'Hello<br/>world<img src="test.jpg"/>';
|
|
75
|
+
const result = yield* testStreamer(text);
|
|
76
|
+
expect(result).toEqual(['Hello', '<br/>', 'world', '<img src="test.jpg"/>']);
|
|
77
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
it.effect('stream with incomplete tag fragment', ({ expect }) =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const text = 'Hello <div class="test';
|
|
83
|
+
const result = yield* testStreamer(text);
|
|
84
|
+
expect(result).toEqual(['Hello ', '<div class="test']);
|
|
85
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// chunkSize: 'word' subdivides plain-text spans at whitespace boundaries while keeping XML
|
|
89
|
+
// fragments atomic, so widgets still mount on a single CM dispatch.
|
|
90
|
+
it.effect('chunkSize "word" splits text spans into words and whitespace runs', ({ expect }) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const result = yield* testStreamer('Hello brave new <b>world</b>!', { chunkSize: 'word' });
|
|
93
|
+
expect(result).toEqual(['Hello', ' ', 'brave', ' ', 'new', ' ', '<b>world</b>', '!']);
|
|
94
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// chunkSize: 'character' is the smallest cadence — one CM dispatch per character of plain
|
|
98
|
+
// text. XML fragments are still atomic.
|
|
99
|
+
it.effect('chunkSize "character" splits text spans char-by-char and keeps tags atomic', ({ expect }) =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const result = yield* testStreamer('Hi <b>X</b>!', { chunkSize: 'character' });
|
|
102
|
+
expect(result).toEqual(['H', 'i', ' ', '<b>X</b>', '!']);
|
|
103
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Default `chunkSize: 'span'` preserves the original behaviour — tests above already cover it.
|
|
107
|
+
it.effect('chunkSize defaults to "span"', ({ expect }) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const result = yield* testStreamer('Hello world');
|
|
110
|
+
expect(result).toEqual(['Hello world']);
|
|
111
|
+
}).pipe(Effect.provide(TestContext.TestContext)),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const testStreamer = (text: string, options?: StreamerOptions) =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const result: string[] = [];
|
|
118
|
+
|
|
119
|
+
// Create a stream from the single text value.
|
|
120
|
+
yield* Stream.make(text).pipe(
|
|
121
|
+
(source) => createStreamer(source, options),
|
|
122
|
+
Stream.runForEach((text) => Effect.sync(() => result.push(text))),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
});
|