@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-markdown",
3
- "version": "0.9.1-staging.ee54ba693a",
3
+ "version": "0.11.0",
4
4
  "description": "Markdown components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -15,7 +15,7 @@
15
15
  ".": {
16
16
  "source": "./src/index.ts",
17
17
  "types": "./dist/types/src/index.d.ts",
18
- "default": "./dist/lib/browser/index.mjs"
18
+ "import": "./dist/lib/index.mjs"
19
19
  }
20
20
  },
21
21
  "types": "dist/types/src/index.d.ts",
@@ -24,10 +24,10 @@
24
24
  "src"
25
25
  ],
26
26
  "dependencies": {
27
- "@codemirror/autocomplete": "^6.20.2",
28
- "@codemirror/language": "^6.12.3",
29
- "@codemirror/state": "^6.6.0",
30
- "@codemirror/view": "^6.43.0",
27
+ "@codemirror/autocomplete": "^6.20.3",
28
+ "@codemirror/language": "^6.12.4",
29
+ "@codemirror/state": "^6.7.1",
30
+ "@codemirror/view": "^6.43.6",
31
31
  "@lezer/common": "^1.5.2",
32
32
  "@lezer/highlight": "^1.2.3",
33
33
  "@lezer/markdown": "^1.6.3",
@@ -39,18 +39,18 @@
39
39
  "react-markdown": "^10.1.0",
40
40
  "react-resize-detector": "^11.0.1",
41
41
  "remark-gfm": "^4.0.1",
42
- "@dxos/async": "0.9.1-staging.ee54ba693a",
43
- "@dxos/echo": "0.9.1-staging.ee54ba693a",
44
- "@dxos/effect": "0.9.1-staging.ee54ba693a",
45
- "@dxos/log": "0.9.1-staging.ee54ba693a",
46
- "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
47
- "@dxos/react-ui-editor": "0.9.1-staging.ee54ba693a",
48
- "@dxos/ui": "0.9.1-staging.ee54ba693a",
49
- "@dxos/react-ui-syntax-highlighter": "0.9.1-staging.ee54ba693a",
50
- "@dxos/ui-editor": "0.9.1-staging.ee54ba693a",
51
- "@dxos/ui-theme": "0.9.1-staging.ee54ba693a",
52
- "@dxos/invariant": "0.9.1-staging.ee54ba693a",
53
- "@dxos/util": "0.9.1-staging.ee54ba693a"
42
+ "@dxos/async": "0.11.0",
43
+ "@dxos/echo": "0.11.0",
44
+ "@dxos/effect": "0.11.0",
45
+ "@dxos/invariant": "0.11.0",
46
+ "@dxos/log": "0.11.0",
47
+ "@dxos/react-ui": "0.11.0",
48
+ "@dxos/react-ui-editor": "0.11.0",
49
+ "@dxos/react-ui-syntax-highlighter": "0.11.0",
50
+ "@dxos/ui": "0.11.0",
51
+ "@dxos/ui-editor": "0.11.0",
52
+ "@dxos/util": "0.11.0",
53
+ "@dxos/ui-theme": "0.11.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@effect/vitest": "0.29.0",
@@ -58,23 +58,23 @@
58
58
  "@types/react-dom": "~19.2.3",
59
59
  "react": "~19.2.7",
60
60
  "react-dom": "~19.2.7",
61
- "vite": "^8.0.16",
62
- "@dxos/keys": "0.9.1-staging.ee54ba693a",
63
- "@dxos/echo": "0.9.1-staging.ee54ba693a",
64
- "@dxos/lit-ui": "0.9.1-staging.ee54ba693a",
65
- "@dxos/random": "0.9.1-staging.ee54ba693a",
66
- "@dxos/react-client": "0.9.1-staging.ee54ba693a",
67
- "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
68
- "@dxos/schema": "0.9.1-staging.ee54ba693a",
69
- "@dxos/storybook-utils": "0.9.1-staging.ee54ba693a",
70
- "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
61
+ "vite": "^8.1.4",
62
+ "@dxos/echo": "0.11.0",
63
+ "@dxos/keys": "0.11.0",
64
+ "@dxos/lit-ui": "0.11.0",
65
+ "@dxos/random": "0.11.0",
66
+ "@dxos/react-client": "0.11.0",
67
+ "@dxos/react-ui": "0.11.0",
68
+ "@dxos/schema": "0.11.0",
69
+ "@dxos/storybook-utils": "0.11.0",
70
+ "@dxos/ui-theme": "0.11.0"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "effect": "3.21.4",
74
74
  "react": "~19.2.7",
75
75
  "react-dom": "~19.2.7",
76
- "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
77
- "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
76
+ "@dxos/react-ui": "^0.11.0",
77
+ "@dxos/ui-theme": "^0.11.0"
78
78
  },
79
79
  "publishConfig": {
80
80
  "access": "public"
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import { EditorSelection, type Extension, Transaction } from '@codemirror/state';
6
- import { type EditorView } from '@codemirror/view';
6
+ import { EditorView } from '@codemirror/view';
7
7
  import * as Effect from 'effect/Effect';
8
8
  import * as Fiber from 'effect/Fiber';
9
9
  import * as Queue from 'effect/Queue';
@@ -26,20 +26,21 @@ import { ErrorBoundary, type ThemedClassName, useDynamicRef, useStateWithRef, us
26
26
  import { type UseTextEditor, useTextEditor } from '@dxos/react-ui-editor';
27
27
  import {
28
28
  type AutoScrollProps,
29
+ PROMPT_ELEMENT,
30
+ ThemeExtensionsOptions,
29
31
  type XmlTagsOptions,
30
32
  type XmlWidgetState,
31
33
  type XmlWidgetStateManager,
32
- crawlerLineEffect,
33
34
  createBasicExtensions,
34
35
  createThemeExtensions,
36
+ createTurnSource,
35
37
  decorateMarkdown,
36
38
  documentSlots,
37
39
  extendedMarkdown,
38
40
  fader,
39
41
  lineSpacing,
40
- navigateNextEffect,
41
- navigatePreviousEffect,
42
42
  scroller,
43
+ turnFolding,
43
44
  typewriter,
44
45
  typewriterBypass,
45
46
  xmlBlockDecoration,
@@ -47,24 +48,38 @@ import {
47
48
  xmlTagContextEffect,
48
49
  xmlTagResetEffect,
49
50
  xmlTags,
50
- xmlTagUpdateEffect,
51
51
  } from '@dxos/ui-editor';
52
52
  import { mx } from '@dxos/ui-theme';
53
53
  import { isTruthy } from '@dxos/util';
54
54
 
55
+ import { createMarkdownStreamController } from './create-controller';
55
56
  import { footer, setFooterVisibleEffect } from './footer';
56
57
  import { type StreamerOptions, createStreamer } from './stream';
58
+
59
+ /** Document offset range (CodeMirror positions). */
60
+ export type DocumentRange = { from: number; to: number };
61
+
57
62
  export interface MarkdownStreamController extends XmlWidgetStateManager {
58
63
  get length(): number | undefined;
59
64
  focus: () => void;
60
65
  scrollToBottom: (behavior?: ScrollBehavior) => void;
66
+ /** Scroll the given document position into view. */
67
+ scrollTo: (pos: number, options?: { y?: 'start' | 'center' | 'end' | 'nearest' }) => void;
68
+ /** The document range currently visible in the viewport, or `undefined` if not mounted. */
69
+ getVisibleRange: () => DocumentRange | undefined;
70
+ /** Subscribe to visible-range changes (fires immediately with the current range). Returns an unsubscribe fn. */
71
+ onVisibleRangeChange: (cb: (range: DocumentRange) => void) => () => void;
61
72
  navigateNext: () => void;
62
73
  navigatePrevious: () => void;
63
74
  setContext: (context: any) => void;
75
+ /** Re-applies the last context set. Idempotent; a no-op when none was set or the view is absent. */
76
+ flushContext: () => void;
64
77
  setContent: (text: string) => Promise<void>;
65
78
  append: (text: string) => Promise<void>;
66
79
  }
67
80
 
81
+ /** Map the scroll container's top/bottom edges to document positions. */
82
+
68
83
  export type MarkdownStreamEvent = {
69
84
  type: 'submit';
70
85
  value: string | null;
@@ -113,6 +128,11 @@ export type MarkdownStreamProps = ThemedClassName<
113
128
  */
114
129
  extensions?: Extension;
115
130
 
131
+ /**
132
+ * Theme extensions.
133
+ */
134
+ slots?: ThemeExtensionsOptions['slots'];
135
+
116
136
  /** Event handler. */
117
137
  onEvent?: (event: MarkdownStreamEvent) => void;
118
138
  } & (XmlTagsOptions & AutoScrollProps)
@@ -122,7 +142,7 @@ export type MarkdownStreamProps = ThemedClassName<
122
142
  * Codemirror-based markdown editor with xml tag widtgets and streaming support.
123
143
  */
124
144
  export const MarkdownStream = forwardRef<MarkdownStreamController | null, MarkdownStreamProps>(
125
- ({ classNames, debug, content, options, registry, extensions, footer, onEvent }, forwardedRef) => {
145
+ ({ classNames, debug, content, options, registry, extensions, footer, slots, onEvent }, forwardedRef) => {
126
146
  // Store current content so that we can toggle debug mode. Default to '' so the
127
147
  // `append()` path (which does `contentRef.current += text`) doesn't concatenate
128
148
  // against `undefined` and stamp `"undefined"` into the transcript snapshot.
@@ -131,8 +151,12 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
131
151
  // DOM node for the footer block widget — populated when its decoration mounts.
132
152
  const [footerRoot, setFooterRoot] = useState<HTMLElement | null>(null);
133
153
 
154
+ // Survives a context set before the view exists; flushed once it does (see `flushContext`).
155
+ const pendingContextRef = useRef<{ value: any } | undefined>(undefined);
156
+
134
157
  // Codemirror editor.
135
158
  const { parentRef, view, viewRef, widgets } = useMarkdownStreamTextEditor(contentRef, {
159
+ slots,
136
160
  debug,
137
161
  registry,
138
162
  options,
@@ -157,9 +181,11 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
157
181
  return;
158
182
  }
159
183
 
160
- // Set content and scroll to bottom.
184
+ // Set content and scroll to bottom. Re-assert the host context rather than clearing it: it
185
+ // belongs to the host, not the document, so nulling it here would strand every widget in the
186
+ // replacement document with `context: undefined`.
161
187
  viewRef.current.dispatch({
162
- effects: [xmlTagContextEffect.of(null), xmlTagResetEffect.of(null)],
188
+ effects: [xmlTagContextEffect.of(pendingContextRef.current?.value ?? null), xmlTagResetEffect.of(null)],
163
189
  changes: [{ from: 0, to: viewRef.current.state.doc.length, insert: text }],
164
190
  annotations: typewriterBypass.of(true),
165
191
  selection: EditorSelection.cursor(text.length),
@@ -168,16 +194,25 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
168
194
  // New queue.
169
195
  setQueue(Effect.runSync(Queue.unbounded<string>()));
170
196
  },
171
- [contentRef, viewRef, setQueue],
197
+ [contentRef, viewRef, setQueue, pendingContextRef],
172
198
  );
173
199
 
174
200
  // Controller API.
175
201
  useImperativeHandle(
176
202
  forwardedRef,
177
- () => createMarkdownStreamController({ contentRef, viewRef, queueRef, onReset }),
203
+ () => createMarkdownStreamController({ contentRef, viewRef, queueRef, onReset, pendingContextRef }),
178
204
  [onReset],
179
205
  );
180
206
 
207
+ // Apply a context that was set before the view existed — the dispatch in `setContext` is a no-op
208
+ // until then, and a host mounting the controller has no way to know.
209
+ useEffect(() => {
210
+ const pending = pendingContextRef.current;
211
+ if (view && pending) {
212
+ view.dispatch({ effects: xmlTagContextEffect.of(pending.value) });
213
+ }
214
+ }, [view]);
215
+
181
216
  // Widget events.
182
217
  useEffect(() => {
183
218
  if (!parentRef.current) {
@@ -226,9 +261,13 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
226
261
  },
227
262
  );
228
263
 
229
- type MarkdownStreamTextEditorParams = Pick<MarkdownStreamProps, 'debug' | 'registry' | 'options' | 'extensions'> & {
230
- setFooterRoot?: (el: HTMLElement | null) => void;
231
- };
264
+ // Fold each agent response beneath its `<prompt>` (the head element rendered by `xmlBlockDecoration`).
265
+ const turnSource = createTurnSource(PROMPT_ELEMENT);
266
+
267
+ type MarkdownStreamTextEditorParams = Pick<MarkdownStreamProps, 'debug' | 'registry' | 'options' | 'extensions'> &
268
+ Pick<ThemeExtensionsOptions, 'slots'> & {
269
+ setFooterRoot?: (el: HTMLElement | null) => void;
270
+ };
232
271
 
233
272
  type MarkdownStreamTextEditorResult = UseTextEditor & {
234
273
  viewRef: RefObject<EditorView | null>;
@@ -240,7 +279,14 @@ type MarkdownStreamTextEditorResult = UseTextEditor & {
240
279
  */
241
280
  const useMarkdownStreamTextEditor = (
242
281
  currentContent: RefObject<string | undefined>,
243
- { debug, registry, options, extensions: extraExtensions, setFooterRoot }: MarkdownStreamTextEditorParams,
282
+ {
283
+ debug,
284
+ registry,
285
+ options,
286
+ extensions: extensionsProp,
287
+ slots = documentSlots,
288
+ setFooterRoot,
289
+ }: MarkdownStreamTextEditorParams,
244
290
  ): MarkdownStreamTextEditorResult => {
245
291
  const { themeMode } = useThemeContext();
246
292
 
@@ -254,40 +300,31 @@ const useMarkdownStreamTextEditor = (
254
300
  initialValue: content,
255
301
  selection: EditorSelection.cursor(content?.length ?? 0),
256
302
  extensions: [
257
- createBasicExtensions({
258
- lineWrapping: true,
259
- readOnly: true,
260
- }),
261
- createThemeExtensions({
262
- slots: documentSlots,
263
- scrollbarThin: true,
264
- syntaxHighlighting: true,
265
- themeMode,
266
- }),
303
+ createBasicExtensions({ lineWrapping: true, readOnly: true }),
304
+ createThemeExtensions({ slots, scrollbarThin: true, syntaxHighlighting: true, themeMode }),
267
305
  xmlFormatting({ skip: debug ? [] : ['prompt'] }),
268
306
  !debug &&
269
307
  [
270
308
  extendedMarkdown({ registry }),
271
309
  decorateMarkdown({
272
- // `echo:`/`dxn:` links/images are handled by the `link-preview` entry in the
273
- // registry via `xmlTags` `urlSchemes`. Skipping them here avoids `decorateMarkdown`
274
- // adding a non-functional `LinkButton` anchor on top of the same node.
310
+ // xmlTags extension will handle `dxn:`/`echo:` links/images.
275
311
  skip: (node) =>
276
312
  (node.name === 'Link' || node.name === 'Image') &&
277
313
  (node.url.startsWith('dxn:') || node.url.startsWith('echo:')),
278
314
  }),
279
315
  // TODO(burdon): Make optional; Removes need for '\n\n'.
280
316
  lineSpacing(),
281
- // NOTE: An ancestor element must set `data-hue` so `.dx-panel` resolves to the user's
282
- // hue tokens (see `packages/ui/ui-theme/src/css/components/panel.css`). Tailwind picks
283
- // up these utility classes from this source file.
284
317
  xmlBlockDecoration({
285
318
  tag: 'prompt',
286
- lineClass: 'cm-prompt-line my-8',
287
- contentClass: 'cm-prompt-bubble dx-panel px-2 py-1.5 box-decoration-clone rounded-sm [&_*]:text-inherit!',
319
+ lineClass:
320
+ 'cm-prompt-line cm-prompt-bubble bg-group-surface text-base-fg border-l-[8px] pl-[8px]! pr-2 [&_*]:text-inherit!',
321
+ firstLineClass: 'pt-1.5 rounded-t-sm',
322
+ lastLineClass: 'pb-1.5 rounded-b-sm',
288
323
  hideTags: true,
289
324
  }),
290
325
  xmlTags({ registry, setWidgets, bookmarks: ['prompt'] }),
326
+ // TODO(burdon): Folding gets progressively off due to some widgets?
327
+ turnFolding({ source: turnSource }),
291
328
  scroller({ overScroll: 80, autoScroll: options?.autoScroll }),
292
329
  options?.typewriter &&
293
330
  typewriter({
@@ -301,7 +338,7 @@ const useMarkdownStreamTextEditor = (
301
338
  options?.fader && fader(),
302
339
  setFooterRoot && footer(setFooterRoot),
303
340
  ].filter(isTruthy),
304
- extraExtensions,
341
+ extensionsProp,
305
342
  ].filter(isTruthy),
306
343
  };
307
344
  }, [
@@ -312,7 +349,8 @@ const useMarkdownStreamTextEditor = (
312
349
  options?.typewriter,
313
350
  options?.cursor,
314
351
  options?.fader,
315
- extraExtensions,
352
+ slots,
353
+ extensionsProp,
316
354
  ]);
317
355
 
318
356
  const viewRef = useDynamicRef(view);
@@ -360,85 +398,3 @@ const useMarkdownStreamQueue = (
360
398
  };
361
399
  }, [view, queue, chunkSize, delayMs]);
362
400
  };
363
-
364
- type MarkdownStreamControllerDeps = {
365
- contentRef: RefObject<string | undefined>;
366
- viewRef: RefObject<EditorView | null>;
367
- queueRef: RefObject<Queue.Queue<string>>;
368
- onReset: (text: string) => Promise<void>;
369
- };
370
-
371
- /**
372
- * External controller API.
373
- */
374
- const createMarkdownStreamController = ({
375
- contentRef,
376
- viewRef,
377
- queueRef,
378
- onReset,
379
- }: MarkdownStreamControllerDeps): MarkdownStreamController => {
380
- return {
381
- get length() {
382
- return viewRef.current?.state.doc.length;
383
- },
384
-
385
- /** Focus the editor. */
386
- focus: () => {
387
- viewRef.current?.focus();
388
- },
389
-
390
- /** Scroll to bottom. */
391
- scrollToBottom: (behavior?: ScrollBehavior) => {
392
- viewRef.current?.dispatch({
393
- effects: crawlerLineEffect.of({ line: -1, behavior }),
394
- });
395
- },
396
-
397
- /** Navigate previous prompt. */
398
- navigatePrevious: () => {
399
- viewRef.current?.dispatch({
400
- effects: navigatePreviousEffect.of(),
401
- });
402
- },
403
-
404
- /** Navigate next prompt. */
405
- navigateNext: () => {
406
- viewRef.current?.dispatch({
407
- effects: navigateNextEffect.of(),
408
- });
409
- },
410
-
411
- /** Set the context for widgets (XML tags). */
412
- setContext: (context: any) => {
413
- viewRef.current?.dispatch({
414
- effects: xmlTagContextEffect.of(context),
415
- });
416
- },
417
-
418
- /** Reset document. */
419
- setContent: onReset,
420
-
421
- /** Append to queue (and stream). */
422
- append: async (text: string) => {
423
- contentRef.current += text;
424
- if (text.length) {
425
- // Always go through the streaming queue, even when the doc starts empty. Skipping the
426
- // queue in that case (via `onReset`) bypasses the `typewriter` extension's transaction filter
427
- // and the first chunk lands in one CM dispatch — defeating the typewriter for any
428
- // consumer (e.g. ChatThread) where the first delta is large because upstream batching
429
- // collected several streaming partials before React rendered.
430
- const queue = queueRef.current;
431
- if (queue) {
432
- await EffectEx.runAndForwardErrors(Queue.offer(queue, text));
433
- }
434
- }
435
- },
436
-
437
- /** Update widget state. */
438
- updateWidget: (id: string, value: any) => {
439
- viewRef.current?.dispatch({
440
- effects: xmlTagUpdateEffect.of({ id, value }),
441
- });
442
- },
443
- } satisfies MarkdownStreamController;
444
- };
@@ -0,0 +1,143 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @vitest-environment jsdom
6
+
7
+ import { EditorState } from '@codemirror/state';
8
+ import { EditorView } from '@codemirror/view';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Queue from 'effect/Queue';
11
+ import { describe, test } from 'vitest';
12
+
13
+ import {
14
+ type XmlWidgetDef,
15
+ decorationSetToArray,
16
+ extendedMarkdown,
17
+ xmlTagContextEffect,
18
+ xmlTags,
19
+ } from '@dxos/ui-editor';
20
+
21
+ import { createMarkdownStreamController } from './create-controller';
22
+
23
+ /**
24
+ * The host↔editor seam: a host mounts the controller and publishes the context widgets call back
25
+ * through, but the controller's dispatches are no-ops until the editor view exists — and the host has no
26
+ * signal for when that is. These pin the contract that a context set at any point still reaches widgets.
27
+ */
28
+ describe('MarkdownStreamController context', () => {
29
+ test('a context set before the view exists is applied once it does', ({ expect }) => {
30
+ const harness = createHarness();
31
+ const context = { rewind: () => {} };
32
+
33
+ // No view yet — this is what a host mounting the controller in an effect actually does.
34
+ harness.controller.setContext(context);
35
+ expect(harness.widgetContext()).toBeUndefined();
36
+
37
+ harness.attachView();
38
+ harness.controller.flushContext();
39
+ expect(harness.widgetContext()).toBe(context);
40
+ });
41
+
42
+ test('a context set after the view exists is applied immediately', ({ expect }) => {
43
+ const harness = createHarness();
44
+ harness.attachView();
45
+
46
+ const context = { rewind: () => {} };
47
+ harness.controller.setContext(context);
48
+ expect(harness.widgetContext()).toBe(context);
49
+ });
50
+
51
+ test('flushContext is a no-op when no context was set', ({ expect }) => {
52
+ const harness = createHarness();
53
+ harness.attachView();
54
+
55
+ harness.controller.flushContext();
56
+ expect(harness.widgetContext()).toBeUndefined();
57
+ });
58
+
59
+ // `setContent` resets the document and rebuilds decorations, which used to clear the context — the
60
+ // context belongs to the host, not the document, so it has to survive.
61
+ test('the context survives a document reset', async ({ expect }) => {
62
+ const harness = createHarness();
63
+ harness.attachView();
64
+
65
+ const context = { rewind: () => {} };
66
+ harness.controller.setContext(context);
67
+ await harness.controller.setContent('<branch id="b" />');
68
+
69
+ expect(harness.widgetContext('b')).toBe(context);
70
+ });
71
+
72
+ test('the latest context wins', ({ expect }) => {
73
+ const harness = createHarness();
74
+ harness.attachView();
75
+
76
+ const first = { rewind: () => {} };
77
+ const second = { rewind: () => {} };
78
+ harness.controller.setContext(first);
79
+ harness.controller.setContext(second);
80
+ expect(harness.widgetContext()).toBe(second);
81
+ });
82
+ });
83
+
84
+ const registry: Record<string, XmlWidgetDef> = {
85
+ branch: { block: true, Component: () => null },
86
+ };
87
+
88
+ /**
89
+ * Mirrors how `MarkdownStream` wires the controller: refs the host holds, a view created later, and
90
+ * `onReset` replacing the document while re-asserting the host context.
91
+ */
92
+ const createHarness = () => {
93
+ const contentRef = { current: '<branch id="a" />' };
94
+ const viewRef: { current: EditorView | null } = { current: null };
95
+ const queueRef = { current: Effect.runSync(Queue.unbounded<string>()) };
96
+ const pendingContextRef: { current: { value: any } | undefined } = { current: undefined };
97
+
98
+ const controller = createMarkdownStreamController({
99
+ contentRef,
100
+ viewRef,
101
+ queueRef,
102
+ pendingContextRef,
103
+ onReset: async (text) => {
104
+ contentRef.current = text;
105
+ viewRef.current?.dispatch({
106
+ effects: [xmlTagContextEffect.of(pendingContextRef.current?.value ?? null)],
107
+ changes: [{ from: 0, to: viewRef.current.state.doc.length, insert: text }],
108
+ });
109
+ },
110
+ });
111
+
112
+ return {
113
+ controller,
114
+ attachView: () => {
115
+ viewRef.current = new EditorView({
116
+ state: EditorState.create({
117
+ doc: contentRef.current,
118
+ extensions: [extendedMarkdown({ registry }), xmlTags({ registry })],
119
+ }),
120
+ parent: document.createElement('div'),
121
+ });
122
+ },
123
+ /** The `context` prop the named widget's decoration was built with. */
124
+ widgetContext: (id = 'a') => {
125
+ const view = viewRef.current;
126
+ if (!view) {
127
+ return undefined;
128
+ }
129
+ for (const source of view.state.facet(EditorView.decorations)) {
130
+ const set = typeof source === 'function' ? source(view) : source;
131
+ if (!set) {
132
+ continue;
133
+ }
134
+ for (const { value } of decorationSetToArray(set)) {
135
+ const widget = value.spec?.widget;
136
+ if (widget?.id === id) {
137
+ return widget.props?.context;
138
+ }
139
+ }
140
+ }
141
+ },
142
+ };
143
+ };