@dxos/ui-editor 0.8.4-main.fcfe5033a5 → 0.9.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.
Files changed (169) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/index.mjs +1258 -1004
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/types/index.mjs +26 -6
  7. package/dist/lib/browser/types/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/index.mjs +1258 -1003
  9. package/dist/lib/node-esm/index.mjs.map +4 -4
  10. package/dist/lib/node-esm/meta.json +1 -1
  11. package/dist/lib/node-esm/types/index.mjs +27 -6
  12. package/dist/lib/node-esm/types/index.mjs.map +4 -4
  13. package/dist/types/src/defaults.d.ts.map +1 -1
  14. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  15. package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -1
  16. package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -1
  17. package/dist/types/src/extensions/autocomplete/placeholder.d.ts +5 -2
  18. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
  19. package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/automerge.d.ts +1 -1
  21. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  23. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  25. package/dist/types/src/extensions/automerge/sync.d.ts +1 -1
  26. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  27. package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
  28. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  29. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
  30. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  31. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  32. package/dist/types/src/extensions/blast.d.ts.map +1 -1
  33. package/dist/types/src/extensions/comments.d.ts +19 -1
  34. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  35. package/dist/types/src/extensions/debug.d.ts.map +1 -1
  36. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  37. package/dist/types/src/extensions/factories.d.ts +3 -2
  38. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  39. package/dist/types/src/extensions/factories.test.d.ts +2 -0
  40. package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
  41. package/dist/types/src/extensions/focus.d.ts +1 -1
  42. package/dist/types/src/extensions/index.d.ts +3 -4
  43. package/dist/types/src/extensions/index.d.ts.map +1 -1
  44. package/dist/types/src/extensions/json.d.ts +1 -1
  45. package/dist/types/src/extensions/json.d.ts.map +1 -1
  46. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
  49. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  51. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  52. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  53. package/dist/types/src/extensions/markdown/image.d.ts +13 -2
  54. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  55. package/dist/types/src/extensions/markdown/image.test.d.ts +2 -0
  56. package/dist/types/src/extensions/markdown/image.test.d.ts.map +1 -0
  57. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  58. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  59. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  60. package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -1
  61. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -1
  62. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -1
  63. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
  64. package/dist/types/src/extensions/preview/preview.d.ts +2 -2
  65. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  66. package/dist/types/src/extensions/replacer.d.ts.map +1 -1
  67. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts +18 -0
  68. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
  69. package/dist/types/src/extensions/scrolling/crawler.d.ts +83 -0
  70. package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
  71. package/dist/types/src/extensions/scrolling/index.d.ts +6 -0
  72. package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
  73. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
  74. package/dist/types/src/extensions/scrolling/scrollbar-autohide.d.ts +15 -0
  75. package/dist/types/src/extensions/scrolling/scrollbar-autohide.d.ts.map +1 -0
  76. package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
  77. package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
  78. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  79. package/dist/types/src/extensions/snippets.d.ts +10 -0
  80. package/dist/types/src/extensions/snippets.d.ts.map +1 -0
  81. package/dist/types/src/extensions/spacing.d.ts +3 -0
  82. package/dist/types/src/extensions/spacing.d.ts.map +1 -0
  83. package/dist/types/src/extensions/submit.d.ts.map +1 -1
  84. package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -1
  85. package/dist/types/src/extensions/tags/fader.d.ts.map +1 -1
  86. package/dist/types/src/extensions/tags/index.d.ts +3 -1
  87. package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
  88. package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
  89. package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
  90. package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
  91. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
  92. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
  93. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
  94. package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
  95. package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
  96. package/dist/types/src/extensions/tags/xml-tags.d.ts +1 -8
  97. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  98. package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -1
  99. package/dist/types/src/index.d.ts +0 -1
  100. package/dist/types/src/index.d.ts.map +1 -1
  101. package/dist/types/src/styles/theme.d.ts.map +1 -1
  102. package/dist/types/src/types/types.d.ts +2 -2
  103. package/dist/types/src/types/types.d.ts.map +1 -1
  104. package/dist/types/src/util/cursor.d.ts.map +1 -1
  105. package/dist/types/src/util/debug.d.ts.map +1 -1
  106. package/dist/types/src/util/decorations.d.ts.map +1 -1
  107. package/dist/types/src/util/dom.d.ts.map +1 -1
  108. package/dist/types/src/util/facet.d.ts.map +1 -1
  109. package/dist/types/src/util/util.d.ts.map +1 -1
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +55 -57
  112. package/src/defaults.ts +6 -4
  113. package/src/extensions/autocomplete/placeholder.ts +37 -18
  114. package/src/extensions/automerge/automerge.test.tsx +35 -9
  115. package/src/extensions/automerge/automerge.ts +1 -1
  116. package/src/extensions/automerge/cursor.ts +1 -1
  117. package/src/extensions/automerge/sync.ts +1 -1
  118. package/src/extensions/automerge/update-automerge.ts +1 -1
  119. package/src/extensions/comments.ts +54 -31
  120. package/src/extensions/factories.test.ts +88 -0
  121. package/src/extensions/factories.ts +22 -4
  122. package/src/extensions/index.ts +3 -4
  123. package/src/extensions/json.ts +1 -1
  124. package/src/extensions/markdown/decorate.ts +1 -1
  125. package/src/extensions/markdown/image.test.ts +54 -0
  126. package/src/extensions/markdown/image.ts +70 -9
  127. package/src/extensions/markdown/link.ts +7 -2
  128. package/src/extensions/outliner/outliner.ts +1 -1
  129. package/src/extensions/preview/preview.ts +14 -12
  130. package/src/extensions/scrolling/auto-scroll.ts +261 -0
  131. package/src/extensions/{scroller.ts → scrolling/crawler.ts} +89 -48
  132. package/src/extensions/scrolling/index.ts +9 -0
  133. package/src/extensions/{scroll-past-end.ts → scrolling/scroll-past-end.ts} +6 -6
  134. package/src/extensions/scrolling/scrollbar-autohide.ts +61 -0
  135. package/src/extensions/scrolling/scroller.ts +27 -0
  136. package/src/extensions/snippets.ts +67 -0
  137. package/src/extensions/spacing.ts +15 -0
  138. package/src/extensions/tags/index.ts +3 -1
  139. package/src/extensions/tags/testing/text.md +36 -0
  140. package/src/extensions/tags/testing/text.txt +35 -0
  141. package/src/extensions/tags/{wire.test.ts → typewriter.test.ts} +2 -2
  142. package/src/extensions/tags/typewriter.ts +594 -0
  143. package/src/extensions/tags/xml-block-decoration.ts +123 -0
  144. package/src/extensions/tags/xml-formatting.ts +125 -0
  145. package/src/extensions/tags/xml-tags.ts +6 -32
  146. package/src/extensions/tags/xml-util.test.ts +90 -3
  147. package/src/extensions/tags/xml-util.ts +62 -5
  148. package/src/index.ts +0 -1
  149. package/src/styles/theme.ts +23 -13
  150. package/src/typings.d.ts +8 -0
  151. package/dist/lib/browser/chunk-D724USEC.mjs +0 -34
  152. package/dist/lib/browser/chunk-D724USEC.mjs.map +0 -7
  153. package/dist/lib/node-esm/chunk-JRVJWKQF.mjs +0 -36
  154. package/dist/lib/node-esm/chunk-JRVJWKQF.mjs.map +0 -7
  155. package/dist/types/src/extensions/auto-scroll.d.ts +0 -8
  156. package/dist/types/src/extensions/auto-scroll.d.ts.map +0 -1
  157. package/dist/types/src/extensions/scroll-past-end.d.ts.map +0 -1
  158. package/dist/types/src/extensions/scroller.d.ts +0 -63
  159. package/dist/types/src/extensions/scroller.d.ts.map +0 -1
  160. package/dist/types/src/extensions/tags/wire.d.ts +0 -23
  161. package/dist/types/src/extensions/tags/wire.d.ts.map +0 -1
  162. package/dist/types/src/extensions/tags/wire.test.d.ts +0 -2
  163. package/dist/types/src/extensions/tags/wire.test.d.ts.map +0 -1
  164. package/dist/types/src/extensions/typewriter.d.ts +0 -10
  165. package/dist/types/src/extensions/typewriter.d.ts.map +0 -1
  166. package/src/extensions/auto-scroll.ts +0 -179
  167. package/src/extensions/tags/wire.ts +0 -459
  168. package/src/extensions/typewriter.ts +0 -68
  169. /package/dist/types/src/extensions/{scroll-past-end.d.ts → scrolling/scroll-past-end.d.ts} +0 -0
@@ -0,0 +1,123 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { xmlLanguage } from '@codemirror/lang-xml';
6
+ import { type Extension, type Range } from '@codemirror/state';
7
+ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
8
+
9
+ export type XmlBlockDecorationOptions = {
10
+ /**
11
+ * Tag name to match (e.g. `'prompt'`).
12
+ */
13
+ tag: string;
14
+
15
+ /**
16
+ * Class added via `Decoration.line` to each line that intersects the element's content
17
+ * range. Use to style the bubble container (e.g. flex alignment, vertical margin).
18
+ */
19
+ lineClass?: string;
20
+
21
+ /**
22
+ * Class added via `Decoration.mark` covering the inner content (between the open and
23
+ * close tags). Use to style the bubble surface (background, padding, rounding).
24
+ */
25
+ contentClass?: string;
26
+
27
+ /**
28
+ * When true, the open and close tag delimiters are hidden via `Decoration.replace`
29
+ * with no widget, so the rendered text is the inner content only.
30
+ */
31
+ hideTags?: boolean;
32
+ };
33
+
34
+ /**
35
+ * Walks the doc with the Lezer XML parser and decorates `<tag>…</tag>` elements without
36
+ * replacing them with a widget — the source text remains in the document and can still
37
+ * be matched by other extensions (e.g. `xmlFormatting`). Use this for "bubble"-style
38
+ * styling of XML blocks (chat prompts, callouts, etc.) where the inner content should
39
+ * stay editable/copyable rather than living inside a CodeMirror widget.
40
+ */
41
+ export const xmlBlockDecoration = ({
42
+ tag,
43
+ lineClass,
44
+ contentClass,
45
+ hideTags,
46
+ }: XmlBlockDecorationOptions): Extension => {
47
+ const lineDecoration = lineClass ? Decoration.line({ class: lineClass }) : undefined;
48
+ const contentDecoration = contentClass ? Decoration.mark({ class: contentClass }) : undefined;
49
+ const hideDecoration = hideTags ? Decoration.replace({}) : undefined;
50
+
51
+ const buildDecorations = (view: EditorView): DecorationSet => {
52
+ const text = view.state.sliceDoc(0, view.state.doc.length);
53
+ if (!text.includes(`<${tag}`)) {
54
+ return Decoration.none;
55
+ }
56
+
57
+ const tree = xmlLanguage.parser.parse(text);
58
+ const ranges: Range<Decoration>[] = [];
59
+ tree.iterate({
60
+ enter: (node) => {
61
+ if (node.type.name !== 'Element') {
62
+ return;
63
+ }
64
+ const openTag = node.node.getChild('OpenTag');
65
+ const closeTag = node.node.getChild('CloseTag') ?? node.node.getChild('MismatchedCloseTag');
66
+ const tagNameNode = openTag?.getChild('TagName');
67
+ if (!openTag || !tagNameNode) {
68
+ return;
69
+ }
70
+ if (text.slice(tagNameNode.from, tagNameNode.to) !== tag) {
71
+ return;
72
+ }
73
+
74
+ const contentFrom = openTag.to;
75
+ const contentTo = closeTag?.from ?? node.node.to;
76
+
77
+ if (hideDecoration) {
78
+ ranges.push(hideDecoration.range(openTag.from, openTag.to));
79
+ if (closeTag) {
80
+ ranges.push(hideDecoration.range(closeTag.from, closeTag.to));
81
+ }
82
+ }
83
+
84
+ if (contentDecoration && contentFrom < contentTo) {
85
+ ranges.push(contentDecoration.range(contentFrom, contentTo));
86
+ }
87
+
88
+ if (lineDecoration && contentFrom <= view.state.doc.length) {
89
+ // Apply line decoration to every line that intersects the content range.
90
+ let pos = contentFrom;
91
+ while (pos <= contentTo && pos <= view.state.doc.length) {
92
+ const line = view.state.doc.lineAt(pos);
93
+ ranges.push(lineDecoration.range(line.from));
94
+ if (line.to >= contentTo) {
95
+ break;
96
+ }
97
+ pos = line.to + 1;
98
+ }
99
+ }
100
+ },
101
+ });
102
+ return Decoration.set(ranges, true);
103
+ };
104
+
105
+ return ViewPlugin.fromClass(
106
+ class {
107
+ decorations: DecorationSet;
108
+
109
+ constructor(view: EditorView) {
110
+ this.decorations = buildDecorations(view);
111
+ }
112
+
113
+ update(update: ViewUpdate) {
114
+ if (update.docChanged) {
115
+ this.decorations = buildDecorations(update.view);
116
+ }
117
+ }
118
+ },
119
+ {
120
+ decorations: (instance) => instance.decorations,
121
+ },
122
+ );
123
+ };
@@ -0,0 +1,125 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { xmlLanguage } from '@codemirror/lang-xml';
6
+ import { type Extension, type Range } from '@codemirror/state';
7
+ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
8
+
9
+ /**
10
+ * Lezer XML node names that represent the angle-bracket delimited portions of an element.
11
+ */
12
+ const XML_TAG_NODES = new Set(['OpenTag', 'CloseTag', 'SelfClosingTag', 'MismatchedCloseTag']);
13
+
14
+ const xmlElementMark = Decoration.mark({ class: 'cm-xml-element' });
15
+ const xmlTagMark = Decoration.mark({ class: 'cm-xml-tag' });
16
+ const xmlContentMark = Decoration.mark({ class: 'cm-xml-content' });
17
+
18
+ export type XmlFormattingOptions = {
19
+ /**
20
+ * Tag names whose elements should NOT receive xmlFormatting decorations. Use to
21
+ * opt-out tags rendered by another extension (e.g. `xmlBlockDecoration` for `<prompt>`)
22
+ * so the two don't double-wrap the same content with stacked styling.
23
+ *
24
+ * Skipping is recursive: descendants of a skipped element are also untouched, so a
25
+ * `<foo>` inside a skipped `<prompt>` still appears literally without xmlFormatting
26
+ * styling.
27
+ */
28
+ skip?: string[];
29
+ };
30
+
31
+ /**
32
+ * Mark decoration extension that highlights XML tag delimiters
33
+ * (e.g., `<tag>`, `</tag>`, `<tag attr="x"/>`) with the `cm-xml-tag` class.
34
+ *
35
+ * Uses `@codemirror/lang-xml`'s Lezer parser standalone — only to compute
36
+ * decoration ranges — without changing the editor's primary language. This
37
+ * keeps the document plain text while still handling nesting and attributes
38
+ * correctly.
39
+ */
40
+ export const xmlFormatting = ({ skip }: XmlFormattingOptions = {}): Extension => {
41
+ const skipSet = skip && skip.length > 0 ? new Set(skip) : undefined;
42
+
43
+ const buildDecorations = (view: EditorView): DecorationSet => {
44
+ const text = view.state.sliceDoc(0, view.state.doc.length);
45
+ if (!text.includes('<')) {
46
+ return Decoration.none;
47
+ }
48
+
49
+ const tagNameAt = (node: { from: number; to: number }) => text.slice(node.from, node.to);
50
+
51
+ const tree = xmlLanguage.parser.parse(text);
52
+ const ranges: Range<Decoration>[] = [];
53
+ tree.iterate({
54
+ enter: (node) => {
55
+ const name = node.type.name;
56
+ if (name === 'SelfClosingTag' && node.from < node.to) {
57
+ if (skipSet) {
58
+ const tagNameNode = node.node.getChild('TagName');
59
+ if (tagNameNode && skipSet.has(tagNameAt(tagNameNode))) {
60
+ return false;
61
+ }
62
+ }
63
+ // Self-closing tag is its own outer block and tag delimiter.
64
+ ranges.push(xmlElementMark.range(node.from, node.to));
65
+ ranges.push(xmlTagMark.range(node.from, node.to));
66
+ return;
67
+ }
68
+ if (XML_TAG_NODES.has(name) && node.from < node.to) {
69
+ ranges.push(xmlTagMark.range(node.from, node.to));
70
+ return;
71
+ }
72
+ if (name === 'Element' && node.from < node.to) {
73
+ const openTag = node.node.getChild('OpenTag');
74
+ if (openTag && skipSet) {
75
+ const tagNameNode = openTag.getChild('TagName');
76
+ if (tagNameNode && skipSet.has(tagNameAt(tagNameNode))) {
77
+ // Skip this element AND its descendants — another extension owns rendering.
78
+ return false;
79
+ }
80
+ }
81
+ const closeTag = node.node.getChild('CloseTag') ?? node.node.getChild('MismatchedCloseTag');
82
+ ranges.push(xmlElementMark.range(node.from, node.to));
83
+ if (openTag && closeTag && openTag.to < closeTag.from) {
84
+ ranges.push(xmlContentMark.range(openTag.to, closeTag.from));
85
+ }
86
+ }
87
+ },
88
+ });
89
+ return Decoration.set(ranges, true);
90
+ };
91
+
92
+ return [
93
+ ViewPlugin.fromClass(
94
+ class {
95
+ decorations: DecorationSet;
96
+
97
+ constructor(view: EditorView) {
98
+ this.decorations = buildDecorations(view);
99
+ }
100
+
101
+ update(update: ViewUpdate) {
102
+ if (update.docChanged) {
103
+ this.decorations = buildDecorations(update.view);
104
+ }
105
+ }
106
+ },
107
+ {
108
+ decorations: (instance) => instance.decorations,
109
+ },
110
+ ),
111
+
112
+ EditorView.baseTheme({
113
+ '.cm-xml-element': {
114
+ backgroundColor: 'var(--color-current-surface)',
115
+ borderRadius: '0.25rem',
116
+ padding: '0.25rem',
117
+ },
118
+ '.cm-xml-tag': {
119
+ color: 'var(--color-blue-500)',
120
+ fontFamily: 'var(--font-mono)',
121
+ },
122
+ '.cm-xml-content': {},
123
+ }),
124
+ ];
125
+ };
@@ -13,7 +13,6 @@ import {
13
13
  WidgetType,
14
14
  keymap,
15
15
  } from '@codemirror/view';
16
- // TODO(burdon): Factor out agnostic types (React/solid).
17
16
  import { type FunctionComponent } from 'react';
18
17
 
19
18
  import { invariant } from '@dxos/invariant';
@@ -22,7 +21,7 @@ import { Domino } from '@dxos/ui';
22
21
 
23
22
  import { type Range } from '../../types';
24
23
  import { decorationSetToArray } from '../../util';
25
- import { scrollerLineEffect } from '../scroller';
24
+ import { crawlerLineEffect } from '../scrolling';
26
25
  import { nodeToJson } from './xml-util';
27
26
 
28
27
  /**
@@ -200,17 +199,8 @@ export type XmlTagsOptions = {
200
199
 
201
200
  /** Called when widgets are mounted or unmounted. */
202
201
  setWidgets?: (widgets: XmlWidgetState[]) => void;
203
-
204
- /**
205
- * When set, adds top margin on block widget lines that immediately follow a line without
206
- * a `data-xml-widget` host (typically narrative text before portaled or status widgets).
207
- */
208
- paragraphToWidgetGapRem?: number;
209
202
  };
210
203
 
211
- /** Marks widget roots used by `paragraphToWidgetGapRem` line spacing in the editor theme. */
212
- export const XML_WIDGET_DATA_ATTR = 'data-xml-widget';
213
-
214
204
  /**
215
205
  * Implements custom XML tags via CodeMirror-native Widgets and portaled React/Solid components.
216
206
  *
@@ -221,22 +211,9 @@ export const XML_WIDGET_DATA_ATTR = 'data-xml-widget';
221
211
  * - Widget state can be update via effects.
222
212
  * - NOTE: Widget state may be updated BEFORE the widget is mounted.
223
213
  */
224
- export const xmlTags = ({
225
- registry,
226
- setWidgets,
227
- bookmarks,
228
- paragraphToWidgetGapRem,
229
- }: XmlTagsOptions = {}): Extension => {
214
+ export const xmlTags = ({ registry, setWidgets, bookmarks }: XmlTagsOptions = {}): Extension => {
230
215
  const notifier = createWidgetMap(setWidgets);
231
216
  const widgetDecorationsField = createWidgetDecorationsField(registry, notifier);
232
- const paragraphGapTheme =
233
- paragraphToWidgetGapRem != null && paragraphToWidgetGapRem > 0
234
- ? EditorView.baseTheme({
235
- [`& .cm-content > .cm-line:not(:has([${XML_WIDGET_DATA_ATTR}])) + .cm-line:has([${XML_WIDGET_DATA_ATTR}])`]: {
236
- marginTop: `${paragraphToWidgetGapRem}rem`,
237
- },
238
- })
239
- : null;
240
217
 
241
218
  return [
242
219
  widgetContextStateField,
@@ -245,7 +222,6 @@ export const xmlTags = ({
245
222
  createWidgetUpdatePlugin(widgetDecorationsField, notifier),
246
223
  createNavigationEffectPlugin(widgetDecorationsField, bookmarks),
247
224
  bookmarks?.length ? Prec.highest(keyHandlers) : [],
248
- ...(paragraphGapTheme ? [paragraphGapTheme] : []),
249
225
  ];
250
226
  };
251
227
 
@@ -323,7 +299,7 @@ const createNavigationEffectPlugin = (
323
299
  const line = view.state.doc.lineAt(widget?.from ?? 0);
324
300
  view.dispatch({
325
301
  selection: { anchor: line.from, head: line.from },
326
- effects: scrollerLineEffect.of({ line: line.number - 1, offset: -16 }),
302
+ effects: crawlerLineEffect.of({ line: line.number - 1, offset: -16 }),
327
303
  });
328
304
 
329
305
  continue;
@@ -349,13 +325,13 @@ const createNavigationEffectPlugin = (
349
325
  const line = view.state.doc.lineAt(widget?.from);
350
326
  view.dispatch({
351
327
  selection: { anchor: line.to, head: line.to },
352
- effects: scrollerLineEffect.of({ line: line.number - 1, offset: -16 }),
328
+ effects: crawlerLineEffect.of({ line: line.number - 1, offset: -16 }),
353
329
  });
354
330
  } else {
355
331
  const line = view.state.doc.lineAt(view.state.doc.length);
356
332
  view.dispatch({
357
333
  selection: { anchor: line.to, head: line.to },
358
- effects: scrollerLineEffect.of({ line: line.number - 1, position: 'end' }),
334
+ effects: crawlerLineEffect.of({ line: line.number - 1, position: 'end' }),
359
335
  });
360
336
  }
361
337
 
@@ -661,9 +637,7 @@ class PlaceholderWidget<TProps extends XmlWidgetProps> extends WidgetType {
661
637
  override toDOM(view: EditorView) {
662
638
  this.#view = view;
663
639
  // NOTE: Set min-height to avoid jumps while scrolling.
664
- this.#root = Domino.of('div')
665
- .classNames('min-h-[24px]')
666
- .attributes({ [XML_WIDGET_DATA_ATTR]: '' }).root;
640
+ this.#root = Domino.of('div').classNames('min-h-[24px]').root;
667
641
  const props = Object.assign({}, this.props, { view }) as TProps;
668
642
  this.notifier.mounted({ id: this.id, root: this.#root, props, Component: this.Component });
669
643
  return this.#root;
@@ -2,13 +2,14 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { syntaxTree } from '@codemirror/language';
5
+ import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
6
6
  import { EditorState } from '@codemirror/state';
7
7
  import { describe, test } from 'vitest';
8
8
 
9
9
  import { trim } from '@dxos/util';
10
10
 
11
11
  import { extendedMarkdown } from './extended-markdown';
12
+ import TEXT from './testing/text.md?raw';
12
13
  import { xmlTags } from './xml-tags';
13
14
  import { nodeToJson, type Tag } from './xml-util';
14
15
 
@@ -27,16 +28,18 @@ const parseElements = (doc: string, registry: Record<string, any> = {}): ParsedE
27
28
  extensions: [extendedMarkdown({ registry }), xmlTags({ registry })],
28
29
  });
29
30
 
31
+ const tree = ensureSyntaxTree(state, doc.length, 5000) ?? syntaxTree(state);
30
32
  const elements: ParsedElement[] = [];
31
- syntaxTree(state).iterate({
33
+ tree.iterate({
32
34
  enter: (node) => {
33
35
  if (node.type.name === 'Element') {
34
36
  const tag = nodeToJson(state, node.node);
35
37
  if (tag) {
36
38
  const hasCloseTag = !!node.node.getChild('CloseTag');
39
+ const hasSelfClose = !!node.node.getChild('SelfClosingTag');
37
40
  elements.push({
38
41
  ...tag,
39
- complete: hasCloseTag,
42
+ complete: hasCloseTag || hasSelfClose,
40
43
  });
41
44
  }
42
45
  return false;
@@ -117,6 +120,34 @@ describe('nodeToJson', () => {
117
120
  expect(elements[0].complete).toBe(true);
118
121
  });
119
122
 
123
+ // Regression: when reasoning text contains escaped XML (e.g. `&lt;foo&gt;`), Lezer XML
124
+ // splits the inline content into Text + EntityReference + Text + EntityReference + Text
125
+ // siblings. The walker used to skip entity references and push each text segment as a
126
+ // separate child, so `getXmlTextChild(children)` only saw the prefix before the first
127
+ // `&lt;` (e.g. ``"The user sent `"``).
128
+ test('should preserve text containing entity references as a single child', ({ expect }) => {
129
+ const xml = trim`
130
+ <reasoning>The user sent \`&lt;foo&gt;\`, which appears to be an XML-like tag.</reasoning>
131
+ `;
132
+
133
+ const registry = { reasoning: { block: true } };
134
+ const elements = parseElements(xml, registry);
135
+ expect(elements).toHaveLength(1);
136
+ expect(elements[0]._tag).toBe('reasoning');
137
+ expect(elements[0].children).toEqual(['The user sent `<foo>`, which appears to be an XML-like tag.']);
138
+ });
139
+
140
+ test('should decode numeric character references', ({ expect }) => {
141
+ const xml = trim`
142
+ <reasoning>arrow &#8594; and hex &#x2192;</reasoning>
143
+ `;
144
+
145
+ const registry = { reasoning: { block: true } };
146
+ const elements = parseElements(xml, registry);
147
+ expect(elements).toHaveLength(1);
148
+ expect(elements[0].children).toEqual(['arrow → and hex →']);
149
+ });
150
+
120
151
  test('should parse tag with multiple blank lines in content', ({ expect }) => {
121
152
  const xml = trim`
122
153
  <reasoning>
@@ -133,4 +164,60 @@ describe('nodeToJson', () => {
133
164
  expect(elements[0]._tag).toBe('reasoning');
134
165
  expect(elements[0].complete).toBe(true);
135
166
  });
167
+
168
+ // Regression: when an unregistered XML tag like `<prompt>` appears earlier in the doc,
169
+ // the markdown parser treats it as a paragraph that lazy-continues into subsequent lines,
170
+ // and a later multi-line tag (e.g. `<reasoning>` whose body contains an ordered list) gets
171
+ // its HTMLBlock truncated to its first line — losing the closing tag and breaking widget
172
+ // rendering. Including the surrounding tag in the registry as a block-only entry (no
173
+ // factory/Component) lets `xmlBlockParsers` keep each tag as its own block.
174
+ test('multi-line reasoning is complete when preceded by a registered prompt block', ({ expect }) => {
175
+ const xml = [
176
+ '<prompt>summarize the posts</prompt>',
177
+ '<toolCall id="x" />',
178
+ '<reasoning>multi line content',
179
+ '1. "First" - desc',
180
+ '5. "Last" - desc</reasoning>',
181
+ ].join('\n');
182
+ const registry = {
183
+ prompt: { block: true },
184
+ reasoning: { block: true },
185
+ toolCall: { block: true },
186
+ };
187
+ const elements = parseElements(xml, registry);
188
+ expect(elements.map((e) => `${e._tag}${e.complete ? '' : '!'}`)).toEqual(['prompt', 'toolCall', 'reasoning']);
189
+ });
190
+
191
+ test('should parse text.md', ({ expect }) => {
192
+ // Mirror the live ChatThread registry, including `prompt` as a block-only entry so
193
+ // unregistered-paragraph lazy-continuation does not break later multi-line tags.
194
+ const registry = {
195
+ prompt: { block: true },
196
+ reasoning: { block: true },
197
+ status: { block: true },
198
+ toolCall: { block: true },
199
+ name: { block: true },
200
+ };
201
+ const elements = parseElements(TEXT, registry);
202
+ const tags = elements.map((element) => element._tag);
203
+ expect(tags).toEqual([
204
+ 'prompt',
205
+ 'reasoning',
206
+ 'status',
207
+ 'toolCall',
208
+ 'toolCall',
209
+ 'reasoning',
210
+ 'status',
211
+ 'toolCall',
212
+ 'reasoning',
213
+ 'prompt',
214
+ 'reasoning',
215
+ 'prompt',
216
+ 'name',
217
+ ]);
218
+ // Every element must be `complete` so the widget renderer wraps it. The bug we fixed:
219
+ // third multi-line `<reasoning>` (lines 9-14) used to be truncated by markdown's
220
+ // OrderedList parser breaking the HTMLBlock, leaving an Element node with no CloseTag.
221
+ expect(elements.filter((e) => !e.complete)).toEqual([]);
222
+ });
136
223
  });
@@ -65,14 +65,25 @@ export const nodeToJson = (state: EditorState, node: SyntaxNode): Tag | undefine
65
65
  const children: any[] = [];
66
66
  let child = node.node.firstChild;
67
67
 
68
+ const appendText = (raw: string) => {
69
+ if (raw.length === 0) {
70
+ return;
71
+ }
72
+ const last = children[children.length - 1];
73
+ if (typeof last === 'string') {
74
+ children[children.length - 1] = last + raw;
75
+ } else {
76
+ children.push(raw);
77
+ }
78
+ };
79
+
68
80
  while (child) {
69
81
  // Skip the opening and closing tags.
70
82
  if (child.type.name !== 'OpenTag' && child.type.name !== 'CloseTag') {
71
83
  if (child.type.name === 'Text') {
72
- const text = state.doc.sliceString(child.from, child.to).trim();
73
- if (text) {
74
- children.push(text);
75
- }
84
+ appendText(state.doc.sliceString(child.from, child.to));
85
+ } else if (child.type.name === 'EntityReference' || child.type.name === 'CharacterReference') {
86
+ appendText(decodeXmlEntity(state.doc.sliceString(child.from, child.to)));
76
87
  } else if (child.type.name === 'Element') {
77
88
  const data = nodeToJson(state, child);
78
89
  if (data) {
@@ -83,11 +94,57 @@ export const nodeToJson = (state: EditorState, node: SyntaxNode): Tag | undefine
83
94
  child = child.nextSibling;
84
95
  }
85
96
 
97
+ // Trim only leading/trailing whitespace on the outer-boundary string segments —
98
+ // interior strings are preserved verbatim so meaningful whitespace around inline
99
+ // child elements (e.g. `<reasoning>foo <ref/> bar</reasoning>`) is not collapsed.
100
+ if (children.length > 0 && typeof children[0] === 'string') {
101
+ children[0] = children[0].trimStart();
102
+ }
86
103
  if (children.length > 0) {
87
- tag.children = children;
104
+ const lastIndex = children.length - 1;
105
+ const last = children[lastIndex];
106
+ if (typeof last === 'string') {
107
+ children[lastIndex] = last.trimEnd();
108
+ }
109
+ }
110
+ const trimmed = children.filter((value) => typeof value !== 'string' || value.length > 0);
111
+
112
+ if (trimmed.length > 0) {
113
+ tag.children = trimmed;
88
114
  }
89
115
  }
90
116
 
91
117
  return tag;
92
118
  }
93
119
  };
120
+
121
+ /**
122
+ * Decode the common XML named entities and numeric character references that
123
+ * Lezer XML produces as `EntityReference` / `CharacterReference` nodes.
124
+ */
125
+ const XML_NAMED_ENTITIES: Record<string, string> = {
126
+ '&lt;': '<',
127
+ '&gt;': '>',
128
+ '&amp;': '&',
129
+ '&quot;': '"',
130
+ '&apos;': "'",
131
+ };
132
+
133
+ const decodeXmlEntity = (raw: string): string => {
134
+ const named = XML_NAMED_ENTITIES[raw];
135
+ if (named !== undefined) {
136
+ return named;
137
+ }
138
+ const numeric = /^&#(x?)([0-9a-fA-F]+);$/.exec(raw);
139
+ if (numeric) {
140
+ const code = parseInt(numeric[2], numeric[1] ? 16 : 10);
141
+ if (Number.isFinite(code)) {
142
+ try {
143
+ return String.fromCodePoint(code);
144
+ } catch {
145
+ // Fall through and return the raw text on out-of-range code points.
146
+ }
147
+ }
148
+ }
149
+ return raw;
150
+ };
package/src/index.ts CHANGED
@@ -10,5 +10,4 @@ export { TextKind } from '@dxos/protocols/proto/dxos/echo/model/text';
10
10
 
11
11
  export * from './defaults';
12
12
  export * from './extensions';
13
- export * from './types';
14
13
  export * from './util';
@@ -108,12 +108,21 @@ export const baseTheme = EditorView.baseTheme({
108
108
  * Scroller
109
109
  */
110
110
  '.cm-scroller': {
111
- overflowAnchor: 'none',
111
+ // Browser scroll-anchoring: see comment in `scrolling/crawler.ts`. `auto` lets the browser pin a
112
+ // stable element near the viewport top so widget resizes (e.g. tool-block TogglePanel
113
+ // open/close) don't jump the user's view.
114
+ overflowAnchor: 'auto',
112
115
  },
113
116
  '.cm-scroller::-webkit-scrollbar': {
114
- width: '8px',
117
+ width: 'var(--scrollbar-size,8px)',
118
+ height: 'var(--scrollbar-size,8px)',
119
+ },
120
+ '.cm-scroller::-webkit-scrollbar-corner': {
121
+ background: 'transparent',
122
+ },
123
+ '.cm-scroller::-webkit-scrollbar-track': {
124
+ background: 'transparent',
115
125
  },
116
- '.cm-scroller::-webkit-scrollbar-track': {},
117
126
  '.cm-scroller::-webkit-scrollbar-thumb': {
118
127
  background: 'transparent',
119
128
  transition: 'background 0.15s',
@@ -152,7 +161,7 @@ export const baseTheme = EditorView.baseTheme({
152
161
  * Height is set to match the corresponding line (which may have wrapped).
153
162
  */
154
163
  '.cm-gutterElement': {
155
- lineHeight: 1.5,
164
+ lineHeight: '24px',
156
165
  fontSize: '12px',
157
166
  },
158
167
 
@@ -218,6 +227,7 @@ export const baseTheme = EditorView.baseTheme({
218
227
  textDecorationColor: 'var(--color-separator)',
219
228
  textUnderlineOffset: '2px',
220
229
  borderRadius: '.125rem',
230
+ cursor: 'pointer',
221
231
  },
222
232
  '.cm-link > span': {
223
233
  color: 'var(--color-accent-text)',
@@ -257,12 +267,12 @@ export const baseTheme = EditorView.baseTheme({
257
267
  padding: '4px',
258
268
  },
259
269
  '.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
260
- background: 'var(--color-active-surface)',
261
- color: 'var(--color-base-surface-text)',
270
+ background: 'var(--color-current-surface)',
271
+ color: 'var(--color-base-fg)',
262
272
  },
263
273
  '.cm-tooltip.cm-tooltip-autocomplete > ul > completion-section': {
264
274
  paddingLeft: '4px !important',
265
- color: 'var(--color-base-surface-text)',
275
+ color: 'var(--color-base-fg)',
266
276
  },
267
277
 
268
278
  /**
@@ -282,7 +292,7 @@ export const baseTheme = EditorView.baseTheme({
282
292
  padding: '0 4px',
283
293
  },
284
294
  '.cm-completionMatchedText': {
285
- color: 'var(--color-base-surface-text)',
295
+ color: 'var(--color-base-fg)',
286
296
  textDecoration: 'none !important',
287
297
  },
288
298
 
@@ -318,7 +328,7 @@ export const baseTheme = EditorView.baseTheme({
318
328
  backgroundColor: 'var(--color-input-surface)',
319
329
  },
320
330
  '.cm-panel input:focus, .cm-panel button:focus': {
321
- outline: '1px solid var(--color-neutral-focus-indicator)',
331
+ outline: '1px solid var(--color-focus-ring-subtle)',
322
332
  },
323
333
  '.cm-panel label': {
324
334
  display: 'inline-flex',
@@ -331,15 +341,15 @@ export const baseTheme = EditorView.baseTheme({
331
341
  height: '8px',
332
342
  marginRight: '6px !important',
333
343
  padding: '2px !important',
334
- color: 'var(--color-neutral-focus-indicator)',
344
+ color: 'var(--color-focus-ring-subtle)',
335
345
  },
336
346
  '.cm-panel button': {
337
347
  '&:hover': {
338
- // TODO(burdon): Replace with layer and @apply bg-accent-surface-hover
339
- backgroundColor: 'var(--color-accent-surface-hover) !important',
348
+ // TODO(burdon): Replace with layer and @apply bg-accent-bg-hover
349
+ backgroundColor: 'var(--color-accent-bg-hover) !important',
340
350
  },
341
351
  '&:active': {
342
- backgroundColor: 'var(--color-accent-surface-hover)',
352
+ backgroundColor: 'var(--color-accent-bg-hover)',
343
353
  },
344
354
  },
345
355
  '.cm-panel.cm-search': {
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ declare module '*.md?raw' {
6
+ const content: string;
7
+ export default content;
8
+ }