tweb/src/components/scrollable.ts

483 lines
14 KiB
TypeScript
Raw Permalink Normal View History

2023-01-06 20:27:29 +01:00
/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import IS_TOUCH_SUPPORTED from '../environment/touchSupport';
import {logger, LogTypes} from '../lib/logger';
import fastSmoothScroll, {ScrollOptions} from '../helpers/fastSmoothScroll';
import useHeavyAnimationCheck from '../hooks/useHeavyAnimationCheck';
import cancelEvent from '../helpers/dom/cancelEvent';
2023-09-27 13:12:41 +02:00
import {IS_OVERLAY_SCROLL_SUPPORTED} from '../environment/overlayScrollSupport';
2023-10-04 16:40:51 +02:00
import {IS_MOBILE_SAFARI, IS_SAFARI} from '../environment/userAgent';
2023-01-06 20:27:29 +01:00
/*
var el = $0;
var height = 0;
var checkUp = false;
do {
height += el.scrollHeight;
} while(el = (checkUp ? el.previousElementSibling : el.nextElementSibling));
console.log(height);
*/
/*
Array.from($0.querySelectorAll('.bubble-content')).forEach((_el) => {
//_el.style.display = '';
//return;
let el = _el.parentElement;
let height = el.scrollHeight;
let width = el.scrollWidth;
el.style.width = width + 'px';
el.style.height = height + 'px';
_el.style.display = 'none';
});
*/
/* const scrollables: Map<HTMLElement, Scrollable> = new Map();
const scrollsIntersector = new IntersectionObserver((entries) => {
for(let entry of entries) {
const scrollable = scrollables.get(entry.target as HTMLElement);
if(entry.isIntersecting) {
scrollable.isVisible = true;
} else {
scrollable.isVisible = false;
if(!isInDOM(entry.target)) {
scrollsIntersector.unobserve(scrollable.container);
scrollables.delete(scrollable.container);
}
}
}
}); */
2023-01-16 23:24:48 +01:00
const SCROLL_THROTTLE = /* IS_ANDROID ? 200 : */24;
2023-09-27 13:12:41 +02:00
const USE_OWN_SCROLL = !IS_OVERLAY_SCROLL_SUPPORTED;
let throttleMeasurement: (callback: () => void) => number,
cancelMeasurement: (id: number) => void;
if(USE_OWN_SCROLL) {
throttleMeasurement = (callback) => requestAnimationFrame(callback);
cancelMeasurement = (id) => cancelAnimationFrame(id);
} else {
throttleMeasurement = (callback) => window.setTimeout(callback, SCROLL_THROTTLE);
cancelMeasurement = (id) => window.clearTimeout(id);
}
2023-01-06 20:27:29 +01:00
export class ScrollableBase {
protected log: ReturnType<typeof logger>;
2023-09-27 13:12:41 +02:00
public padding: HTMLElement;
2023-01-06 20:27:29 +01:00
public splitUp: HTMLElement;
public onScrollMeasure: number = 0;
public lastScrollPosition: number = 0;
public lastScrollDirection: number = 0;
public onAdditionalScroll: () => void;
public onScrolledTop: () => void;
public onScrolledBottom: () => void;
public isHeavyAnimationInProgress = false;
protected needCheckAfterAnimation = false;
public checkForTriggers?: () => void;
2023-09-27 13:12:41 +02:00
public scrollPositionProperty: 'scrollTop' | 'scrollLeft';
public scrollSizeProperty: 'scrollHeight' | 'scrollWidth';
public clientSizeProperty: 'clientHeight' | 'clientWidth';
public offsetSizeProperty: 'offsetHeight' | 'offsetWidth';
public clientAxis: 'clientY' | 'clientX';
protected startMousePosition: number;
protected startScrollPosition: number;
protected thumb: HTMLElement;
protected thumbContainer: HTMLElement;
2023-01-06 20:27:29 +01:00
protected removeHeavyAnimationListener: () => void;
protected addedScrollListener: boolean;
2023-09-27 13:12:41 +02:00
constructor(
public el?: HTMLElement,
logPrefix = '',
public container: HTMLElement = document.createElement('div')
) {
2023-01-06 20:27:29 +01:00
this.container.classList.add('scrollable');
this.log = logger('SCROLL' + (logPrefix ? '-' + logPrefix : ''), LogTypes.Error);
if(el) {
Array.from(el.children).forEach((c) => this.container.append(c));
el.append(this.container);
}
2023-09-27 13:12:41 +02:00
2023-01-06 20:27:29 +01:00
// this.onScroll();
}
public addScrollListener() {
if(this.addedScrollListener) {
return;
}
this.addedScrollListener = true;
this.container.addEventListener('scroll', this.onScroll, {passive: true, capture: true});
}
public removeScrollListener() {
if(!this.addedScrollListener) {
return;
}
this.addedScrollListener = false;
this.container.removeEventListener('scroll', this.onScroll, {capture: true});
}
public setListeners() {
if(this.removeHeavyAnimationListener) {
return;
}
window.addEventListener('resize', this.onScroll, {passive: true});
this.addScrollListener();
this.removeHeavyAnimationListener = useHeavyAnimationCheck(() => {
this.isHeavyAnimationInProgress = true;
if(this.onScrollMeasure) {
this.cancelMeasure();
this.needCheckAfterAnimation = true;
}
}, () => {
this.isHeavyAnimationInProgress = false;
if(this.needCheckAfterAnimation) {
this.onScroll();
this.needCheckAfterAnimation = false;
}
});
}
public removeListeners() {
if(!this.removeHeavyAnimationListener) {
return;
}
window.removeEventListener('resize', this.onScroll);
2023-09-27 13:12:41 +02:00
if(this.thumb) {
this.thumb.removeEventListener('mousedown', this.onMouseMove);
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
}
2023-01-06 20:27:29 +01:00
this.removeScrollListener();
this.removeHeavyAnimationListener();
this.removeHeavyAnimationListener = undefined;
}
public destroy() {
this.removeListeners();
this.onAdditionalScroll = undefined;
this.onScrolledTop = undefined;
this.onScrolledBottom = undefined;
}
2023-09-27 13:12:41 +02:00
public prepend(...elements: (string | Node)[]) {
const prependTo = this.splitUp || this.padding || this.container;
this.thumb && prependTo !== this.container && elements.unshift(this.thumbContainer);
prependTo.prepend(...elements);
this.onSizeChange();
}
public append(...elements: (string | Node)[]) {
(this.splitUp || this.padding || this.container).append(...elements);
this.onSizeChange();
2023-01-06 20:27:29 +01:00
}
public scrollIntoViewNew(options: Omit<ScrollOptions, 'container'>) {
// return Promise.resolve();
// this.removeListeners();
return fastSmoothScroll({
...options,
container: this.container
});/* .finally(() => {
this.setListeners();
}); */
}
public onScroll = () => {
// if(this.debug) {
// this.log('onScroll call', this.onScrollMeasure);
// }
// return;
if(this.isHeavyAnimationInProgress) {
this.cancelMeasure();
this.needCheckAfterAnimation = true;
return;
}
// if(this.onScrollMeasure || ((this.scrollLocked || (!this.onScrolledTop && !this.onScrolledBottom)) && !this.splitUp && !this.onAdditionalScroll)) return;
if((!this.onScrolledTop && !this.onScrolledBottom) && !this.splitUp && !this.onAdditionalScroll) return;
if(this.onScrollMeasure) return;
2023-09-27 13:12:41 +02:00
this.onScrollMeasure = throttleMeasurement(() => {
2023-01-06 20:27:29 +01:00
this.onScrollMeasure = 0;
2023-09-27 13:12:41 +02:00
const scrollPosition = this.scrollPosition;
2023-01-06 20:27:29 +01:00
this.lastScrollDirection = this.lastScrollPosition === scrollPosition ? 0 : (this.lastScrollPosition < scrollPosition ? 1 : -1); // * 1 - bottom, -1 - top
this.lastScrollPosition = scrollPosition;
2023-11-06 15:35:37 +01:00
this.updateThumb(scrollPosition);
2023-09-27 13:12:41 +02:00
2023-01-06 20:27:29 +01:00
// lastScrollDirection check is useless here, every callback should decide on its own
if(this.onAdditionalScroll/* && this.lastScrollDirection !== 0 */) {
this.onAdditionalScroll();
}
if(this.checkForTriggers) {
this.checkForTriggers();
}
2023-09-27 13:12:41 +02:00
});
2023-01-06 20:27:29 +01:00
};
2023-11-06 15:35:37 +01:00
public updateThumb(scrollPosition = this.scrollPosition) {
if(!USE_OWN_SCROLL || !this.thumb) {
return;
}
const scrollSize = this.container[this.scrollSizeProperty];
const clientSize = this.container[this.clientSizeProperty];
const divider = scrollSize / clientSize / 0.75;
const thumbSize = Math.max(20, clientSize / divider);
const value = scrollPosition / (scrollSize - clientSize) * clientSize;
// const b = (scrollPosition + clientSize) / scrollSize;
const b = scrollPosition / (scrollSize - clientSize);
const maxValue = clientSize - thumbSize;
if(clientSize < scrollSize) {
this.thumb.style.height = thumbSize + 'px';
2024-02-20 14:07:13 +01:00
// this.thumb.style.top = `${Math.min(maxValue, value - thumbSize * b)}px`;
2023-11-06 15:35:37 +01:00
this.thumb.style.transform = `translateY(${Math.min(maxValue, value - thumbSize * b)}px)`;
} else {
this.thumb.style.height = '0px';
}
}
2023-01-06 20:27:29 +01:00
public cancelMeasure() {
if(this.onScrollMeasure) {
2023-09-27 13:12:41 +02:00
cancelMeasurement(this.onScrollMeasure);
2023-01-06 20:27:29 +01:00
this.onScrollMeasure = 0;
}
}
2023-09-27 13:12:41 +02:00
protected onMouseMove = (e: MouseEvent) => {
cancelEvent(e);
const contentHeight = this.scrollSize;
const viewportHeight = this.clientSize;
const scrollbarSize = this.thumb.offsetHeight;
const maxScrollTop = contentHeight - viewportHeight;
const maxScrollbarOffset = viewportHeight - scrollbarSize;
const deltaY = e[this.clientAxis] - this.startMousePosition;
const scrollAmount = (deltaY / maxScrollbarOffset) * maxScrollTop;
const newScrollTop = this.startScrollPosition + scrollAmount;
this.scrollPosition = newScrollTop;
};
protected onMouseDown = (e: MouseEvent) => {
cancelEvent(e);
this.startMousePosition = e[this.clientAxis];
this.startScrollPosition = this.scrollPosition;
this.thumb.classList.add('is-focused');
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp, {once: true});
};
protected onMouseUp = (e: MouseEvent) => {
window.removeEventListener('mousemove', this.onMouseMove);
this.thumb.classList.remove('is-focused');
};
public onSizeChange() {
if(USE_OWN_SCROLL && this.thumb) {
this.onScroll();
}
}
public getDistanceToEnd() {
return this.scrollSize - Math.round(this.scrollPosition + this.offsetSize);
}
get isScrolledToEnd() {
return this.getDistanceToEnd() <= 1;
}
get scrollPosition() {
return this.container[this.scrollPositionProperty];
}
set scrollPosition(value: number) {
this.container[this.scrollPositionProperty] = value;
}
get scrollSize() {
return this.container[this.scrollSizeProperty];
}
get clientSize() {
return this.container[this.clientSizeProperty];
}
get offsetSize() {
return this.container[this.offsetSizeProperty];
}
get firstElementChild() {
return this.thumb ? this.thumbContainer.nextElementSibling : this.container.firstElementChild;
}
public setScrollPositionSilently(value: number) {
this.lastScrollPosition = value;
this.ignoreNextScrollEvent();
this.scrollPosition = value;
}
public ignoreNextScrollEvent() {
if(this.removeHeavyAnimationListener) {
this.removeScrollListener();
this.container.addEventListener('scroll', (e) => {
cancelEvent(e);
this.addScrollListener();
}, {capture: true, passive: false, once: true});
}
}
public replaceChildren(...args: (string | Node)[]) {
this.thumb && args.unshift(this.thumbContainer);
this.container.replaceChildren(...args);
}
2023-01-06 20:27:29 +01:00
}
export type SliceSides = 'top' | 'bottom';
export type SliceSidesContainer = {[k in SliceSides]: boolean};
export default class Scrollable extends ScrollableBase {
public loadedAll: SliceSidesContainer = {top: true, bottom: false};
2023-09-27 13:12:41 +02:00
constructor(
el?: HTMLElement,
logPrefix = '',
public onScrollOffset = 300,
2024-01-26 20:40:00 +01:00
withPaddingContainer?: boolean,
container?: HTMLElement
2023-09-27 13:12:41 +02:00
) {
2024-01-26 20:40:00 +01:00
super(el, logPrefix, container);
2023-01-06 20:27:29 +01:00
2023-09-27 13:12:41 +02:00
// withPaddingContainer = true;
// if(withPaddingContainer) {
// this.padding = document.createElement('div');
// this.padding.classList.add('scrollable-padding');
// this.padding.append(...Array.from(this.container.children));
// this.container.append(this.padding);
// }
this.scrollPositionProperty = 'scrollTop';
this.scrollSizeProperty = 'scrollHeight';
this.clientSizeProperty = 'clientHeight';
this.offsetSizeProperty = 'offsetHeight';
this.clientAxis = 'clientY';
if(USE_OWN_SCROLL) {
this.thumbContainer = document.createElement('div');
this.thumbContainer.classList.add('scrollable-thumb-container');
this.thumb = document.createElement('div');
this.thumb.classList.add('scrollable-thumb');
this.thumbContainer.append(this.thumb);
this.container.prepend(this.thumbContainer);
this.thumb.addEventListener('mousedown', this.onMouseDown);
}
2023-01-06 20:27:29 +01:00
this.container.classList.add('scrollable-y');
2023-10-04 16:40:51 +02:00
if(IS_SAFARI && !IS_MOBILE_SAFARI) {
this.container.classList.add('no-scrollbar');
}
2023-01-06 20:27:29 +01:00
this.setListeners();
}
public attachBorderListeners(setClassOn = this.container) {
const cb = this.onAdditionalScroll;
this.onAdditionalScroll = () => {
cb?.();
2023-09-27 13:12:41 +02:00
setClassOn.classList.toggle('scrolled-top', !this.scrollPosition);
setClassOn.classList.toggle('scrolled-bottom', this.isScrolledToEnd);
2023-01-06 20:27:29 +01:00
};
setClassOn.classList.add('scrolled-top', 'scrolled-bottom', 'scrollable-y-bordered');
}
public setVirtualContainer(el?: HTMLElement) {
this.splitUp = el;
this.log('setVirtualContainer:', el, this);
}
public checkForTriggers = () => {
if((!this.onScrolledTop && !this.onScrolledBottom)) return;
if(this.isHeavyAnimationInProgress) {
this.onScroll();
return;
}
const {scrollSize, scrollPosition, clientSize} = this;
if(!scrollSize) { // незачем вызывать триггеры если блок пустой или не виден
2023-01-06 20:27:29 +01:00
return;
}
const maxScrollPosition = scrollSize - clientSize;
2023-01-06 20:27:29 +01:00
// this.log('checkForTriggers:', scrollTop, maxScrollTop);
if(this.onScrolledTop && scrollPosition <= this.onScrollOffset && this.lastScrollDirection <= 0/* && direction === -1 */) {
2023-01-06 20:27:29 +01:00
this.onScrolledTop();
}
if(this.onScrolledBottom && (maxScrollPosition - scrollPosition) <= this.onScrollOffset && this.lastScrollDirection >= 0/* && direction === 1 */) {
2023-01-06 20:27:29 +01:00
this.onScrolledBottom();
}
};
}
export class ScrollableX extends ScrollableBase {
constructor(el: HTMLElement, logPrefix = '', public onScrollOffset = 300, public splitCount = 15, public container: HTMLElement = document.createElement('div')) {
super(el, logPrefix, container);
this.container.classList.add('scrollable-x');
if(!IS_TOUCH_SUPPORTED) {
const scrollHorizontally = (e: WheelEvent) => {
e.stopPropagation();
2023-01-06 20:27:29 +01:00
if(!e.deltaX && this.container.scrollWidth > this.container.clientWidth) {
this.container.scrollLeft += e.deltaY / 4;
cancelEvent(e);
}
};
this.container.addEventListener('wheel', scrollHorizontally, {passive: false});
}
2023-09-27 13:12:41 +02:00
this.scrollPositionProperty = 'scrollLeft';
this.scrollSizeProperty = 'scrollWidth';
this.clientSizeProperty = 'clientWidth';
this.offsetSizeProperty = 'offsetWidth';
2023-01-06 20:27:29 +01:00
}
}