426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
|
import { logger, deferredPromise, CancellablePromise } from "../lib/polyfill";
|
||
|
|
||
|
/*
|
||
|
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__container')).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';
|
||
|
});
|
||
|
*/
|
||
|
|
||
|
export default class Scrollable {
|
||
|
public container: HTMLDivElement;
|
||
|
|
||
|
public type: string;
|
||
|
public side: string;
|
||
|
public translate: string;
|
||
|
public scrollType: string;
|
||
|
public scrollSide: string;
|
||
|
public clientAxis: string;
|
||
|
public clientSize: string;
|
||
|
|
||
|
public scrollSize = -1; // it will be scrollHeight
|
||
|
public size = 0; // it will be outerHeight of container (not scrollHeight)
|
||
|
|
||
|
public splitUp: HTMLElement;
|
||
|
|
||
|
public onScrolledTop: () => void = null;
|
||
|
public onScrolledBottom: () => void = null;
|
||
|
public onScrolledTopFired = false;
|
||
|
public onScrolledBottomFired = false;
|
||
|
|
||
|
public onScrollMeasure: number = null;
|
||
|
|
||
|
public lastScrollTop: number = 0;
|
||
|
public scrollTopOffset: number = 0;
|
||
|
|
||
|
private disableHoverTimeout: number = 0;
|
||
|
|
||
|
private log: ReturnType<typeof logger>;
|
||
|
private debug = false;
|
||
|
|
||
|
private measureMutex: CancellablePromise<void>;
|
||
|
|
||
|
private observer: IntersectionObserver;
|
||
|
private visible: Set<HTMLElement>;
|
||
|
private virtualTempIDTop = 0;
|
||
|
private virtualTempIDBottom = 0;
|
||
|
private lastTopID = 0;
|
||
|
private lastBottomID = 0;
|
||
|
private lastScrollDirection = true; // true = bottom
|
||
|
|
||
|
private setVisible(element: HTMLElement) {
|
||
|
if(this.visible.has(element)) return;
|
||
|
|
||
|
this.debug && this.log('setVisible id:', element.dataset.virtual);
|
||
|
(element.firstElementChild as HTMLElement).style.display = '';
|
||
|
this.visible.add(element);
|
||
|
}
|
||
|
|
||
|
private setHidden(element: HTMLElement) {
|
||
|
if(!this.visible.has(element)) return;
|
||
|
|
||
|
this.debug && this.log('setHidden id:', element.dataset.virtual);
|
||
|
(element.firstElementChild as HTMLElement).style.display = 'none';
|
||
|
this.visible.delete(element);
|
||
|
}
|
||
|
|
||
|
constructor(public el: HTMLElement, axis: 'y' | 'x' = 'y', public splitOffset = 300, logPrefix = '', public appendTo = el, public onScrollOffset = splitOffset, public splitCount = 15) {
|
||
|
this.container = document.createElement('div');
|
||
|
this.container.classList.add('scrollable');
|
||
|
|
||
|
this.visible = new Set();
|
||
|
this.observer = new IntersectionObserver(entries => {
|
||
|
let filtered = entries.filter(entry => entry.isIntersecting);
|
||
|
|
||
|
//this.log('entries:', entries);
|
||
|
|
||
|
entries.forEach(entry => {
|
||
|
let target = entry.target as HTMLElement;
|
||
|
|
||
|
if(entry.isIntersecting) {
|
||
|
this.setVisible(target);
|
||
|
|
||
|
this.debug && this.log('intersection entry:', entry, this.lastTopID, this.lastBottomID);
|
||
|
} else {
|
||
|
let id = +target.dataset.virtual;
|
||
|
let isTop = entry.boundingClientRect.top < 0;
|
||
|
|
||
|
if(isTop) {
|
||
|
this.lastTopID = id + 1;
|
||
|
} else {
|
||
|
this.lastBottomID = id - 1;
|
||
|
}
|
||
|
|
||
|
//this.setHidden(target);
|
||
|
//this.log('intersection entry setHidden:', entry);
|
||
|
}
|
||
|
|
||
|
//this.debug && this.log('intersection entry:', entry, isTop, isBottom, this.lastTopID, this.lastBottomID);
|
||
|
});
|
||
|
|
||
|
if(!filtered.length) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if(this.lastScrollDirection) { // bottom
|
||
|
let target = filtered[filtered.length - 1].target as HTMLElement;
|
||
|
this.lastBottomID = +target.dataset.virtual;
|
||
|
|
||
|
for(let i = 0; i < this.splitCount; ++i) {
|
||
|
target = target.nextElementSibling as HTMLElement;
|
||
|
if(!target) break;
|
||
|
this.setVisible(target);
|
||
|
}
|
||
|
} else {
|
||
|
let target = filtered[0].target as HTMLElement;
|
||
|
this.lastTopID = +target.dataset.virtual;
|
||
|
|
||
|
for(let i = 0; i < this.splitCount; ++i) {
|
||
|
target = target.previousElementSibling as HTMLElement;
|
||
|
if(!target) break;
|
||
|
this.setVisible(target);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.debug && this.log('entries:', entries, filtered, this.lastScrollDirection, this.lastTopID, this.lastBottomID);
|
||
|
|
||
|
let minVisibleID = this.lastTopID - this.splitCount;
|
||
|
let maxVisibleID = this.lastBottomID + this.splitCount;
|
||
|
for(let target of this.visible) {
|
||
|
let id = +target.dataset.virtual;
|
||
|
if(id < minVisibleID || id > maxVisibleID) {
|
||
|
this.setHidden(target);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// внизу - самый производительный вариант
|
||
|
if(false) this.observer = new IntersectionObserver(entries => {
|
||
|
entries/* .filter(entry => entry.isIntersecting) */.forEach((entry, idx, arr) => {
|
||
|
let target = entry.target as HTMLElement;
|
||
|
|
||
|
if(entry.isIntersecting) {
|
||
|
let isTop = entry.boundingClientRect.top <= 0;
|
||
|
let isBottom = entry.rootBounds.height <= (entry.boundingClientRect.top + entry.boundingClientRect.height);
|
||
|
|
||
|
/* let id = +target.dataset.virtual;
|
||
|
let isOutOfRange = id < (this.lastTopID - 15) || id > (this.lastBottomID + 15);
|
||
|
if(isOutOfRange) {
|
||
|
this.debug && this.log('out of range, scroll jumped!');
|
||
|
if(idx == 0) this.lastTopID = id;
|
||
|
else if(idx == (arr.length - 1)) this.lastBottomID = id;
|
||
|
} */
|
||
|
|
||
|
this.setVisible(target);
|
||
|
if(isTop) {
|
||
|
/* this.lastTopID = id;
|
||
|
this.debug && this.log('set lastTopID to:', this.lastTopID); */
|
||
|
|
||
|
for(let i = 0; i < 15; ++i) {
|
||
|
target = target.previousElementSibling as HTMLElement;
|
||
|
if(!target) break;
|
||
|
this.setVisible(target);
|
||
|
}
|
||
|
} else if(isBottom) {
|
||
|
/* this.lastBottomID = id;
|
||
|
this.debug && this.log('set lastBottomID to:', this.lastBottomID); */
|
||
|
|
||
|
for(let i = 0; i < 15; ++i) {
|
||
|
target = target.nextElementSibling as HTMLElement;
|
||
|
if(!target) break;
|
||
|
this.setVisible(target);
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
this.setHidden(target);
|
||
|
}
|
||
|
|
||
|
|
||
|
//this.debug && this.log('intersection entry:', entry, isTop, isBottom, this.lastTopID, this.lastBottomID);
|
||
|
});
|
||
|
|
||
|
/* let minVisibleID = this.lastTopID - 15;
|
||
|
let maxVisibleID = this.lastBottomID + 15;
|
||
|
for(let target of this.visible) {
|
||
|
let id = +target.dataset.virtual;
|
||
|
if(id < minVisibleID || id > maxVisibleID) {
|
||
|
this.setHidden(target);
|
||
|
}
|
||
|
} */
|
||
|
});
|
||
|
|
||
|
if(!appendTo) {
|
||
|
this.appendTo = this.container;
|
||
|
}
|
||
|
|
||
|
this.log = logger('SCROLL' + (logPrefix ? '-' + logPrefix : ''));
|
||
|
|
||
|
this.measureMutex = deferredPromise<void>();
|
||
|
this.measureMutex.resolve();
|
||
|
|
||
|
if(axis == 'x') {
|
||
|
this.container.classList.add('scrollable-x');
|
||
|
this.type = 'width';
|
||
|
this.side = 'left';
|
||
|
this.translate = 'translateX';
|
||
|
this.scrollType = 'scrollWidth';
|
||
|
this.scrollSide = 'scrollLeft';
|
||
|
this.clientAxis = 'clientX';
|
||
|
this.clientSize = 'clientWidth';
|
||
|
|
||
|
let scrollHorizontally = (e: any) => {
|
||
|
e = window.event || e;
|
||
|
var delta = Math.max(-1, Math.min(1, (e.wheelDelta || -e.detail)));
|
||
|
this.container.scrollLeft -= (delta * 20);
|
||
|
e.preventDefault();
|
||
|
};
|
||
|
if(this.container.addEventListener) {
|
||
|
// IE9, Chrome, Safari, Opera
|
||
|
this.container.addEventListener("mousewheel", scrollHorizontally, false);
|
||
|
// Firefox
|
||
|
this.container.addEventListener("DOMMouseScroll", scrollHorizontally, false);
|
||
|
} else {
|
||
|
// IE 6/7/8
|
||
|
// @ts-ignore
|
||
|
this.container.attachEvent("onmousewheel", scrollHorizontally);
|
||
|
}
|
||
|
} else if(axis == 'y') {
|
||
|
this.container.classList.add('scrollable-y');
|
||
|
this.type = 'height';
|
||
|
this.side = 'top';
|
||
|
this.translate = 'translateY';
|
||
|
this.scrollType = 'scrollHeight';
|
||
|
this.scrollSide = 'scrollTop';
|
||
|
this.clientAxis = 'clientY';
|
||
|
this.clientSize = 'clientHeight';
|
||
|
} else {
|
||
|
throw new Error('no side for scroll');
|
||
|
}
|
||
|
|
||
|
//this.container.addEventListener('mouseover', this.resize.bind(this)); // omg
|
||
|
window.addEventListener('resize', () => {
|
||
|
window.requestAnimationFrame(() => {
|
||
|
this.onScroll();
|
||
|
});
|
||
|
});
|
||
|
|
||
|
this.container.addEventListener('scroll', () => this.onScroll(), {passive: true, capture: true});
|
||
|
|
||
|
Array.from(el.children).forEach(c => this.container.append(c));
|
||
|
|
||
|
el.append(this.container);
|
||
|
//this.onScroll();
|
||
|
}
|
||
|
|
||
|
public setVirtualContainer(el?: HTMLElement) {
|
||
|
this.splitUp = el;
|
||
|
|
||
|
this.onScrolledBottomFired = this.onScrolledTopFired = false;
|
||
|
this.lastScrollTop = 0;
|
||
|
|
||
|
this.log('setVirtualContainer:', el, this);
|
||
|
}
|
||
|
|
||
|
public onScroll() {
|
||
|
/* let scrollTop = this.scrollTop;
|
||
|
this.lastScrollDirection = this.lastScrollTop < scrollTop;
|
||
|
this.lastScrollTop = scrollTop;
|
||
|
return; */
|
||
|
|
||
|
/* if(this.debug) {
|
||
|
this.log('onScroll call', this.onScrollMeasure);
|
||
|
} */
|
||
|
|
||
|
let appendTo = this.splitUp || this.appendTo;
|
||
|
|
||
|
clearTimeout(this.disableHoverTimeout);
|
||
|
if(this.el != this.appendTo && this.appendTo != this.container) {
|
||
|
if(!appendTo.classList.contains('disable-hover')) {
|
||
|
appendTo.classList.add('disable-hover');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.disableHoverTimeout = setTimeout(() => {
|
||
|
appendTo.classList.remove('disable-hover');
|
||
|
|
||
|
if(!this.measureMutex.isFulfilled) {
|
||
|
this.measureMutex.resolve();
|
||
|
}
|
||
|
}, 100);
|
||
|
|
||
|
if(this.onScrollMeasure) return; //window.cancelAnimationFrame(this.onScrollMeasure);
|
||
|
this.onScrollMeasure = window.requestAnimationFrame(() => {
|
||
|
// @ts-ignore
|
||
|
let scrollPos = this.container[this.scrollSide];
|
||
|
|
||
|
//if(this.measureMutex.isFulfilled) {
|
||
|
// @ts-ignore quick brown fix
|
||
|
this.size = this.container[this.clientSize];
|
||
|
|
||
|
// @ts-ignore
|
||
|
let scrollSize = this.container[this.scrollType];
|
||
|
this.scrollSize = scrollSize;
|
||
|
|
||
|
//this.measureMutex = deferredPromise<void>();
|
||
|
//}
|
||
|
|
||
|
let scrollTop = scrollPos - this.scrollTopOffset;
|
||
|
let maxScrollTop = this.scrollSize - this.scrollTopOffset - this.size;
|
||
|
|
||
|
if(this.onScrolledBottom) {
|
||
|
if((maxScrollTop - scrollTop) <= this.onScrollOffset) {
|
||
|
//if(!this.onScrolledBottomFired) {
|
||
|
this.onScrolledBottomFired = true;
|
||
|
this.onScrolledBottom();
|
||
|
//}
|
||
|
} else {
|
||
|
this.onScrolledBottomFired = false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if(this.onScrolledTop) {
|
||
|
//this.log('onScrolledTop:', scrollTop, this.onScrollOffset);
|
||
|
if(scrollTop <= this.onScrollOffset) {
|
||
|
this.onScrolledTopFired = true;
|
||
|
this.onScrolledTop();
|
||
|
} else {
|
||
|
this.onScrolledTopFired = false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.lastScrollDirection = this.lastScrollTop < scrollTop;
|
||
|
this.lastScrollTop = scrollTop;
|
||
|
this.onScrollMeasure = 0;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
public prepareElement(element: HTMLElement, append = true) {
|
||
|
element.dataset.virtual = '' + (append ? this.virtualTempIDBottom++ : this.virtualTempIDTop--);
|
||
|
|
||
|
this.debug && this.log('prepareElement: prepared');
|
||
|
|
||
|
window.requestAnimationFrame(() => {
|
||
|
let {scrollHeight/* , scrollWidth */} = element;
|
||
|
|
||
|
this.debug && this.log('prepareElement: first rAF');
|
||
|
|
||
|
window.requestAnimationFrame(() => {
|
||
|
//element.style.height = scrollHeight + 'px';
|
||
|
element.style.minHeight = scrollHeight + 'px'; // height doesn't work for safari
|
||
|
//element.style.width = scrollWidth + 'px';
|
||
|
//(element.firstElementChild as HTMLElement).style.display = 'none';
|
||
|
});
|
||
|
|
||
|
this.visible.add(element);
|
||
|
this.observer.observe(element);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
public prepend(element: HTMLElement, splitable = true) {
|
||
|
if(splitable) this.prepareElement(element, false);
|
||
|
|
||
|
if(this.splitUp) this.splitUp.prepend(element);
|
||
|
else this.appendTo.prepend(element);
|
||
|
}
|
||
|
|
||
|
public append(element: HTMLElement, splitable = true) {
|
||
|
if(splitable) this.prepareElement(element);
|
||
|
|
||
|
if(this.splitUp) this.splitUp.append(element);
|
||
|
else this.appendTo.append(element);
|
||
|
}
|
||
|
|
||
|
public contains(element: Element) {
|
||
|
if(!this.splitUp) {
|
||
|
return this.appendTo.contains(element);
|
||
|
}
|
||
|
|
||
|
return !!element.parentElement;
|
||
|
}
|
||
|
|
||
|
public scrollIntoView(element: Element) {
|
||
|
if(element.parentElement) {
|
||
|
element.scrollIntoView();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public removeElement(element: Element) {
|
||
|
element.remove();
|
||
|
}
|
||
|
|
||
|
set scrollTop(y: number) {
|
||
|
this.container.scrollTop = y;
|
||
|
}
|
||
|
|
||
|
get scrollTop() {
|
||
|
//this.log.trace('get scrollTop');
|
||
|
return this.container.scrollTop;
|
||
|
}
|
||
|
|
||
|
get scrollHeight() {
|
||
|
return this.container.scrollHeight;
|
||
|
}
|
||
|
|
||
|
get length() {
|
||
|
return this.appendTo.childElementCount;
|
||
|
}
|
||
|
}
|