-
Notifications
You must be signed in to change notification settings - Fork 2.5k
/
Copy pathscroller.js
468 lines (423 loc) · 18.3 KB
/
scroller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
// activatedElement is different from document.activeElement -- the latter seems to be reserved
// mostly for input elements. This mechanism allows us to decide whether to scroll a div or to
// scroll the whole document.
let activatedElement = null;
// Previously, the main scrolling element was document.body. If the "experimental web platform
// features" flag is enabled, then we need to use document.scrollingElement instead. There's an
// explanation in #2168: https://github.com/philc/vimium/pull/2168#issuecomment-236488091
const getScrollingElement = () =>
getSpecialScrollingElement() || document.scrollingElement || document.body;
// Return 0, -1 or 1: the sign of the argument.
// NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign)
// Math.sign() was only introduced in Chrome 38. This caused problems in R1.48 for users with old
// Chrome installations. We can replace this with Math.sign() at some point.
// TODO(philc): 2020-04-28: now we can make this replacement.
const getSign = function (val) {
if (!val) {
return 0;
} else {
if (val < 0) {
return -1;
} else {
return 1;
}
}
};
const scrollProperties = {
x: {
axisName: "scrollLeft",
max: "scrollWidth",
viewSize: "clientWidth",
},
y: {
axisName: "scrollTop",
max: "scrollHeight",
viewSize: "clientHeight",
},
};
// Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative
// amount, or by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be
// either a number (in which case it is simply returned) or a string. If :amount is a string, then
// it is either "max" (meaning the height or width of element), or "viewSize". In both cases, we
// look up and return the requested amount, either in `element` or in `window`, as appropriate.
const getDimension = function (el, direction, amount) {
if (Utils.isString(amount)) {
const name = amount;
// the clientSizes of the body are the dimensions of the entire page, but the viewport should
// only be the part visible through the window
if ((name === "viewSize") && (el === getScrollingElement())) {
// TODO(smblott) Should we not be returning the width/height of element, here?
return (direction === "x") ? globalThis.innerWidth : globalThis.innerHeight;
} else {
return el[scrollProperties[direction][name]];
}
} else {
return amount;
}
};
// Perform a scroll. Return true if we successfully scrolled by any amount, and false otherwise.
const performScroll = function (element, direction, amount) {
const axisName = scrollProperties[direction].axisName;
const before = element[axisName];
if (element.scrollBy) {
const scrollArg = { behavior: "instant" };
scrollArg[direction === "x" ? "left" : "top"] = amount;
element.scrollBy(scrollArg);
} else {
element[axisName] += amount;
}
return element[axisName] !== before;
};
// Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled.
const shouldScroll = function (element, direction) {
const computedStyle = globalThis.getComputedStyle(element);
// Elements with `overflow: hidden` must not be scrolled.
if (computedStyle.getPropertyValue(`overflow-${direction}`) === "hidden") {
return false;
}
// Elements which are not visible should not be scrolled.
if (["hidden", "collapse"].includes(computedStyle.getPropertyValue("visibility"))) {
return false;
}
if (computedStyle.getPropertyValue("display") === "none") {
return false;
}
return true;
};
// Test whether element does actually scroll in the direction required when asked to do so. Due to
// chrome bug 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an
// element will scroll. Instead, we scroll the element by 1 or -1 and see if it moved (then put it
// back). :factor is the factor by which :scrollBy and :scrollTo will later scale the scroll amount.
// :factor can be negative, so we need it here in order to decide whether we should test a forward
// scroll or a backward scroll.
// Bug last verified in Chrome 38.0.2125.104.
const doesScroll = function (element, direction, amount, factor) {
// amount is treated as a relative amount, which is correct for relative scrolls. For absolute
// scrolls (only gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero.
// In the former case, we're definitely scrolling forwards, so any positive value will do for
// delta. In the latter, we're definitely scrolling backwards, so a delta of -1 will do. For
// absolute scrolls, factor is always 1.
let delta = (factor * getDimension(element, direction, amount)) || -1;
delta = getSign(delta); // 1 or -1
return performScroll(element, direction, delta) && performScroll(element, direction, -delta);
};
const isScrollableElement = function (element, direction, amount, factor) {
if (direction == null) direction = "y";
if (amount == null) amount = 1;
if (factor == null) factor = 1;
return doesScroll(element, direction, amount, factor) && shouldScroll(element, direction);
};
// From element and its parents, find the first which we should scroll and which does scroll.
const findScrollableElement = function (element, direction, amount, factor) {
while (
(element !== getScrollingElement()) && !isScrollableElement(element, direction, amount, factor)
) {
element = DomUtils.getContainingElement(element) || getScrollingElement();
}
return element;
};
// On some pages, the scrolling element is not actually scrollable. Here, we search the document for
// the largest visible element which does scroll vertically. This is used to initialize
// activatedElement. See #1358.
const firstScrollableElement = function (element = null) {
let child;
if (!element) {
const scrollingElement = getScrollingElement();
if (doesScroll(scrollingElement, "y", 1, 1) || doesScroll(scrollingElement, "y", -1, 1)) {
return scrollingElement;
} else {
element = document.body || getScrollingElement();
}
}
if (doesScroll(element, "y", 1, 1) || doesScroll(element, "y", -1, 1)) {
return element;
} else {
// children = children.filter (c) -> c.rect # Filter out non-visible elements.
const children = Array.from(element.children)
.map((c) => ({ "element": c, "rect": DomUtils.getVisibleClientRect(c) }))
.filter((child) => child.rect); // Filter out non-visible elements.
children.map((child) => child.area = child.rect.width * child.rect.height);
for (child of children.sort((a, b) => b.area - a.area)) { // Largest to smallest by visible area.
const el = firstScrollableElement(child.element);
if (el) {
return el;
}
}
return null;
}
};
const checkVisibility = function (element) {
// If the activated element has been scrolled completely offscreen, then subsequent changes in its
// scroll position will not provide any more visual feedback to the user. Therefore, we deactivate
// it so that subsequent scrolls affect the parent element.
const rect = activatedElement.getBoundingClientRect();
if (
(rect.bottom < 0) || (rect.top > globalThis.innerHeight) || (rect.right < 0) ||
(rect.left > globalThis.innerWidth)
) {
return activatedElement = element;
}
};
// How scrolling is handled by CoreScroller.
// - For jump scrolling, the entire scroll happens immediately.
// - For smooth scrolling with distinct key presses, a separate animator is initiated for each key
// press. Therefore, several animators may be active at the same time. This ensures that two
// quick taps on `j` scroll to the same position as two slower taps.
// - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated
// animator continues scrolling at least until its keyup event is received. We never initiate a
// new animator on keyboard repeat.
// CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are
// ultimately translated to relative scrolls. CoreScroller is not exported.
const CoreScroller = {
init() {
this.time = 0;
this.lastEvent = this.keyIsDown = null;
this.installCanceEventListener();
},
// This installs listeners for events which should cancel smooth scrolling.
installCanceEventListener() {
// NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup
// event for every keydown, in which case tapping "j" scrolls indefinitely. This appears to be a
// Chrome/OS/XOrg bug of some kind. See #1549.
// TODO(philc): I believe some of these returns are unnecessary.
return handlerStack.push({
_name: "scroller/track-key-status",
keydown: (event) => {
return handlerStack.alwaysContinueBubbling(() => {
this.keyIsDown = true;
if (!event.repeat) this.time += 1;
this.lastEvent = event;
});
},
keyup: (_event) => {
return handlerStack.alwaysContinueBubbling(() => {
this.keyIsDown = false;
this.time += 1;
});
},
blur: (event) => {
return handlerStack.alwaysContinueBubbling(() => {
if (event.target === window) this.time += 1;
});
},
});
},
// Return true if CoreScroller would not initiate a new scroll right now.
wouldNotInitiateScroll() {
return this.lastEvent && this.lastEvent.repeat && Settings.get("smoothScroll");
},
// Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We
// then increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or
// decrease it (until it is less than @minCalibration) if we guess that the scroll is too fast.
// The cutoff point for which guess we make is @calibrationBoundary. We require: 0
// < @minCalibration <= 1 <= @maxCalibration.
// Controls how much we're willing to slow scrolls down; smaller means more slow down.
minCalibration: 0.5,
// Controls how much we're willing to speed scrolls up; bigger means more speed up.
maxCalibration: 1.6,
// Boundary between scrolls which are considered too slow, or too fast.
calibrationBoundary: 150,
// Scroll element by a relative amount (a number) in some direction.
scroll(element, direction, amount, continuous) {
if (continuous == null) continuous = true;
if (!amount) {
return;
}
if (!Settings.get("smoothScroll")) {
// Jump scrolling.
performScroll(element, direction, amount);
checkVisibility(element);
return;
}
// We don't activate new animators on keyboard repeats; rather, the most-recently activated
// animator continues scrolling.
if (this.lastEvent != null ? this.lastEvent.repeat : undefined) {
return;
}
const activationTime = ++this.time;
const myKeyIsStillDown = () => (this.time === activationTime) && this.keyIsDown;
// Store amount's sign and make amount positive; the arithmetic is clearer when amount is
// positive.
const sign = getSign(amount);
amount = Math.abs(amount);
// Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls.
const duration = Math.max(100, 20 * Math.log(amount));
let totalDelta = 0;
let totalElapsed = 0.0;
let calibration = 1.0;
let previousTimestamp = null;
const cancelEventListener = this.installCanceEventListener();
const animate = (timestamp) => {
if (previousTimestamp == null) {
previousTimestamp = timestamp;
}
if (timestamp === previousTimestamp) {
return requestAnimationFrame(animate);
}
// The elapsed time is typically about 16ms.
const elapsed = timestamp - previousTimestamp;
totalElapsed += elapsed;
previousTimestamp = timestamp;
// The constants in the duration calculation, above, are chosen to provide reasonable scroll
// speeds for distinct keypresses. For continuous scrolls, some scrolls are too slow, and
// others too fast. Here, we speed up the slower scrolls, and slow down the faster scrolls.
if (
myKeyIsStillDown() && (75 <= totalElapsed) &&
(this.minCalibration <= calibration && calibration <= this.maxCalibration)
) {
// Speed up slow scrolls.
if ((1.05 * calibration * amount) < this.calibrationBoundary) {
calibration *= 1.05;
}
// Slow down fast scrolls.
if (this.calibrationBoundary < (0.95 * calibration * amount)) {
calibration *= 0.95;
}
}
// Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account
// for the current scroll state.
let delta = Math.ceil(amount * (elapsed / duration) * calibration);
delta = myKeyIsStillDown() ? delta : Math.max(0, Math.min(delta, amount - totalDelta));
if (delta && performScroll(element, direction, sign * delta)) {
totalDelta += delta;
return requestAnimationFrame(animate);
} else {
// We're done.
handlerStack.remove(cancelEventListener);
return checkVisibility(element);
}
};
// If we've been asked not to be continuous, then we advance time, so the myKeyIsStillDown test
// always fails.
if (!continuous) {
++this.time;
}
// Start scrolling.
requestAnimationFrame(animate);
},
};
// Scroller contains the two main scroll functions which are used by clients.
const Scroller = {
init() {
const handler = { _name: "scroller/active-element" };
// Only Chrome has a DOMActivate event. On Firefox, we must listen for click. See #3287.
const eventName = Utils.isFirefox() ? "click" : "DOMActivate";
handler[eventName] = (event) =>
handlerStack.alwaysContinueBubbling(function () {
// If event.path is present, the true event taget (potentially inside a Shadow DOM inside
// event.target) can be found as its first element.
// NOTE(mrmr1993): event.path has been renamed to event.deepPath in the spec, but this
// change is not yet implemented by Chrome.
const path = event.deepPath || event.path;
return activatedElement = path ? path[0] : event.target;
});
handlerStack.push(handler);
CoreScroller.init();
this.reset();
},
reset() {
activatedElement = null;
},
// scroll the active element in :direction by :amount * :factor.
// :factor is needed because :amount can take on string values, which scrollBy converts to element
// dimensions.
scrollBy(direction, amount, factor, continuous) {
// if this is called before domReady, just use the window scroll function
if (factor == null) {
factor = 1;
}
if (continuous == null) {
continuous = true;
}
if (!getScrollingElement() && amount instanceof Number) {
if (direction === "x") {
globalThis.scrollBy(amount, 0);
} else {
globalThis.scrollBy(0, amount);
}
return;
}
if (!activatedElement) {
activatedElement = (getScrollingElement() && firstScrollableElement()) ||
getScrollingElement();
}
if (!activatedElement) {
return;
}
// Avoid the expensive scroll calculation if it will not be used. This reduces costs during
// smooth, continuous scrolls, and is just an optimization.
if (!CoreScroller.wouldNotInitiateScroll()) {
const element = findScrollableElement(activatedElement, direction, amount, factor);
const elementAmount = factor * getDimension(element, direction, amount);
return CoreScroller.scroll(element, direction, elementAmount, continuous);
}
},
scrollTo(direction, pos) {
if (!activatedElement) {
activatedElement = (getScrollingElement() && firstScrollableElement()) ||
getScrollingElement();
}
if (!activatedElement) {
return;
}
const element = findScrollableElement(activatedElement, direction, pos, 1);
const amount = getDimension(element, direction, pos) -
element[scrollProperties[direction].axisName];
return CoreScroller.scroll(element, direction, amount);
},
// Is element scrollable and not the activated element?
isScrollableElement(element) {
if (!activatedElement) {
activatedElement = (getScrollingElement() && firstScrollableElement()) ||
getScrollingElement();
}
return (element !== activatedElement) && isScrollableElement(element);
},
// Scroll the top, bottom, left and right of element into view. The is used by visual mode to
// ensure the focus remains visible.
scrollIntoView(element) {
if (!activatedElement) {
activatedElement = getScrollingElement() && firstScrollableElement();
}
const rects = element.getClientRects();
const rect = rects ? rects[0] : undefined;
if (rect) {
// Scroll y axis.
let amount;
if (rect.bottom < 0) {
amount = rect.bottom - Math.min(rect.height, globalThis.innerHeight);
element = findScrollableElement(element, "y", amount, 1);
CoreScroller.scroll(element, "y", amount, false);
} else if (globalThis.innerHeight < rect.top) {
amount = rect.top + Math.min(rect.height - globalThis.innerHeight, 0);
element = findScrollableElement(element, "y", amount, 1);
CoreScroller.scroll(element, "y", amount, false);
}
// Scroll x axis.
if (rect.right < 0) {
amount = rect.right - Math.min(rect.width, globalThis.innerWidth);
element = findScrollableElement(element, "x", amount, 1);
CoreScroller.scroll(element, "x", amount, false);
} else if (globalThis.innerWidth < rect.left) {
amount = rect.left + Math.min(rect.width - globalThis.innerWidth, 0);
element = findScrollableElement(element, "x", amount, 1);
CoreScroller.scroll(element, "x", amount, false);
}
}
},
};
const getSpecialScrollingElement = function () {
const selector = specialScrollingElementMap[globalThis.location.host];
if (selector) {
return document.querySelector(selector);
}
};
const specialScrollingElementMap = {
"twitter.com": "div.permalink-container div.permalink[role=main]",
"reddit.com": "#overlayScrollContainer",
"new.reddit.com": "#overlayScrollContainer",
"www.reddit.com": "#overlayScrollContainer",
"web.telegram.org": ".MessageList",
};
globalThis.Scroller = Scroller;