@dxos/ui-editor 0.0.0 → 0.8.4-main.05e74ebcff

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 (263) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/index.mjs +8657 -0
  4. package/dist/lib/browser/index.mjs.map +7 -0
  5. package/dist/lib/browser/meta.json +1 -0
  6. package/dist/lib/browser/types/index.mjs +33 -0
  7. package/dist/lib/browser/types/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/index.mjs +8659 -0
  9. package/dist/lib/node-esm/index.mjs.map +7 -0
  10. package/dist/lib/node-esm/meta.json +1 -0
  11. package/dist/lib/node-esm/types/index.mjs +35 -0
  12. package/dist/lib/node-esm/types/index.mjs.map +7 -0
  13. package/dist/types/src/defaults.d.ts +6 -0
  14. package/dist/types/src/defaults.d.ts.map +1 -0
  15. package/dist/types/src/extensions/annotations.d.ts +9 -0
  16. package/dist/types/src/extensions/annotations.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 +30 -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/scrolling/auto-scroll.d.ts +18 -0
  142. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
  143. package/dist/types/src/extensions/scrolling/crawler.d.ts +75 -0
  144. package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
  145. package/dist/types/src/extensions/scrolling/index.d.ts +5 -0
  146. package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
  147. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts +3 -0
  148. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
  149. package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
  150. package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
  151. package/dist/types/src/extensions/selection.d.ts +24 -0
  152. package/dist/types/src/extensions/selection.d.ts.map +1 -0
  153. package/dist/types/src/extensions/snippets.d.ts +10 -0
  154. package/dist/types/src/extensions/snippets.d.ts.map +1 -0
  155. package/dist/types/src/extensions/state.d.ts +2 -0
  156. package/dist/types/src/extensions/state.d.ts.map +1 -0
  157. package/dist/types/src/extensions/submit.d.ts +10 -0
  158. package/dist/types/src/extensions/submit.d.ts.map +1 -0
  159. package/dist/types/src/extensions/tags/extended-markdown.d.ts +10 -0
  160. package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -0
  161. package/dist/types/src/extensions/tags/extended-markdown.test.d.ts +2 -0
  162. package/dist/types/src/extensions/tags/extended-markdown.test.d.ts.map +1 -0
  163. package/dist/types/src/extensions/tags/fader.d.ts +12 -0
  164. package/dist/types/src/extensions/tags/fader.d.ts.map +1 -0
  165. package/dist/types/src/extensions/tags/index.d.ts +7 -0
  166. package/dist/types/src/extensions/tags/index.d.ts.map +1 -0
  167. package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
  168. package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
  169. package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
  170. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
  171. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
  172. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
  173. package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
  174. package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
  175. package/dist/types/src/extensions/tags/xml-tags.d.ts +117 -0
  176. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -0
  177. package/dist/types/src/extensions/tags/xml-util.d.ts +10 -0
  178. package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -0
  179. package/dist/types/src/extensions/tags/xml-util.test.d.ts +2 -0
  180. package/dist/types/src/extensions/tags/xml-util.test.d.ts.map +1 -0
  181. package/dist/types/src/index.d.ts +8 -0
  182. package/dist/types/src/index.d.ts.map +1 -0
  183. package/dist/types/src/styles/index.d.ts +2 -0
  184. package/dist/types/src/styles/index.d.ts.map +1 -0
  185. package/dist/types/src/styles/theme.d.ts +58 -0
  186. package/dist/types/src/styles/theme.d.ts.map +1 -0
  187. package/dist/types/src/types/index.d.ts +2 -0
  188. package/dist/types/src/types/index.d.ts.map +1 -0
  189. package/dist/types/src/types/types.d.ts +21 -0
  190. package/dist/types/src/types/types.d.ts.map +1 -0
  191. package/dist/types/src/util/cursor.d.ts +31 -0
  192. package/dist/types/src/util/cursor.d.ts.map +1 -0
  193. package/dist/types/src/util/debug.d.ts +17 -0
  194. package/dist/types/src/util/debug.d.ts.map +1 -0
  195. package/dist/types/src/util/decorations.d.ts +4 -0
  196. package/dist/types/src/util/decorations.d.ts.map +1 -0
  197. package/dist/types/src/util/dom.d.ts +10 -0
  198. package/dist/types/src/util/dom.d.ts.map +1 -0
  199. package/dist/types/src/util/facet.d.ts +3 -0
  200. package/dist/types/src/util/facet.d.ts.map +1 -0
  201. package/dist/types/src/util/index.d.ts +7 -0
  202. package/dist/types/src/util/index.d.ts.map +1 -0
  203. package/dist/types/src/util/util.d.ts +8 -0
  204. package/dist/types/src/util/util.d.ts.map +1 -0
  205. package/dist/types/tsconfig.tsbuildinfo +1 -0
  206. package/package.json +43 -44
  207. package/src/defaults.ts +33 -20
  208. package/src/extensions/annotations.ts +1 -1
  209. package/src/extensions/autocomplete/placeholder.ts +37 -18
  210. package/src/extensions/automerge/automerge.test.tsx +37 -11
  211. package/src/extensions/automerge/automerge.ts +5 -7
  212. package/src/extensions/blocks.ts +5 -5
  213. package/src/extensions/comments.ts +5 -6
  214. package/src/extensions/dnd.ts +2 -2
  215. package/src/extensions/factories.test.ts +88 -0
  216. package/src/extensions/factories.ts +32 -15
  217. package/src/extensions/folding.ts +5 -22
  218. package/src/extensions/index.ts +2 -3
  219. package/src/extensions/markdown/action.ts +0 -1
  220. package/src/extensions/markdown/bundle.ts +23 -9
  221. package/src/extensions/markdown/decorate.ts +15 -12
  222. package/src/extensions/markdown/formatting.ts +5 -10
  223. package/src/extensions/markdown/highlight.ts +15 -7
  224. package/src/extensions/markdown/link.ts +27 -33
  225. package/src/extensions/markdown/parser.test.ts +0 -1
  226. package/src/extensions/markdown/styles.ts +42 -9
  227. package/src/extensions/markdown/table.ts +24 -2
  228. package/src/extensions/outliner/outliner.test.ts +0 -1
  229. package/src/extensions/outliner/outliner.ts +3 -4
  230. package/src/extensions/outliner/tree.test.ts +0 -1
  231. package/src/extensions/preview/preview.ts +62 -15
  232. package/src/extensions/scrolling/auto-scroll.ts +244 -0
  233. package/src/extensions/scrolling/crawler.ts +263 -0
  234. package/src/extensions/scrolling/index.ts +8 -0
  235. package/src/extensions/scrolling/scroll-past-end.ts +32 -0
  236. package/src/extensions/scrolling/scroller.ts +27 -0
  237. package/src/extensions/selection.ts +1 -1
  238. package/src/extensions/snippets.ts +67 -0
  239. package/src/extensions/tags/extended-markdown.test.ts +120 -2
  240. package/src/extensions/tags/extended-markdown.ts +80 -1
  241. package/src/extensions/tags/fader.ts +195 -0
  242. package/src/extensions/tags/index.ts +4 -1
  243. package/src/extensions/tags/testing/text.md +36 -0
  244. package/src/extensions/tags/testing/text.txt +35 -0
  245. package/src/extensions/tags/typewriter.test.ts +65 -0
  246. package/src/extensions/tags/typewriter.ts +594 -0
  247. package/src/extensions/tags/xml-block-decoration.ts +123 -0
  248. package/src/extensions/tags/xml-formatting.ts +125 -0
  249. package/src/extensions/tags/xml-tags.ts +186 -35
  250. package/src/extensions/tags/xml-util.test.ts +199 -24
  251. package/src/extensions/tags/xml-util.ts +62 -5
  252. package/src/index.ts +0 -1
  253. package/src/styles/index.ts +0 -2
  254. package/src/styles/theme.ts +125 -33
  255. package/src/types/types.ts +10 -2
  256. package/src/typings.d.ts +8 -0
  257. package/src/util/cursor.ts +1 -2
  258. package/src/extensions/autoscroll.ts +0 -165
  259. package/src/extensions/scrolling.ts +0 -189
  260. package/src/extensions/tags/streamer.ts +0 -243
  261. package/src/extensions/typewriter.ts +0 -68
  262. package/src/styles/markdown.ts +0 -26
  263. package/src/styles/tokens.ts +0 -17
@@ -0,0 +1,263 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { StateEffect } from '@codemirror/state';
6
+ import { EditorView, ViewPlugin } from '@codemirror/view';
7
+
8
+ import { log } from '@dxos/log';
9
+
10
+ /**
11
+ * Parameters for the scroll to line effect.
12
+ */
13
+ export type ScrollToProps = {
14
+ /**
15
+ * Zero-based line number; -1 for end of document.
16
+ * NOTE: view.state.doc.lineAt() uses 1-based line numbers.
17
+ */
18
+ line: number;
19
+
20
+ /**
21
+ * Additional offset from the target line in pixels.
22
+ * Positive values scroll past the line, negative values stop before it.
23
+ * @default 0
24
+ */
25
+ offset?: number;
26
+
27
+ /**
28
+ * Position of the target line in the viewport.
29
+ * - 'start': Line appears at the start (top) of the screen
30
+ * - 'end': Line appears at the end (bottom) of the screen
31
+ * @default 'start'
32
+ */
33
+ position?: 'start' | 'end';
34
+
35
+ /**
36
+ * Whether to use smooth scrolling.
37
+ * @default 'instant'
38
+ */
39
+ behavior?: ScrollBehavior;
40
+ };
41
+
42
+ /** Scroll to a specific line. */
43
+ export const crawlerLineEffect = StateEffect.define<ScrollToProps>();
44
+
45
+ /** Start/stop crawling the end of the document. */
46
+ export const crawlerActiveEffect = StateEffect.define<boolean>();
47
+
48
+ /**
49
+ * Helper function to scroll to a specific line.
50
+ * This is a convenience function that can be used directly with an EditorView.
51
+ */
52
+ export const scrollToLine = (view: EditorView, options: ScrollToProps) => {
53
+ view.dispatch({
54
+ effects: crawlerLineEffect.of(options),
55
+ });
56
+ };
57
+
58
+ export type CrawlerOptions = {
59
+ /** Threshold in px to trigger scroll from bottom. */
60
+ overScroll?: number;
61
+ };
62
+
63
+ /**
64
+ * Imperative scroll-control primitive for streaming editor views.
65
+ *
66
+ * Owns the scroll-related effects (`crawlerLineEffect`, `crawlerActiveEffect`), the spring
67
+ * crawler that follows the bottom of the document, and the `.cm-scroller` theme (overflow,
68
+ * scrollbar styling, and the `::after` overscroll spacer).
69
+ *
70
+ * Use directly for jump-to-line navigation, or pair with `autoScroll` for a pin-to-bottom
71
+ * streaming policy. The composite `streamScroll` bundles both for the common case.
72
+ */
73
+ export const crawler = ({ overScroll = 0 }: CrawlerOptions = {}) => {
74
+ // ViewPlugin to manage scroll animations.
75
+ const crawlerPlugin = ViewPlugin.fromClass(
76
+ class CrawlerPlugin {
77
+ private readonly crawler: ReturnType<typeof createCrawler>;
78
+ constructor(private readonly view: EditorView) {
79
+ this.crawler = createCrawler(this.view);
80
+ }
81
+
82
+ // No-op.
83
+ destroy() {
84
+ this.crawler.cancel();
85
+ }
86
+
87
+ cancel() {
88
+ this.crawler.cancel();
89
+ }
90
+
91
+ crawl(start = false) {
92
+ if (start) {
93
+ this.crawler.scroll();
94
+ } else {
95
+ this.crawler.cancel();
96
+ }
97
+ }
98
+
99
+ scroll({ line, offset = 0, position, behavior = 'instant' }: ScrollToProps) {
100
+ const { scrollTop, scrollHeight, clientHeight } = this.view.scrollDOM;
101
+ const scrollerRect = this.view.scrollDOM.getBoundingClientRect();
102
+ const doc = this.view.state.doc;
103
+
104
+ let targetScrollTop = scrollHeight - clientHeight + offset;
105
+ if (line >= 0 && line <= doc.lines - 1) {
106
+ const lineStart = doc.line(line + 1).from;
107
+ const coords = this.view.coordsAtPos(lineStart);
108
+ if (coords) {
109
+ // Calculate target scroll position based on position option.
110
+ const currentScrollTop = scrollTop;
111
+ const maxScrollTop = scrollHeight - clientHeight;
112
+
113
+ if (position === 'end') {
114
+ // Position line at end (bottom) of viewport.
115
+ // Calculate how far down we need to scroll so the line's bottom aligns with viewport bottom.
116
+ targetScrollTop = currentScrollTop + coords.bottom - scrollerRect.bottom + offset;
117
+ } else {
118
+ // Default: position line at start (top) of viewport.
119
+ targetScrollTop = currentScrollTop + coords.top - scrollerRect.top + offset;
120
+ }
121
+
122
+ // Clamp to valid scroll range.
123
+ targetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
124
+ }
125
+ }
126
+
127
+ // TODO(burdon): Smooth scrolling doesn't work when the document is being streamed into.
128
+ requestAnimationFrame(() => {
129
+ this.view.scrollDOM.scrollTo({ top: targetScrollTop }); //, behavior });
130
+ });
131
+ }
132
+ },
133
+ );
134
+
135
+ return [
136
+ crawlerPlugin,
137
+
138
+ // Listen for effect.
139
+ EditorView.updateListener.of((update) => {
140
+ update.transactions.forEach((transaction) => {
141
+ try {
142
+ const plugin = update.view.plugin(crawlerPlugin);
143
+ if (plugin) {
144
+ for (const effect of transaction.effects) {
145
+ if (effect.is(crawlerActiveEffect)) {
146
+ plugin.crawl(effect.value);
147
+ } else if (effect.is(crawlerLineEffect)) {
148
+ plugin.scroll(effect.value);
149
+ }
150
+ }
151
+ }
152
+ } catch (err) {
153
+ log.catch(err);
154
+ }
155
+ });
156
+ }),
157
+
158
+ // Styles.
159
+ EditorView.theme({
160
+ '.cm-scroller': {
161
+ overflowY: 'scroll',
162
+ // Browser scroll-anchoring: when widgets above the viewport resize (e.g. tool blocks
163
+ // expanding their TogglePanel), the browser picks a stable element near the viewport
164
+ // top and adjusts `scrollTop` so the user's view doesn't jump. Auto-scroll's pinning
165
+ // logic still has the final word when pinned (forces scrollTop to scrollHeight).
166
+ overflowAnchor: 'auto',
167
+ },
168
+ '.cm-scroller.cm-hide-scrollbar::-webkit-scrollbar': {
169
+ display: 'none',
170
+ },
171
+ '.cm-scroller::-webkit-scrollbar-thumb': {
172
+ background: 'transparent',
173
+ transition: 'background 0.15s',
174
+ },
175
+ '&:hover .cm-scroller::-webkit-scrollbar-thumb': {
176
+ background: 'var(--color-scrollbar-thumb)',
177
+ },
178
+ // Spacer below the last text line. Implemented as a real block pseudo-element
179
+ // (rather than `padding-bottom` on `.cm-content`) so it materializes in the
180
+ // scroller's `scrollHeight` regardless of how `padding` is reset by the base
181
+ // theme or downstream classes — this is what gives auto-scroll its head-room
182
+ // so the last line stays `overScroll` px above the viewport bottom.
183
+ '.cm-content::after': {
184
+ content: '""',
185
+ display: 'block',
186
+ height: `${overScroll}px`,
187
+ },
188
+ '.cm-scroll-button': {
189
+ position: 'absolute',
190
+ bottom: '0.5rem',
191
+ right: '1rem',
192
+ },
193
+ }),
194
+ ];
195
+ };
196
+
197
+ /**
198
+ * Creates a smooth crawler that follows the live bottom of a CodeMirror 6 EditorView.
199
+ *
200
+ * Uses a critically-damped spring: each frame applies a restoring force toward the
201
+ * target and a damping force opposing current velocity. With damping = 2·ω, the
202
+ * system is critically damped — fastest approach without overshoot. The spring
203
+ * naturally sprints when far behind and eases as it approaches, so streaming
204
+ * content is followed tightly without the jerk of explicit accel/decel state.
205
+ *
206
+ * Integration uses real elapsed wall-clock time so the perceived speed stays
207
+ * constant when requestAnimationFrame is throttled (e.g. low-power mode dropping
208
+ * from 60Hz to 30Hz).
209
+ *
210
+ * @param omega Spring stiffness in rad/s. Higher = snappier follow. ~12–18 feels good.
211
+ * @param snapThreshold Snap-to-target distance threshold in px.
212
+ * @param snapVelocity Snap-to-target velocity threshold in px/s.
213
+ */
214
+ export function createCrawler(view: EditorView, omega = 5, snapThreshold = 5, snapVelocity = 50) {
215
+ const el = view.scrollDOM;
216
+
217
+ let currentTop = 0;
218
+ let velocity = 0;
219
+ let rafId: number | null = null;
220
+ let lastTime = 0;
221
+
222
+ function frame(now: number) {
223
+ // Clamp dt to handle long pauses (tab backgrounded) and the first frame.
224
+ const dt = lastTime === 0 ? 1 / 60 : Math.min(0.1, (now - lastTime) / 1000);
225
+ lastTime = now;
226
+
227
+ const targetTop = el.scrollHeight - el.clientHeight;
228
+ const delta = targetTop - currentTop;
229
+ if (Math.abs(delta) < snapThreshold && Math.abs(velocity) < snapVelocity) {
230
+ el.scrollTop = targetTop;
231
+ currentTop = targetTop;
232
+ velocity = 0;
233
+ rafId = null;
234
+ lastTime = 0;
235
+ return;
236
+ }
237
+
238
+ // Critically-damped spring: a = ω²·delta − 2ω·v.
239
+ const accel = omega * omega * delta - 2 * omega * velocity;
240
+ velocity += accel * dt;
241
+ currentTop += velocity * dt;
242
+ el.scrollTop = currentTop;
243
+ rafId = requestAnimationFrame(frame);
244
+ }
245
+
246
+ return {
247
+ scroll: () => {
248
+ if (rafId === null) {
249
+ currentTop = el.scrollTop;
250
+ lastTime = 0;
251
+ rafId = requestAnimationFrame(frame);
252
+ }
253
+ },
254
+ cancel: () => {
255
+ if (rafId !== null) {
256
+ cancelAnimationFrame(rafId);
257
+ velocity = 0;
258
+ lastTime = 0;
259
+ rafId = null;
260
+ }
261
+ },
262
+ };
263
+ }
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './auto-scroll';
6
+ export * from './crawler';
7
+ export * from './scroll-past-end';
8
+ export * from './scroller';
@@ -0,0 +1,32 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView, ViewPlugin } from '@codemirror/view';
7
+
8
+ /**
9
+ * Custom scroll-past-end extension that accounts for the actual height of the last line.
10
+ * The built-in CodeMirror `scrollPastEnd` uses `defaultLineHeight` which doesn't account
11
+ * for taller elements like headings, block widgets, etc.
12
+ */
13
+ const scrollPastEndPlugin = ViewPlugin.fromClass(
14
+ class {
15
+ _height = 1_000;
16
+ _attrs: { style: string } | null = { style: `padding-bottom: ${this._height}px` };
17
+
18
+ update({ view }: { view: EditorView }) {
19
+ const lastLineBlock = view.lineBlockAt(view.state.doc.length);
20
+ const height = view.dom.clientHeight - lastLineBlock.height - view.documentPadding.top - 0.5;
21
+ if (height >= 0 && height !== this._height) {
22
+ this._height = height;
23
+ this._attrs = { style: `padding-bottom: ${height}px` };
24
+ }
25
+ }
26
+ },
27
+ );
28
+
29
+ export const scrollPastEnd = (): Extension => [
30
+ scrollPastEndPlugin,
31
+ EditorView.contentAttributes.of((view) => view.plugin(scrollPastEndPlugin)?._attrs ?? null),
32
+ ];
@@ -0,0 +1,27 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+
7
+ import { isTruthy } from '@dxos/util';
8
+
9
+ import { type AutoScrollProps, autoScroll as autoScrollExtension } from './auto-scroll';
10
+ import { type CrawlerOptions, crawler } from './crawler';
11
+
12
+ export type ScrollerOptions = CrawlerOptions &
13
+ AutoScrollProps & {
14
+ /**
15
+ * Include the auto-scroll policy (pin-to-bottom, user-scroll unpin, scroll-to-bottom button).
16
+ * Set to `false` to get only the crawler primitive (line jumps, theme, overscroll spacer).
17
+ * @default true
18
+ */
19
+ autoScroll?: boolean;
20
+ };
21
+
22
+ /**
23
+ * Composite scroll extension for streaming editor views (chat threads, transcripts, logs).
24
+ */
25
+ export const scroller = ({ overScroll, scrollOnResize, autoScroll = true }: ScrollerOptions = {}): Extension[] => {
26
+ return [crawler({ overScroll }), autoScroll && autoScrollExtension({ scrollOnResize })].filter(isTruthy);
27
+ };
@@ -31,7 +31,7 @@ export type EditorStateStore = {
31
31
  getState: (id: string) => EditorSelectionState | undefined;
32
32
  };
33
33
 
34
- const stateRestoreAnnotation = 'dxos.org/cm/state-restore';
34
+ const stateRestoreAnnotation = 'org.dxos.cm.state-restore';
35
35
 
36
36
  export const createEditorStateTransaction = ({ scrollTo, selection }: EditorSelectionState): TransactionSpec => {
37
37
  return {
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+
8
+ const defaultItems = ['hello world!', 'this is a test.', 'this is [DXOS](https://dxos.org)'];
9
+
10
+ export type SnippetsOptions = {
11
+ delay?: number;
12
+ items?: string[];
13
+ };
14
+
15
+ /**
16
+ * Configurable plugin that lets users cycle through pre-configured input snippets.
17
+ */
18
+ // TODO(burdon): Review https://github.com/sergeche/codemirror-movie?tab=readme-ov-file
19
+ export const snippets = ({ delay = 75, items = defaultItems }: SnippetsOptions = {}): Extension => {
20
+ let timer: ReturnType<typeof setTimeout> | undefined;
21
+ let index = 0; // TODO(burdon): Make global.
22
+
23
+ return [
24
+ keymap.of([
25
+ {
26
+ // Reset.
27
+ key: "alt-meta-'",
28
+ run: () => {
29
+ clearTimeout(timer);
30
+ index = 0;
31
+ return true;
32
+ },
33
+ },
34
+ {
35
+ // Next snippet.
36
+ // TODO(burdon): Press 1-9 to select snippet?
37
+ key: "Shift-Meta-'",
38
+ run: (view) => {
39
+ clearTimeout(timer);
40
+ // TODO(burdon): Add space if needed.
41
+ const text = items[index++];
42
+ if (index === items?.length) {
43
+ index = 0;
44
+ }
45
+
46
+ let offset = 0;
47
+ const insert = (delayMs = 0) => {
48
+ timer = setTimeout(() => {
49
+ const pos = view.state.selection.main.head;
50
+ view.dispatch({
51
+ changes: { from: pos, insert: text[offset++] },
52
+ selection: { anchor: pos + 1 },
53
+ });
54
+
55
+ if (offset < text.length) {
56
+ insert(Math.random() * delay * (text[offset] === ' ' ? 2 : 1));
57
+ }
58
+ }, delayMs);
59
+ };
60
+
61
+ insert();
62
+ return true;
63
+ },
64
+ },
65
+ ]),
66
+ ];
67
+ };
@@ -12,11 +12,24 @@ import { trim } from '@dxos/util';
12
12
  import { extendedMarkdown } from './extended-markdown';
13
13
  import { nodeToJson } from './xml-util';
14
14
 
15
+ const testRegistry = {
16
+ prompt: { block: true },
17
+ suggestion: { block: true },
18
+ choice: { block: true },
19
+ toolkit: { block: true },
20
+ toolCall: { block: true },
21
+ summary: { block: true },
22
+ reasoning: { block: true },
23
+ stats: { block: true },
24
+ reference: { block: false },
25
+ select: { block: true },
26
+ };
27
+
15
28
  describe('extended-markdown', () => {
16
- const createEditorState = (doc: string) => {
29
+ const createEditorState = (doc: string, registry?: Record<string, any>) => {
17
30
  return EditorState.create({
18
31
  doc,
19
- extensions: [extendedMarkdown()],
32
+ extensions: [extendedMarkdown({ registry: registry ?? testRegistry })],
20
33
  });
21
34
  };
22
35
 
@@ -121,6 +134,111 @@ describe('extended-markdown', () => {
121
134
  });
122
135
  });
123
136
 
137
+ test('self-closing tags with attributes should not consume subsequent content', ({ expect }) => {
138
+ const doc = trim`
139
+ <toolCall id="toolu_01ABC123" />
140
+
141
+ ## Architecture
142
+
143
+ Regular paragraph.
144
+ `;
145
+
146
+ const state = createEditorState(doc);
147
+ const tree = syntaxTree(state);
148
+
149
+ const nodeNames: string[] = [];
150
+ tree.iterate({
151
+ enter: (node) => {
152
+ nodeNames.push(node.name);
153
+ },
154
+ });
155
+
156
+ // The heading must be parsed as ATXHeading2, not swallowed into an HTMLBlock.
157
+ expect(nodeNames).toContain('ATXHeading2');
158
+ });
159
+
160
+ test('self-closing tags without attributes should parse correctly', ({ expect }) => {
161
+ const doc = trim`
162
+ <toolkit />
163
+
164
+ ## Heading After Self-Close
165
+
166
+ Paragraph text.
167
+ `;
168
+
169
+ const state = createEditorState(doc);
170
+ const tree = syntaxTree(state);
171
+
172
+ const nodeNames: string[] = [];
173
+ tree.iterate({
174
+ enter: (node) => {
175
+ nodeNames.push(node.name);
176
+ },
177
+ });
178
+
179
+ expect(nodeNames).toContain('ATXHeading2');
180
+ });
181
+
182
+ test('self-closing tags with trailing text should not consume subsequent content', ({ expect }) => {
183
+ const doc = trim`
184
+ <toolkit /> trailing text
185
+
186
+ ## Heading After Trailing
187
+ `;
188
+
189
+ const state = createEditorState(doc);
190
+ const tree = syntaxTree(state);
191
+
192
+ const nodeNames: string[] = [];
193
+ tree.iterate({
194
+ enter: (node) => {
195
+ nodeNames.push(node.name);
196
+ },
197
+ });
198
+
199
+ // The trailing text line should not be swallowed as an HTMLBlock.
200
+ expect(nodeNames).toContain('ATXHeading2');
201
+ });
202
+
203
+ test('mixed self-closing and block tags should not break markdown parsing', ({ expect }) => {
204
+ const doc = trim`
205
+ <prompt>What is markdown?</prompt>
206
+
207
+ <reasoning>
208
+ The user is asking about markdown.
209
+ </reasoning>
210
+
211
+ Some explanation text.
212
+
213
+ <toolCall id="toolu_01ABC123analyze" />
214
+
215
+ <summary>Analyzed 42 files.</summary>
216
+
217
+ ## Architecture
218
+
219
+ The project follows a modular pattern.
220
+
221
+ <stats>3 tool uses · 12.4k tokens</stats>
222
+
223
+ ### Sub-heading
224
+
225
+ More text.
226
+ `;
227
+
228
+ const state = createEditorState(doc);
229
+ const tree = syntaxTree(state);
230
+
231
+ const nodeNames: string[] = [];
232
+ tree.iterate({
233
+ enter: (node) => {
234
+ nodeNames.push(node.name);
235
+ },
236
+ });
237
+
238
+ expect(nodeNames).toContain('ATXHeading2');
239
+ expect(nodeNames).toContain('ATXHeading3');
240
+ });
241
+
124
242
  //
125
243
  // TODO(burdon): All tests below should test the tree.
126
244
  //
@@ -5,15 +5,18 @@
5
5
  import { xmlLanguage } from '@codemirror/lang-xml';
6
6
  import { type Extension } from '@codemirror/state';
7
7
  import { type ParseWrapper, parseMixed } from '@lezer/common';
8
+ import { type BlockParser } from '@lezer/markdown';
8
9
 
9
10
  import { createMarkdownExtensions } from '../markdown';
10
-
11
11
  import { type XmlWidgetRegistry } from './xml-tags';
12
12
 
13
13
  export type ExtendedMarkdownOptions = {
14
14
  registry?: XmlWidgetRegistry;
15
15
  };
16
16
 
17
+ /** Escapes a string for safe embedding in RegExp source. */
18
+ const escapeRegExpSource = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+
17
20
  /**
18
21
  * Extended markdown parser with mixed parser for custom rendering of XML tags.
19
22
  */
@@ -29,6 +32,9 @@ export const extendedMarkdown = ({ registry }: ExtendedMarkdownOptions = {}): Ex
29
32
  name: 'SetextHeading',
30
33
  parse: () => false,
31
34
  },
35
+ // Custom XML block parser that keeps registered tags as a single HTMLBlock
36
+ // even when their content contains blank lines.
37
+ ...xmlBlockParsers(registry),
32
38
  ],
33
39
  },
34
40
  ],
@@ -36,6 +42,79 @@ export const extendedMarkdown = ({ registry }: ExtendedMarkdownOptions = {}): Ex
36
42
  ];
37
43
  };
38
44
 
45
+ /**
46
+ * Creates block parsers for registered XML tags that may contain blank lines.
47
+ *
48
+ * By default, the markdown parser treats custom HTML tags as type 6 HTML blocks,
49
+ * which end at blank lines. This causes tags like `<reasoning>...\n\n...</reasoning>`
50
+ * to be split into separate blocks, preventing the XML mixed parser from seeing the
51
+ * full element. This custom parser consumes all lines (including blanks) until the
52
+ * matching closing tag, emitting a single HTMLBlock.
53
+ */
54
+ const xmlBlockParsers = (registry?: XmlWidgetRegistry): BlockParser[] => {
55
+ const customTags = Object.keys(registry ?? {});
56
+ if (customTags.length === 0) {
57
+ return [];
58
+ }
59
+
60
+ const tagPattern = customTags.map(escapeRegExpSource).join('|');
61
+ const selfClosePattern = new RegExp(`^\\s*<(${tagPattern})(\\s[^>]*)?\\/>\\s*$`);
62
+ const openPattern = new RegExp(`^\\s*<(${tagPattern})(\\s[^>]*)?\\/?>`);
63
+
64
+ return [
65
+ {
66
+ name: 'XMLBlock',
67
+ before: 'HTMLBlock',
68
+ parse: (cx, line) => {
69
+ const match = openPattern.exec(line.text);
70
+ if (!match) {
71
+ return false;
72
+ }
73
+
74
+ // Self-closing tag (e.g., `<tag />` or `<tag id="x" />`).
75
+ if (selfClosePattern.test(line.text)) {
76
+ const end = cx.lineStart + line.text.length;
77
+ cx.addElement(cx.elt('HTMLBlock', cx.lineStart, end));
78
+ cx.nextLine();
79
+ return true;
80
+ }
81
+
82
+ // Self-closing tag with trailing text (e.g., `<tag /> text`).
83
+ // Let markdown parse it as a normal paragraph.
84
+ if (match[0].trimEnd().endsWith('/>')) {
85
+ return false;
86
+ }
87
+
88
+ const tagName = match[1];
89
+ const closeTag = `</${tagName}>`;
90
+ const start = cx.lineStart;
91
+
92
+ // Check if closing tag is on the same line.
93
+ if (line.text.includes(closeTag)) {
94
+ cx.addElement(cx.elt('HTMLBlock', start, start + line.text.length));
95
+ cx.nextLine();
96
+ return true;
97
+ }
98
+
99
+ // Consume lines (including blank lines) until the closing tag.
100
+ let end = cx.lineStart + line.text.length;
101
+ while (cx.nextLine()) {
102
+ end = cx.lineStart + line.text.length;
103
+ if (line.text.includes(closeTag)) {
104
+ cx.addElement(cx.elt('HTMLBlock', start, end));
105
+ cx.nextLine();
106
+ return true;
107
+ }
108
+ }
109
+
110
+ // Unclosed tag (e.g., still streaming) — emit what we have.
111
+ cx.addElement(cx.elt('HTMLBlock', start, end));
112
+ return true;
113
+ },
114
+ },
115
+ ];
116
+ };
117
+
39
118
  /**
40
119
  * Configure mixed parser to recognize custom tags.
41
120
  */