main
js 232 lines 6.58 KB
Raw
1 /**
2 * Stop delayed or in-flight scrolling previously scheduled for an element.
3 * Virtualized containers call this before replacing their children so an old
4 * auto-scroll cannot override the restored viewport anchor.
5 */
6 export function cancelPendingScroll(element) {
7 if (!element) return;
8
9 const timeoutRaw = element.dataset?.scrollerTimeout;
10 const timeoutId = timeoutRaw == null ? null : Number(timeoutRaw);
11 if (Number.isFinite(timeoutId)) clearTimeout(timeoutId);
12
13 if (element.dataset?.scrollingTo != null && element.scrollTo) {
14 element.scrollTo({ top: element.scrollTop, behavior: "instant" });
15 }
16
17 delete element.dataset.scrollerTimeout;
18 delete element.dataset.scrollerReapplySnapshot;
19 delete element.dataset.scrollingTo;
20 }
21
22 export class Scroller {
23 constructor(
24 element,
25 {
26 smooth = false,
27 toleranceRem = 2,
28 reapplyDelayMs = 0,
29 reapplyToleranceRatio = 0,
30 applyStabilization = false,
31 } = {},
32 ) {
33 this.element = element;
34 this.smooth = smooth;
35 this.reapplyDelayMs = reapplyDelayMs;
36 this.reapplyToleranceRatio = reapplyToleranceRatio;
37 this.tolerance =
38 toleranceRem *
39 parseFloat(getComputedStyle(document.documentElement).fontSize);
40 this.wasAtBottom = this.isAtBottom();
41 this._scrollListener = null;
42
43 this._resizeObserver = null;
44 this._lastClientHeight = null;
45
46 if (applyStabilization) this._initScrollStabilization();
47 }
48
49 _initScrollStabilization() {
50 if (!this.element) return;
51
52 const hasStabilizationFlag =
53 this.element?.dataset?.scrollStabilization != null;
54 if (hasStabilizationFlag) return;
55
56 if (typeof ResizeObserver === "undefined") return;
57
58 this._lastClientHeight = this.element.clientHeight;
59 this._resizeObserver = new ResizeObserver(() => {
60 const prevHeight = this._lastClientHeight;
61 const nextHeight = this.element.clientHeight;
62
63 if (prevHeight == null) {
64 this._lastClientHeight = nextHeight;
65 return;
66 }
67
68 if (nextHeight === prevHeight) return;
69
70 const delta = nextHeight - prevHeight;
71 this._lastClientHeight = nextHeight;
72
73 const scrollTop = this._getEffectiveScrollTop();
74 const bottomDistanceBefore =
75 this.element.scrollHeight - scrollTop - prevHeight;
76 const wasAtBottom = bottomDistanceBefore <= this.tolerance;
77 this.wasAtBottom = wasAtBottom;
78
79 if (wasAtBottom) {
80 this.scrollToBottom();
81 return;
82 }
83
84 if (delta !== 0) {
85 this._clearScrollingTo();
86 this.element.scrollTop = Math.max(0, this.element.scrollTop - delta);
87 }
88 });
89
90 this._resizeObserver.observe(this.element);
91
92 this.element.dataset.scrollStabilization = "1";
93 }
94
95 _getEffectiveScrollTop() {
96 const scrollingToRaw = this.element?.dataset?.scrollingTo;
97 const scrollingTo = scrollingToRaw == null ? null : Number(scrollingToRaw);
98 if (Number.isFinite(scrollingTo)) return scrollingTo;
99 return this.element.scrollTop;
100 }
101
102 _setScrollingTo(target) {
103 this.element.dataset.scrollingTo = String(target);
104
105 if (this._scrollListener) return;
106
107 this._scrollListener = () => {
108 const current = this.element.scrollTop;
109 const activeTargetRaw = this.element?.dataset?.scrollingTo;
110 const activeTarget = activeTargetRaw == null ? null : Number(activeTargetRaw);
111 if (!Number.isFinite(activeTarget)) {
112 this._clearScrollingTo();
113 return;
114 }
115
116 if (current >= activeTarget - 1) this._clearScrollingTo();
117 };
118
119 this.element.addEventListener("scroll", this._scrollListener, {
120 passive: true,
121 });
122 }
123
124 _clearScrollingTo() {
125 delete this.element.dataset.scrollingTo;
126 if (this._scrollListener) {
127 this.element.removeEventListener("scroll", this._scrollListener);
128 this._scrollListener = null;
129 }
130 }
131
132 isAtBottom() {
133 const { scrollHeight, clientHeight } = this.element;
134 const scrollTop = this._getEffectiveScrollTop();
135 return scrollHeight - scrollTop - clientHeight <= this.tolerance;
136 }
137
138 _getBottomDistancePx() {
139 const { scrollHeight, clientHeight } = this.element;
140 const scrollTop = this._getEffectiveScrollTop();
141 return scrollHeight - scrollTop - clientHeight;
142 }
143
144 scrollToBottom() {
145 const target = Math.max(
146 0,
147 this.element.scrollHeight - this.element.clientHeight,
148 );
149 if (this.smooth) {
150 this._setScrollingTo(target);
151 this.element.scrollTo({ top: target, behavior: "smooth" });
152 } else {
153 this._clearScrollingTo();
154 this.element.scrollTop = target;
155 }
156 }
157
158 _getReapplyTimeoutId() {
159 const raw = this.element?.dataset?.scrollerTimeout;
160 const parsed = raw == null ? null : Number(raw);
161 if (!Number.isFinite(parsed)) return null;
162 return parsed;
163 }
164
165 _getReapplyScrollSnapshot() {
166 const raw = this.element?.dataset?.scrollerReapplySnapshot;
167 const parsed = raw == null ? null : Number(raw);
168 if (!Number.isFinite(parsed)) return null;
169 return parsed;
170 }
171
172 _clearReapplyTimeout() {
173 const id = this._getReapplyTimeoutId();
174 if (id != null) clearTimeout(id);
175 delete this.element.dataset.scrollerTimeout;
176 delete this.element.dataset.scrollerReapplySnapshot;
177 }
178
179 _scheduleReapplyScrollToBottom() {
180 this._clearReapplyTimeout();
181
182 const snapshot = this._getEffectiveScrollTop();
183 this.element.dataset.scrollerReapplySnapshot = String(snapshot);
184
185 const id = setTimeout(() => {
186 delete this.element.dataset.scrollerTimeout;
187
188 const expected = this._getReapplyScrollSnapshot();
189 delete this.element.dataset.scrollerReapplySnapshot;
190 if (expected != null && this._getEffectiveScrollTop() !== expected) return;
191
192 if (!this.wasAtBottom) return;
193 if (!this.isAtBottom()) return;
194
195 this.scrollToBottom();
196 }, this.reapplyDelayMs);
197
198 this.element.dataset.scrollerTimeout = String(id);
199 }
200
201 reApplyScroll(instant = false) {
202 this._clearReapplyTimeout();
203
204 if (!this.wasAtBottom) return;
205
206 if (instant) {
207 this.scrollToBottom();
208 return;
209 }
210
211 if (!this.isAtBottom()) {
212 this.scrollToBottom();
213 return;
214 }
215
216 const ratio = this.reapplyToleranceRatio;
217 if (ratio <= 0) {
218 if (this.reapplyDelayMs > 0) this._scheduleReapplyScrollToBottom();
219 else this.scrollToBottom();
220 return;
221 }
222
223 const dist = this._getBottomDistancePx();
224 const tolerance = this.tolerance;
225 const clampedDist = Math.max(0, Math.min(dist, tolerance));
226 const progress = tolerance > 0 ? 1 - clampedDist / tolerance : 1;
227 if (progress < ratio) return;
228
229 if (this.reapplyDelayMs > 0) this._scheduleReapplyScrollToBottom();
230 else this.scrollToBottom();
231 }
232 }