Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.

Commit f3bec86

Browse files
committed
Also stabilize parent elements' scroll position
FIX: Perform scroll stabilization on the document or wrapping scrollable elements, when the user scrolls the editor.
1 parent 218358b commit f3bec86

4 files changed

Lines changed: 58 additions & 31 deletions

File tree

src/dom.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,14 @@ export function scrollRectIntoView(dom: HTMLElement, rect: Rect, side: -1 | 1,
208208
}
209209
}
210210

211-
export function scrollableParents(dom: HTMLElement) {
212-
let doc = dom.ownerDocument, x: HTMLElement | undefined, y: HTMLElement | undefined
211+
export function scrollableParents(dom: HTMLElement, getX = true) {
212+
let doc = dom.ownerDocument, x: HTMLElement | null = null, y: HTMLElement | null = null
213213
for (let cur = dom.parentNode as HTMLElement | null; cur;) {
214-
if (cur == doc.body || (x && y)) {
214+
if (cur == doc.body || ((!getX || x) && y)) {
215215
break
216216
} else if (cur.nodeType == 1) {
217217
if (!y && cur.scrollHeight > cur.clientHeight) y = cur
218-
if (!x && cur.scrollWidth > cur.clientWidth) x = cur
218+
if (getX && !x && cur.scrollWidth > cur.clientWidth) x = cur
219219
cur = cur.assignedSlot || cur.parentNode as HTMLElement | null
220220
} else if (cur.nodeType == 11) {
221221
cur = (cur as any).host
@@ -340,7 +340,8 @@ export function atElementStart(doc: HTMLElement, selection: SelectionRange) {
340340
}
341341
}
342342

343-
export function isScrolledToBottom(elt: HTMLElement) {
343+
export function isScrolledToBottom(elt: HTMLElement | Window) {
344+
if (elt instanceof Window) return elt.pageYOffset > Math.max(0, elt.document.documentElement.scrollHeight - elt.innerHeight - 4)
344345
return elt.scrollTop > Math.max(1, elt.scrollHeight - elt.clientHeight - 4)
345346
}
346347

src/editorview.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export class EditorView {
125125
get root() { return this._root }
126126

127127
/// @internal
128-
get win() { return this.dom.ownerDocument.defaultView || window }
128+
get win(): Window { return this.dom.ownerDocument.defaultView || window }
129129

130130
/// The DOM element that wraps the entire editor view.
131131
readonly dom: HTMLElement
@@ -200,7 +200,7 @@ export class EditorView {
200200
this.dispatch = this.dispatch.bind(this)
201201
this._root = (config.root || getRoot(config.parent) || document) as DocumentOrShadowRoot
202202

203-
this.viewState = new ViewState(config.state || EditorState.create(config))
203+
this.viewState = new ViewState(this, config.state || EditorState.create(config))
204204
if (config.scrollTo && config.scrollTo.is(scrollIntoView))
205205
this.viewState.scrollTarget = config.scrollTo.value.clip(this.viewState.state)
206206
this.plugins = this.state.facet(viewPlugin).map(spec => new PluginInstance(spec))
@@ -358,7 +358,7 @@ export class EditorView {
358358
let hadFocus = this.hasFocus
359359
try {
360360
for (let plugin of this.plugins) plugin.destroy(this)
361-
this.viewState = new ViewState(newState)
361+
this.viewState = new ViewState(this, newState)
362362
this.plugins = newState.facet(viewPlugin).map(spec => new PluginInstance(spec))
363363
this.pluginMap.clear()
364364
for (let plugin of this.plugins) plugin.update(this)
@@ -421,25 +421,25 @@ export class EditorView {
421421
if (flush) this.observer.forceFlush()
422422

423423
let updated: ViewUpdate | null = null
424-
let sDOM = this.scrollDOM, scrollTop = sDOM.scrollTop * this.scaleY
424+
let scroll = this.viewState.scrollParent, scrollOffset = this.viewState.getScrollOffset()
425425
let {scrollAnchorPos, scrollAnchorHeight} = this.viewState
426-
if (Math.abs(scrollTop - this.viewState.scrollTop) > 1) scrollAnchorHeight = -1
426+
if (Math.abs(scrollOffset - this.viewState.scrollOffset) > 1) scrollAnchorHeight = -1
427427
this.viewState.scrollAnchorHeight = -1
428428

429429
try {
430430
for (let i = 0;; i++) {
431431
if (scrollAnchorHeight < 0) {
432-
if (isScrolledToBottom(sDOM)) {
432+
if (isScrolledToBottom(scroll || this.win)) {
433433
scrollAnchorPos = -1
434434
scrollAnchorHeight = this.viewState.heightMap.height
435435
} else {
436-
let block = this.viewState.scrollAnchorAt(scrollTop)
436+
let block = this.viewState.scrollAnchorAt(scrollOffset)
437437
scrollAnchorPos = block.from
438438
scrollAnchorHeight = block.top
439439
}
440440
}
441441
this.updateState = UpdateState.Measuring
442-
let changed = this.viewState.measure(this)
442+
let changed = this.viewState.measure()
443443
if (!changed && !this.measureRequests.length && this.viewState.scrollTarget == null) break
444444
if (i > 5) {
445445
console.warn(this.measureRequests.length
@@ -484,10 +484,13 @@ export class EditorView {
484484
} else {
485485
let newAnchorHeight = scrollAnchorPos < 0 ? this.viewState.heightMap.height :
486486
this.viewState.lineBlockAt(scrollAnchorPos).top
487-
let diff = newAnchorHeight - scrollAnchorHeight
488-
if (diff > 1 || diff < -1) {
489-
scrollTop = scrollTop + diff
490-
sDOM.scrollTop = scrollTop / this.scaleY
487+
let diff = (newAnchorHeight - scrollAnchorHeight) / this.scaleY
488+
if ((diff > 1 || diff < -1) &&
489+
(scroll == this.scrollDOM || this.hasFocus ||
490+
Math.max(this.inputState.lastWheelEvent, this.inputState.lastTouchTime) > Date.now() - 100)) {
491+
scrollOffset = scrollOffset + diff
492+
if (scroll) scroll.scrollTop += diff
493+
else this.win.scrollBy(0, diff)
491494
scrollAnchorHeight = -1
492495
continue
493496
}

src/input.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class InputState {
1616
lastFocusTime = 0
1717
lastScrollTop = 0
1818
lastScrollLeft = 0
19+
lastWheelEvent = 0
1920

2021
// On iOS, some keys need to have their default behavior happen
2122
// (after which we retroactively handle them and reset the DOM) to
@@ -495,6 +496,10 @@ observers.scroll = view => {
495496
view.inputState.lastScrollLeft = view.scrollDOM.scrollLeft
496497
}
497498

499+
observers.wheel = observers.mousewheel = view => {
500+
view.inputState.lastWheelEvent = Date.now()
501+
}
502+
498503
handlers.keydown = (view, event: KeyboardEvent) => {
499504
view.inputState.setSelectionOrigin("select")
500505
if (event.keyCode == 27 && view.inputState.tabFocusMode != 0) view.inputState.tabFocusMode = Date.now() + 2000

src/viewstate.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Text, EditorState, ChangeSet, ChangeDesc, RangeSet, EditorSelection} from "@codemirror/state"
2-
import {Rect, isScrolledToBottom, getScale} from "./dom"
2+
import {Rect, isScrolledToBottom, getScale, scrollableParents} from "./dom"
33
import {HeightMap, HeightOracle, BlockInfo, MeasuredHeights, QueryType, heightRelevantDecoChanges,
44
clearHeightChangeFlag, heightChangeFlag} from "./heightmap"
55
import {decorations, outerDecorations, ViewUpdate, UpdateFlag, ChangedRange, ScrollTarget, nativeSelectionHidden,
@@ -120,12 +120,17 @@ export class ViewState {
120120
contentDOMHeight = 0 // contentDOM.getBoundingClientRect().height
121121
editorHeight = 0 // scrollDOM.clientHeight, unscaled
122122
editorWidth = 0 // scrollDOM.clientWidth, unscaled
123-
scrollTop = 0 // Last seen scrollDOM.scrollTop, scaled
124-
scrolledToBottom = false
125123
// The CSS-transformation scale of the editor (transformed size /
126124
// concrete size)
127125
scaleX = 1
128126
scaleY = 1
127+
128+
// The nearest vertically-scrollable parent. null means window is nearest
129+
scrollParent: HTMLElement | null
130+
// Last seen vertical offset of the element at the top of the scroll
131+
// container, or top of the window if there's no wrapping scroller
132+
scrollOffset = 0
133+
scrolledToBottom = false
129134
// The vertical position (document-relative) to which to anchor the
130135
// scroll position. -1 means anchor to the end of the document.
131136
scrollAnchorPos = 0
@@ -169,7 +174,7 @@ export class ViewState {
169174
// the right place.
170175
mustEnforceCursorAssoc = false
171176

172-
constructor(public state: EditorState) {
177+
constructor(public view: EditorView, public state: EditorState) {
173178
let guessWrapping = state.facet(contentAttributes).some(v => typeof v != "function" && v.class == "cm-lineWrapping")
174179
this.heightOracle = new HeightOracle(guessWrapping)
175180
this.stateDeco = staticDeco(state)
@@ -182,6 +187,7 @@ export class ViewState {
182187
this.updateViewportLines()
183188
this.lineGaps = this.ensureLineGaps([])
184189
this.lineGapDeco = Decoration.set(this.lineGaps.map(gap => gap.draw(this, false)))
190+
this.scrollParent = view.scrollDOM
185191
this.computeVisibleRanges()
186192
}
187193

@@ -222,7 +228,7 @@ export class ViewState {
222228
let heightChanges = ChangedRange.extendWithRanges(contentChanges, heightRelevantDecoChanges(
223229
prevDeco, this.stateDeco, update ? update.changes : ChangeSet.empty(this.state.doc.length)))
224230
let prevHeight = this.heightMap.height
225-
let scrollAnchor = this.scrolledToBottom ? null : this.scrollAnchorAt(this.scrollTop)
231+
let scrollAnchor = this.scrolledToBottom ? null : this.scrollAnchorAt(this.scrollOffset)
226232
clearHeightChangeFlag()
227233
this.heightMap = this.heightMap.applyChanges(this.stateDeco, update.startState.doc,
228234
this.heightOracle.setDoc(this.state.doc), heightChanges)
@@ -258,8 +264,8 @@ export class ViewState {
258264
this.mustEnforceCursorAssoc = true
259265
}
260266

261-
measure(view: EditorView) {
262-
let dom = view.contentDOM, style = window.getComputedStyle(dom)
267+
measure() {
268+
let {view} = this, dom = view.contentDOM, style = window.getComputedStyle(dom)
263269
let oracle = this.heightOracle
264270
let whiteSpace = style.whiteSpace!
265271
this.defaultTextDirection = style.direction == "rtl" ? Direction.RTL : Direction.LTR
@@ -294,12 +300,18 @@ export class ViewState {
294300
this.editorWidth = view.scrollDOM.clientWidth
295301
result |= UpdateFlag.Geometry
296302
}
297-
let scrollTop = view.scrollDOM.scrollTop * this.scaleY
298-
if (this.scrollTop != scrollTop) {
303+
let scrollParent = scrollableParents(this.view.contentDOM, false).y
304+
if (scrollParent != this.scrollParent) {
305+
this.scrollParent = scrollParent
299306
this.scrollAnchorHeight = -1
300-
this.scrollTop = scrollTop
307+
this.scrollOffset = 0
301308
}
302-
this.scrolledToBottom = isScrolledToBottom(view.scrollDOM)
309+
let scrollOffset = this.getScrollOffset()
310+
if (this.scrollOffset != scrollOffset) {
311+
this.scrollAnchorHeight = -1
312+
this.scrollOffset = scrollOffset
313+
}
314+
this.scrolledToBottom = isScrolledToBottom(this.scrollParent || view.win)
303315

304316
// Pixel viewport
305317
let pixelViewport = (this.printing ? fullPixelRange : visiblePixelRange)(dom, this.paddingTop)
@@ -581,9 +593,15 @@ export class ViewState {
581593
this.scaler)
582594
}
583595

584-
scrollAnchorAt(scrollTop: number) {
585-
let block = this.lineBlockAtHeight(scrollTop + 8)
586-
return block.from >= this.viewport.from || this.viewportLines[0].top - scrollTop > 200 ? block : this.viewportLines[0]
596+
getScrollOffset() {
597+
let base = this.scrollParent == this.view.scrollDOM ? this.scrollParent.scrollTop
598+
: (this.scrollParent ? this.scrollParent.getBoundingClientRect().top : 0) - this.view.contentDOM.getBoundingClientRect().top
599+
return base * this.scaleY
600+
}
601+
602+
scrollAnchorAt(scrollOffset: number) {
603+
let block = this.lineBlockAtHeight(scrollOffset + 8)
604+
return block.from >= this.viewport.from || this.viewportLines[0].top - scrollOffset > 200 ? block : this.viewportLines[0]
587605
}
588606

589607
elementAtHeight(height: number): BlockInfo {

0 commit comments

Comments
 (0)