@dxos/react-ui-markdown 0.9.1-staging.ee54ba693a → 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.
- 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,166 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorView } from '@codemirror/view';
|
|
6
|
+
import * as Queue from 'effect/Queue';
|
|
7
|
+
import { type RefObject } from 'react';
|
|
8
|
+
|
|
9
|
+
import { addEventListener } from '@dxos/async';
|
|
10
|
+
import { EffectEx } from '@dxos/effect';
|
|
11
|
+
import {
|
|
12
|
+
crawlerLineEffect,
|
|
13
|
+
navigateNextEffect,
|
|
14
|
+
navigatePreviousEffect,
|
|
15
|
+
xmlTagContextEffect,
|
|
16
|
+
xmlTagUpdateEffect,
|
|
17
|
+
} from '@dxos/ui-editor';
|
|
18
|
+
|
|
19
|
+
import { type DocumentRange, type MarkdownStreamController } from './MarkdownStream';
|
|
20
|
+
|
|
21
|
+
// Kept out of `MarkdownStream.tsx`: react-refresh only fast-refreshes a module whose
|
|
22
|
+
// exports are all components, so values exported beside them force a full page reload on
|
|
23
|
+
// every edit.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* External controller API.
|
|
27
|
+
*
|
|
28
|
+
* @internal Exported for tests via `@dxos/react-ui-markdown/testing`; hosts get this through the
|
|
29
|
+
* component's ref.
|
|
30
|
+
*/
|
|
31
|
+
export const computeVisibleRange = (view: EditorView): DocumentRange => {
|
|
32
|
+
const rect = view.scrollDOM.getBoundingClientRect();
|
|
33
|
+
// `posAtCoords(_, false)` clamps to the nearest position rather than returning null.
|
|
34
|
+
const from = view.posAtCoords({ x: rect.left + 1, y: rect.top + 1 }, false);
|
|
35
|
+
const to = view.posAtCoords({ x: rect.left + 1, y: rect.bottom - 1 }, false);
|
|
36
|
+
return { from, to };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MarkdownStreamControllerDeps = {
|
|
40
|
+
contentRef: RefObject<string | undefined>;
|
|
41
|
+
viewRef: RefObject<EditorView | null>;
|
|
42
|
+
queueRef: RefObject<Queue.Queue<string>>;
|
|
43
|
+
onReset: (text: string) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Holds a context set before the view existed, for {@link flushContext} to apply once it does.
|
|
46
|
+
* Also re-applied after a document reset, which rebuilds decorations from a state without it.
|
|
47
|
+
*/
|
|
48
|
+
pendingContextRef: RefObject<{ value: any } | undefined>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const createMarkdownStreamController = ({
|
|
52
|
+
contentRef,
|
|
53
|
+
viewRef,
|
|
54
|
+
queueRef,
|
|
55
|
+
onReset,
|
|
56
|
+
pendingContextRef,
|
|
57
|
+
}: MarkdownStreamControllerDeps): MarkdownStreamController => {
|
|
58
|
+
return {
|
|
59
|
+
get length() {
|
|
60
|
+
return viewRef.current?.state.doc.length;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/** Focus the editor. */
|
|
64
|
+
focus: () => {
|
|
65
|
+
viewRef.current?.focus();
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Scroll to bottom. */
|
|
69
|
+
scrollToBottom: (behavior?: ScrollBehavior) => {
|
|
70
|
+
viewRef.current?.dispatch({
|
|
71
|
+
effects: crawlerLineEffect.of({ line: -1, behavior }),
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Scroll the given document position into view. */
|
|
76
|
+
scrollTo: (pos: number, options?: { y?: 'start' | 'center' | 'end' | 'nearest' }) => {
|
|
77
|
+
const view = viewRef.current;
|
|
78
|
+
if (!view) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const clamped = Math.max(0, Math.min(pos, view.state.doc.length));
|
|
82
|
+
view.dispatch({ effects: EditorView.scrollIntoView(clamped, { y: options?.y ?? 'start' }) });
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** The document range currently visible in the viewport. */
|
|
86
|
+
getVisibleRange: () => {
|
|
87
|
+
const view = viewRef.current;
|
|
88
|
+
return view ? computeVisibleRange(view) : undefined;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/** Subscribe to visible-range changes (scroll). Fires immediately with the current range. */
|
|
92
|
+
onVisibleRangeChange: (cb: (range: DocumentRange) => void) => {
|
|
93
|
+
const view = viewRef.current;
|
|
94
|
+
if (!view) {
|
|
95
|
+
return () => {};
|
|
96
|
+
}
|
|
97
|
+
const handler = () => cb(computeVisibleRange(view));
|
|
98
|
+
handler();
|
|
99
|
+
return addEventListener(view.scrollDOM, 'scroll', handler, { passive: true });
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/** Navigate previous prompt. */
|
|
103
|
+
navigatePrevious: () => {
|
|
104
|
+
viewRef.current?.dispatch({
|
|
105
|
+
effects: navigatePreviousEffect.of(),
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/** Navigate next prompt. */
|
|
110
|
+
navigateNext: () => {
|
|
111
|
+
viewRef.current?.dispatch({
|
|
112
|
+
effects: navigateNextEffect.of(),
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Set the context for widgets (XML tags).
|
|
118
|
+
*
|
|
119
|
+
* Remembered as well as dispatched: a host has no signal for when the view exists, and the
|
|
120
|
+
* dispatch is a no-op before it does — so without this a context set on mount is silently lost and
|
|
121
|
+
* every widget callback through it dies on an optional call. {@link flushContext} re-applies it.
|
|
122
|
+
*/
|
|
123
|
+
setContext: (context: any) => {
|
|
124
|
+
pendingContextRef.current = { value: context };
|
|
125
|
+
viewRef.current?.dispatch({
|
|
126
|
+
effects: xmlTagContextEffect.of(context),
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** Re-applies the last context set, if any. Called once the view exists and after a reset. */
|
|
131
|
+
flushContext: () => {
|
|
132
|
+
const pending = pendingContextRef.current;
|
|
133
|
+
if (pending && viewRef.current) {
|
|
134
|
+
viewRef.current.dispatch({
|
|
135
|
+
effects: xmlTagContextEffect.of(pending.value),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
/** Reset document. */
|
|
141
|
+
setContent: onReset,
|
|
142
|
+
|
|
143
|
+
/** Append to queue (and stream). */
|
|
144
|
+
append: async (text: string) => {
|
|
145
|
+
contentRef.current += text;
|
|
146
|
+
if (text.length) {
|
|
147
|
+
// Always go through the streaming queue, even when the doc starts empty. Skipping the
|
|
148
|
+
// queue in that case (via `onReset`) bypasses the `typewriter` extension's transaction filter
|
|
149
|
+
// and the first chunk lands in one CM dispatch — defeating the typewriter for any
|
|
150
|
+
// consumer (e.g. ChatThread) where the first delta is large because upstream batching
|
|
151
|
+
// collected several streaming partials before React rendered.
|
|
152
|
+
const queue = queueRef.current;
|
|
153
|
+
if (queue) {
|
|
154
|
+
await EffectEx.runAndForwardErrors(Queue.offer(queue, text));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/** Update widget state. */
|
|
160
|
+
updateWidget: (id: string, value: any) => {
|
|
161
|
+
viewRef.current?.dispatch({
|
|
162
|
+
effects: xmlTagUpdateEffect.of({ id, value }),
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
} satisfies MarkdownStreamController;
|
|
166
|
+
};
|
|
@@ -69,7 +69,7 @@ const content = trim`
|
|
|
69
69
|
|
|
70
70
|
export const Default: Story = {
|
|
71
71
|
args: {
|
|
72
|
-
classNames: 'p-4 border border-border rounded-md overflow-y-auto
|
|
72
|
+
classNames: 'p-4 border border-border rounded-md overflow-y-auto dx-base-surface',
|
|
73
73
|
content,
|
|
74
74
|
},
|
|
75
75
|
};
|
|
@@ -105,7 +105,7 @@ const defaultComponents: ReactMarkdownOptions['components'] = {
|
|
|
105
105
|
return (
|
|
106
106
|
<SyntaxHighlighter
|
|
107
107
|
language={language}
|
|
108
|
-
classNames='mt-2 mb-2 p-2 border border-separator rounded-xs text-sm
|
|
108
|
+
classNames='mt-2 mb-2 p-2 border border-separator rounded-xs text-sm dx-group-surface'
|
|
109
109
|
copyButton
|
|
110
110
|
PreTag='pre'
|
|
111
111
|
>
|