@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,261 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { StateEffect } from '@codemirror/state';
6
+ import { EditorView, ViewPlugin } from '@codemirror/view';
7
+
8
+ import { addEventListener, combine, throttle } from '@dxos/async';
9
+ import { Domino } from '@dxos/ui';
10
+ import { getSize } from '@dxos/ui-theme';
11
+
12
+ import { crawlerActiveEffect, crawlerLineEffect } from './crawler';
13
+
14
+ /** Enable or disable autoscroll. */
15
+ export const autoScrollEffect = StateEffect.define<boolean>();
16
+
17
+ export type AutoScrollProps = {
18
+ /**
19
+ * If true, immediately jump to the bottom and re-pin whenever the size of the
20
+ * document view (the scroll container) changes — e.g. when a sidebar toggles
21
+ * or the window is resized. This avoids the visible "stuck near bottom" gap
22
+ * that otherwise appears because the previous `scrollTop` no longer reaches
23
+ * the new content height.
24
+ * @default true
25
+ */
26
+ scrollOnResize?: boolean;
27
+ };
28
+
29
+ /**
30
+ * Extension that supports pinning the scroll position and automatically scrolls to the bottom when content is added.
31
+ */
32
+ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
33
+ let buttonContainer: HTMLDivElement | undefined;
34
+ let isPinned = true;
35
+ let jumpPending = false;
36
+ let enabled = true;
37
+ let firstUpdate = true;
38
+ // Latches once the thread streams real content. Until then (initial open of a populated
39
+ // thread, whose height grows only as widgets measure) we snap instantly; afterwards we follow
40
+ // the bottom smoothly — so the first paint jumps to the end, then streaming eases.
41
+ let streamed = false;
42
+
43
+ const setPinned = (pinned: boolean) => {
44
+ buttonContainer?.classList.toggle('opacity-0', pinned);
45
+ isPinned = pinned;
46
+ };
47
+
48
+ return [
49
+ // Update listener for scrolling when content changes.
50
+ EditorView.updateListener.of((update) => {
51
+ const { view, heightChanged, state, startState } = update;
52
+
53
+ // Handle enable/disable effect.
54
+ for (const tr of update.transactions) {
55
+ for (const effect of tr.effects) {
56
+ if (effect.is(autoScrollEffect)) {
57
+ enabled = effect.value;
58
+ if (enabled) {
59
+ setPinned(true);
60
+ view.dispatch({
61
+ effects: crawlerActiveEffect.of({ active: true }),
62
+ });
63
+ } else {
64
+ view.dispatch({
65
+ effects: crawlerActiveEffect.of({ active: false }),
66
+ });
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ if (!enabled) {
73
+ return;
74
+ }
75
+
76
+ // Jump to bottom instantly when content first appears (either inserted into
77
+ // an empty doc, or present as initialValue when the editor is created).
78
+ if (isPinned && (firstUpdate || startState.doc.length === 0) && state.doc.length > 0) {
79
+ firstUpdate = false;
80
+ jumpPending = true;
81
+ requestAnimationFrame(() => {
82
+ view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
83
+ jumpPending = false;
84
+ });
85
+ return;
86
+ }
87
+ firstUpdate = false;
88
+
89
+ // Suppress crawl while the initial jump is pending.
90
+ if (jumpPending) {
91
+ return;
92
+ }
93
+
94
+ // Maybe scroll if doc changed and pinned.
95
+ // NOTE: Geometry changed is triggered when widgets change height (e.g., toggle tool block).
96
+ if (heightChanged) {
97
+ if (isPinned) {
98
+ // NOTE: Use scroll geometry instead of coordsAtPos to avoid forced layout/scroll side-effects.
99
+ const { scrollTop, scrollHeight, clientHeight } = view.scrollDOM;
100
+ const delta = scrollHeight - scrollTop - clientHeight;
101
+ if (update.docChanged) {
102
+ streamed = true;
103
+ }
104
+ if (delta > 0) {
105
+ setPinned(true);
106
+ // Before the thread has streamed any real content, height grows only from layout
107
+ // (widgets measuring on open) — snap instantly so we don't animate through the
108
+ // backlog. Once content has streamed, follow the bottom smoothly with the spring,
109
+ // including reflow frames interleaved with token edits. Both keep following until
110
+ // the height stabilizes.
111
+ view.dispatch({ effects: crawlerActiveEffect.of({ active: true, instant: !streamed }) });
112
+ } else if (delta < -1) {
113
+ setPinned(false);
114
+ }
115
+ } else {
116
+ // TODO(burdon): Should re-pin if content shrinks.
117
+ if (state.doc.length === 0) {
118
+ setPinned(true);
119
+ }
120
+ }
121
+ }
122
+ }),
123
+
124
+ // Re-pin and jump to bottom when the scroll container itself resizes (e.g. sidebar toggle,
125
+ // window resize). Doc-driven height changes are handled by the updateListener above; this
126
+ // observer covers the case where the viewport changes while the doc length is unchanged.
127
+ scrollOnResize
128
+ ? ViewPlugin.fromClass(
129
+ class {
130
+ private readonly observer: ResizeObserver;
131
+ private firstObservation = true;
132
+ private destroyed = false;
133
+ constructor(view: EditorView) {
134
+ // Throttle so a continuous drag-resize (or a flurry of layout changes) coalesces
135
+ // into a single re-pin per ~50ms instead of dispatching every frame.
136
+ const onResize = throttle(() => {
137
+ if (this.destroyed || !enabled) {
138
+ return;
139
+ }
140
+
141
+ setPinned(true);
142
+ requestAnimationFrame(() => {
143
+ if (this.destroyed) {
144
+ return;
145
+ }
146
+
147
+ view.scrollDOM.scrollTo({ top: view.scrollDOM.scrollHeight, behavior: 'instant' });
148
+ view.dispatch({ effects: crawlerActiveEffect.of({ active: false }) });
149
+ });
150
+ }, 50);
151
+
152
+ this.observer = new ResizeObserver(() => {
153
+ // Skip the initial fire that ResizeObserver emits on `observe()`.
154
+ if (this.firstObservation) {
155
+ this.firstObservation = false;
156
+ return;
157
+ }
158
+ onResize();
159
+ });
160
+
161
+ this.observer.observe(view.scrollDOM);
162
+ }
163
+ destroy() {
164
+ this.destroyed = true;
165
+ this.observer.disconnect();
166
+ }
167
+ },
168
+ )
169
+ : [],
170
+
171
+ // Detect user scroll and unpin (or re-pin if scrolled to the bottom).
172
+ ViewPlugin.fromClass(
173
+ class {
174
+ private readonly cleanup: () => void;
175
+ constructor(view: EditorView) {
176
+ // Re-pin check is throttled so the listener doesn't thrash while scrolling, but
177
+ // unpinning must be immediate — otherwise content arriving during the throttle
178
+ // window re-applies the crawl effect and yanks the viewport back to the bottom.
179
+ const onUserScroll = throttle(() => {
180
+ requestAnimationFrame(() => {
181
+ const { scrollTop, scrollHeight, clientHeight } = view.scrollDOM;
182
+ const delta = scrollHeight - scrollTop - clientHeight;
183
+ // Sub-pixel tolerance: fractional scroll positions can leave delta at e.g. 0.5
184
+ // even when the user is visually at the bottom.
185
+ const pinned = Math.abs(delta) <= 1;
186
+ setPinned(pinned);
187
+ if (!pinned) {
188
+ view.dispatch({ effects: crawlerActiveEffect.of({ active: false }) });
189
+ }
190
+ });
191
+ }, 500);
192
+
193
+ this.cleanup = createUserScrollDetector(view.scrollDOM, () => {
194
+ if (isPinned) {
195
+ setPinned(false);
196
+ view.dispatch({ effects: crawlerActiveEffect.of({ active: false }) });
197
+ }
198
+ onUserScroll();
199
+ });
200
+ }
201
+ destroy() {
202
+ this.cleanup();
203
+ }
204
+ },
205
+ ),
206
+
207
+ // Scroll button.
208
+ ViewPlugin.fromClass(
209
+ class {
210
+ constructor(view: EditorView) {
211
+ const icon = Domino.of('dx-icon' as any)
212
+ .classNames(getSize(4))
213
+ .attributes({ icon: 'ph--arrow-down--regular' });
214
+ const button = Domino.of('button')
215
+ .classNames('dx-button bg-accent-bg')
216
+ .attributes({ 'data-density': 'md' })
217
+ .append(icon)
218
+ .on('click', () => {
219
+ setPinned(true);
220
+ view.dispatch({
221
+ effects: [
222
+ crawlerLineEffect.of({ line: -1, position: 'end', behavior: 'smooth' }),
223
+ // Re-engage the follower so it keeps tracking the bottom as content continues
224
+ // to stream after the catch-up jump.
225
+ crawlerActiveEffect.of({ active: true, instant: !streamed }),
226
+ ],
227
+ });
228
+ });
229
+
230
+ buttonContainer = Domino.of('div')
231
+ .classNames('cm-scroll-button transition-opacity duration-300 opacity-0 z-1')
232
+ .append(button).root as HTMLDivElement;
233
+
234
+ view.scrollDOM.parentElement!.appendChild(buttonContainer);
235
+ }
236
+ },
237
+ ),
238
+ ];
239
+ };
240
+
241
+ /**
242
+ * Attaches listeners to detect genuine user-initiated scrolling on an element.
243
+ * Two sources are covered:
244
+ * - `wheel`: fires only from physical mouse wheel / trackpad gestures.
245
+ * - `pointerdown` on the scrollbar gutter: detected by comparing clientX to
246
+ * the element's clientWidth (the content area, excluding the scrollbar).
247
+ * Returns a cleanup function that removes the listeners.
248
+ */
249
+ // TODO(burdon): Still jumps when widgets are rendered.
250
+ // - Track position of specific element/line in document and scroll relative to that.
251
+ function createUserScrollDetector(element: HTMLElement, onUserScroll: () => void): () => void {
252
+ return combine(
253
+ addEventListener(element, 'wheel', () => onUserScroll(), { passive: true }),
254
+ addEventListener(element, 'pointerdown', (event) => {
255
+ // If the pointer lands beyond the content width it hit the scrollbar gutter.
256
+ if (event.clientX > element.getBoundingClientRect().right - (element.offsetWidth - element.clientWidth)) {
257
+ onUserScroll();
258
+ }
259
+ }),
260
+ );
261
+ }
@@ -40,10 +40,15 @@ export type ScrollToProps = {
40
40
  };
41
41
 
42
42
  /** Scroll to a specific line. */
43
- export const scrollerLineEffect = StateEffect.define<ScrollToProps>();
43
+ export const crawlerLineEffect = StateEffect.define<ScrollToProps>();
44
44
 
45
- /** Start/stop crawling the end of the document. */
46
- export const scrollerCrawlEffect = StateEffect.define<boolean>();
45
+ /**
46
+ * Start/stop crawling the end of the document.
47
+ * `instant: true` snaps to the bottom each frame (no easing) — used for non-streaming height
48
+ * growth (e.g. widgets measuring when a populated thread is first opened); the default eases
49
+ * with the spring for live streaming.
50
+ */
51
+ export const crawlerActiveEffect = StateEffect.define<{ active: boolean; instant?: boolean }>();
47
52
 
48
53
  /**
49
54
  * Helper function to scroll to a specific line.
@@ -51,22 +56,29 @@ export const scrollerCrawlEffect = StateEffect.define<boolean>();
51
56
  */
52
57
  export const scrollToLine = (view: EditorView, options: ScrollToProps) => {
53
58
  view.dispatch({
54
- effects: scrollerLineEffect.of(options),
59
+ effects: crawlerLineEffect.of(options),
55
60
  });
56
61
  };
57
62
 
58
- export type ScrollerOptions = {
63
+ export type CrawlerOptions = {
59
64
  /** Threshold in px to trigger scroll from bottom. */
60
65
  overScroll?: number;
61
66
  };
62
67
 
63
68
  /**
64
- * Extension that provides smooth scrolling to specific lines in the editor.
69
+ * Imperative scroll-control primitive for streaming editor views.
70
+ *
71
+ * Owns the scroll-related effects (`crawlerLineEffect`, `crawlerActiveEffect`), the spring
72
+ * crawler that follows the bottom of the document, and the `.cm-scroller` theme (overflow,
73
+ * scrollbar styling, and the `::after` overscroll spacer).
74
+ *
75
+ * Use directly for jump-to-line navigation, or pair with `autoScroll` for a pin-to-bottom
76
+ * streaming policy. The composite `streamScroll` bundles both for the common case.
65
77
  */
66
- export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
78
+ export const crawler = ({ overScroll = 0 }: CrawlerOptions = {}) => {
67
79
  // ViewPlugin to manage scroll animations.
68
- const scrollPlugin = ViewPlugin.fromClass(
69
- class ScrollerPlugin {
80
+ const crawlerPlugin = ViewPlugin.fromClass(
81
+ class CrawlerPlugin {
70
82
  private readonly crawler: ReturnType<typeof createCrawler>;
71
83
  constructor(private readonly view: EditorView) {
72
84
  this.crawler = createCrawler(this.view);
@@ -81,9 +93,9 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
81
93
  this.crawler.cancel();
82
94
  }
83
95
 
84
- crawl(start = false) {
85
- if (start) {
86
- this.crawler.scroll();
96
+ crawl({ active, instant }: { active: boolean; instant?: boolean } = { active: false }) {
97
+ if (active) {
98
+ this.crawler.scroll(instant);
87
99
  } else {
88
100
  this.crawler.cancel();
89
101
  }
@@ -126,18 +138,18 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
126
138
  );
127
139
 
128
140
  return [
129
- scrollPlugin,
141
+ crawlerPlugin,
130
142
 
131
- // Listen for effect.s
143
+ // Listen for effect.
132
144
  EditorView.updateListener.of((update) => {
133
145
  update.transactions.forEach((transaction) => {
134
146
  try {
135
- const plugin = update.view.plugin(scrollPlugin);
147
+ const plugin = update.view.plugin(crawlerPlugin);
136
148
  if (plugin) {
137
149
  for (const effect of transaction.effects) {
138
- if (effect.is(scrollerCrawlEffect)) {
150
+ if (effect.is(crawlerActiveEffect)) {
139
151
  plugin.crawl(effect.value);
140
- } else if (effect.is(scrollerLineEffect)) {
152
+ } else if (effect.is(crawlerLineEffect)) {
141
153
  plugin.scroll(effect.value);
142
154
  }
143
155
  }
@@ -150,13 +162,13 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
150
162
 
151
163
  // Styles.
152
164
  EditorView.theme({
153
- '.cm-content': {
154
- paddingBottom: `${overScroll}px`,
155
- },
156
165
  '.cm-scroller': {
157
166
  overflowY: 'scroll',
158
- overflowAnchor: 'none',
159
- paddingBottom: '0',
167
+ // Browser scroll-anchoring: when widgets above the viewport resize (e.g. tool blocks
168
+ // expanding their TogglePanel), the browser picks a stable element near the viewport
169
+ // top and adjusts `scrollTop` so the user's view doesn't jump. Auto-scroll's pinning
170
+ // logic still has the final word when pinned (forces scrollTop to scrollHeight).
171
+ overflowAnchor: 'auto',
160
172
  },
161
173
  '.cm-scroller.cm-hide-scrollbar::-webkit-scrollbar': {
162
174
  display: 'none',
@@ -168,6 +180,16 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
168
180
  '&:hover .cm-scroller::-webkit-scrollbar-thumb': {
169
181
  background: 'var(--color-scrollbar-thumb)',
170
182
  },
183
+ // Spacer below the last text line. Implemented as a real block pseudo-element
184
+ // (rather than `padding-bottom` on `.cm-content`) so it materializes in the
185
+ // scroller's `scrollHeight` regardless of how `padding` is reset by the base
186
+ // theme or downstream classes — this is what gives auto-scroll its head-room
187
+ // so the last line stays `overScroll` px above the viewport bottom.
188
+ '.cm-content::after': {
189
+ content: '""',
190
+ display: 'block',
191
+ height: `${overScroll}px`,
192
+ },
171
193
  '.cm-scroll-button': {
172
194
  position: 'absolute',
173
195
  bottom: '0.5rem',
@@ -180,65 +202,84 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
180
202
  /**
181
203
  * Creates a smooth crawler that follows the live bottom of a CodeMirror 6 EditorView.
182
204
  *
183
- * Uses a velocity-based approach with easing:
184
- * - Accelerates smoothly when content starts arriving.
185
- * - Maintains a steady cruise velocity during continuous streaming.
186
- * - Decelerates smoothly when content stops arriving.
205
+ * Uses a critically-damped spring: each frame applies a restoring force toward the
206
+ * target and a damping force opposing current velocity. With damping = 2·ω, the
207
+ * system is critically damped fastest approach without overshoot. The spring
208
+ * naturally sprints when far behind and eases as it approaches, so streaming
209
+ * content is followed tightly without the jerk of explicit accel/decel state.
210
+ *
211
+ * Integration uses real elapsed wall-clock time so the perceived speed stays
212
+ * constant when requestAnimationFrame is throttled (e.g. low-power mode dropping
213
+ * from 60Hz to 30Hz).
187
214
  *
188
- * @param accel Acceleration in px/frame^2 for ease-in/ease-out.
189
- * @param maxVelocity Maximum scroll velocity in px/frame.
190
- * @param snapThreshold Snap-to-target threshold in px.
215
+ * @param omega Spring stiffness in rad/s. Higher = snappier follow. ~12–18 feels good.
216
+ * @param snapThreshold Snap-to-target distance threshold in px.
217
+ * @param snapVelocity Snap-to-target velocity threshold in px/s.
191
218
  */
192
- export function createCrawler(view: EditorView, accel = 0.15, maxVelocity = 1, snapThreshold = 0.5) {
219
+ export function createCrawler(view: EditorView, omega = 5, snapThreshold = 5, snapVelocity = 50) {
193
220
  const el = view.scrollDOM;
194
221
 
195
222
  let currentTop = 0;
196
223
  let velocity = 0;
197
224
  let rafId: number | null = null;
225
+ let lastTime = 0;
226
+ let instant = false;
227
+
228
+ function frame(now: number) {
229
+ // Clamp dt to handle long pauses (tab backgrounded) and the first frame.
230
+ const dt = lastTime === 0 ? 1 / 60 : Math.min(0.1, (now - lastTime) / 1000);
231
+ lastTime = now;
198
232
 
199
- function frame() {
200
233
  const targetTop = el.scrollHeight - el.clientHeight;
201
234
  const delta = targetTop - currentTop;
202
- const absDelta = Math.abs(delta);
203
235
 
204
- if (absDelta < snapThreshold && Math.abs(velocity) < snapThreshold) {
236
+ // Instant mode: snap to the bottom each frame (no easing) and keep following while the
237
+ // height is still growing (e.g. widgets measuring on open), stopping once it stabilizes.
238
+ if (instant) {
205
239
  el.scrollTop = targetTop;
206
240
  currentTop = targetTop;
207
241
  velocity = 0;
208
- rafId = null;
242
+ if (Math.abs(delta) < snapThreshold) {
243
+ rafId = null;
244
+ lastTime = 0;
245
+ return;
246
+ }
247
+ rafId = requestAnimationFrame(frame);
209
248
  return;
210
249
  }
211
250
 
212
- // Stopping distance at current velocity: v^2 / (2 * accel).
213
- const stoppingDistance = (velocity * velocity) / (2 * accel);
214
- const direction = Math.sign(delta);
215
-
216
- if (velocity !== 0 && (absDelta <= stoppingDistance || direction !== Math.sign(velocity))) {
217
- // Decelerate: close enough to target or moving the wrong way.
218
- velocity -= Math.sign(velocity) * Math.min(accel, Math.abs(velocity));
219
- } else {
220
- // Accelerate toward target, capped at maxVelocity.
221
- velocity += direction * accel;
222
- velocity = Math.sign(velocity) * Math.min(Math.abs(velocity), maxVelocity);
251
+ if (Math.abs(delta) < snapThreshold && Math.abs(velocity) < snapVelocity) {
252
+ el.scrollTop = targetTop;
253
+ currentTop = targetTop;
254
+ velocity = 0;
255
+ rafId = null;
256
+ lastTime = 0;
257
+ return;
223
258
  }
224
259
 
225
- currentTop += velocity;
260
+ // Critically-damped spring: a = ω²·delta − 2ω·v.
261
+ const accel = omega * omega * delta - 2 * omega * velocity;
262
+ velocity += accel * dt;
263
+ currentTop += velocity * dt;
226
264
  el.scrollTop = currentTop;
227
265
  rafId = requestAnimationFrame(frame);
228
266
  }
229
267
 
230
268
  return {
231
- scroll: () => {
269
+ scroll: (useInstant = false) => {
270
+ instant = useInstant;
232
271
  if (rafId === null) {
233
272
  currentTop = el.scrollTop;
273
+ lastTime = 0;
234
274
  rafId = requestAnimationFrame(frame);
235
275
  }
236
276
  },
237
277
  cancel: () => {
238
278
  if (rafId !== null) {
239
279
  cancelAnimationFrame(rafId);
240
- rafId = null;
241
280
  velocity = 0;
281
+ lastTime = 0;
282
+ rafId = null;
242
283
  }
243
284
  },
244
285
  };
@@ -0,0 +1,9 @@
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 './scrollbar-autohide';
9
+ export * from './scroller';
@@ -12,15 +12,15 @@ import { EditorView, ViewPlugin } from '@codemirror/view';
12
12
  */
13
13
  const scrollPastEndPlugin = ViewPlugin.fromClass(
14
14
  class {
15
- height = 1000;
16
- attrs: { style: string } | null = { style: 'padding-bottom: 1000px' };
15
+ _height = 1_000;
16
+ _attrs: { style: string } | null = { style: `padding-bottom: ${this._height}px` };
17
17
 
18
18
  update({ view }: { view: EditorView }) {
19
19
  const lastLineBlock = view.lineBlockAt(view.state.doc.length);
20
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` };
21
+ if (height >= 0 && height !== this._height) {
22
+ this._height = height;
23
+ this._attrs = { style: `padding-bottom: ${height}px` };
24
24
  }
25
25
  }
26
26
  },
@@ -28,5 +28,5 @@ const scrollPastEndPlugin = ViewPlugin.fromClass(
28
28
 
29
29
  export const scrollPastEnd = (): Extension => [
30
30
  scrollPastEndPlugin,
31
- EditorView.contentAttributes.of((view) => view.plugin(scrollPastEndPlugin)?.attrs ?? null),
31
+ EditorView.contentAttributes.of((view) => view.plugin(scrollPastEndPlugin)?._attrs ?? null),
32
32
  ];
@@ -0,0 +1,61 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView, ViewPlugin } from '@codemirror/view';
7
+
8
+ export type ScrollbarAutohideOptions = {
9
+ /**
10
+ * Milliseconds of scroll inactivity before the scrollbar fades out again.
11
+ * @default 800
12
+ */
13
+ timeout?: number;
14
+ };
15
+
16
+ /**
17
+ * macOS-style overlay scrollbar: the thumb is hidden until the user scrolls, then fades out after a
18
+ * short idle `timeout`. Opt-in — include it in the editor's extension list. Independent of the
19
+ * default hover behaviour in the base theme (which still reveals the thumb on hover).
20
+ */
21
+ export const scrollbarAutohide = ({ timeout = 800 }: ScrollbarAutohideOptions = {}): Extension => [
22
+ ViewPlugin.fromClass(
23
+ // NOTE: Uses TS `private`/plain fields rather than ES `#private`. CodeMirror's plugin lifecycle
24
+ // (and the source/prebundled double-load in dev) can invoke `destroy` with a `this` that the
25
+ // WeakMap-based `#private` transpilation rejects ("private field on non-instance"), crashing
26
+ // editor teardown. Plain fields avoid the membership check.
27
+ class {
28
+ private readonly _scroller: HTMLElement;
29
+ private _timer?: ReturnType<typeof setTimeout>;
30
+
31
+ constructor(view: EditorView) {
32
+ this._scroller = view.scrollDOM;
33
+ this._scroller.addEventListener('scroll', this._handleScroll, { passive: true });
34
+ }
35
+
36
+ destroy(): void {
37
+ this._scroller.removeEventListener('scroll', this._handleScroll);
38
+ clearTimeout(this._timer);
39
+ this._scroller.classList.remove('cm-scrolling');
40
+ }
41
+
42
+ // Show the thumb while scrolling; remove the class once scrolling has been idle for `timeout`.
43
+ private readonly _handleScroll = (): void => {
44
+ this._scroller.classList.add('cm-scrolling');
45
+ clearTimeout(this._timer);
46
+ this._timer = setTimeout(() => this._scroller.classList.remove('cm-scrolling'), timeout);
47
+ };
48
+ },
49
+ ),
50
+ EditorView.theme({
51
+ // Reveal the thumb only while actively scrolling.
52
+ '.cm-scroller.cm-scrolling::-webkit-scrollbar-thumb': {
53
+ background: 'var(--color-scrollbar-thumb)',
54
+ },
55
+ // Suppress the base theme's hover-reveal (higher specificity via `:not(.cm-scrolling)`), so a
56
+ // hovered/focused-but-idle editor keeps the thumb hidden.
57
+ '&:hover .cm-scroller:not(.cm-scrolling)::-webkit-scrollbar-thumb': {
58
+ background: 'transparent',
59
+ },
60
+ }),
61
+ ];
@@ -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
+ };