@dxos/ui-editor 0.8.4-main.abd8ff62ef → 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 (41) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/index.mjs +503 -469
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/node-esm/index.mjs +503 -469
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/types/src/extensions/autocomplete/placeholder.d.ts +5 -2
  10. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
  11. package/dist/types/src/extensions/index.d.ts +1 -3
  12. package/dist/types/src/extensions/index.d.ts.map +1 -1
  13. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
  14. package/dist/types/src/extensions/{scroller.d.ts → scrolling/crawler.d.ts} +13 -6
  15. package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
  16. package/dist/types/src/extensions/scrolling/index.d.ts +5 -0
  17. package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
  18. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
  19. package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
  20. package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
  21. package/dist/types/src/styles/theme.d.ts.map +1 -1
  22. package/dist/types/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +32 -32
  24. package/src/extensions/autocomplete/placeholder.ts +37 -18
  25. package/src/extensions/automerge/automerge.test.tsx +8 -2
  26. package/src/extensions/factories.ts +1 -1
  27. package/src/extensions/index.ts +1 -3
  28. package/src/extensions/outliner/outliner.ts +1 -1
  29. package/src/extensions/{auto-scroll.ts → scrolling/auto-scroll.ts} +37 -25
  30. package/src/extensions/{scroller.ts → scrolling/crawler.ts} +20 -13
  31. package/src/extensions/scrolling/index.ts +8 -0
  32. package/src/extensions/{scroll-past-end.ts → scrolling/scroll-past-end.ts} +6 -6
  33. package/src/extensions/scrolling/scroller.ts +27 -0
  34. package/src/extensions/tags/xml-formatting.ts +1 -1
  35. package/src/extensions/tags/xml-tags.ts +4 -4
  36. package/src/styles/theme.ts +8 -7
  37. package/dist/types/src/extensions/auto-scroll.d.ts.map +0 -1
  38. package/dist/types/src/extensions/scroll-past-end.d.ts.map +0 -1
  39. package/dist/types/src/extensions/scroller.d.ts.map +0 -1
  40. /package/dist/types/src/extensions/{auto-scroll.d.ts → scrolling/auto-scroll.d.ts} +0 -0
  41. /package/dist/types/src/extensions/{scroll-past-end.d.ts → scrolling/scroll-past-end.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/ui-editor",
3
- "version": "0.8.4-main.abd8ff62ef",
3
+ "version": "0.8.4-main.bc2380dfbc",
4
4
  "description": "Text editor components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": false,
14
14
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "src"
33
33
  ],
34
34
  "dependencies": {
35
- "@automerge/automerge": "3.2.3",
35
+ "@automerge/automerge": "3.2.6",
36
36
  "@codemirror/autocomplete": "^6.19.0",
37
37
  "@codemirror/commands": "^6.8.1",
38
38
  "@codemirror/lang-html": "^6.4.11",
@@ -47,7 +47,7 @@
47
47
  "@codemirror/search": "^6.5.11",
48
48
  "@codemirror/state": "^6.5.2",
49
49
  "@codemirror/theme-one-dark": "^6.1.3",
50
- "@codemirror/view": "^6.38.4",
50
+ "@codemirror/view": "^6.38.5",
51
51
  "@lezer/common": "^1.2.2",
52
52
  "@lezer/generator": "^1.7.1",
53
53
  "@lezer/highlight": "^1.2.1",
@@ -62,27 +62,27 @@
62
62
  "lodash.merge": "^4.6.2",
63
63
  "lodash.sortby": "^4.7.0",
64
64
  "style-mod": "^4.1.0",
65
- "@dxos/app-graph": "0.8.4-main.abd8ff62ef",
66
- "@dxos/client": "0.8.4-main.abd8ff62ef",
67
- "@dxos/async": "0.8.4-main.abd8ff62ef",
68
- "@dxos/context": "0.8.4-main.abd8ff62ef",
69
- "@dxos/debug": "0.8.4-main.abd8ff62ef",
70
- "@dxos/display-name": "0.8.4-main.abd8ff62ef",
71
- "@dxos/lit-ui": "0.8.4-main.abd8ff62ef",
72
- "@dxos/echo-db": "0.8.4-main.abd8ff62ef",
73
- "@dxos/invariant": "0.8.4-main.abd8ff62ef",
74
- "@dxos/echo": "0.8.4-main.abd8ff62ef",
75
- "@dxos/log": "0.8.4-main.abd8ff62ef",
76
- "@dxos/protocols": "0.8.4-main.abd8ff62ef",
77
- "@dxos/ui": "0.8.4-main.abd8ff62ef",
78
- "@dxos/util": "0.8.4-main.abd8ff62ef",
79
- "@dxos/ui-types": "0.8.4-main.abd8ff62ef",
80
- "@dxos/ui-theme": "0.8.4-main.abd8ff62ef"
65
+ "@dxos/app-graph": "0.8.4-main.bc2380dfbc",
66
+ "@dxos/async": "0.8.4-main.bc2380dfbc",
67
+ "@dxos/client": "0.8.4-main.bc2380dfbc",
68
+ "@dxos/debug": "0.8.4-main.bc2380dfbc",
69
+ "@dxos/context": "0.8.4-main.bc2380dfbc",
70
+ "@dxos/echo": "0.8.4-main.bc2380dfbc",
71
+ "@dxos/display-name": "0.8.4-main.bc2380dfbc",
72
+ "@dxos/invariant": "0.8.4-main.bc2380dfbc",
73
+ "@dxos/echo-db": "0.8.4-main.bc2380dfbc",
74
+ "@dxos/log": "0.8.4-main.bc2380dfbc",
75
+ "@dxos/lit-ui": "0.8.4-main.bc2380dfbc",
76
+ "@dxos/protocols": "0.8.4-main.bc2380dfbc",
77
+ "@dxos/ui-theme": "0.8.4-main.bc2380dfbc",
78
+ "@dxos/ui": "0.8.4-main.bc2380dfbc",
79
+ "@dxos/ui-types": "0.8.4-main.bc2380dfbc",
80
+ "@dxos/util": "0.8.4-main.bc2380dfbc"
81
81
  },
82
82
  "devDependencies": {
83
- "@automerge/automerge": "3.2.3",
84
- "@automerge/automerge-repo": "2.5.1",
85
- "@automerge/automerge-repo-network-broadcastchannel": "2.5.1",
83
+ "@automerge/automerge": "3.2.6",
84
+ "@automerge/automerge-repo": "2.6.0-subduction.17",
85
+ "@automerge/automerge-repo-network-broadcastchannel": "2.6.0-subduction.17",
86
86
  "@effect/platform": "0.94.4",
87
87
  "@types/chai": "^4.2.15",
88
88
  "@types/chai-dom": "^1.11.0",
@@ -95,21 +95,21 @@
95
95
  "happy-dom": "^20.0.0",
96
96
  "jsdom": "^27.0.0",
97
97
  "mocha": "^10.6.0",
98
- "vite": "^8.0.10",
98
+ "vite": "^8.0.13",
99
99
  "vite-plugin-top-level-await": "^1.6.0",
100
100
  "vite-plugin-wasm": "^3.6.0",
101
- "@dxos/config": "0.8.4-main.abd8ff62ef",
102
- "@dxos/echo": "0.8.4-main.abd8ff62ef",
103
- "@dxos/keyboard": "0.8.4-main.abd8ff62ef",
104
- "@dxos/storybook-utils": "0.8.4-main.abd8ff62ef",
105
- "@dxos/random": "0.8.4-main.abd8ff62ef",
106
- "@dxos/schema": "0.8.4-main.abd8ff62ef",
107
- "@dxos/ui-theme": "0.8.4-main.abd8ff62ef"
101
+ "@dxos/echo": "0.8.4-main.bc2380dfbc",
102
+ "@dxos/config": "0.8.4-main.bc2380dfbc",
103
+ "@dxos/random": "0.8.4-main.bc2380dfbc",
104
+ "@dxos/schema": "0.8.4-main.bc2380dfbc",
105
+ "@dxos/ui-theme": "0.8.4-main.bc2380dfbc",
106
+ "@dxos/keyboard": "0.8.4-main.bc2380dfbc",
107
+ "@dxos/storybook-utils": "0.8.4-main.bc2380dfbc"
108
108
  },
109
109
  "peerDependencies": {
110
110
  "@effect/platform": "0.94.4",
111
111
  "effect": "3.20.0",
112
- "@dxos/ui-theme": "0.8.4-main.abd8ff62ef"
112
+ "@dxos/ui-theme": "0.8.4-main.bc2380dfbc"
113
113
  },
114
114
  "publishConfig": {
115
115
  "access": "public"
@@ -13,42 +13,61 @@ type Content = string | HTMLElement | ((view: EditorView) => HTMLElement);
13
13
  export type PlaceholderOptions = {
14
14
  content: Content;
15
15
  delay?: number;
16
+ focusOnly?: boolean;
16
17
  };
17
18
 
18
19
  /**
19
- * Shows a transient placeholder at the current cursor position.
20
+ * Shows a transient placeholder at the current cursor position. When
21
+ * `focusOnly` is set the placeholder is suppressed unless the editor has DOM
22
+ * focus, and is hidden again when focus is lost.
20
23
  */
21
- export const placeholder = ({ content, delay = 3_000 }: PlaceholderOptions): Extension => {
24
+ export const placeholder = ({ content, delay = 3_000, focusOnly = false }: PlaceholderOptions): Extension => {
22
25
  const plugin = ViewPlugin.fromClass(
23
26
  class {
24
27
  _timeout: ReturnType<typeof setTimeout> | undefined;
25
28
  _decorations = Decoration.none;
26
29
 
27
30
  update(update: ViewUpdate) {
31
+ // React to actual user activity only. The empty `view.update([])`
32
+ // dispatched from the timeout below carries no doc/selection/focus
33
+ // change, so it falls through here as a no-op — that's how the
34
+ // freshly-set widget survives long enough for the decoration
35
+ // provider to read it. Without this gate, the unconditional reset
36
+ // would clobber the decoration in the same tick and the placeholder
37
+ // would never visibly render.
38
+ if (!update.docChanged && !update.selectionSet && !update.focusChanged) {
39
+ return;
40
+ }
41
+
28
42
  if (this._timeout) {
29
43
  window.clearTimeout(this._timeout);
30
44
  this._timeout = undefined;
31
45
  }
46
+ this._decorations = Decoration.none;
47
+
48
+ // Honour `focusOnly`: when the option is set and the editor isn't
49
+ // focused, leave the placeholder hidden and skip rescheduling. The
50
+ // next `focusChanged` update reschedules once focus returns.
51
+ if (focusOnly && !update.view.hasFocus) {
52
+ return;
53
+ }
32
54
 
33
- // Check if the active line (where cursor is) is empty.
34
55
  const activeLine = update.view.state.doc.lineAt(update.view.state.selection.main.head);
35
- const isEmpty = activeLine.text.trim() === '';
36
- if (isEmpty) {
37
- // Create widget decoration at the start of the current line.
38
- const lineStart = activeLine.from;
39
- this._timeout = setTimeout(() => {
40
- this._decorations = Decoration.set([
41
- Decoration.widget({
42
- widget: new PlaceholderWidget(content),
43
- side: 1,
44
- }).range(lineStart),
45
- ]);
46
-
47
- update.view.update([]);
48
- }, delay);
56
+ if (activeLine.text.trim() !== '') {
57
+ return;
49
58
  }
50
59
 
51
- this._decorations = Decoration.none;
60
+ const lineStart = activeLine.from;
61
+ const view = update.view;
62
+ this._timeout = setTimeout(() => {
63
+ this._decorations = Decoration.set([
64
+ Decoration.widget({
65
+ widget: new PlaceholderWidget(content),
66
+ side: 1,
67
+ }).range(lineStart),
68
+ ]);
69
+ view.update([]);
70
+ }, delay);
52
71
  }
53
72
 
54
73
  destroy() {
@@ -3,12 +3,12 @@
3
3
  //
4
4
 
5
5
  import { type ChangeFn, type ChangeOptions, type Doc, type Heads } from '@automerge/automerge';
6
- import { type DocHandle, Repo, decodeHeads, encodeHeads } from '@automerge/automerge-repo';
6
+ import { type DocHandle, Repo, decodeHeads, encodeHeads, initSubduction } from '@automerge/automerge-repo';
7
7
  import { EditorState } from '@codemirror/state';
8
8
  import { EditorView } from '@codemirror/view';
9
9
  import { render, screen } from '@testing-library/react';
10
10
  import React, { type FC, useEffect, useRef, useState } from 'react';
11
- import { describe, test } from 'vitest';
11
+ import { beforeAll, describe, test } from 'vitest';
12
12
 
13
13
  import { type IDocHandle } from '@dxos/echo-db';
14
14
  import { getDeep } from '@dxos/util';
@@ -81,6 +81,12 @@ const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ han
81
81
  // TODO(burdon): https://testing-library.com/docs/react-testing-library/example-intro/
82
82
 
83
83
  describe('Automerge', () => {
84
+ // Subduction-fork `Repo` constructs a `MemorySigner` internally; WASM must be
85
+ // initialized first or the constructor throws `'set_subduction_logger' of undefined`.
86
+ beforeAll(async () => {
87
+ await initSubduction();
88
+ });
89
+
84
90
  test('basic sync', ({ expect }) => {
85
91
  const repo = new Repo({ network: [] });
86
92
  const handle = repo.create<TestObject>();
@@ -33,7 +33,7 @@ import { baseTheme, createFontTheme, editorGutter } from '../styles';
33
33
  import { automerge } from './automerge';
34
34
  import { SpaceAwarenessProvider, awareness } from './awareness';
35
35
  import { focus } from './focus';
36
- import { scrollPastEnd } from './scroll-past-end';
36
+ import { scrollPastEnd } from './scrolling';
37
37
 
38
38
  //
39
39
  // Basic
@@ -4,7 +4,6 @@
4
4
 
5
5
  export * from './annotations';
6
6
  export * from './autocomplete';
7
- export * from './auto-scroll';
8
7
  export * from './automerge';
9
8
  export * from './awareness';
10
9
  export * from './blast';
@@ -26,8 +25,7 @@ export * from './modes';
26
25
  export * from './outliner';
27
26
  export * from './preview';
28
27
  export * from './replacer';
29
- export * from './scroll-past-end';
30
- export * from './scroller';
28
+ export * from './scrolling';
31
29
  export * from './selection';
32
30
  export * from './snippets';
33
31
  export * from './state';
@@ -156,7 +156,7 @@ const decorations = () => [
156
156
  },
157
157
 
158
158
  '.cm-list-item-focused': {
159
- borderColor: 'var(--color-neutral-focus-indicator)',
159
+ borderColor: 'var(--color-focus-ring-subtle)',
160
160
  },
161
161
  '&:focus-within .cm-list-item-selected': {
162
162
  borderColor: 'var(--color-separator)',
@@ -9,7 +9,7 @@ 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>();
@@ -54,11 +54,11 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
54
54
  if (enabled) {
55
55
  setPinned(true);
56
56
  view.dispatch({
57
- effects: scrollerCrawlEffect.of(true),
57
+ effects: crawlerActiveEffect.of(true),
58
58
  });
59
59
  } else {
60
60
  view.dispatch({
61
- effects: scrollerCrawlEffect.of(false),
61
+ effects: crawlerActiveEffect.of(false),
62
62
  });
63
63
  }
64
64
  }
@@ -96,9 +96,7 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
96
96
  const delta = scrollHeight - scrollTop - clientHeight;
97
97
  if (delta > 0) {
98
98
  setPinned(true);
99
- view.dispatch({
100
- effects: scrollerCrawlEffect.of(true),
101
- });
99
+ view.dispatch({ effects: crawlerActiveEffect.of(true) });
102
100
  } else if (delta < -1) {
103
101
  setPinned(false);
104
102
  }
@@ -122,20 +120,23 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
122
120
  private destroyed = false;
123
121
  constructor(view: EditorView) {
124
122
  // Throttle so a continuous drag-resize (or a flurry of layout changes) coalesces
125
- // into a single re-pin per ~100ms instead of dispatching every frame.
123
+ // into a single re-pin per ~50ms instead of dispatching every frame.
126
124
  const onResize = throttle(() => {
127
125
  if (this.destroyed || !enabled) {
128
126
  return;
129
127
  }
128
+
130
129
  setPinned(true);
131
130
  requestAnimationFrame(() => {
132
131
  if (this.destroyed) {
133
132
  return;
134
133
  }
135
- view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
136
- view.dispatch({ effects: scrollerCrawlEffect.of(true) });
134
+
135
+ view.scrollDOM.scrollTo({ top: view.scrollDOM.scrollHeight, behavior: 'instant' });
136
+ view.dispatch({ effects: crawlerActiveEffect.of(false) });
137
137
  });
138
- }, 100);
138
+ }, 50);
139
+
139
140
  this.observer = new ResizeObserver(() => {
140
141
  // Skip the initial fire that ResizeObserver emits on `observe()`.
141
142
  if (this.firstObservation) {
@@ -144,6 +145,7 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
144
145
  }
145
146
  onResize();
146
147
  });
148
+
147
149
  this.observer.observe(view.scrollDOM);
148
150
  }
149
151
  destroy() {
@@ -159,20 +161,30 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
159
161
  class {
160
162
  private readonly cleanup: () => void;
161
163
  constructor(view: EditorView) {
162
- this.cleanup = createUserScrollDetector(
163
- view.scrollDOM,
164
- throttle(() => {
165
- requestAnimationFrame(() => {
166
- const { scrollTop, scrollHeight, clientHeight } = view.scrollDOM;
167
- const delta = scrollHeight - scrollTop - clientHeight;
168
- const pinned = delta === 0;
169
- setPinned(pinned);
170
- if (!pinned) {
171
- view.dispatch({ effects: scrollerCrawlEffect.of(false) });
172
- }
173
- });
174
- }, 500),
175
- );
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
+ });
176
188
  }
177
189
  destroy() {
178
190
  this.cleanup();
@@ -194,7 +206,7 @@ export const autoScroll = ({ scrollOnResize = true }: AutoScrollProps = {}) => {
194
206
  .on('click', () => {
195
207
  setPinned(true);
196
208
  view.dispatch({
197
- effects: scrollerLineEffect.of({ line: -1, position: 'end', behavior: 'smooth' }),
209
+ effects: crawlerLineEffect.of({ line: -1, position: 'end', behavior: 'smooth' }),
198
210
  });
199
211
  });
200
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
  }
@@ -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
+ };
@@ -111,7 +111,7 @@ export const xmlFormatting = ({ skip }: XmlFormattingOptions = {}): Extension =>
111
111
 
112
112
  EditorView.baseTheme({
113
113
  '.cm-xml-element': {
114
- backgroundColor: 'var(--color-active-surface)',
114
+ backgroundColor: 'var(--color-current-surface)',
115
115
  borderRadius: '0.25rem',
116
116
  padding: '0.25rem',
117
117
  },
@@ -21,7 +21,7 @@ import { Domino } from '@dxos/ui';
21
21
 
22
22
  import { type Range } from '../../types';
23
23
  import { decorationSetToArray } from '../../util';
24
- import { scrollerLineEffect } from '../scroller';
24
+ import { crawlerLineEffect } from '../scrolling';
25
25
  import { nodeToJson } from './xml-util';
26
26
 
27
27
  /**
@@ -299,7 +299,7 @@ const createNavigationEffectPlugin = (
299
299
  const line = view.state.doc.lineAt(widget?.from ?? 0);
300
300
  view.dispatch({
301
301
  selection: { anchor: line.from, head: line.from },
302
- effects: scrollerLineEffect.of({ line: line.number - 1, offset: -16 }),
302
+ effects: crawlerLineEffect.of({ line: line.number - 1, offset: -16 }),
303
303
  });
304
304
 
305
305
  continue;
@@ -325,13 +325,13 @@ const createNavigationEffectPlugin = (
325
325
  const line = view.state.doc.lineAt(widget?.from);
326
326
  view.dispatch({
327
327
  selection: { anchor: line.to, head: line.to },
328
- effects: scrollerLineEffect.of({ line: line.number - 1, offset: -16 }),
328
+ effects: crawlerLineEffect.of({ line: line.number - 1, offset: -16 }),
329
329
  });
330
330
  } else {
331
331
  const line = view.state.doc.lineAt(view.state.doc.length);
332
332
  view.dispatch({
333
333
  selection: { anchor: line.to, head: line.to },
334
- effects: scrollerLineEffect.of({ line: line.number - 1, position: 'end' }),
334
+ effects: crawlerLineEffect.of({ line: line.number - 1, position: 'end' }),
335
335
  });
336
336
  }
337
337
 
@@ -108,7 +108,7 @@ export const baseTheme = EditorView.baseTheme({
108
108
  * Scroller
109
109
  */
110
110
  '.cm-scroller': {
111
- // Browser scroll-anchoring: see comment in `scroller.ts`. `auto` lets the browser pin a
111
+ // Browser scroll-anchoring: see comment in `scrolling/crawler.ts`. `auto` lets the browser pin a
112
112
  // stable element near the viewport top so widget resizes (e.g. tool-block TogglePanel
113
113
  // open/close) don't jump the user's view.
114
114
  overflowAnchor: 'auto',
@@ -227,6 +227,7 @@ export const baseTheme = EditorView.baseTheme({
227
227
  textDecorationColor: 'var(--color-separator)',
228
228
  textUnderlineOffset: '2px',
229
229
  borderRadius: '.125rem',
230
+ cursor: 'pointer',
230
231
  },
231
232
  '.cm-link > span': {
232
233
  color: 'var(--color-accent-text)',
@@ -266,12 +267,12 @@ export const baseTheme = EditorView.baseTheme({
266
267
  padding: '4px',
267
268
  },
268
269
  '.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
269
- background: 'var(--color-active-surface)',
270
- color: 'var(--color-base-surface-text)',
270
+ background: 'var(--color-current-surface)',
271
+ color: 'var(--color-base-foreground)',
271
272
  },
272
273
  '.cm-tooltip.cm-tooltip-autocomplete > ul > completion-section': {
273
274
  paddingLeft: '4px !important',
274
- color: 'var(--color-base-surface-text)',
275
+ color: 'var(--color-base-foreground)',
275
276
  },
276
277
 
277
278
  /**
@@ -291,7 +292,7 @@ export const baseTheme = EditorView.baseTheme({
291
292
  padding: '0 4px',
292
293
  },
293
294
  '.cm-completionMatchedText': {
294
- color: 'var(--color-base-surface-text)',
295
+ color: 'var(--color-base-foreground)',
295
296
  textDecoration: 'none !important',
296
297
  },
297
298
 
@@ -327,7 +328,7 @@ export const baseTheme = EditorView.baseTheme({
327
328
  backgroundColor: 'var(--color-input-surface)',
328
329
  },
329
330
  '.cm-panel input:focus, .cm-panel button:focus': {
330
- outline: '1px solid var(--color-neutral-focus-indicator)',
331
+ outline: '1px solid var(--color-focus-ring-subtle)',
331
332
  },
332
333
  '.cm-panel label': {
333
334
  display: 'inline-flex',
@@ -340,7 +341,7 @@ export const baseTheme = EditorView.baseTheme({
340
341
  height: '8px',
341
342
  marginRight: '6px !important',
342
343
  padding: '2px !important',
343
- color: 'var(--color-neutral-focus-indicator)',
344
+ color: 'var(--color-focus-ring-subtle)',
344
345
  },
345
346
  '.cm-panel button': {
346
347
  '&:hover': {
@@ -1 +0,0 @@
1
- {"version":3,"file":"auto-scroll.d.ts","sourceRoot":"","sources":["../../../../src/extensions/auto-scroll.ts"],"names":[],"mappings":"AAaA,oCAAoC;AACpC,eAAO,MAAM,gBAAgB,sDAAgC,CAAC;AAE9D,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,UAAU,wBAA+B,eAAe,4CAkLpE,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"scroll-past-end.d.ts","sourceRoot":"","sources":["../../../../src/extensions/scroll-past-end.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAwBnD,eAAO,MAAM,aAAa,QAAO,SAGhC,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"scroller.d.ts","sourceRoot":"","sources":["../../../../src/extensions/scroller.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,UAAU,EAAc,MAAM,kBAAkB,CAAC;AAI1D;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IAEb;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,KAAK,CAAC;IAE3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC3B,CAAC;AAEF,iCAAiC;AACjC,eAAO,MAAM,kBAAkB,4DAAsC,CAAC;AAEtE,mDAAmD;AACnD,eAAO,MAAM,mBAAmB,sDAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,YAAY,SAAU,UAAU,WAAW,aAAa,SAIpE,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,QAAQ,oBAAwB,eAAe,4CA0H3D,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,SAAI,EAAE,aAAa,SAAI,EAAE,YAAY,SAAK;IAiC3F,MAAM;IAON,MAAM;EAST"}