@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.
- package/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/browser/index.mjs +1258 -1004
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/types/index.mjs +26 -6
- package/dist/lib/browser/types/index.mjs.map +4 -4
- package/dist/lib/node-esm/index.mjs +1258 -1003
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/types/index.mjs +27 -6
- package/dist/lib/node-esm/types/index.mjs.map +4 -4
- package/dist/types/src/defaults.d.ts.map +1 -1
- package/dist/types/src/extensions/annotations.d.ts.map +1 -1
- package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -1
- package/dist/types/src/extensions/autocomplete/placeholder.d.ts +5 -2
- package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
- package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/sync.d.ts +1 -1
- package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
- package/dist/types/src/extensions/blast.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +19 -1
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/debug.d.ts.map +1 -1
- package/dist/types/src/extensions/dnd.d.ts.map +1 -1
- package/dist/types/src/extensions/factories.d.ts +3 -2
- package/dist/types/src/extensions/factories.d.ts.map +1 -1
- package/dist/types/src/extensions/factories.test.d.ts +2 -0
- package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
- package/dist/types/src/extensions/focus.d.ts +1 -1
- package/dist/types/src/extensions/index.d.ts +3 -4
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/json.d.ts +1 -1
- package/dist/types/src/extensions/json.d.ts.map +1 -1
- package/dist/types/src/extensions/listener.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/image.d.ts +13 -2
- package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/image.test.d.ts +2 -0
- package/dist/types/src/extensions/markdown/image.test.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
- package/dist/types/src/extensions/mention.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
- package/dist/types/src/extensions/preview/preview.d.ts +2 -2
- package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
- package/dist/types/src/extensions/replacer.d.ts.map +1 -1
- package/dist/types/src/extensions/scrolling/auto-scroll.d.ts +18 -0
- package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/crawler.d.ts +83 -0
- package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/index.d.ts +6 -0
- package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/scrollbar-autohide.d.ts +15 -0
- package/dist/types/src/extensions/scrolling/scrollbar-autohide.d.ts.map +1 -0
- package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
- package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
- package/dist/types/src/extensions/selection.d.ts.map +1 -1
- package/dist/types/src/extensions/snippets.d.ts +10 -0
- package/dist/types/src/extensions/snippets.d.ts.map +1 -0
- package/dist/types/src/extensions/spacing.d.ts +3 -0
- package/dist/types/src/extensions/spacing.d.ts.map +1 -0
- package/dist/types/src/extensions/submit.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/fader.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/index.d.ts +3 -1
- package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
- package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
- package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
- package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
- package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
- package/dist/types/src/extensions/tags/xml-tags.d.ts +1 -8
- package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
- package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/theme.d.ts.map +1 -1
- package/dist/types/src/types/types.d.ts +2 -2
- package/dist/types/src/types/types.d.ts.map +1 -1
- package/dist/types/src/util/cursor.d.ts.map +1 -1
- package/dist/types/src/util/debug.d.ts.map +1 -1
- package/dist/types/src/util/decorations.d.ts.map +1 -1
- package/dist/types/src/util/dom.d.ts.map +1 -1
- package/dist/types/src/util/facet.d.ts.map +1 -1
- package/dist/types/src/util/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +55 -57
- package/src/defaults.ts +6 -4
- package/src/extensions/autocomplete/placeholder.ts +37 -18
- package/src/extensions/automerge/automerge.test.tsx +35 -9
- package/src/extensions/automerge/automerge.ts +1 -1
- package/src/extensions/automerge/cursor.ts +1 -1
- package/src/extensions/automerge/sync.ts +1 -1
- package/src/extensions/automerge/update-automerge.ts +1 -1
- package/src/extensions/comments.ts +54 -31
- package/src/extensions/factories.test.ts +88 -0
- package/src/extensions/factories.ts +22 -4
- package/src/extensions/index.ts +3 -4
- package/src/extensions/json.ts +1 -1
- package/src/extensions/markdown/decorate.ts +1 -1
- package/src/extensions/markdown/image.test.ts +54 -0
- package/src/extensions/markdown/image.ts +70 -9
- package/src/extensions/markdown/link.ts +7 -2
- package/src/extensions/outliner/outliner.ts +1 -1
- package/src/extensions/preview/preview.ts +14 -12
- package/src/extensions/scrolling/auto-scroll.ts +261 -0
- package/src/extensions/{scroller.ts → scrolling/crawler.ts} +89 -48
- package/src/extensions/scrolling/index.ts +9 -0
- package/src/extensions/{scroll-past-end.ts → scrolling/scroll-past-end.ts} +6 -6
- package/src/extensions/scrolling/scrollbar-autohide.ts +61 -0
- package/src/extensions/scrolling/scroller.ts +27 -0
- package/src/extensions/snippets.ts +67 -0
- package/src/extensions/spacing.ts +15 -0
- package/src/extensions/tags/index.ts +3 -1
- package/src/extensions/tags/testing/text.md +36 -0
- package/src/extensions/tags/testing/text.txt +35 -0
- package/src/extensions/tags/{wire.test.ts → typewriter.test.ts} +2 -2
- package/src/extensions/tags/typewriter.ts +594 -0
- package/src/extensions/tags/xml-block-decoration.ts +123 -0
- package/src/extensions/tags/xml-formatting.ts +125 -0
- package/src/extensions/tags/xml-tags.ts +6 -32
- package/src/extensions/tags/xml-util.test.ts +90 -3
- package/src/extensions/tags/xml-util.ts +62 -5
- package/src/index.ts +0 -1
- package/src/styles/theme.ts +23 -13
- package/src/typings.d.ts +8 -0
- package/dist/lib/browser/chunk-D724USEC.mjs +0 -34
- package/dist/lib/browser/chunk-D724USEC.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-JRVJWKQF.mjs +0 -36
- package/dist/lib/node-esm/chunk-JRVJWKQF.mjs.map +0 -7
- package/dist/types/src/extensions/auto-scroll.d.ts +0 -8
- package/dist/types/src/extensions/auto-scroll.d.ts.map +0 -1
- package/dist/types/src/extensions/scroll-past-end.d.ts.map +0 -1
- package/dist/types/src/extensions/scroller.d.ts +0 -63
- package/dist/types/src/extensions/scroller.d.ts.map +0 -1
- package/dist/types/src/extensions/tags/wire.d.ts +0 -23
- package/dist/types/src/extensions/tags/wire.d.ts.map +0 -1
- package/dist/types/src/extensions/tags/wire.test.d.ts +0 -2
- package/dist/types/src/extensions/tags/wire.test.d.ts.map +0 -1
- package/dist/types/src/extensions/typewriter.d.ts +0 -10
- package/dist/types/src/extensions/typewriter.d.ts.map +0 -1
- package/src/extensions/auto-scroll.ts +0 -179
- package/src/extensions/tags/wire.ts +0 -459
- package/src/extensions/typewriter.ts +0 -68
- /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
|
|
43
|
+
export const crawlerLineEffect = StateEffect.define<ScrollToProps>();
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
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:
|
|
59
|
+
effects: crawlerLineEffect.of(options),
|
|
55
60
|
});
|
|
56
61
|
};
|
|
57
62
|
|
|
58
|
-
export type
|
|
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
|
-
*
|
|
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
|
|
78
|
+
export const crawler = ({ overScroll = 0 }: CrawlerOptions = {}) => {
|
|
67
79
|
// ViewPlugin to manage scroll animations.
|
|
68
|
-
const
|
|
69
|
-
class
|
|
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(
|
|
85
|
-
if (
|
|
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
|
-
|
|
141
|
+
crawlerPlugin,
|
|
130
142
|
|
|
131
|
-
// Listen for effect.
|
|
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(
|
|
147
|
+
const plugin = update.view.plugin(crawlerPlugin);
|
|
136
148
|
if (plugin) {
|
|
137
149
|
for (const effect of transaction.effects) {
|
|
138
|
-
if (effect.is(
|
|
150
|
+
if (effect.is(crawlerActiveEffect)) {
|
|
139
151
|
plugin.crawl(effect.value);
|
|
140
|
-
} else if (effect.is(
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
189
|
-
* @param
|
|
190
|
-
* @param
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -12,15 +12,15 @@ import { EditorView, ViewPlugin } from '@codemirror/view';
|
|
|
12
12
|
*/
|
|
13
13
|
const scrollPastEndPlugin = ViewPlugin.fromClass(
|
|
14
14
|
class {
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
22
|
-
this.
|
|
23
|
-
this.
|
|
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)?.
|
|
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
|
+
};
|