@dxos/react-ui-markdown 0.10.0 → 0.11.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.
@@ -0,0 +1,648 @@
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { ErrorBoundary, MediaPlayer, useDynamicRef, useStateWithRef, useThemeContext } from "@dxos/react-ui";
5
+ import { SyntaxHighlighter } from "@dxos/react-ui-syntax-highlighter";
6
+ import { mx } from "@dxos/ui-theme";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Stream from "effect/Stream";
10
+ import { Obj } from "@dxos/echo";
11
+ import { Decoration, EditorView, WidgetType } from "@codemirror/view";
12
+ import * as Queue from "effect/Queue";
13
+ import { addEventListener } from "@dxos/async";
14
+ import { EffectEx } from "@dxos/effect";
15
+ import { PROMPT_ELEMENT, crawlerLineEffect, createBasicExtensions, createThemeExtensions, createTurnSource, decorateMarkdown, documentSlots, extendedMarkdown, fader, lineSpacing, navigateNextEffect, navigatePreviousEffect, scroller, turnFolding, typewriter, typewriterBypass, typewriterDrainingEffect, xmlBlockDecoration, xmlFormatting, xmlTagContextEffect, xmlTagResetEffect, xmlTagUpdateEffect, xmlTags } from "@dxos/ui-editor";
16
+ import { EditorSelection, StateEffect, StateField, Transaction } from "@codemirror/state";
17
+ import * as Fiber from "effect/Fiber";
18
+ import { createPortal } from "react-dom";
19
+ import { useTextEditor } from "@dxos/react-ui-editor";
20
+ import { isTruthy } from "@dxos/util";
21
+ import { Domino } from "@dxos/ui";
22
+ //#region src/MarkdownView/MarkdownView.tsx
23
+ /**
24
+ * Transforms markdown text into react elements.
25
+ * https://github.com/remarkjs/react-markdown
26
+ * markdown -> remark -> [mdast -> remark plugins] -> [hast -> rehype plugins] -> components -> react elements.
27
+ * Consider using @dxos/react-ui-editor.
28
+ */
29
+ var MarkdownView = ({ classNames, children, components, content = "" }) => {
30
+ return /* @__PURE__ */ jsxs("div", {
31
+ className: mx(classNames),
32
+ children: [/* @__PURE__ */ jsx(ReactMarkdown, {
33
+ remarkPlugins: [remarkGfm],
34
+ skipHtml: true,
35
+ components: {
36
+ ...defaultComponents,
37
+ ...components
38
+ },
39
+ children: content
40
+ }), children]
41
+ });
42
+ };
43
+ var defaultComponents = {
44
+ h1: ({ children }) => {
45
+ return /* @__PURE__ */ jsx("h1", {
46
+ className: "pt-1 pb-1 text-accent-text text-xl",
47
+ children
48
+ });
49
+ },
50
+ h2: ({ children }) => {
51
+ return /* @__PURE__ */ jsx("h2", {
52
+ className: "pt-1 pb-1 text-accent-text text-lg",
53
+ children
54
+ });
55
+ },
56
+ h3: ({ children }) => {
57
+ return /* @__PURE__ */ jsx("h3", {
58
+ className: "pt-1 pb-1 text-accent-text text-base",
59
+ children
60
+ });
61
+ },
62
+ h4: ({ children }) => {
63
+ return /* @__PURE__ */ jsx("h4", {
64
+ className: "pt-1 pb-1 uppercase text-base",
65
+ children
66
+ });
67
+ },
68
+ blockquote: ({ children, ...props }) => /* @__PURE__ */ jsx("blockquote", {
69
+ className: "my-2 py-2 ps-4 border-l-4 border-accent-text text-accent-text",
70
+ ...props,
71
+ children
72
+ }),
73
+ p: ({ children }) => {
74
+ return /* @__PURE__ */ jsx("div", {
75
+ className: "pt-1 pb-1",
76
+ children
77
+ });
78
+ },
79
+ a: ({ children, href, ...props }) => /* @__PURE__ */ jsx("a", {
80
+ href,
81
+ className: "text-primary-500 hover:text-primary-500",
82
+ target: "_blank",
83
+ rel: "noopener noreferrer",
84
+ ...props,
85
+ children
86
+ }),
87
+ img: ({ src, alt }) => {
88
+ if (!src) return null;
89
+ return /* @__PURE__ */ jsx(MediaPlayer, {
90
+ src,
91
+ alt,
92
+ classNames: "w-full"
93
+ });
94
+ },
95
+ ol: ({ children, ...props }) => /* @__PURE__ */ jsx("ol", {
96
+ className: "pt-1 pb-1 ps-6 leading-tight list-decimal",
97
+ ...props,
98
+ children
99
+ }),
100
+ ul: ({ children, ...props }) => /* @__PURE__ */ jsx("ul", {
101
+ className: "pt-1 pb-1 ps-6 leading-tight list-disc",
102
+ ...props,
103
+ children
104
+ }),
105
+ li: ({ children, ...props }) => /* @__PURE__ */ jsx("li", {
106
+ className: "",
107
+ ...props,
108
+ children
109
+ }),
110
+ pre: ({ children }) => children,
111
+ code: ({ children, className, node }) => {
112
+ const [, language] = /language-(\w+)/.exec(className || "") || [];
113
+ if (!className && node?.position?.start.line === node?.position?.end.line) return /* @__PURE__ */ jsx("code", {
114
+ className: "rounded-xs bg-group-surface px-1 py-0.5 text-sm text-info-text",
115
+ children
116
+ });
117
+ return /* @__PURE__ */ jsx(SyntaxHighlighter, {
118
+ language,
119
+ classNames: "mt-2 mb-2 p-2 border border-separator rounded-xs text-sm dx-group-surface",
120
+ copyButton: true,
121
+ PreTag: "pre",
122
+ children
123
+ });
124
+ }
125
+ };
126
+ //#endregion
127
+ //#region src/MarkdownStream/stream.ts
128
+ var renderObjectLink = (obj, block) => `${block ? "!" : ""}[${Obj.getLabel(obj)}](${Obj.getURI(obj)})`;
129
+ /**
130
+ * Streams tokens to the consumer, keeping XML/HTML fragments intact.
131
+ *
132
+ * The cadence is controlled by `options.chunkSize`. `'span'` (default) preserves the
133
+ * one-token-per-source-chunk behaviour the function had originally. Use `'word'` or
134
+ * `'character'` to decouple the visible cadence from the source's chunk size — useful when
135
+ * the AI service emits large partial blocks but you want a smoother typewriter effect.
136
+ */
137
+ var createStreamer = (source, { chunkSize = "span", delayMs = 0 } = {}) => {
138
+ const subdivide = chunkSize === "span" ? (token) => [token] : (token) => isXmlFragment(token) ? [token] : splitTextSpan(token, chunkSize);
139
+ let stream = source.pipe(Stream.flatMap((chunk) => Stream.fromIterable(splitFragments(chunk).flatMap(subdivide))));
140
+ if (delayMs > 0) stream = stream.pipe(Stream.tap(() => Effect.sleep(`${delayMs} millis`)));
141
+ return stream;
142
+ };
143
+ /** A token starts with `<` if and only if it is an XML/HTML fragment produced by `splitFragments`. */
144
+ var isXmlFragment = (token) => token.startsWith("<");
145
+ /**
146
+ * Subdivide a non-XML text span into smaller tokens. `'word'` splits into runs of non-whitespace
147
+ * and runs of whitespace as separate tokens (so the renderer can display whitespace cadence too).
148
+ */
149
+ var splitTextSpan = (span, chunkSize) => {
150
+ if (chunkSize === "character") return [...span];
151
+ return span.match(/\s+|\S+/g) ?? [span];
152
+ };
153
+ /** Matches opening tag names, including custom elements with hyphens (e.g. dom-widget). */
154
+ var OPENING_TAG_NAME = /^<([a-zA-Z][\w-]*)(?:\s[^>]*)?>/;
155
+ /**
156
+ * Splits text into chunks, preserving XML/HTML fragments.
157
+ */
158
+ var splitFragments = (text) => {
159
+ const initialTokens = splitSpans(text);
160
+ const tokens = [];
161
+ let i = 0;
162
+ while (i < initialTokens.length) {
163
+ const token = initialTokens[i];
164
+ if (token.startsWith("<") && !token.startsWith("</") && !token.endsWith("/>")) {
165
+ const tagMatch = token.match(OPENING_TAG_NAME);
166
+ if (tagMatch) {
167
+ const closingTag = `</${tagMatch[1]}>`;
168
+ let fragment = token;
169
+ let foundClosing = false;
170
+ let j = i + 1;
171
+ while (j < initialTokens.length) {
172
+ fragment += initialTokens[j];
173
+ if (initialTokens[j] === closingTag) {
174
+ foundClosing = true;
175
+ break;
176
+ }
177
+ j++;
178
+ }
179
+ if (foundClosing) {
180
+ tokens.push(fragment);
181
+ i = j + 1;
182
+ } else {
183
+ tokens.push(token);
184
+ i++;
185
+ }
186
+ } else {
187
+ tokens.push(token);
188
+ i++;
189
+ }
190
+ } else {
191
+ tokens.push(token);
192
+ i++;
193
+ }
194
+ }
195
+ return tokens;
196
+ };
197
+ var splitSpans = (text) => {
198
+ const spans = [];
199
+ let currentText = "";
200
+ let i = 0;
201
+ while (i < text.length) if (text[i] === "<") {
202
+ if (currentText) {
203
+ spans.push(currentText);
204
+ currentText = "";
205
+ }
206
+ const closeIndex = text.indexOf(">", i);
207
+ if (closeIndex !== -1) {
208
+ spans.push(text.slice(i, closeIndex + 1));
209
+ i = closeIndex + 1;
210
+ } else {
211
+ currentText = text.slice(i);
212
+ break;
213
+ }
214
+ } else {
215
+ currentText += text[i];
216
+ i++;
217
+ }
218
+ if (currentText) spans.push(currentText);
219
+ return spans;
220
+ };
221
+ /**
222
+ * Split text into sentences, preserving the sentence-ending punctuation.
223
+ */
224
+ var splitSentences = (text) => {
225
+ const sentenceRegex = /[^.!?]*[.!?]+(?:\s+|$)/g;
226
+ const sentences = [];
227
+ let lastIndex = 0;
228
+ let match;
229
+ while ((match = sentenceRegex.exec(text)) !== null) {
230
+ sentences.push(match[0]);
231
+ lastIndex = match.index + match[0].length;
232
+ }
233
+ if (lastIndex < text.length) {
234
+ const remaining = text.slice(lastIndex);
235
+ if (remaining) sentences.push(remaining);
236
+ }
237
+ if (sentences.length === 0 && text) sentences.push(text);
238
+ return sentences;
239
+ };
240
+ //#endregion
241
+ //#region src/MarkdownStream/testing/testing.ts
242
+ /**
243
+ * Simulates word-by-word streaming (more natural for LLMs).
244
+ */
245
+ async function* textStream(text, options = {}) {
246
+ const { chunkDelay = 100, variance = .3, wordsPerChunk = 3 } = options;
247
+ const words = text.match(/\S+|\s+/g) || [];
248
+ let i = 0;
249
+ while (i < words.length) {
250
+ const chunkWords = [];
251
+ let wordCount = 0;
252
+ while (i < words.length && wordCount < wordsPerChunk) {
253
+ const word = words[i];
254
+ chunkWords.push(word);
255
+ if (word.trim()) wordCount++;
256
+ i++;
257
+ }
258
+ yield chunkWords.join("");
259
+ const delay = chunkDelay * (1 + (Math.random() - .5) * variance * 2);
260
+ await new Promise((resolve) => setTimeout(resolve, delay));
261
+ }
262
+ }
263
+ //#endregion
264
+ //#region src/MarkdownStream/create-controller.ts
265
+ /**
266
+ * External controller API.
267
+ *
268
+ * @internal Exported for tests via `@dxos/react-ui-markdown/testing`; hosts get this through the
269
+ * component's ref.
270
+ */
271
+ var computeVisibleRange = (view) => {
272
+ const rect = view.scrollDOM.getBoundingClientRect();
273
+ return {
274
+ from: view.posAtCoords({
275
+ x: rect.left + 1,
276
+ y: rect.top + 1
277
+ }, false),
278
+ to: view.posAtCoords({
279
+ x: rect.left + 1,
280
+ y: rect.bottom - 1
281
+ }, false)
282
+ };
283
+ };
284
+ var createMarkdownStreamController = ({ contentRef, viewRef, queueRef, onReset, pendingContextRef }) => {
285
+ return {
286
+ get length() {
287
+ return viewRef.current?.state.doc.length;
288
+ },
289
+ /** Focus the editor. */
290
+ focus: () => {
291
+ viewRef.current?.focus();
292
+ },
293
+ /** Scroll to bottom. */
294
+ scrollToBottom: (behavior) => {
295
+ viewRef.current?.dispatch({ effects: crawlerLineEffect.of({
296
+ line: -1,
297
+ behavior
298
+ }) });
299
+ },
300
+ /** Scroll the given document position into view. */
301
+ scrollTo: (pos, options) => {
302
+ const view = viewRef.current;
303
+ if (!view) return;
304
+ const clamped = Math.max(0, Math.min(pos, view.state.doc.length));
305
+ view.dispatch({ effects: EditorView.scrollIntoView(clamped, { y: options?.y ?? "start" }) });
306
+ },
307
+ /** The document range currently visible in the viewport. */
308
+ getVisibleRange: () => {
309
+ const view = viewRef.current;
310
+ return view ? computeVisibleRange(view) : void 0;
311
+ },
312
+ /** Subscribe to visible-range changes (scroll). Fires immediately with the current range. */
313
+ onVisibleRangeChange: (cb) => {
314
+ const view = viewRef.current;
315
+ if (!view) return () => {};
316
+ const handler = () => cb(computeVisibleRange(view));
317
+ handler();
318
+ return addEventListener(view.scrollDOM, "scroll", handler, { passive: true });
319
+ },
320
+ /** Navigate previous prompt. */
321
+ navigatePrevious: () => {
322
+ viewRef.current?.dispatch({ effects: navigatePreviousEffect.of() });
323
+ },
324
+ /** Navigate next prompt. */
325
+ navigateNext: () => {
326
+ viewRef.current?.dispatch({ effects: navigateNextEffect.of() });
327
+ },
328
+ /**
329
+ * Set the context for widgets (XML tags).
330
+ *
331
+ * Remembered as well as dispatched: a host has no signal for when the view exists, and the
332
+ * dispatch is a no-op before it does — so without this a context set on mount is silently lost and
333
+ * every widget callback through it dies on an optional call. {@link flushContext} re-applies it.
334
+ */
335
+ setContext: (context) => {
336
+ pendingContextRef.current = { value: context };
337
+ viewRef.current?.dispatch({ effects: xmlTagContextEffect.of(context) });
338
+ },
339
+ /** Re-applies the last context set, if any. Called once the view exists and after a reset. */
340
+ flushContext: () => {
341
+ const pending = pendingContextRef.current;
342
+ if (pending && viewRef.current) viewRef.current.dispatch({ effects: xmlTagContextEffect.of(pending.value) });
343
+ },
344
+ /** Reset document. */
345
+ setContent: onReset,
346
+ /** Append to queue (and stream). */
347
+ append: async (text) => {
348
+ contentRef.current += text;
349
+ if (text.length) {
350
+ const queue = queueRef.current;
351
+ if (queue) await EffectEx.runAndForwardErrors(Queue.offer(queue, text));
352
+ }
353
+ },
354
+ /** Update widget state. */
355
+ updateWidget: (id, value) => {
356
+ viewRef.current?.dispatch({ effects: xmlTagUpdateEffect.of({
357
+ id,
358
+ value
359
+ }) });
360
+ }
361
+ };
362
+ };
363
+ //#endregion
364
+ //#region src/MarkdownStream/footer.ts
365
+ /**
366
+ * Host-controlled visibility intent. The footer block widget is rendered only when this is
367
+ * `true` AND typewriter is not actively draining its typewriter buffer — the latter is observed
368
+ * through {@link typewriterDrainingEffect}. Removing the decoration during a drip avoids the
369
+ * scroll-measure conflict between CM's view-line model and the floating absolute child.
370
+ */
371
+ var setFooterVisibleEffect = StateEffect.define();
372
+ /**
373
+ * Renders a host-supplied React subtree as a CodeMirror block widget anchored at
374
+ * `doc.length`. The widget lives inside the document, so it scrolls with content.
375
+ *
376
+ * Toggle the host-intent visibility via {@link setFooterVisibleEffect}. The widget is also
377
+ * automatically removed while {@link typewriterDrainingEffect} reports `true` and re-mounted
378
+ * once the typewriter's buffer drains — this prevents the block widget from interfering with CM's
379
+ * view-line measurement during the typewriter drip.
380
+ *
381
+ * For pure doc edits (no visibility transition), the decoration's position is mapped
382
+ * through the change set so insertions at the end translate the anchor without destroying
383
+ * the widget's DOM.
384
+ */
385
+ var footer = (setRoot) => {
386
+ const widget = new FooterWidget(setRoot);
387
+ const buildSet = (length) => Decoration.set([Decoration.widget({
388
+ widget,
389
+ block: true,
390
+ side: 1
391
+ }).range(length)]);
392
+ return [StateField.define({
393
+ create: () => ({
394
+ wanted: false,
395
+ draining: false,
396
+ decorations: Decoration.none
397
+ }),
398
+ update: (state, tr) => {
399
+ let { wanted, draining, decorations } = state;
400
+ for (const effect of tr.effects) {
401
+ if (effect.is(setFooterVisibleEffect)) wanted = effect.value;
402
+ if (effect.is(typewriterDrainingEffect)) draining = effect.value;
403
+ }
404
+ const docLength = tr.state.doc.length;
405
+ const visible = wanted && !draining && docLength > 0;
406
+ if (visible !== decorations.size > 0) decorations = visible ? buildSet(docLength) : Decoration.none;
407
+ else if (tr.docChanged && decorations.size > 0) decorations = decorations.map(tr.changes);
408
+ return {
409
+ wanted,
410
+ draining,
411
+ decorations
412
+ };
413
+ },
414
+ provide: (f) => EditorView.decorations.from(f, (state) => state.decorations)
415
+ })];
416
+ };
417
+ /**
418
+ * Block widget rendered at the end of the document. The DOM element is reported via
419
+ * `setRoot` so the host React component can `createPortal` arbitrary content into it.
420
+ */
421
+ var FooterWidget = class extends WidgetType {
422
+ _setRoot;
423
+ constructor(_setRoot) {
424
+ super();
425
+ this._setRoot = _setRoot;
426
+ }
427
+ eq(_other) {
428
+ return true;
429
+ }
430
+ ignoreEvent() {
431
+ return true;
432
+ }
433
+ toDOM() {
434
+ const inner = Domino.of("div").classNames("cm-stream-footer-content").style({
435
+ position: "absolute",
436
+ left: "0",
437
+ top: "0"
438
+ });
439
+ const el = Domino.of("div").classNames("cm-stream-footer").style({
440
+ position: "relative",
441
+ height: "0"
442
+ }).append(inner);
443
+ this._setRoot(inner.root);
444
+ return el.root;
445
+ }
446
+ destroy() {
447
+ this._setRoot(null);
448
+ }
449
+ };
450
+ //#endregion
451
+ //#region src/MarkdownStream/MarkdownStream.tsx
452
+ /**
453
+ * Codemirror-based markdown editor with xml tag widtgets and streaming support.
454
+ */
455
+ var MarkdownStream = forwardRef(({ classNames, debug, content, options, registry, extensions, footer, slots, onEvent }, forwardedRef) => {
456
+ const contentRef = useRef(content ?? "");
457
+ const [footerRoot, setFooterRoot] = useState(null);
458
+ const pendingContextRef = useRef(void 0);
459
+ const { parentRef, view, viewRef, widgets } = useMarkdownStreamTextEditor(contentRef, {
460
+ slots,
461
+ debug,
462
+ registry,
463
+ options,
464
+ extensions,
465
+ setFooterRoot: footer ? setFooterRoot : void 0
466
+ });
467
+ const footerVisible = !!footer;
468
+ useEffect(() => {
469
+ view?.dispatch({ effects: setFooterVisibleEffect.of(footerVisible) });
470
+ }, [view, footerVisible]);
471
+ const [queue, setQueue, queueRef] = useStateWithRef(Effect.runSync(Queue.unbounded()));
472
+ const onReset = useCallback(async (text) => {
473
+ contentRef.current = text;
474
+ if (!viewRef.current) return;
475
+ viewRef.current.dispatch({
476
+ effects: [xmlTagContextEffect.of(pendingContextRef.current?.value ?? null), xmlTagResetEffect.of(null)],
477
+ changes: [{
478
+ from: 0,
479
+ to: viewRef.current.state.doc.length,
480
+ insert: text
481
+ }],
482
+ annotations: typewriterBypass.of(true),
483
+ selection: EditorSelection.cursor(text.length)
484
+ });
485
+ setQueue(Effect.runSync(Queue.unbounded()));
486
+ }, [
487
+ contentRef,
488
+ viewRef,
489
+ setQueue,
490
+ pendingContextRef
491
+ ]);
492
+ useImperativeHandle(forwardedRef, () => createMarkdownStreamController({
493
+ contentRef,
494
+ viewRef,
495
+ queueRef,
496
+ onReset,
497
+ pendingContextRef
498
+ }), [onReset]);
499
+ useEffect(() => {
500
+ const pending = pendingContextRef.current;
501
+ if (view && pending) view.dispatch({ effects: xmlTagContextEffect.of(pending.value) });
502
+ }, [view]);
503
+ useEffect(() => {
504
+ if (!parentRef.current) return;
505
+ return addEventListener(parentRef.current, "click", (event) => {
506
+ const button = event.target.closest("[data-action=\"submit\"]");
507
+ if (button?.getAttribute("data-action") === "submit") onEvent?.({
508
+ type: "submit",
509
+ value: button.getAttribute("data-value")
510
+ });
511
+ });
512
+ }, [
513
+ view,
514
+ parentRef,
515
+ onEvent
516
+ ]);
517
+ useMarkdownStreamQueue(view, queue, {
518
+ chunkSize: options?.streamCadence,
519
+ delayMs: options?.streamDelayMs
520
+ });
521
+ useEffect(() => {
522
+ return () => {
523
+ view?.destroy();
524
+ };
525
+ }, [view]);
526
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
527
+ className: mx("dx-container", classNames),
528
+ ref: parentRef
529
+ }), /* @__PURE__ */ jsxs(ErrorBoundary, {
530
+ name: "markdown-stream",
531
+ children: [widgets.map(({ Component, root, id, props }) => /* @__PURE__ */ jsx("div", { children: createPortal(/* @__PURE__ */ jsx(Component, {
532
+ view,
533
+ ...props
534
+ }), root) }, id)), footerRoot && footerVisible && createPortal(footer, footerRoot)]
535
+ })] });
536
+ });
537
+ var turnSource = createTurnSource(PROMPT_ELEMENT);
538
+ /**
539
+ * Read-only markdown editor configured for streaming (theme, markdown extensions, XML widgets).
540
+ */
541
+ var useMarkdownStreamTextEditor = (currentContent, { debug, registry, options, extensions: extensionsProp, slots = documentSlots, setFooterRoot }) => {
542
+ const { themeMode } = useThemeContext();
543
+ const [widgets, setWidgets] = useState([]);
544
+ const { view, parentRef } = useTextEditor(() => {
545
+ const content = currentContent.current;
546
+ return {
547
+ initialValue: content,
548
+ selection: EditorSelection.cursor(content?.length ?? 0),
549
+ extensions: [
550
+ createBasicExtensions({
551
+ lineWrapping: true,
552
+ readOnly: true
553
+ }),
554
+ createThemeExtensions({
555
+ slots,
556
+ scrollbarThin: true,
557
+ syntaxHighlighting: true,
558
+ themeMode
559
+ }),
560
+ xmlFormatting({ skip: debug ? [] : ["prompt"] }),
561
+ !debug && [
562
+ extendedMarkdown({ registry }),
563
+ decorateMarkdown({ skip: (node) => (node.name === "Link" || node.name === "Image") && (node.url.startsWith("dxn:") || node.url.startsWith("echo:")) }),
564
+ lineSpacing(),
565
+ xmlBlockDecoration({
566
+ tag: "prompt",
567
+ lineClass: "cm-prompt-line cm-prompt-bubble bg-group-surface text-base-fg border-l-[8px] pl-[8px]! pr-2 [&_*]:text-inherit!",
568
+ firstLineClass: "pt-1.5 rounded-t-sm",
569
+ lastLineClass: "pb-1.5 rounded-b-sm",
570
+ hideTags: true
571
+ }),
572
+ xmlTags({
573
+ registry,
574
+ setWidgets,
575
+ bookmarks: ["prompt"]
576
+ }),
577
+ turnFolding({ source: turnSource }),
578
+ scroller({
579
+ overScroll: 80,
580
+ autoScroll: options?.autoScroll
581
+ }),
582
+ options?.typewriter && typewriter({
583
+ cursor: options?.cursor,
584
+ streamingTags: new Set(Object.entries(registry ?? {}).filter(([, def]) => def.streaming).map(([tag]) => tag))
585
+ }),
586
+ options?.fader && fader(),
587
+ setFooterRoot && footer(setFooterRoot)
588
+ ].filter(isTruthy),
589
+ extensionsProp
590
+ ].filter(isTruthy)
591
+ };
592
+ }, [
593
+ themeMode,
594
+ registry,
595
+ debug,
596
+ options?.autoScroll,
597
+ options?.typewriter,
598
+ options?.cursor,
599
+ options?.fader,
600
+ slots,
601
+ extensionsProp
602
+ ]);
603
+ return {
604
+ view,
605
+ viewRef: useDynamicRef(view),
606
+ parentRef,
607
+ widgets
608
+ };
609
+ };
610
+ /**
611
+ * Consumes streaming text from the queue and appends it to the editor document.
612
+ */
613
+ var useMarkdownStreamQueue = (view, queue, streamerOptions) => {
614
+ const chunkSize = streamerOptions?.chunkSize;
615
+ const delayMs = streamerOptions?.delayMs;
616
+ useEffect(() => {
617
+ if (!view) return;
618
+ const fork = Stream.fromQueue(queue).pipe((source) => createStreamer(source, {
619
+ chunkSize,
620
+ delayMs
621
+ }), Stream.runForEach((text) => Effect.sync(() => {
622
+ const scrollTop = view.scrollDOM.scrollTop;
623
+ view.dispatch({
624
+ changes: [{
625
+ from: view.state.doc.length,
626
+ insert: text
627
+ }],
628
+ annotations: Transaction.remote.of(true),
629
+ scrollIntoView: false
630
+ });
631
+ requestAnimationFrame(() => {
632
+ view.scrollDOM.scrollTop = scrollTop;
633
+ });
634
+ })), Effect.runFork);
635
+ return () => {
636
+ EffectEx.runAndForwardErrors(Fiber.interrupt(fork));
637
+ };
638
+ }, [
639
+ view,
640
+ queue,
641
+ chunkSize,
642
+ delayMs
643
+ ]);
644
+ };
645
+ //#endregion
646
+ export { MarkdownStream, MarkdownView, computeVisibleRange, createMarkdownStreamController, createStreamer, renderObjectLink, splitFragments, splitSentences, splitSpans, textStream };
647
+
648
+ //# sourceMappingURL=index.mjs.map