@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.
@@ -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
+ };
@@ -5,4 +5,5 @@
5
5
  export * from './stream';
6
6
  export * from './testing';
7
7
 
8
+ export * from './create-controller';
8
9
  export * from './MarkdownStream';
@@ -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 bg-base-surface',
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 bg-group-surface'
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
  >