@dxos/ui-editor 0.8.4-main.9be5663bfe → 0.8.4-main.bc2380dfbc

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 (144) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/index.mjs +995 -879
  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 +995 -878
  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 +1 -0
  14. package/dist/types/src/defaults.d.ts.map +1 -1
  15. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  16. package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -1
  17. package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -1
  18. package/dist/types/src/extensions/autocomplete/placeholder.d.ts +5 -2
  19. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
  20. package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +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.map +1 -1
  23. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  25. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  26. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
  27. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  28. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  29. package/dist/types/src/extensions/blast.d.ts.map +1 -1
  30. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  31. package/dist/types/src/extensions/debug.d.ts.map +1 -1
  32. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  33. package/dist/types/src/extensions/factories.d.ts +2 -1
  34. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  35. package/dist/types/src/extensions/factories.test.d.ts +2 -0
  36. package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
  37. package/dist/types/src/extensions/focus.d.ts +1 -1
  38. package/dist/types/src/extensions/index.d.ts +2 -4
  39. package/dist/types/src/extensions/index.d.ts.map +1 -1
  40. package/dist/types/src/extensions/json.d.ts.map +1 -1
  41. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  42. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  43. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
  44. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  45. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  49. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  51. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  52. package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -1
  53. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -1
  54. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -1
  55. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
  56. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  57. package/dist/types/src/extensions/replacer.d.ts.map +1 -1
  58. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts +18 -0
  59. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
  60. package/dist/types/src/extensions/scrolling/crawler.d.ts +75 -0
  61. package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
  62. package/dist/types/src/extensions/scrolling/index.d.ts +5 -0
  63. package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
  64. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
  65. package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
  66. package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
  67. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  68. package/dist/types/src/extensions/snippets.d.ts +10 -0
  69. package/dist/types/src/extensions/snippets.d.ts.map +1 -0
  70. package/dist/types/src/extensions/submit.d.ts.map +1 -1
  71. package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -1
  72. package/dist/types/src/extensions/tags/fader.d.ts.map +1 -1
  73. package/dist/types/src/extensions/tags/index.d.ts +3 -1
  74. package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
  75. package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
  76. package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
  77. package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
  78. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
  79. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
  80. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
  81. package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
  82. package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
  83. package/dist/types/src/extensions/tags/xml-tags.d.ts +1 -8
  84. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  85. package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -1
  86. package/dist/types/src/index.d.ts +0 -1
  87. package/dist/types/src/index.d.ts.map +1 -1
  88. package/dist/types/src/styles/theme.d.ts.map +1 -1
  89. package/dist/types/src/types/types.d.ts +4 -4
  90. package/dist/types/src/types/types.d.ts.map +1 -1
  91. package/dist/types/src/util/cursor.d.ts.map +1 -1
  92. package/dist/types/src/util/debug.d.ts.map +1 -1
  93. package/dist/types/src/util/decorations.d.ts.map +1 -1
  94. package/dist/types/src/util/dom.d.ts.map +1 -1
  95. package/dist/types/src/util/facet.d.ts.map +1 -1
  96. package/dist/types/src/util/util.d.ts.map +1 -1
  97. package/dist/types/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +33 -36
  99. package/src/defaults.ts +12 -3
  100. package/src/extensions/autocomplete/placeholder.ts +37 -18
  101. package/src/extensions/automerge/automerge.test.tsx +35 -9
  102. package/src/extensions/automerge/automerge.ts +2 -4
  103. package/src/extensions/factories.test.ts +88 -0
  104. package/src/extensions/factories.ts +25 -6
  105. package/src/extensions/index.ts +2 -4
  106. package/src/extensions/outliner/outliner.ts +1 -1
  107. package/src/extensions/{auto-scroll.ts → scrolling/auto-scroll.ts} +88 -23
  108. package/src/extensions/{scroller.ts → scrolling/crawler.ts} +63 -45
  109. package/src/extensions/scrolling/index.ts +8 -0
  110. package/src/extensions/{scroll-past-end.ts → scrolling/scroll-past-end.ts} +6 -6
  111. package/src/extensions/scrolling/scroller.ts +27 -0
  112. package/src/extensions/snippets.ts +67 -0
  113. package/src/extensions/tags/index.ts +3 -1
  114. package/src/extensions/tags/testing/text.md +36 -0
  115. package/src/extensions/tags/testing/text.txt +35 -0
  116. package/src/extensions/tags/{wire.test.ts → typewriter.test.ts} +2 -2
  117. package/src/extensions/tags/typewriter.ts +594 -0
  118. package/src/extensions/tags/xml-block-decoration.ts +123 -0
  119. package/src/extensions/tags/xml-formatting.ts +125 -0
  120. package/src/extensions/tags/xml-tags.ts +6 -32
  121. package/src/extensions/tags/xml-util.test.ts +90 -3
  122. package/src/extensions/tags/xml-util.ts +62 -5
  123. package/src/index.ts +0 -1
  124. package/src/styles/theme.ts +20 -10
  125. package/src/types/types.ts +10 -2
  126. package/src/typings.d.ts +8 -0
  127. package/dist/lib/browser/chunk-HL3YF6WC.mjs +0 -22
  128. package/dist/lib/browser/chunk-HL3YF6WC.mjs.map +0 -7
  129. package/dist/lib/node-esm/chunk-YJZGD3LY.mjs +0 -24
  130. package/dist/lib/node-esm/chunk-YJZGD3LY.mjs.map +0 -7
  131. package/dist/types/src/extensions/auto-scroll.d.ts +0 -8
  132. package/dist/types/src/extensions/auto-scroll.d.ts.map +0 -1
  133. package/dist/types/src/extensions/scroll-past-end.d.ts.map +0 -1
  134. package/dist/types/src/extensions/scroller.d.ts +0 -63
  135. package/dist/types/src/extensions/scroller.d.ts.map +0 -1
  136. package/dist/types/src/extensions/tags/wire.d.ts +0 -23
  137. package/dist/types/src/extensions/tags/wire.d.ts.map +0 -1
  138. package/dist/types/src/extensions/tags/wire.test.d.ts +0 -2
  139. package/dist/types/src/extensions/tags/wire.test.d.ts.map +0 -1
  140. package/dist/types/src/extensions/typewriter.d.ts +0 -10
  141. package/dist/types/src/extensions/typewriter.d.ts.map +0 -1
  142. package/src/extensions/tags/wire.ts +0 -459
  143. package/src/extensions/typewriter.ts +0 -68
  144. /package/dist/types/src/extensions/{scroll-past-end.d.ts → scrolling/scroll-past-end.d.ts} +0 -0
@@ -9,17 +9,27 @@ import { addEventListener, combine, throttle } from '@dxos/async';
9
9
  import { Domino } from '@dxos/ui';
10
10
  import { getSize } from '@dxos/ui-theme';
11
11
 
12
- import { scrollerCrawlEffect, scrollerLineEffect } from './scroller';
12
+ import { crawlerActiveEffect, crawlerLineEffect } from './crawler';
13
13
 
14
14
  /** Enable or disable autoscroll. */
15
15
  export const autoScrollEffect = StateEffect.define<boolean>();
16
16
 
17
- export type AutoScrollProps = {};
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
+ };
18
28
 
19
29
  /**
20
30
  * Extension that supports pinning the scroll position and automatically scrolls to the bottom when content is added.
21
31
  */
22
- export const autoScroll = (_: AutoScrollProps = {}) => {
32
+ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
23
33
  let buttonContainer: HTMLDivElement | undefined;
24
34
  let isPinned = true;
25
35
  let jumpPending = false;
@@ -44,11 +54,11 @@ export const autoScroll = (_: AutoScrollProps = {}) => {
44
54
  if (enabled) {
45
55
  setPinned(true);
46
56
  view.dispatch({
47
- effects: scrollerCrawlEffect.of(true),
57
+ effects: crawlerActiveEffect.of(true),
48
58
  });
49
59
  } else {
50
60
  view.dispatch({
51
- effects: scrollerCrawlEffect.of(false),
61
+ effects: crawlerActiveEffect.of(false),
52
62
  });
53
63
  }
54
64
  }
@@ -86,9 +96,7 @@ export const autoScroll = (_: AutoScrollProps = {}) => {
86
96
  const delta = scrollHeight - scrollTop - clientHeight;
87
97
  if (delta > 0) {
88
98
  setPinned(true);
89
- view.dispatch({
90
- effects: scrollerCrawlEffect.of(true),
91
- });
99
+ view.dispatch({ effects: crawlerActiveEffect.of(true) });
92
100
  } else if (delta < -1) {
93
101
  setPinned(false);
94
102
  }
@@ -101,25 +109,82 @@ export const autoScroll = (_: AutoScrollProps = {}) => {
101
109
  }
102
110
  }),
103
111
 
112
+ // Re-pin and jump to bottom when the scroll container itself resizes (e.g. sidebar toggle,
113
+ // window resize). Doc-driven height changes are handled by the updateListener above; this
114
+ // observer covers the case where the viewport changes while the doc length is unchanged.
115
+ scrollOnResize
116
+ ? ViewPlugin.fromClass(
117
+ class {
118
+ private readonly observer: ResizeObserver;
119
+ private firstObservation = true;
120
+ private destroyed = false;
121
+ constructor(view: EditorView) {
122
+ // Throttle so a continuous drag-resize (or a flurry of layout changes) coalesces
123
+ // into a single re-pin per ~50ms instead of dispatching every frame.
124
+ const onResize = throttle(() => {
125
+ if (this.destroyed || !enabled) {
126
+ return;
127
+ }
128
+
129
+ setPinned(true);
130
+ requestAnimationFrame(() => {
131
+ if (this.destroyed) {
132
+ return;
133
+ }
134
+
135
+ view.scrollDOM.scrollTo({ top: view.scrollDOM.scrollHeight, behavior: 'instant' });
136
+ view.dispatch({ effects: crawlerActiveEffect.of(false) });
137
+ });
138
+ }, 50);
139
+
140
+ this.observer = new ResizeObserver(() => {
141
+ // Skip the initial fire that ResizeObserver emits on `observe()`.
142
+ if (this.firstObservation) {
143
+ this.firstObservation = false;
144
+ return;
145
+ }
146
+ onResize();
147
+ });
148
+
149
+ this.observer.observe(view.scrollDOM);
150
+ }
151
+ destroy() {
152
+ this.destroyed = true;
153
+ this.observer.disconnect();
154
+ }
155
+ },
156
+ )
157
+ : [],
158
+
104
159
  // Detect user scroll and unpin (or re-pin if scrolled to the bottom).
105
160
  ViewPlugin.fromClass(
106
161
  class {
107
162
  private readonly cleanup: () => void;
108
163
  constructor(view: EditorView) {
109
- this.cleanup = createUserScrollDetector(
110
- view.scrollDOM,
111
- throttle(() => {
112
- requestAnimationFrame(() => {
113
- const { scrollTop, scrollHeight, clientHeight } = view.scrollDOM;
114
- const delta = scrollHeight - scrollTop - clientHeight;
115
- const pinned = delta === 0;
116
- setPinned(pinned);
117
- if (!pinned) {
118
- view.dispatch({ effects: scrollerCrawlEffect.of(false) });
119
- }
120
- });
121
- }, 500),
122
- );
164
+ // Re-pin check is throttled so the listener doesn't thrash while scrolling, but
165
+ // unpinning must be immediate — otherwise content arriving during the throttle
166
+ // window re-applies the crawl effect and yanks the viewport back to the bottom.
167
+ const onUserScroll = throttle(() => {
168
+ requestAnimationFrame(() => {
169
+ const { scrollTop, scrollHeight, clientHeight } = view.scrollDOM;
170
+ const delta = scrollHeight - scrollTop - clientHeight;
171
+ // Sub-pixel tolerance: fractional scroll positions can leave delta at e.g. 0.5
172
+ // even when the user is visually at the bottom.
173
+ const pinned = Math.abs(delta) <= 1;
174
+ setPinned(pinned);
175
+ if (!pinned) {
176
+ view.dispatch({ effects: crawlerActiveEffect.of(false) });
177
+ }
178
+ });
179
+ }, 500);
180
+
181
+ this.cleanup = createUserScrollDetector(view.scrollDOM, () => {
182
+ if (isPinned) {
183
+ setPinned(false);
184
+ view.dispatch({ effects: crawlerActiveEffect.of(false) });
185
+ }
186
+ onUserScroll();
187
+ });
123
188
  }
124
189
  destroy() {
125
190
  this.cleanup();
@@ -141,7 +206,7 @@ export const autoScroll = (_: AutoScrollProps = {}) => {
141
206
  .on('click', () => {
142
207
  setPinned(true);
143
208
  view.dispatch({
144
- effects: scrollerLineEffect.of({ line: -1, position: 'end', behavior: 'smooth' }),
209
+ effects: crawlerLineEffect.of({ line: -1, position: 'end', behavior: 'smooth' }),
145
210
  });
146
211
  });
147
212
 
@@ -40,10 +40,10 @@ 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
45
  /** Start/stop crawling the end of the document. */
46
- export const scrollerCrawlEffect = StateEffect.define<boolean>();
46
+ export const crawlerActiveEffect = StateEffect.define<boolean>();
47
47
 
48
48
  /**
49
49
  * Helper function to scroll to a specific line.
@@ -51,22 +51,29 @@ export const scrollerCrawlEffect = StateEffect.define<boolean>();
51
51
  */
52
52
  export const scrollToLine = (view: EditorView, options: ScrollToProps) => {
53
53
  view.dispatch({
54
- effects: scrollerLineEffect.of(options),
54
+ effects: crawlerLineEffect.of(options),
55
55
  });
56
56
  };
57
57
 
58
- export type ScrollerOptions = {
58
+ export type CrawlerOptions = {
59
59
  /** Threshold in px to trigger scroll from bottom. */
60
60
  overScroll?: number;
61
61
  };
62
62
 
63
63
  /**
64
- * Extension that provides smooth scrolling to specific lines in the editor.
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.
65
72
  */
66
- export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
73
+ export const crawler = ({ overScroll = 0 }: CrawlerOptions = {}) => {
67
74
  // ViewPlugin to manage scroll animations.
68
- const scrollPlugin = ViewPlugin.fromClass(
69
- class ScrollerPlugin {
75
+ const crawlerPlugin = ViewPlugin.fromClass(
76
+ class CrawlerPlugin {
70
77
  private readonly crawler: ReturnType<typeof createCrawler>;
71
78
  constructor(private readonly view: EditorView) {
72
79
  this.crawler = createCrawler(this.view);
@@ -126,18 +133,18 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
126
133
  );
127
134
 
128
135
  return [
129
- scrollPlugin,
136
+ crawlerPlugin,
130
137
 
131
- // Listen for effect.s
138
+ // Listen for effect.
132
139
  EditorView.updateListener.of((update) => {
133
140
  update.transactions.forEach((transaction) => {
134
141
  try {
135
- const plugin = update.view.plugin(scrollPlugin);
142
+ const plugin = update.view.plugin(crawlerPlugin);
136
143
  if (plugin) {
137
144
  for (const effect of transaction.effects) {
138
- if (effect.is(scrollerCrawlEffect)) {
145
+ if (effect.is(crawlerActiveEffect)) {
139
146
  plugin.crawl(effect.value);
140
- } else if (effect.is(scrollerLineEffect)) {
147
+ } else if (effect.is(crawlerLineEffect)) {
141
148
  plugin.scroll(effect.value);
142
149
  }
143
150
  }
@@ -150,13 +157,13 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
150
157
 
151
158
  // Styles.
152
159
  EditorView.theme({
153
- '.cm-content': {
154
- paddingBottom: `${overScroll}px`,
155
- },
156
160
  '.cm-scroller': {
157
161
  overflowY: 'scroll',
158
- overflowAnchor: 'none',
159
- paddingBottom: '0',
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',
160
167
  },
161
168
  '.cm-scroller.cm-hide-scrollbar::-webkit-scrollbar': {
162
169
  display: 'none',
@@ -168,6 +175,16 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
168
175
  '&:hover .cm-scroller::-webkit-scrollbar-thumb': {
169
176
  background: 'var(--color-scrollbar-thumb)',
170
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
+ },
171
188
  '.cm-scroll-button': {
172
189
  position: 'absolute',
173
190
  bottom: '0.5rem',
@@ -180,49 +197,48 @@ export const scroller = ({ overScroll = 0 }: ScrollerOptions = {}) => {
180
197
  /**
181
198
  * Creates a smooth crawler that follows the live bottom of a CodeMirror 6 EditorView.
182
199
  *
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.
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).
187
209
  *
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.
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.
191
213
  */
192
- export function createCrawler(view: EditorView, accel = 0.15, maxVelocity = 1, snapThreshold = 0.5) {
214
+ export function createCrawler(view: EditorView, omega = 5, snapThreshold = 5, snapVelocity = 50) {
193
215
  const el = view.scrollDOM;
194
216
 
195
217
  let currentTop = 0;
196
218
  let velocity = 0;
197
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;
198
226
 
199
- function frame() {
200
227
  const targetTop = el.scrollHeight - el.clientHeight;
201
228
  const delta = targetTop - currentTop;
202
- const absDelta = Math.abs(delta);
203
-
204
- if (absDelta < snapThreshold && Math.abs(velocity) < snapThreshold) {
229
+ if (Math.abs(delta) < snapThreshold && Math.abs(velocity) < snapVelocity) {
205
230
  el.scrollTop = targetTop;
206
231
  currentTop = targetTop;
207
232
  velocity = 0;
208
233
  rafId = null;
234
+ lastTime = 0;
209
235
  return;
210
236
  }
211
237
 
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);
223
- }
224
-
225
- currentTop += velocity;
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;
226
242
  el.scrollTop = currentTop;
227
243
  rafId = requestAnimationFrame(frame);
228
244
  }
@@ -231,14 +247,16 @@ export function createCrawler(view: EditorView, accel = 0.15, maxVelocity = 1, s
231
247
  scroll: () => {
232
248
  if (rafId === null) {
233
249
  currentTop = el.scrollTop;
250
+ lastTime = 0;
234
251
  rafId = requestAnimationFrame(frame);
235
252
  }
236
253
  },
237
254
  cancel: () => {
238
255
  if (rafId !== null) {
239
256
  cancelAnimationFrame(rafId);
240
- rafId = null;
241
257
  velocity = 0;
258
+ lastTime = 0;
259
+ rafId = null;
242
260
  }
243
261
  },
244
262
  };
@@ -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';
@@ -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,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
+ };
@@ -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
+ };
@@ -4,5 +4,7 @@
4
4
 
5
5
  export * from './extended-markdown';
6
6
  export * from './fader';
7
- export * from './wire';
7
+ export * from './typewriter';
8
+ export * from './xml-block-decoration';
9
+ export * from './xml-formatting';
8
10
  export * from './xml-tags';
@@ -0,0 +1,36 @@
1
+ <prompt>summarize the posts</prompt>
2
+ <reasoning>The user wants me to summarize the posts related to the magazine in context. Let me first load the magazine object to understand its structure, and then list the candidate posts.</reasoning>
3
+ <status>Loading the magazine and listing candidate posts…</status>
4
+ <toolCall id="toolu_01WMMcsaHxTvuGxcMh2CkfL4" />
5
+ <toolCall id="toolu_01CxWNvXZ4jExfLEJumfMzqm" />
6
+ <reasoning>The magazine has 10 posts already curated, and there are no uncurated candidate posts. The user wants me to summarize the posts that are already in the magazine. Let me load them.</reasoning>
7
+ <status>No uncurated candidates — loading the 10 posts already in the magazine…</status>
8
+ <toolCall id="toolu_0152ftz33GL5M1DDi8GGDHi2" />
9
+ <reasoning>The magazine has 10 posts, but several are duplicates. Let me deduplicate by title/guid and summarize the unique articles. Unique articles:
10
+
11
+ 1. "FBI affidavit quotes White House press dinner shooting suspect expressing rage at 'a pedophile, rapist and traitor' – US politics live" - About Cole Allen's manifesto and charges, plus King Charles visiting Trump at the White House.
12
+ 2. "Australia news live: UK inquiry says 'cracks already beginning to show' on Aukus; search for missing five-year-old near Alice Springs grows" - Live blog covering Aukus inquiry, David Brat nominated as ambassador to Australia, fuel reserve crisis.
13
+ 3. "Rebel Wilson gives evidence in high-profile defamation case against star of directorial debut" - Rebel Wilson testifying in defamation case brought by Charlotte MacInnes.
14
+ 4. "Baby on board: US woman gives birth on Delta flight" - Ashley Blair delivered her baby aboard a Delta flight traveling from Atlanta to Portland.
15
+ 5. "'Shortcomings and failures' could sink Aukus nuclear submarines plan, UK inquiry warns" - A UK House of Commons defence committee report identifies significant funding gaps threatening the Aukus submarine program.</reasoning>
16
+ Here's a summary of the **5 unique articles** currently in the magazine (some posts are duplicates):
17
+
18
+ 6. **FBI affidavit quotes White House press dinner shooting suspect**
19
+ Federal charges filed against Cole Allen include excerpts from a manifesto he emailed to family and friends before attempting to charge into the White House Correspondents' Dinner. Separately, King Charles and Queen Camilla visited Donald Trump at the White House for tea and a beehive tour.
20
+
21
+ 7. **Australia news live: Aukus 'cracks' & missing child search**
22
+ A UK House of Commons inquiry reveals "shortcomings and failings" in the Aukus submarine program. Also covered: David Brat nominated as the next US ambassador to Australia, and a push to extend Australia's fuel reserves from 30 to 60 days amid rising security concerns.
23
+
24
+ 8. **Rebel Wilson testifies in defamation case**
25
+ Hollywood star Rebel Wilson gave evidence in a defamation lawsuit brought by Charlotte MacInnes, the lead actor of Wilson's directorial debut _The Deb_. The case centres on claims MacInnes confided to Wilson about being uncomfortable with a bathing request from a colleague.
26
+
27
+ 9. **Baby born on Delta flight**
28
+ Passenger Ashley Blair went into labor on a packed Delta flight from Atlanta to Portland. Two off-duty EMTs on the plane delivered baby Brielle Renee just before landing — using a shoelace as a tourniquet for an IV. Mother and baby were healthy on arrival.
29
+
30
+ 10. **UK inquiry warns Aukus submarine plan at risk**
31
+ A detailed House of Commons defence committee report finds UK submarine funding has "cracks already beginning to show," with chronic under-investment in shipbuilding and only one submarine yard (Barrow-in-Furness) already behind schedule. Australia has committed A$4.6 billion to help, but the committee warns political drift could derail the entire program.
32
+ <prompt>how many articles are there?</prompt>
33
+ <reasoning>The user is asking about the number of articles. From my previous analysis, there are 10 posts in the magazine, but only 5 unique articles (the rest are duplicates). Let me answer based on what I already know.</reasoning>
34
+ There are **10 posts** in the magazine, but only **5 unique articles** — each article appears twice (as duplicates from the two feeds).
35
+ <prompt>say your name inside an xml tag</prompt>
36
+ <name>Kai</name>
@@ -0,0 +1,35 @@
1
+ <prompt>summarize the posts</prompt>
2
+ <reasoning>The user wants me to summarize the posts related to the magazine in context. Let me first load the magazine object to understand its structure, and then list the candidate posts.</reasoning>
3
+ <status>Loading the magazine and listing candidate posts…</status>
4
+ <toolCall id="toolu_01WMMcsaHxTvuGxcMh2CkfL4" />
5
+ <toolCall id="toolu_01CxWNvXZ4jExfLEJumfMzqm" />
6
+ <reasoning>The magazine has 10 posts already curated, and there are no uncurated candidate posts. The user wants me to summarize the posts that are already in the magazine. Let me load them.</reasoning>
7
+ <status>No uncurated candidates — loading the 10 posts already in the magazine…</status>
8
+ <toolCall id="toolu_0152ftz33GL5M1DDi8GGDHi2" />
9
+ <reasoning>The magazine has 10 posts, but several are duplicates. Let me deduplicate by title/guid and summarize the unique articles. Unique articles:
10
+ 1. "FBI affidavit quotes White House press dinner shooting suspect expressing rage at 'a pedophile, rapist and traitor' – US politics live" - About Cole Allen's manifesto and charges, plus King Charles visiting Trump at the White House.
11
+ 2. "Australia news live: UK inquiry says 'cracks already beginning to show' on Aukus; search for missing five-year-old near Alice Springs grows" - Live blog covering Aukus inquiry, David Brat nominated as ambassador to Australia, fuel reserve crisis.
12
+ 3. "Rebel Wilson gives evidence in high-profile defamation case against star of directorial debut" - Rebel Wilson testifying in defamation case brought by Charlotte MacInnes.
13
+ 4. "Baby on board: US woman gives birth on Delta flight" - Ashley Blair delivered her baby aboard a Delta flight traveling from Atlanta to Portland.
14
+ 5. "'Shortcomings and failures' could sink Aukus nuclear submarines plan, UK inquiry warns" - A UK House of Commons defence committee report identifies significant funding gaps threatening the Aukus submarine program.</reasoning>
15
+ Here's a summary of the **5 unique articles** currently in the magazine (some posts are duplicates):
16
+
17
+ 1. **FBI affidavit quotes White House press dinner shooting suspect**
18
+ Federal charges filed against Cole Allen include excerpts from a manifesto he emailed to family and friends before attempting to charge into the White House Correspondents' Dinner. Separately, King Charles and Queen Camilla visited Donald Trump at the White House for tea and a beehive tour.
19
+
20
+ 2. **Australia news live: Aukus 'cracks' & missing child search**
21
+ A UK House of Commons inquiry reveals "shortcomings and failings" in the Aukus submarine program. Also covered: David Brat nominated as the next US ambassador to Australia, and a push to extend Australia's fuel reserves from 30 to 60 days amid rising security concerns.
22
+
23
+ 3. **Rebel Wilson testifies in defamation case**
24
+ Hollywood star Rebel Wilson gave evidence in a defamation lawsuit brought by Charlotte MacInnes, the lead actor of Wilson's directorial debut *The Deb*. The case centres on claims MacInnes confided to Wilson about being uncomfortable with a bathing request from a colleague.
25
+
26
+ 4. **Baby born on Delta flight**
27
+ Passenger Ashley Blair went into labor on a packed Delta flight from Atlanta to Portland. Two off-duty EMTs on the plane delivered baby Brielle Renee just before landing — using a shoelace as a tourniquet for an IV. Mother and baby were healthy on arrival.
28
+
29
+ 5. **UK inquiry warns Aukus submarine plan at risk**
30
+ A detailed House of Commons defence committee report finds UK submarine funding has "cracks already beginning to show," with chronic under-investment in shipbuilding and only one submarine yard (Barrow-in-Furness) already behind schedule. Australia has committed A$4.6 billion to help, but the committee warns political drift could derail the entire program.
31
+ <prompt>how many articles are there?</prompt>
32
+ <reasoning>The user is asking about the number of articles. From my previous analysis, there are 10 posts in the magazine, but only 5 unique articles (the rest are duplicates). Let me answer based on what I already know.</reasoning>
33
+ There are **10 posts** in the magazine, but only **5 unique articles** — each article appears twice (as duplicates from the two feeds).
34
+ <prompt>say your name inside an xml tag</prompt>
35
+ <name>Kai</name>
@@ -6,9 +6,9 @@ import { describe, test } from 'vitest';
6
6
 
7
7
  import { trim } from '@dxos/util';
8
8
 
9
- import { xmlElementLength } from './wire';
9
+ import { xmlElementLength } from './typewriter';
10
10
 
11
- describe('wire', () => {
11
+ describe('typewriter', () => {
12
12
  test('xmlElementLength handles hyphenated custom element names', ({ expect }) => {
13
13
  const inner = 'Hello';
14
14
  const el = `<dom-widget>${inner}</dom-widget>`;