@dxos/react-ui-markdown 0.10.0 → 0.11.1
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/index.mjs +648 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/MarkdownStream/MarkdownStream.d.ts +25 -1
- package/dist/types/src/MarkdownStream/MarkdownStream.d.ts.map +1 -1
- package/dist/types/src/MarkdownStream/controller.test.d.ts +2 -0
- package/dist/types/src/MarkdownStream/controller.test.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/create-controller.d.ts +19 -0
- package/dist/types/src/MarkdownStream/create-controller.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/index.d.ts +1 -0
- package/dist/types/src/MarkdownStream/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -30
- package/src/MarkdownStream/MarkdownStream.tsx +72 -116
- package/src/MarkdownStream/controller.test.ts +143 -0
- package/src/MarkdownStream/create-controller.ts +166 -0
- package/src/MarkdownStream/index.ts +1 -0
- package/src/MarkdownView/MarkdownView.stories.tsx +1 -1
- package/src/MarkdownView/MarkdownView.tsx +1 -1
- package/dist/lib/browser/index.mjs +0 -631
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
|
@@ -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
|