@dxos/ui-editor 0.0.0 → 0.8.4-main.03d5cd7b56

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 (255) hide show
  1. package/dist/lib/browser/index.mjs +8633 -0
  2. package/dist/lib/browser/index.mjs.map +7 -0
  3. package/dist/lib/browser/meta.json +1 -0
  4. package/dist/lib/browser/types/index.mjs +33 -0
  5. package/dist/lib/browser/types/index.mjs.map +7 -0
  6. package/dist/lib/node-esm/index.mjs +8635 -0
  7. package/dist/lib/node-esm/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/meta.json +1 -0
  9. package/dist/lib/node-esm/types/index.mjs +35 -0
  10. package/dist/lib/node-esm/types/index.mjs.map +7 -0
  11. package/dist/types/src/defaults.d.ts +6 -0
  12. package/dist/types/src/defaults.d.ts.map +1 -0
  13. package/dist/types/src/extensions/annotations.d.ts +9 -0
  14. package/dist/types/src/extensions/annotations.d.ts.map +1 -0
  15. package/dist/types/src/extensions/auto-scroll.d.ts +18 -0
  16. package/dist/types/src/extensions/auto-scroll.d.ts.map +1 -0
  17. package/dist/types/src/extensions/autocomplete/autocomplete.d.ts +17 -0
  18. package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -0
  19. package/dist/types/src/extensions/autocomplete/index.d.ts +5 -0
  20. package/dist/types/src/extensions/autocomplete/index.d.ts.map +1 -0
  21. package/dist/types/src/extensions/autocomplete/match.d.ts +13 -0
  22. package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -0
  23. package/dist/types/src/extensions/autocomplete/placeholder.d.ts +23 -0
  24. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -0
  25. package/dist/types/src/extensions/autocomplete/typeahead.d.ts +10 -0
  26. package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +1 -0
  27. package/dist/types/src/extensions/automerge/automerge.d.ts +4 -0
  28. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -0
  29. package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
  30. package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
  31. package/dist/types/src/extensions/automerge/cursor.d.ts +4 -0
  32. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -0
  33. package/dist/types/src/extensions/automerge/defs.d.ts +17 -0
  34. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -0
  35. package/dist/types/src/extensions/automerge/index.d.ts +2 -0
  36. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -0
  37. package/dist/types/src/extensions/automerge/sync.d.ts +17 -0
  38. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -0
  39. package/dist/types/src/extensions/automerge/update-automerge.d.ts +6 -0
  40. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -0
  41. package/dist/types/src/extensions/automerge/update-codemirror.d.ts +5 -0
  42. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -0
  43. package/dist/types/src/extensions/awareness/awareness-provider.d.ts +31 -0
  44. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -0
  45. package/dist/types/src/extensions/awareness/awareness.d.ts +46 -0
  46. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -0
  47. package/dist/types/src/extensions/awareness/index.d.ts +3 -0
  48. package/dist/types/src/extensions/awareness/index.d.ts.map +1 -0
  49. package/dist/types/src/extensions/blast.d.ts +25 -0
  50. package/dist/types/src/extensions/blast.d.ts.map +1 -0
  51. package/dist/types/src/extensions/blocks.d.ts +2 -0
  52. package/dist/types/src/extensions/blocks.d.ts.map +1 -0
  53. package/dist/types/src/extensions/bookmarks.d.ts +12 -0
  54. package/dist/types/src/extensions/bookmarks.d.ts.map +1 -0
  55. package/dist/types/src/extensions/comments.d.ts +90 -0
  56. package/dist/types/src/extensions/comments.d.ts.map +1 -0
  57. package/dist/types/src/extensions/debug.d.ts +3 -0
  58. package/dist/types/src/extensions/debug.d.ts.map +1 -0
  59. package/dist/types/src/extensions/dnd.d.ts +9 -0
  60. package/dist/types/src/extensions/dnd.d.ts.map +1 -0
  61. package/dist/types/src/extensions/factories.d.ts +88 -0
  62. package/dist/types/src/extensions/factories.d.ts.map +1 -0
  63. package/dist/types/src/extensions/factories.test.d.ts +2 -0
  64. package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
  65. package/dist/types/src/extensions/focus.d.ts +7 -0
  66. package/dist/types/src/extensions/focus.d.ts.map +1 -0
  67. package/dist/types/src/extensions/folding.d.ts +6 -0
  68. package/dist/types/src/extensions/folding.d.ts.map +1 -0
  69. package/dist/types/src/extensions/hashtag.d.ts +3 -0
  70. package/dist/types/src/extensions/hashtag.d.ts.map +1 -0
  71. package/dist/types/src/extensions/index.d.ts +32 -0
  72. package/dist/types/src/extensions/index.d.ts.map +1 -0
  73. package/dist/types/src/extensions/json.d.ts +7 -0
  74. package/dist/types/src/extensions/json.d.ts.map +1 -0
  75. package/dist/types/src/extensions/listener.d.ts +13 -0
  76. package/dist/types/src/extensions/listener.d.ts.map +1 -0
  77. package/dist/types/src/extensions/markdown/action.d.ts +12 -0
  78. package/dist/types/src/extensions/markdown/action.d.ts.map +1 -0
  79. package/dist/types/src/extensions/markdown/bundle.d.ts +25 -0
  80. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -0
  81. package/dist/types/src/extensions/markdown/changes.d.ts +10 -0
  82. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -0
  83. package/dist/types/src/extensions/markdown/changes.test.d.ts +2 -0
  84. package/dist/types/src/extensions/markdown/changes.test.d.ts.map +1 -0
  85. package/dist/types/src/extensions/markdown/debug.d.ts +11 -0
  86. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -0
  87. package/dist/types/src/extensions/markdown/decorate.d.ts +25 -0
  88. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -0
  89. package/dist/types/src/extensions/markdown/formatting.d.ts +63 -0
  90. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
  91. package/dist/types/src/extensions/markdown/formatting.test.d.ts +3 -0
  92. package/dist/types/src/extensions/markdown/formatting.test.d.ts.map +1 -0
  93. package/dist/types/src/extensions/markdown/highlight.d.ts +37 -0
  94. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -0
  95. package/dist/types/src/extensions/markdown/image.d.ts +7 -0
  96. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -0
  97. package/dist/types/src/extensions/markdown/index.d.ts +10 -0
  98. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -0
  99. package/dist/types/src/extensions/markdown/link.d.ts +7 -0
  100. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -0
  101. package/dist/types/src/extensions/markdown/parser.test.d.ts +2 -0
  102. package/dist/types/src/extensions/markdown/parser.test.d.ts.map +1 -0
  103. package/dist/types/src/extensions/markdown/styles.d.ts +4 -0
  104. package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -0
  105. package/dist/types/src/extensions/markdown/table.d.ts +8 -0
  106. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -0
  107. package/dist/types/src/extensions/mention.d.ts +7 -0
  108. package/dist/types/src/extensions/mention.d.ts.map +1 -0
  109. package/dist/types/src/extensions/modal.d.ts +7 -0
  110. package/dist/types/src/extensions/modal.d.ts.map +1 -0
  111. package/dist/types/src/extensions/modes.d.ts +10 -0
  112. package/dist/types/src/extensions/modes.d.ts.map +1 -0
  113. package/dist/types/src/extensions/outliner/commands.d.ts +10 -0
  114. package/dist/types/src/extensions/outliner/commands.d.ts.map +1 -0
  115. package/dist/types/src/extensions/outliner/editor.d.ts +5 -0
  116. package/dist/types/src/extensions/outliner/editor.d.ts.map +1 -0
  117. package/dist/types/src/extensions/outliner/editor.test.d.ts +2 -0
  118. package/dist/types/src/extensions/outliner/editor.test.d.ts.map +1 -0
  119. package/dist/types/src/extensions/outliner/index.d.ts +4 -0
  120. package/dist/types/src/extensions/outliner/index.d.ts.map +1 -0
  121. package/dist/types/src/extensions/outliner/menu.d.ts +8 -0
  122. package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -0
  123. package/dist/types/src/extensions/outliner/outliner.d.ts +11 -0
  124. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -0
  125. package/dist/types/src/extensions/outliner/outliner.test.d.ts +2 -0
  126. package/dist/types/src/extensions/outliner/outliner.test.d.ts.map +1 -0
  127. package/dist/types/src/extensions/outliner/selection.d.ts +12 -0
  128. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -0
  129. package/dist/types/src/extensions/outliner/tree.d.ts +79 -0
  130. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -0
  131. package/dist/types/src/extensions/outliner/tree.test.d.ts +2 -0
  132. package/dist/types/src/extensions/outliner/tree.test.d.ts.map +1 -0
  133. package/dist/types/src/extensions/preview/index.d.ts +2 -0
  134. package/dist/types/src/extensions/preview/index.d.ts.map +1 -0
  135. package/dist/types/src/extensions/preview/preview.d.ts +34 -0
  136. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -0
  137. package/dist/types/src/extensions/replacer.d.ts +21 -0
  138. package/dist/types/src/extensions/replacer.d.ts.map +1 -0
  139. package/dist/types/src/extensions/replacer.test.d.ts +2 -0
  140. package/dist/types/src/extensions/replacer.test.d.ts.map +1 -0
  141. package/dist/types/src/extensions/scroll-past-end.d.ts +3 -0
  142. package/dist/types/src/extensions/scroll-past-end.d.ts.map +1 -0
  143. package/dist/types/src/extensions/scroller.d.ts +68 -0
  144. package/dist/types/src/extensions/scroller.d.ts.map +1 -0
  145. package/dist/types/src/extensions/selection.d.ts +24 -0
  146. package/dist/types/src/extensions/selection.d.ts.map +1 -0
  147. package/dist/types/src/extensions/snippets.d.ts +10 -0
  148. package/dist/types/src/extensions/snippets.d.ts.map +1 -0
  149. package/dist/types/src/extensions/state.d.ts +2 -0
  150. package/dist/types/src/extensions/state.d.ts.map +1 -0
  151. package/dist/types/src/extensions/submit.d.ts +10 -0
  152. package/dist/types/src/extensions/submit.d.ts.map +1 -0
  153. package/dist/types/src/extensions/tags/extended-markdown.d.ts +10 -0
  154. package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -0
  155. package/dist/types/src/extensions/tags/extended-markdown.test.d.ts +2 -0
  156. package/dist/types/src/extensions/tags/extended-markdown.test.d.ts.map +1 -0
  157. package/dist/types/src/extensions/tags/fader.d.ts +12 -0
  158. package/dist/types/src/extensions/tags/fader.d.ts.map +1 -0
  159. package/dist/types/src/extensions/tags/index.d.ts +7 -0
  160. package/dist/types/src/extensions/tags/index.d.ts.map +1 -0
  161. package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
  162. package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
  163. package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
  164. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
  165. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
  166. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
  167. package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
  168. package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
  169. package/dist/types/src/extensions/tags/xml-tags.d.ts +117 -0
  170. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -0
  171. package/dist/types/src/extensions/tags/xml-util.d.ts +10 -0
  172. package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -0
  173. package/dist/types/src/extensions/tags/xml-util.test.d.ts +2 -0
  174. package/dist/types/src/extensions/tags/xml-util.test.d.ts.map +1 -0
  175. package/dist/types/src/index.d.ts +8 -0
  176. package/dist/types/src/index.d.ts.map +1 -0
  177. package/dist/types/src/styles/index.d.ts +2 -0
  178. package/dist/types/src/styles/index.d.ts.map +1 -0
  179. package/dist/types/src/styles/theme.d.ts +58 -0
  180. package/dist/types/src/styles/theme.d.ts.map +1 -0
  181. package/dist/types/src/types/index.d.ts +2 -0
  182. package/dist/types/src/types/index.d.ts.map +1 -0
  183. package/dist/types/src/types/types.d.ts +21 -0
  184. package/dist/types/src/types/types.d.ts.map +1 -0
  185. package/dist/types/src/util/cursor.d.ts +31 -0
  186. package/dist/types/src/util/cursor.d.ts.map +1 -0
  187. package/dist/types/src/util/debug.d.ts +17 -0
  188. package/dist/types/src/util/debug.d.ts.map +1 -0
  189. package/dist/types/src/util/decorations.d.ts +4 -0
  190. package/dist/types/src/util/decorations.d.ts.map +1 -0
  191. package/dist/types/src/util/dom.d.ts +10 -0
  192. package/dist/types/src/util/dom.d.ts.map +1 -0
  193. package/dist/types/src/util/facet.d.ts +3 -0
  194. package/dist/types/src/util/facet.d.ts.map +1 -0
  195. package/dist/types/src/util/index.d.ts +7 -0
  196. package/dist/types/src/util/index.d.ts.map +1 -0
  197. package/dist/types/src/util/util.d.ts +8 -0
  198. package/dist/types/src/util/util.d.ts.map +1 -0
  199. package/dist/types/tsconfig.tsbuildinfo +1 -0
  200. package/package.json +42 -43
  201. package/src/defaults.ts +33 -20
  202. package/src/extensions/annotations.ts +1 -1
  203. package/src/extensions/auto-scroll.ts +234 -0
  204. package/src/extensions/autocomplete/placeholder.ts +37 -18
  205. package/src/extensions/automerge/automerge.test.tsx +37 -11
  206. package/src/extensions/automerge/automerge.ts +5 -7
  207. package/src/extensions/blocks.ts +5 -5
  208. package/src/extensions/comments.ts +5 -6
  209. package/src/extensions/dnd.ts +2 -2
  210. package/src/extensions/factories.test.ts +88 -0
  211. package/src/extensions/factories.ts +32 -15
  212. package/src/extensions/folding.ts +5 -22
  213. package/src/extensions/index.ts +4 -3
  214. package/src/extensions/markdown/action.ts +0 -1
  215. package/src/extensions/markdown/bundle.ts +23 -9
  216. package/src/extensions/markdown/decorate.ts +15 -12
  217. package/src/extensions/markdown/formatting.ts +5 -10
  218. package/src/extensions/markdown/highlight.ts +15 -7
  219. package/src/extensions/markdown/link.ts +27 -33
  220. package/src/extensions/markdown/parser.test.ts +0 -1
  221. package/src/extensions/markdown/styles.ts +42 -9
  222. package/src/extensions/markdown/table.ts +24 -2
  223. package/src/extensions/outliner/outliner.test.ts +0 -1
  224. package/src/extensions/outliner/outliner.ts +3 -4
  225. package/src/extensions/outliner/tree.test.ts +0 -1
  226. package/src/extensions/preview/preview.ts +62 -15
  227. package/src/extensions/scroll-past-end.ts +32 -0
  228. package/src/extensions/scroller.ts +256 -0
  229. package/src/extensions/selection.ts +1 -1
  230. package/src/extensions/snippets.ts +67 -0
  231. package/src/extensions/tags/extended-markdown.test.ts +120 -2
  232. package/src/extensions/tags/extended-markdown.ts +80 -1
  233. package/src/extensions/tags/fader.ts +195 -0
  234. package/src/extensions/tags/index.ts +4 -1
  235. package/src/extensions/tags/testing/text.md +36 -0
  236. package/src/extensions/tags/testing/text.txt +35 -0
  237. package/src/extensions/tags/typewriter.test.ts +65 -0
  238. package/src/extensions/tags/typewriter.ts +594 -0
  239. package/src/extensions/tags/xml-block-decoration.ts +123 -0
  240. package/src/extensions/tags/xml-formatting.ts +125 -0
  241. package/src/extensions/tags/xml-tags.ts +186 -35
  242. package/src/extensions/tags/xml-util.test.ts +199 -24
  243. package/src/extensions/tags/xml-util.ts +62 -5
  244. package/src/index.ts +0 -1
  245. package/src/styles/index.ts +0 -2
  246. package/src/styles/theme.ts +124 -33
  247. package/src/types/types.ts +10 -2
  248. package/src/typings.d.ts +8 -0
  249. package/src/util/cursor.ts +1 -2
  250. package/src/extensions/autoscroll.ts +0 -165
  251. package/src/extensions/scrolling.ts +0 -189
  252. package/src/extensions/tags/streamer.ts +0 -243
  253. package/src/extensions/typewriter.ts +0 -68
  254. package/src/styles/markdown.ts +0 -26
  255. package/src/styles/tokens.ts +0 -17
@@ -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-active-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,16 +13,15 @@ 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';
20
19
  import { log } from '@dxos/log';
20
+ import { Domino } from '@dxos/ui';
21
21
 
22
22
  import { type Range } from '../../types';
23
23
  import { decorationSetToArray } from '../../util';
24
- import { scrollToLineEffect } from '../scrolling';
25
-
24
+ import { scrollerLineEffect } from '../scroller';
26
25
  import { nodeToJson } from './xml-util';
27
26
 
28
27
  /**
@@ -54,8 +53,9 @@ export type XmlEventHandler<TEvent = any> = (event: TEvent) => void;
54
53
  */
55
54
  export type XmlWidgetProps<TProps = any, TContext = any> = TProps & {
56
55
  _tag: string;
56
+ range: { from: number; to: number };
57
+ children?: any[];
57
58
  context?: TContext;
58
- range?: { from: number; to: number };
59
59
  view?: EditorView;
60
60
  onEvent?: XmlEventHandler;
61
61
  };
@@ -63,19 +63,40 @@ export type XmlWidgetProps<TProps = any, TContext = any> = TProps & {
63
63
  /**
64
64
  * Factory for creating widgets.
65
65
  */
66
- export type XmlWidgetFactory = (props: XmlWidgetProps, onEvent?: XmlEventHandler) => WidgetType | null;
66
+ export type XmlWidgetFactory = (props: XmlWidgetProps) => WidgetType | null;
67
67
 
68
68
  /**
69
69
  * Widget registry definition.
70
+ * NOTE: Widgets should NOT use top/bottom margins (it causes unstable measurements while scrolling which leads to jumps).
71
+ * If required, use encapsulated divs with padding instead.
70
72
  */
71
73
  export type XmlWidgetDef = {
72
- /** Block widget. */
74
+ /**
75
+ * Block widget.
76
+ */
73
77
  block?: boolean;
74
78
 
75
- /** Native widget (rendered inline). */
79
+ /**
80
+ * When true, the opening tag is flushed immediately and inner content streams character-by-character.
81
+ */
82
+ streaming?: boolean;
83
+
84
+ /**
85
+ * Debug only.
86
+ */
87
+ debug?: boolean;
88
+
89
+ /**
90
+ * Native widget (rendered inline).
91
+ */
76
92
  factory?: XmlWidgetFactory;
77
93
 
78
- /** React/Solid widget (rendered in portals outside of the editor). */
94
+ /**
95
+ * React/Solid widget (rendered in portals outside of the editor).
96
+ * Prefer an `id="..."` attribute on the tag so `updateWidget` can target the instance; if omitted,
97
+ * an id is derived from the tag’s document range (non-streaming) or opening position (streaming).
98
+ * Streaming tags use `cm-xml-<from>` so the same portal id is kept when the closing tag arrives.
99
+ */
79
100
  Component?: FunctionComponent<XmlWidgetProps>;
80
101
  };
81
102
 
@@ -86,6 +107,13 @@ export const getXmlTextChild = (children: any[]): string | null => {
86
107
  return typeof child === 'string' ? child : null;
87
108
  };
88
109
 
110
+ /** Stable id for portaled React/Solid widgets; explicit `id` on the tag wins for `updateWidget`. */
111
+ const xmlWidgetId = (explicit: unknown, fallback: string): string =>
112
+ typeof explicit === 'string' && explicit.length > 0 ? explicit : fallback;
113
+
114
+ /** Escapes a string for safe embedding in RegExp source (tag names from the registry). */
115
+ const escapeRegExpSource = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+
89
117
  /**
90
118
  * Update context.
91
119
  */
@@ -103,6 +131,8 @@ export const xmlTagUpdateEffect = StateEffect.define<{ id: string; value: any }>
103
131
 
104
132
  type WidgetDecorationSet = {
105
133
  from: number;
134
+ /** Start position of an active unclosed streaming tag (for rebuild range). */
135
+ streamingFrom?: number;
106
136
  decorations: DecorationSet;
107
137
  };
108
138
 
@@ -164,11 +194,11 @@ export type XmlTagsOptions = {
164
194
  /** Tag registry. */
165
195
  registry?: XmlWidgetRegistry;
166
196
 
197
+ /** Tags to bookmark for navigation. */
198
+ bookmarks?: string[];
199
+
167
200
  /** Called when widgets are mounted or unmounted. */
168
201
  setWidgets?: (widgets: XmlWidgetState[]) => void;
169
-
170
- /** Tags to bookmark. */
171
- bookmarks?: string[];
172
202
  };
173
203
 
174
204
  /**
@@ -184,6 +214,7 @@ export type XmlTagsOptions = {
184
214
  export const xmlTags = ({ registry, setWidgets, bookmarks }: XmlTagsOptions = {}): Extension => {
185
215
  const notifier = createWidgetMap(setWidgets);
186
216
  const widgetDecorationsField = createWidgetDecorationsField(registry, notifier);
217
+
187
218
  return [
188
219
  widgetContextStateField,
189
220
  widgetStateMapStateField,
@@ -268,7 +299,7 @@ const createNavigationEffectPlugin = (
268
299
  const line = view.state.doc.lineAt(widget?.from ?? 0);
269
300
  view.dispatch({
270
301
  selection: { anchor: line.from, head: line.from },
271
- effects: scrollToLineEffect.of({ line: line.number, options: { offset: -16 } }),
302
+ effects: scrollerLineEffect.of({ line: line.number - 1, offset: -16 }),
272
303
  });
273
304
 
274
305
  continue;
@@ -294,13 +325,13 @@ const createNavigationEffectPlugin = (
294
325
  const line = view.state.doc.lineAt(widget?.from);
295
326
  view.dispatch({
296
327
  selection: { anchor: line.to, head: line.to },
297
- effects: scrollToLineEffect.of({ line: line.number, options: { offset: -16 } }),
328
+ effects: scrollerLineEffect.of({ line: line.number - 1, offset: -16 }),
298
329
  });
299
330
  } else {
300
331
  const line = view.state.doc.lineAt(view.state.doc.length);
301
332
  view.dispatch({
302
333
  selection: { anchor: line.to, head: line.to },
303
- effects: scrollToLineEffect.of({ line: line.number, options: { position: 'end' } }),
334
+ effects: scrollerLineEffect.of({ line: line.number - 1, position: 'end' }),
304
335
  });
305
336
  }
306
337
 
@@ -355,10 +386,13 @@ const createWidgetDecorationsField = (registry: XmlWidgetRegistry = {}, notifier
355
386
  create: (state) => {
356
387
  return buildDecorations(state, { from: 0, to: state.doc.length }, registry, notifier);
357
388
  },
358
- update: ({ from, decorations }, tr) => {
389
+ update: ({ from, streamingFrom, decorations }, tr) => {
359
390
  // Check for reset effect.
360
391
  for (const effect of tr.effects) {
361
392
  if (effect.is(xmlTagResetEffect)) {
393
+ if (tr.docChanged) {
394
+ return buildDecorations(tr.state, { from: 0, to: tr.state.doc.length }, registry, notifier);
395
+ }
362
396
  return { from: 0, decorations: Decoration.none };
363
397
  }
364
398
  }
@@ -372,16 +406,22 @@ const createWidgetDecorationsField = (registry: XmlWidgetRegistry = {}, notifier
372
406
  // Full rebuild from start.
373
407
  return buildDecorations(state, { from: 0, to: state.doc.length }, registry, notifier);
374
408
  } else {
375
- // Append-only: rebuild decorations from after the last widget and merge with existing decorations.
376
- const result = buildDecorations(state, { from, to: state.doc.length }, registry, notifier);
409
+ // Rebuild from the streaming tag start (if active) so the tree walk can detect completion.
410
+ const rebuildFrom = streamingFrom ?? from;
411
+ const result = buildDecorations(state, { from: rebuildFrom, to: state.doc.length }, registry, notifier);
377
412
  return {
378
413
  from: result.from,
379
- decorations: decorations.update({ add: decorationSetToArray(result.decorations) }),
414
+ streamingFrom: result.streamingFrom,
415
+ decorations: decorations.update({
416
+ // Remove old streaming decorations — they are rebuilt each tick.
417
+ filter: (_f, _t, deco) => !deco.spec.streaming,
418
+ add: decorationSetToArray(result.decorations),
419
+ }),
380
420
  };
381
421
  }
382
422
  }
383
423
 
384
- return { from, decorations };
424
+ return { from, streamingFrom, decorations };
385
425
  },
386
426
  provide: (field) => [
387
427
  EditorView.decorations.from(field, (v) => v.decorations),
@@ -391,6 +431,7 @@ const createWidgetDecorationsField = (registry: XmlWidgetRegistry = {}, notifier
391
431
 
392
432
  /**
393
433
  * Creates widget decorations for XML tags in the document using the syntax tree.
434
+ * After the tree walk, scans for unclosed streaming tags and creates provisional decorations.
394
435
  */
395
436
  const buildDecorations = (
396
437
  state: EditorState,
@@ -408,6 +449,8 @@ const buildDecorations = (
408
449
  }
409
450
 
410
451
  let last = range.from;
452
+ let streamingFrom: number | undefined;
453
+
411
454
  tree.iterate({
412
455
  from: range.from,
413
456
  to: range.to,
@@ -420,17 +463,32 @@ const buildDecorations = (
420
463
  if (args) {
421
464
  const def = registry[args._tag];
422
465
  if (def) {
466
+ // Skip unclosed streaming elements — the unclosed tag scan handles them.
467
+ if (def.streaming && !node.node.getChild('CloseTag')) {
468
+ return false;
469
+ }
470
+
423
471
  // NOTE: The widget state may already have been updated before the widget is mounted.
424
472
  const { block, factory, Component } = def;
425
- const widgetState = args.id ? widgetStateMap[args.id] : undefined;
426
473
  const nodeRange = { from: node.node.from, to: node.node.to };
427
- const props = { context, range: nodeRange, ...args, ...widgetState } satisfies XmlWidgetProps;
474
+ const widgetId = xmlWidgetId(
475
+ args.id,
476
+ def.streaming ? `cm-xml-${nodeRange.from}` : `cm-xml-${nodeRange.from}-${nodeRange.to}`,
477
+ );
478
+ const widgetState = widgetStateMap[widgetId];
479
+ const props = {
480
+ id: widgetId,
481
+ range: nodeRange,
482
+ context,
483
+ ...args,
484
+ ...widgetState,
485
+ } satisfies XmlWidgetProps;
428
486
 
429
487
  // Create widget.
430
488
  const widget: WidgetType | undefined = factory
431
- ? factory(props)
489
+ ? (factory(props) ?? undefined)
432
490
  : Component
433
- ? args.id && new PlaceholderWidget(args.id, Component, props, notifier)
491
+ ? new PlaceholderWidget(widgetId, Component, props, notifier)
434
492
  : undefined;
435
493
 
436
494
  // Add decoration.
@@ -463,30 +521,112 @@ const buildDecorations = (
463
521
  },
464
522
  });
465
523
 
466
- return { from: last, decorations: builder.finish() };
524
+ // Scan for unclosed streaming tags at the document tail.
525
+ const streamingTagNames = Object.entries(registry)
526
+ .filter(([, def]) => def.streaming)
527
+ .map(([name]) => name)
528
+ // Longest names first so `react-widget` wins over `react` in alternation.
529
+ .sort((a, b) => b.length - a.length);
530
+
531
+ if (streamingTagNames.length > 0) {
532
+ const tailText = state.sliceDoc(range.from, range.to);
533
+ const streamingPattern = streamingTagNames.map(escapeRegExpSource).join('|');
534
+ const tagPattern = new RegExp(`<(${streamingPattern})(\\s[^>]*)?>`, 'g');
535
+ let match: RegExpExecArray | null;
536
+
537
+ while ((match = tagPattern.exec(tailText)) !== null) {
538
+ const tagName = match[1];
539
+ const closeTag = `</${tagName}>`;
540
+ const afterOpen = match.index + match[0].length;
541
+
542
+ // Only process if there's no closing tag after this opening tag.
543
+ if (tailText.indexOf(closeTag, afterOpen) === -1) {
544
+ const absoluteFrom = range.from + match.index;
545
+ const contentFrom = range.from + afterOpen;
546
+ const innerText = state.sliceDoc(contentFrom, range.to).trim();
547
+
548
+ const def = registry[tagName];
549
+ const props: XmlWidgetProps = {
550
+ _tag: tagName,
551
+ context,
552
+ range: { from: absoluteFrom, to: range.to },
553
+ children: innerText ? [innerText] : undefined,
554
+ };
555
+
556
+ // Parse attributes from the opening tag.
557
+ const attrPattern = /(\w+)="([^"]*)"/g;
558
+ let attrMatch: RegExpExecArray | null;
559
+ while ((attrMatch = attrPattern.exec(match[0])) !== null) {
560
+ props[attrMatch[1]] = attrMatch[2];
561
+ }
562
+
563
+ const widgetId = xmlWidgetId(props.id, `cm-xml-${absoluteFrom}`);
564
+ const widgetState = widgetStateMap[widgetId];
565
+ const mergedProps = { ...props, id: widgetId, ...widgetState };
566
+
567
+ const widget: WidgetType | undefined = def.factory
568
+ ? (def.factory(mergedProps) ?? undefined)
569
+ : def.Component
570
+ ? new PlaceholderWidget(widgetId, def.Component, mergedProps, notifier, true)
571
+ : undefined;
572
+
573
+ if (widget) {
574
+ builder.add(
575
+ absoluteFrom,
576
+ range.to,
577
+ Decoration.replace({
578
+ widget,
579
+ block: def.block,
580
+ atomic: true,
581
+ inclusive: true,
582
+ tag: tagName,
583
+ streaming: true,
584
+ contentFrom,
585
+ }),
586
+ );
587
+
588
+ // Set from to just before the streaming tag so next rebuild covers it.
589
+ streamingFrom = absoluteFrom;
590
+ last = absoluteFrom;
591
+ }
592
+
593
+ // Only one streaming tag at a time.
594
+ break;
595
+ }
596
+ }
597
+ }
598
+
599
+ return { from: last, streamingFrom, decorations: builder.finish() };
467
600
  };
468
601
 
469
602
  /**
470
603
  * Placeholder for widgets.
471
604
  */
472
605
  class PlaceholderWidget<TProps extends XmlWidgetProps> extends WidgetType {
473
- private _root: HTMLElement | null = null;
606
+ #root: HTMLElement | null = null;
607
+ #view: EditorView | undefined;
474
608
 
475
609
  constructor(
476
- public readonly id: string,
477
- public readonly Component: FunctionComponent<TProps>,
478
- public readonly props: TProps,
479
- private readonly notifier: XmlWidgetNotifier,
610
+ readonly id: string,
611
+ readonly Component: FunctionComponent<TProps>,
612
+ readonly props: TProps,
613
+ readonly notifier: XmlWidgetNotifier,
614
+ readonly streaming?: boolean,
480
615
  ) {
481
616
  super();
482
617
  invariant(id);
483
618
  }
484
619
 
485
620
  get root(): HTMLElement | null {
486
- return this._root;
621
+ return this.#root;
487
622
  }
488
623
 
489
624
  override eq(other: this) {
625
+ // Streaming widgets always need updating (content changes on each tick).
626
+ if (this.streaming) {
627
+ return false;
628
+ }
629
+
490
630
  return this.id === other.id;
491
631
  }
492
632
 
@@ -494,14 +634,25 @@ class PlaceholderWidget<TProps extends XmlWidgetProps> extends WidgetType {
494
634
  return true;
495
635
  }
496
636
 
497
- override toDOM(_view: EditorView) {
498
- this._root = document.createElement('span');
499
- this.notifier.mounted({ id: this.id, root: this._root, props: this.props, Component: this.Component });
500
- return this._root;
637
+ override toDOM(view: EditorView) {
638
+ this.#view = view;
639
+ // NOTE: Set min-height to avoid jumps while scrolling.
640
+ this.#root = Domino.of('div').classNames('min-h-[24px]').root;
641
+ const props = Object.assign({}, this.props, { view }) as TProps;
642
+ this.notifier.mounted({ id: this.id, root: this.#root, props, Component: this.Component });
643
+ return this.#root;
644
+ }
645
+
646
+ override updateDOM(dom: HTMLElement) {
647
+ this.#root = dom;
648
+ const props = Object.assign({}, this.props, { view: this.#view }) as TProps;
649
+ this.notifier.mounted({ id: this.id, root: this.#root, props, Component: this.Component });
650
+ return true;
501
651
  }
502
652
 
503
653
  override destroy(_dom: HTMLElement) {
504
654
  this.notifier.unmounted(this.id);
505
- this._root = null;
655
+ this.#root = null;
656
+ this.#view = undefined;
506
657
  }
507
658
  }