import EventEmitter from "@scena/event-emitter";
import Gesto from "gesto";
import { InjectResult } from "css-styled";
import { Properties } from "framework-utils";
import { camelize, IObject, addEvent, removeEvent, addClass, convertUnitSize, between, isObject, isArray, isString, isNode, getDocument, getWindow } from "@daybrush/utils";
import { InfiniteViewerOptions, InfiniteViewerProperties, InfiniteViewerEvents, OnPinch, AnimationOptions, ScrollOptions, ZoomOptions, GetScollPosOptions, InnerScrollOptions, ScrollCenterOptions, SetOptions } from "./types";
import {
PROPERTIES, injector, CLASS_NAME, TINY_NUM,
DEFAULT_OPTIONS,
WRAPPER_CLASS_NAME, SCROLL_AREA_CLASS_NAME,
HORIZONTAL_SCROLL_BAR_CLASS_NAME, VERTICAL_SCROLL_BAR_CLASS_NAME, NAMES, DEFAULT_EASING,
} from "./consts";
import { measureSpeed, getDuration, getDestPos, abs, getRange, checkDefault, startAnimation } from "./utils";
import ScrollBar from "./ScrollBar";
@Properties(PROPERTIES as any, (prototype, property) => {
const attributes: IObject<any> = {
enumerable: true,
configurable: true,
get() {
return this.options[property];
},
};
const setter = camelize(`set ${property}`);
if (prototype[setter]) {
attributes.set = function (value) {
this[setter](value);
};
} else {
attributes.set = function (value) {
this.options[property] = value;
};
}
Object.defineProperty(prototype, property, attributes);
})
/**
* @sort 1
*/
class InfiniteViewer extends EventEmitter<InfiniteViewerEvents> {
public options: InfiniteViewerOptions;
private injectResult!: InjectResult;
private wrapperElement!: HTMLElement;
private scrollAreaElement!: HTMLElement;
private horizontalScrollbar: ScrollBar;
private verticalScrollbar: ScrollBar;
private gesto!: Gesto;
private offsetX: number = 0;
private offsetY: number = 0;
private containerWidth: number = 0;
private containerHeight: number = 0;
private viewportWidth: number = 0;
private viewportHeight: number = 0;
private viewportScrollWidth: number = 0;
private viewportScrollHeight: number = 0;
private scrollLeft: number = 0;
private scrollTop: number = 0;
private _scrollTimer = 0;
private _zoomTimer = 0;
private _viewportElement: HTMLElement | null = null;
private _wheelContainerElement: HTMLElement | null = null;
private dragFlag: boolean = false;
private isLoop: boolean = false;
private _tempScale: number[] = [1, 1];
private _tempRect: { top: number, left: number, width: number, height: number } | null = null;
private _tempRectTimer: number | null = null;
private _onDestroys: Array<() => void> = [];
private _asLeft = 0;
private _asTop = 0;
/**
* @sort 1
*/
constructor(
private _containerElement: HTMLElement,
viewportElement: HTMLElement | Partial<InfiniteViewerOptions> = {},
options: Partial<InfiniteViewerOptions> = {},
) {
super();
if (isNode(viewportElement)) {
this._viewportElement = viewportElement;
this.options = {
...DEFAULT_OPTIONS,
...options,
};
} else {
this._viewportElement = _containerElement.children[0] as HTMLElement;
this.options = {
...DEFAULT_OPTIONS,
...viewportElement,
};
}
this.init();
}
/**
* Get Container Element
*/
public getContainer(): HTMLElement {
return this._containerElement;
}
/**
* Get Wheel Container Element
*/
public getWheelContainer(): HTMLElement {
return this._wheelContainerElement;
}
/**
* Get Viewport Element
*/
public getViewport(): HTMLElement {
return this._viewportElement;
}
/**
* Get Wrapper Element
*/
public getWrapper(): HTMLElement {
return this.wrapperElement;
}
/**
* Get Scroll Area Element
*/
public geScrollArea(): HTMLElement {
return this.scrollAreaElement;
}
/**
* Destroy elements, properties, and events.
*/
public destroy(): void {
this.off();
this.gesto.unset();
this.verticalScrollbar.destroy();
this.horizontalScrollbar.destroy();
this.injectResult.destroy();
const containerElement = this._containerElement;
this._onDestroys.forEach(callback => {
callback();
});
removeEvent(this.wrapperElement, "scroll", this._onScroll);
removeEvent(this._wheelContainerElement, "wheel", this.onWheel);
removeEvent(containerElement, "gesturestart", this.onGestureStart);
removeEvent(containerElement, "gesturechange", this.onGestureChange);
removeEvent(containerElement, "gesturesend", this.onGestureEnd);
this.gesto = null;
this.injectResult = null;
this._containerElement = null;
this._viewportElement = null;
this.options = null;
}
/**
* Gets the number of pixels that an element's content is scrolled vertically.
*/
public getScrollTop(options: GetScollPosOptions | boolean = {}) {
let range = false;
let absolute = false;
if (isObject(options)) {
range = options.range;
absolute = options.absolute;
} else {
range = options;
}
const zoom = this.zoomY;
const pos = this.scrollTop / zoom + this.offsetY
+ (range ? abs(this.getRangeY()[0]) : 0);
return absolute ? pos * zoom : pos;
}
/**
* Gets the number of pixels that an element's content is scrolled vertically.
*/
public getScrollLeft(options: GetScollPosOptions | boolean = {}) {
let range = false;
let absolute = false;
if (isObject(options)) {
range = options.range;
absolute = options.absolute;
} else {
range = options;
}
const zoom = this.zoomX;
const pos = this.scrollLeft / zoom + this.offsetX
+ (range ? abs(this.getRangeX()[0]) : 0);
return absolute ? pos * zoom : pos;
}
/**
* Gets measurement of the width of an element's content with overflow
*/
public getScrollWidth(isZoom?: boolean) {
const range = this._getScrollRangeX();
const zoom = this.zoomX;
const size = this.containerWidth / zoom + abs(range[0]) + range[1];
return isZoom ? size : size * zoom;
}
/**
* Gets measurement of the height of an element's content with overflow
*/
public getScrollHeight(isZoom?: boolean) {
const range = this._getScrollRangeY();
const zoom = this.zoomY;
const size = this.containerHeight / zoom + abs(range[0]) + range[1];
return isZoom ? size : size * zoom;
}
/**
* Scroll the element to the center
*/
public scrollCenter(options: ScrollCenterOptions = {}) {
this.resize();
const zoomX = this.zoomX;
const zoomY = this.zoomY;
let left = -(this.containerWidth / zoomX - this.viewportWidth) / 2;
let top = -(this.containerHeight / zoomY - this.viewportHeight) / 2;
if (options.absolute) {
left *= zoomX;
top *= zoomY;
}
if (options.horizontal === false) {
left = this.getScrollLeft();
}
if (options.vertical === false) {
top = this.getScrollTop();
}
return this.scrollTo(left, top, options);
}
/**
* Update Viewer Sizes
* @method
*/
public resize = () => {
const {
offsetWidth: containerWidth,
offsetHeight: containerHeight,
} = this._containerElement;
const {
offsetWidth: viewportWidth,
offsetHeight: viewportHeight,
scrollWidth: viewportScrollWidth,
scrollHeight: viewportScrollHeight,
} = this._viewportElement;
this.containerWidth = containerWidth;
this.containerHeight = containerHeight;
this.viewportWidth = viewportWidth;
this.viewportHeight = viewportHeight;
this.viewportScrollWidth = Math.max(viewportWidth, viewportScrollWidth);
this.viewportScrollHeight = Math.max(viewportHeight, viewportScrollHeight);
this.render();
this._scrollBy(0, 0);
}
/**
* Move to that position or zoom.
* @since 0.25.0
*/
public setTo(options: SetOptions) {
const {
x = this.getScrollLeft(),
y = this.getScrollTop(),
zoom = [this.getZoomX(), this.getZoomY()],
duration,
} = options;
const {
zoomX: prevZoomX,
zoomY: prevZoomY,
zoomRange,
} = this;
let {
zoomOffsetX = DEFAULT_OPTIONS.zoomOffsetX,
zoomOffsetY = DEFAULT_OPTIONS.zoomOffsetY,
} = this;
if ("zoomOffsetX" in options) {
zoomOffsetX = options.zoomOffsetX;
}
if ("zoomOffsetY" in options) {
zoomOffsetY = options.zoomOffsetY;
}
const [zoomX, zoomY] = isArray(zoom) ? zoom : [zoom, zoom];
const zoomRangeX = this.zoomRangeX || zoomRange;
const zoomRangeY = this.zoomRangeY || zoomRange;
const nextZoomX = between(zoomX, zoomRangeX[0], zoomRangeX[1]);
const nextZoomY = between(zoomY, zoomRangeY[0], zoomRangeY[1]);
const zoomXPos = convertUnitSize(`${zoomOffsetX}`, this.viewportWidth) * (1 / prevZoomX - 1 / nextZoomX);
const zoomYPos = convertUnitSize(`${zoomOffsetY}`, this.viewportHeight) * (1 / prevZoomY - 1 / nextZoomY);
this.scrollTo(x - zoomXPos, y - zoomYPos, {
duration,
});
this.setZoom(zoom, {
zoomOffsetX,
zoomOffsetY,
duration,
zoomBase: "fixed",
});
}
/**
* Move by the position or zoom delta value.
* @since 0.25.0
*/
public setBy(options: SetOptions) {
const {
x = 0,
y = 0,
zoom = [0, 0],
} = options;
const [zoomX, zoomY] = isArray(zoom) ? zoom : [zoom, zoom];
this.setTo({
...options,
x: this.getScrollLeft() + x,
y: this.getScrollTop() + y,
zoom: [this.zoomX + zoomX, this.zoomY + zoomY],
});
}
/**
* Scrolls the container by the given amount.
*/
public scrollBy(deltaX: number, deltaY: number, options?: ScrollOptions) {
this._pauseScrollAnimation();
if (!options || !options.duration) {
let scrollLeft = this.getScrollLeft();
let scrollTop = this.getScrollTop();
if (options?.absolute) {
scrollLeft *= this.zoomX;
scrollTop *= this.zoomY;
}
return this._scrollTo(scrollLeft + deltaX, scrollTop + deltaY, options);
} else {
this._startScrollAnimation([deltaX, deltaY], options);
return true;
}
}
/**
* Scrolls the container to set of coordinates.
* @param scrollLeft
* @param scrollTop
*/
public scrollTo(x: number, y: number, options?: ScrollOptions) {
this._pauseScrollAnimation();
if (!options || !options.duration) {
return this._scrollTo(x, y, options);
} else {
let scrollLeft = this.getScrollLeft();
let scrollTop = this.getScrollTop();
if (options?.absolute) {
scrollLeft *= this.zoomX;
scrollTop *= this.zoomY;
}
return this.scrollBy(x - scrollLeft, y - scrollTop, options);
}
}
/**
* Set viewer zoom by the given amount
*/
public zoomBy(deltaZoom: number | number[], options?: ZoomOptions) {
this._pauseZoomAnimation();
const [deltaX, deltaY] = isArray(deltaZoom)
? deltaZoom
: [deltaZoom, deltaZoom];
if (!options || !options.duration) {
this._setZoom([
this.zoomX + deltaX,
this.zoomY + deltaY,
], options);
} else {
this._startZoomAnimation([deltaX, deltaY], options);
}
}
/**
* Set viewer zoom
*/
public setZoom(zoom: number | number[], options?: ZoomOptions) {
this._pauseZoomAnimation();
if (!options || !options.duration) {
this._setZoom(zoom, options);
} else {
const [zoomX, zoomY] = isArray(zoom)
? zoom
: [zoom, zoom];
this._startZoomAnimation([
zoomX - this.zoomX,
zoomY - this.zoomY,
], options);
}
}
public getViewportWidth() {
return this.viewportWidth;
}
public getViewportHeight() {
return this.viewportWidth;
}
public getViewportScrollWidth() {
return this.viewportScrollWidth;
}
public getViewportScrollHeight() {
return this.viewportScrollHeight;
}
public getContainerWidth() {
return this.containerWidth;
}
public getContainerHeight() {
return this.containerHeight;
}
/**
* Get viewer zoom
*/
public getZoom() {
return (this.zoomX + this.zoomY) / 2;
}
/**
* Get viewer zoomX
* @since 0.20.0
*/
public getZoomX() {
return this.zoomX;
}
/**
* Get viewer zoom
* @since 0.20.0
*/
public getZoomY() {
return this.zoomY;
}
/**
* get x ranges
*/
public getRangeX(isZoom?: boolean, isReal?: boolean) {
return this._getRangeCoord("horizontal", isZoom, isReal);
}
/**
* get y ranges
*/
public getRangeY(isZoom?: boolean, isReal?: boolean) {
return this._getRangeCoord("vertical", isZoom, isReal);
}
private init() {
// infinite-viewer(container)
// viewportㅌ
// children
const containerElement = this._containerElement;
const options = this.options;
const doc = getDocument(containerElement);
const win = getWindow(containerElement);
// vanilla
let wrapperElement = options.wrapperElement
|| containerElement.querySelector(`.${WRAPPER_CLASS_NAME}`);
let scrollAreaElement = options.scrollAreaElement
|| containerElement.querySelector(`.${SCROLL_AREA_CLASS_NAME}`);
const horizontalScrollElement = options.horizontalScrollElement
|| containerElement.querySelector(`.${HORIZONTAL_SCROLL_BAR_CLASS_NAME}`);
const verticalScrollElement = options.verticalScrollElement
|| containerElement.querySelector(`.${VERTICAL_SCROLL_BAR_CLASS_NAME}`);
if (!wrapperElement) {
wrapperElement = doc.createElement("div");
wrapperElement.insertBefore(this._viewportElement, null);
containerElement.insertBefore(wrapperElement, null);
}
this.wrapperElement = wrapperElement;
if (!scrollAreaElement) {
scrollAreaElement = doc.createElement("div");
wrapperElement.insertBefore(scrollAreaElement, wrapperElement.firstChild);
}
this.scrollAreaElement = scrollAreaElement;
addClass(containerElement, CLASS_NAME);
addClass(wrapperElement, WRAPPER_CLASS_NAME);
// addClass(restrictElement, RESTRICT_WRAPPER_CLASS_NAME);
addClass(scrollAreaElement, SCROLL_AREA_CLASS_NAME);
const horizontalBar = new ScrollBar(
containerElement,
"horizontal",
horizontalScrollElement,
);
const verticalBar = new ScrollBar(
containerElement,
"vertical",
verticalScrollElement,
);
this.horizontalScrollbar = horizontalBar;
this.verticalScrollbar = verticalBar;
horizontalBar.on("scroll", e => {
this.scrollBy(e.delta / this.zoomX, 0);
});
verticalBar.on("scroll", e => {
this.scrollBy(0, e.delta / this.zoomY);
});
if (horizontalBar.isAppend) {
containerElement.insertBefore(horizontalBar.barElement, null);
}
if (verticalBar.isAppend) {
containerElement.insertBefore(verticalBar.barElement, null);
}
this.injectResult = injector.inject(containerElement, {
nonce: this.options.cspNonce,
});
const wheelContainerOption = options.wheelContainer;
let wheelContainerElement: HTMLElement | null = null;
if (wheelContainerOption) {
if (isString(wheelContainerOption)) {
wheelContainerElement = doc.querySelector(wheelContainerOption);
} else if (isNode(wheelContainerOption)) {
wheelContainerElement = wheelContainerOption;
} else if ("value" in wheelContainerOption || "current" in wheelContainerOption) {
wheelContainerElement = wheelContainerOption.current || wheelContainerOption.value;
}
}
wheelContainerElement ||= containerElement;
this._wheelContainerElement = wheelContainerElement;
/**
* the `dragStart` event fires when `touchstart` does occur.
* @memberof InfiniteViewer
* @event dragStart
* @param {InfiniteViewer.OnDragStart} - Parameters for the `dragStart` event
* @example
* import InfiniteViewer from "infinite-viewer";
*
* const viewer = new InfiniteViewer(
* document.querySelector(".container"),
* document.querySelector(".viewport"),
* ).on("dragStart", e => {
* console.log(e.inputEvent);
* });
*/
/**
* the `drag` event fires when `touch` does occur.
* @memberof InfiniteViewer
* @event drag
* @param {InfiniteViewer.OnDrag} - Parameters for the `drag` event
* @example
* import InfiniteViewer from "infinite-viewer";
*
* const viewer = new InfiniteViewer(
* document.querySelector(".container"),
* document.querySelector(".viewport"),
* ).on("drag", e => {
* console.log(e.inputEvent);
* });
*/
/**
* the `dragEnd` event fires when `touchend` does occur.
* @memberof InfiniteViewer
* @event dragEnd
* @param {InfiniteViewer.OnDragEnd} - Parameters for the `dragEnd` event
* @example
* import InfiniteViewer from "infinite-viewer";
*
* const viewer = new InfiniteViewer(
* document.querySelector(".container"),
* document.querySelector(".viewport"),
* ).on("dragEnd", e => {
* console.log(e.inputEvent);
* });
*/
/**
* the `abortPinch` event fires when `pinch` event does not occur by dragging a certain area.
* @memberof InfiniteViewer
* @event abortPinch
* @param {InfiniteViewer.OnAbortPinch} - Parameters for the abortPinch event
* @example
* import InfiniteViewer from "infinite-viewer";
*
* const viewer = new InfiniteViewer(
* document.querySelector(".container"),
* document.querySelector(".viewport"),
* {
* usePinch: true,
* }
* ).on("abortPinch", e => {
* console.log(e.inputEvent);
* });
*/
/**
* the `pinch` event fires when two points pinch the viewer
* The pinchStart and abortPinch events do not occur when pinching through the wheel.
* @memberof InfiniteViewer
* @event pinch
* @param {InfiniteViewer.OnPinch} - Parameters for the `pinch` event
* @example
* import InfiniteViewer from "infinite-viewer";
*
* const viewer = new InfiniteViewer(
* document.querySelector(".container"),
* document.querySelector(".viewport"),
* {
* usePinch: true,
* }
* ).on("pinch", e => {
* console.log(e.zoom, e.inputEvent);
* });
*/
this.gesto = new Gesto(containerElement, {
container: getWindow(containerElement),
events: ["touch", "mouse"],
preventWheelClick: this.options.preventWheelClick ?? true,
}).on("dragStart", e => {
const {
inputEvent,
stop,
datas,
} = e;
if (!this.useMouseDrag && e.isMouseEvent) {
stop();
return;
}
this._pauseScrollAnimation();
this.dragFlag = false;
const result = this.trigger("dragStart", e);
if (result === false) {
stop();
return;
}
inputEvent.preventDefault();
datas.startEvent = inputEvent;
}).on("drag", e => {
if (!this.options.usePinch || e.isPinch || (this.useMouseDrag && e.isMouseEvent)) {
this.trigger("drag", {
...e,
inputEvent: e.inputEvent,
});
measureSpeed(e);
this.scrollBy(-e.deltaX / this.zoomX, -e.deltaY / this.zoomY);
} else if (!this.dragFlag && e.movement > options.pinchThreshold) {
this.dragFlag = true;
this.trigger("abortPinch", {
inputEvent: e.datas.startEvent || e.inputEvent,
});
}
}).on("dragEnd", e => {
this.trigger("dragEnd", {
isDrag: e.isDrag,
isDouble: e.isDouble,
inputEvent: e.inputEvent,
});
this._startScrollAnimationBySpeed(e.datas.speed);
}).on("pinchStart", ({ inputEvent, datas, stop }) => {
inputEvent.preventDefault();
this._pauseScrollAnimation();
datas.startZoom = [this.zoomX, this.zoomY];
const result = this.trigger("pinchStart", {
inputEvent,
});
if (result === false) {
stop();
}
this._setClientRect();
}).on("pinch", e => {
const scale = e.scale;
const pinchDirection = this.options.pinchDirection;
this._triggerPinch({
rotation: e.rotation,
distance: e.distance,
scale: e.scale,
inputEvent: e.inputEvent,
isWheel: false,
zoom: e.datas.startZoom * scale,
zoomX: this.zoomX * (pinchDirection === "vertical" ? 1 : scale),
zoomY: this.zoomY * (pinchDirection === "horizontal" ? 1 : scale),
clientX: e.clientX,
clientY: e.clientY,
ratioX: 0,
ratioY: 0,
});
}).on("pinchEnd", () => {
this._tempRect = null;
});
addEvent(wrapperElement, "scroll", this._onScroll);
if (options.useResizeObserver) {
const observer = new win.ResizeObserver(() => {
this.resize();
});
observer.observe(this._viewportElement);
observer.observe(this._containerElement);
this._onDestroys.push(() => {
observer.disconnect();
});
} else {
addEvent(win, "resize", this.resize);
this._onDestroys.push(() => {
removeEvent(win, "resize", this.resize);
})
}
if (options.useWheelPinch || options.useWheelScroll) {
addEvent(wheelContainerElement, "wheel", this.onWheel, {
passive: false,
});
}
if (options.useGesture) {
addEvent(containerElement, "gesturestart", this.onGestureStart, {
passive: false,
});
addEvent(containerElement, "gesturechange", this.onGestureChange, {
passive: false,
});
}
this.resize();
}
private render() {
const {
offsetX,
offsetY,
zoomX = DEFAULT_OPTIONS.zoomX,
zoomY = DEFAULT_OPTIONS.zoomY,
translateZ = 0,
rangeX,
rangeY,
containerWidth,
containerHeight,
} = this;
const {
useTransform = DEFAULT_OPTIONS.useTransform,
} = this.options;
let nextOffsetX = -offsetX * zoomX;
let nextOffsetY = -offsetY * zoomY;
this.scrollAreaElement.style.cssText
= `width:calc(100% + ${this.getScrollAreaWidth()}px);`
+ `height:calc(100% + ${this.getScrollAreaHeight()}px);`;
const viewportStyle = this._viewportElement.style;
if (useTransform === false) {
viewportStyle.cssText += `position: relative; left: ${nextOffsetX}px; top: ${nextOffsetY}px; `;
// if (restrictOffsetX || restrictOffsetY) {
// viewportStyle.cssText += `position: relative; left: ${restrictOffsetX}px; top: ${restrictOffsetY}px`;
// }
} else {
viewportStyle.cssText += `transform-origin: 0 0;`
+ `transform:translate3d(${nextOffsetX}px, ${nextOffsetY}px, ${translateZ}px) scale(${zoomX}, ${zoomY});`;
// if (restrictOffsetX || restrictOffsetY) {
// viewportStyle.cssText += `transform:translate3d(${restrictOffsetX}px, ${restrictOffsetY}px, 0px)`;
// }
}
this.renderScroll();
}
private renderScroll() {
const {
zoomX,
zoomY,
containerWidth,
containerHeight,
} = this;
const horizontalBar = this.horizontalScrollbar;
const verticalBar = this.verticalScrollbar;
if (this.options.useBounceScrollBar) {
const scrollLeft = this.getScrollLeft(true) * zoomX;
const rangeX = this.getRangeX(true);
const scrollWidth = containerWidth + abs(rangeX[0]) + abs(rangeX[1]);
const scrollTop = this.getScrollTop(true) * zoomY;
const rangeY = this.getRangeY(true);
const scrollHeight = containerHeight + abs(rangeY[0]) + abs(rangeY[1]);
horizontalBar.render(
this.displayHorizontalScroll,
scrollLeft,
containerWidth,
scrollWidth,
);
verticalBar.render(
this.displayVerticalScroll,
scrollTop,
containerHeight,
scrollHeight,
);
} else {
const scrollRangeX = this._getScrollRangeX();
const scrollRangeY = this._getScrollRangeY();
const scrollLeft = this.getScrollLeft();
const scrollTop = this.getScrollTop();
const scrollWidth = this.containerWidth + abs(scrollRangeX[0]) + scrollRangeX[1];
const scrollHeight = this.containerHeight + abs(scrollRangeY[0]) + scrollRangeY[1];
horizontalBar.render(
this.displayHorizontalScroll,
scrollLeft - scrollRangeX[0],
containerWidth,
scrollWidth,
);
verticalBar.render(
this.displayVerticalScroll,
scrollTop - scrollRangeY[0],
containerHeight,
scrollHeight,
);
}
}
private move(scrollLeft: number, scrollTop: number) {
const wrapperElement = this.wrapperElement;
wrapperElement.scrollLeft = scrollLeft;
wrapperElement.scrollTop = scrollTop;
}
private setDisplayVerticalScroll(displayVerticalScroll: boolean) {
this.options.displayVerticalScroll = displayVerticalScroll;
this.renderScroll();
}
private setDisplayHorizontalScroll(displayHorizontalScroll: boolean) {
this.options.displayHorizontalScroll = displayHorizontalScroll;
this.renderScroll();
}
private _onScroll = () => {
const { scrollLeft, scrollTop } = this.wrapperElement;
const {
zoom = DEFAULT_OPTIONS.zoom,
} = this;
const deltaX = scrollLeft - this.scrollLeft;
const deltaY = scrollTop - this.scrollTop;
const viewerScrollLeft = this.getScrollLeft();
const viewerScrollTop = this.getScrollTop();
if (this.isLoop) {
this.isLoop = false;
}
this.scrollLeft = scrollLeft;
this.scrollTop = scrollTop;
this.scrollTo(
viewerScrollLeft + deltaX / zoom,
viewerScrollTop + deltaY / zoom,
);
}
private onWheel = (e: WheelEvent) => {
const options = this.options;
const pinchDirection = options.pinchDirection;
const maxPinchWheel = options.maxPinchWheel || Infinity;
const isKeydown = e[`${this.wheelPinchKey}Key`] || e.ctrlKey;
if (options.useWheelPinch && isKeydown) {
let deltaY = e.deltaY;
const sign = deltaY >= 0 ? 1 : -1;
const distance = Math.min(maxPinchWheel, Math.abs(deltaY));
deltaY = sign * distance;
const delta = -deltaY;
const scale = Math.max(1 + delta * (options.wheelScale || 0.01), TINY_NUM);
clearTimeout(this._tempRectTimer);
this._tempRectTimer = window.setTimeout(() => {
this._tempRect = null;
}, 100);
this._triggerPinch({
distance,
scale,
rotation: 0,
zoom: this.zoom * scale,
zoomX: this.zoomX * (pinchDirection === "vertical" ? 1 : scale),
zoomY: this.zoomY * (pinchDirection === "horizontal" ? 1 : scale),
inputEvent: e,
isWheel: true,
clientX: e.clientX,
clientY: e.clientY,
ratioX: 0,
ratioY: 0,
});
} else if (options.useWheelScroll) {
let deltaX = e.deltaX;
let deltaY = e.deltaY;
if (e.shiftKey && !deltaX) {
deltaX = deltaY;
deltaY = 0;
}
this.scrollBy(deltaX / this.zoomX, deltaY / this.zoomY);
} else {
return;
}
e.preventDefault();
}
private onGestureStart = (e: any) => {
this._tempScale = [this.zoomX, this.zoomY];
this._setClientRect();
e.preventDefault();
}
private onGestureChange = (e: any) => {
e.preventDefault();
if (this.gesto.isFlag() || !this._tempScale) {
this._tempScale = [1, 1];
return;
}
const scale = e.scale;
const zoomX = this._tempScale[0];
const zoomY = this._tempScale[1];
const pinchDirection = this.options.pinchDirection;
this._triggerPinch({
distance: 0,
scale,
rotation: e.rotation,
inputEvent: e,
isWheel: true,
zoom: (zoomX + zoomY) * scale / 2,
zoomX: zoomX * (pinchDirection === "vertical" ? 1 : scale),
zoomY: zoomY * (pinchDirection === "horizontal" ? 1 : scale),
clientX: e.clientX,
clientY: e.clientY,
ratioX: 0,
ratioY: 0,
});
}
private onGestureEnd = () => {
}
private _startZoomAnimation(dest: number[], options: ZoomOptions) {
if (!dest) {
return;
}
const duration = options.duration;
const easing = options.easing || DEFAULT_EASING;
startAnimation(
distRatio => this._setZoom(
[
this.zoomX + dest[0] * distRatio,
this.zoomY + dest[1] * distRatio,
],
options,
),
next => {
this._zoomTimer = requestAnimationFrame(next);
},
{
easing,
duration,
},
);
}
private _startScrollAnimation(dest: number[], options: AnimationOptions) {
if (!dest[0] && !dest[1]) {
return;
}
const duration = options.duration;
const easing = options.easing || DEFAULT_EASING;
startAnimation(
distRatio => this._scrollBy(
dest[0] * distRatio,
dest[1] * distRatio,
options,
),
next => {
this._scrollTimer = requestAnimationFrame(next);
},
{
easing,
duration,
},
);
}
private _startScrollAnimationBySpeed(speed: number[]) {
if (!speed || (!speed[0] && !speed[1])) {
return;
}
const a = -0.0006;
const duration = getDuration(speed, a);
const destPos = getDestPos(speed, a);
return this._startScrollAnimation(destPos, {
duration,
})
}
private _pauseScrollAnimation() {
cancelAnimationFrame(this._scrollTimer);
this._scrollTimer = 0;
}
private _pauseZoomAnimation() {
cancelAnimationFrame(this._zoomTimer);
this._zoomTimer = 0;
}
private getScrollAreaWidth() {
const [min, max] = this.getRangeX(true);
return min || max ? this.margin * 2 : 0;
}
private getScrollAreaHeight() {
const [min, max] = this.getRangeY(true);
return min || max ? this.margin * 2 : 0;
}
private _triggerPinch(event: OnPinch) {
const {
clientX,
clientY,
zoomX,
zoomY,
} = event;
if (this.useAutoZoom) {
this._zoomByClient([zoomX, zoomY], clientX, clientY);
}
if (!this._tempRect) {
this._setClientRect();
}
const zoomRange = this.zoomRange;
const zoomRangeX = this.zoomRangeX || zoomRange;
const zoomRangeY = this.zoomRangeY || zoomRange;
const {
left,
top,
width,
height,
} = this._tempRect;
const ratioX = (clientX - left) / width * 100;
const ratioY = (clientY - top) / height * 100;
this.trigger("pinch", {
...event,
zoom: between((zoomX + zoomY) / 2, zoomRange[0], zoomRange[1]),
zoomX: between(zoomX, zoomRangeX[0], zoomRangeX[1]),
zoomY: between(zoomY, zoomRangeY[0], zoomRangeY[1]),
ratioX,
ratioY,
});
}
private _setClientRect() {
const rect = this.getContainer().getBoundingClientRect();
this._tempRect = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
private _zoomByClient(zoom: number[], clientX: number, clientY: number) {
if (!this._tempRect) {
this._setClientRect();
}
const {
left,
top,
width,
height,
} = this._tempRect;
const options = this.options;;
const originalZoomOffsetX = options.zoomOffsetX;
const originalZoomOffsetY = options.zoomOffsetY;
options.zoomOffsetX = `${(clientX - left) / width * 100}%`;
options.zoomOffsetY = `${(clientY - top) / height * 100}%`;
this._setZoom(zoom, {
zoomBase: "screen",
});
options.zoomOffsetX = originalZoomOffsetX;
options.zoomOffsetY = originalZoomOffsetY;
}
private _setZoom(
zoom: number | number[],
zoomOptions: ZoomOptions = {},
) {
const zoomBase = zoomOptions.zoomBase;
const {
containerWidth,
containerHeight,
zoomX: prevZoomX,
zoomY: prevZoomY,
zoomRange,
} = this;
let {
zoomOffsetX = DEFAULT_OPTIONS.zoomOffsetX,
zoomOffsetY = DEFAULT_OPTIONS.zoomOffsetY,
} = this;
if ("zoomOffsetX" in zoomOptions) {
zoomOffsetX = zoomOptions.zoomOffsetX;
}
if ("zoomOffsetY" in zoomOptions) {
zoomOffsetY = zoomOptions.zoomOffsetY;
}
const scrollLeft = this.getScrollLeft();
const scrollTop = this.getScrollTop();
const [zoomX, zoomY] = isArray(zoom) ? zoom : [zoom, zoom];
const zoomRangeX = this.zoomRangeX || zoomRange;
const zoomRangeY = this.zoomRangeY || zoomRange;
const nextZoomX = between(zoomX, zoomRangeX[0], zoomRangeX[1]);
const nextZoomY = between(zoomY, zoomRangeY[0], zoomRangeY[1]);
const options = this.options;
options.zoomX = nextZoomX;
options.zoomY = nextZoomY;
options.zoom = (nextZoomX + nextZoomY) / 2;
const nextScrollLeft = this.getScrollLeft();
const nextScrollTop = this.getScrollTop();
let zoomXPos = 0;
let zoomYPos = 0;
if (zoomBase === "fixed") {
zoomXPos = convertUnitSize(`${zoomOffsetX}`, this.viewportWidth);
zoomYPos = convertUnitSize(`${zoomOffsetY}`, this.viewportHeight);
} else if (zoomBase === "viewport") {
zoomXPos = (-scrollLeft + convertUnitSize(`${zoomOffsetX}`, this.viewportWidth)) * prevZoomX;
zoomYPos = (-scrollTop + convertUnitSize(`${zoomOffsetY}`, this.viewportHeight)) * prevZoomY;
} else {
zoomXPos = convertUnitSize(`${zoomOffsetX}`, containerWidth);
zoomYPos = convertUnitSize(`${zoomOffsetY}`, containerHeight);
}
const centerX = scrollLeft + zoomXPos / prevZoomX;
const centerY = scrollTop + zoomYPos / prevZoomY;
const nextCenterX = nextScrollLeft + zoomXPos / nextZoomX;
const nextCenterY = nextScrollTop + zoomYPos / nextZoomY;
this._scrollBy(
centerX - nextCenterX,
centerY - nextCenterY,
{
zoom: !!(nextZoomX - prevZoomX || nextZoomY - prevZoomY),
},
);
this.render();
}
private _scrollBy(deltaX: number, deltaY: number, options?: InnerScrollOptions) {
let scrollLeft = this.getScrollLeft();
let scrollTop = this.getScrollTop();
if (options?.absolute) {
scrollLeft *= this.zoomX;
scrollTop *= this.zoomY;
}
return this._scrollTo(scrollLeft + deltaX, scrollTop + deltaY, options);
}
private _scrollTo(x: number, y: number, options?: InnerScrollOptions) {
const {
scrollLeft: prevScrollLeft,
scrollTop: prevScrollTop,
} = this;
const isAbsolute = options?.absolute;
this._scrollToType("horizontal", x, isAbsolute);
this._scrollToType("vertical", y, isAbsolute);
const scrollLeft = this.scrollLeft;
const scrollTop = this.scrollTop;
this.render();
const nextScrollAbsoluteLeft = this.getScrollLeft();
const nextScrollAbsoluteTop = this.getScrollTop();
this._emitScrollEvent(nextScrollAbsoluteLeft, nextScrollAbsoluteTop, options?.zoom);
if (Math.round(prevScrollLeft) !== scrollLeft || Math.round(prevScrollTop) !== scrollTop) {
this.isLoop = true;
this.move(scrollLeft, scrollTop);
requestAnimationFrame(() => {
if (!this.isLoop) {
return;
}
this.isLoop = false;
const {
scrollLeft: requestScrollLeft,
scrollTop: requestScrollTop,
} = this.wrapperElement;
this.scrollLeft = requestScrollLeft;
this.scrollTop = requestScrollTop;
if (
scrollLeft !== Math.round(requestScrollLeft)
|| scrollTop !== Math.round(requestScrollTop)
) {
this._scrollTo(nextScrollAbsoluteLeft, nextScrollAbsoluteTop);
}
});
return false;
}
return true;
}
private _scrollToType(type: "horizontal" | "vertical", coord: number, isAbsolute?: boolean) {
const names = NAMES[type];
const {
margin = DEFAULT_OPTIONS.margin,
threshold = DEFAULT_OPTIONS.threshold,
} = this;
const prevScrollPos = this[`scroll${names.pos}`];
const [minCoord, maxCoord] = this[`getRange${names.coord}`](true, true);
let scrollPos = Math.round(prevScrollPos);
const scrollAreaSize = this[`getScrollArea${names.size}`]();
const zoom = this[`zoom${names.coord}`];
if (isAbsolute) {
coord = coord / zoom;
}
const zoomCoord = coord * zoom;
if (minCoord === maxCoord) {
scrollPos = minCoord;
coord = minCoord / zoom;
} else if (zoomCoord - threshold <= minCoord) {
const minThreshold = Math.max(0, zoomCoord - minCoord);
scrollPos = minThreshold;
coord = (minCoord + minThreshold) / zoom;
} else if (zoomCoord + threshold >= maxCoord) {
const maxThreshold = Math.max(0, maxCoord - zoomCoord);
scrollPos = scrollAreaSize - maxThreshold;
coord = (maxCoord - maxThreshold) / zoom;
} else if (scrollPos < threshold) {
scrollPos += margin;
} else if (scrollPos > scrollAreaSize - threshold) {
scrollPos -= margin;
}
scrollPos = Math.round(scrollPos);
this[`scroll${names.pos}`] = scrollPos;
this[`offset${names.coord}`] = coord - scrollPos / zoom;
}
private _getRangeCoord(type: "vertical" | "horizontal", isZoom?: boolean, isReal?: boolean) {
const {
margin = DEFAULT_OPTIONS.margin,
threshold,
} = this;
const names = NAMES[type];
const rangeCoord = checkDefault(
this[`range${names.coord}`],
DEFAULT_OPTIONS[`range${names.coord}`],
);
const rangeOffsetCoord = checkDefault(
this[`rangeOffset${names.coord}`],
DEFAULT_OPTIONS[`rangeOffset${names.coord}`],
);
const zoom = this[`zoom${names.coord}`];
const range = getRange(
this[`getScroll${names.pos}`](),
margin,
rangeCoord,
threshold,
isReal,
);
if (!isZoom) {
return [
range[0] + rangeOffsetCoord[0],
range[1] + rangeOffsetCoord[1],
];
}
return [
range[0] * zoom + rangeOffsetCoord[0],
this.options.useOverflowScroll
? Math.max(this[`viewport${names.size}`] * zoom - this[`container${names.size}`], range[1] * zoom + rangeOffsetCoord[1])
: range[1] * zoom + rangeOffsetCoord[1],
];
}
private _emitScrollEvent(scrollLeft: number, scrollTop: number, zoom?: boolean) {
const prevScrollLeft = this._asLeft;
const prevScrollTop = this._asTop;
if (!zoom && prevScrollLeft === scrollLeft && prevScrollTop === scrollTop) {
return;
}
this._asLeft = scrollLeft;
this._asTop = scrollTop;
/**
* The `scroll` event fires when the document view or an element has been scrolled.
* @memberof InfiniteViewer
* @event scroll
* @param {InfiniteViewer.OnScroll} - Parameters for the scroll event
* @example
* import InfiniteViewer from "infinite-viewer";
*
* const viewer = new InfiniteViewer(
* document.querySelector(".container"),
* document.querySelector(".viewport"),
* ).on("scroll", () => {
* console.log(viewer.getScrollLeft(), viewer.getScrollTop());
* });
*/
this.trigger("scroll", {
scrollLeft,
scrollTop,
zoomX: this.zoomX,
zoomY: this.zoomY,
});
}
private _getScrollRangeX() {
const pos = this.getScrollLeft();
const rangeX = this.rangeX;
const startRange = rangeX[0];
let endRange = rangeX[1];
if (this.useOverflowScroll && isFinite(endRange)) {
endRange = Math.max(endRange, this.viewportWidth - this.containerWidth / this.zoomX);
}
const startMargin = Math.min(0, isFinite(startRange) ? Math.min(startRange, pos) : pos);
const endMargin = Math.max(0, isFinite(endRange) ? Math.max(endRange, pos) : pos);
const viewportSize = this.viewportScrollWidth;
const margin = Math.max(this.containerWidth / this.zoomX, viewportSize) - viewportSize;
const startSizeOffset = Math.min(0, margin + startMargin);
return [
startSizeOffset,
endMargin,
];
}
private _getScrollRangeY() {
const pos = this.getScrollTop();
const rangeY = this.rangeY;
const startRange = rangeY[0];
let endRange = rangeY[1];
if (this.useOverflowScroll && isFinite(endRange)) {
endRange = Math.max(endRange, this.viewportHeight - this.containerHeight / this.zoomY);
}
const startMargin = Math.min(0, isFinite(startRange) ? Math.min(startRange, pos) : pos);
const endMargin = Math.max(0, isFinite(endRange) ? Math.max(endRange, pos) : pos);
const viewportSize = this.viewportScrollHeight;
const margin = Math.max(this.containerHeight / this.zoomY, viewportSize) - viewportSize;
const startSizeOffset = Math.min(0, margin + startMargin);
return [
startSizeOffset,
endMargin,
];
}
}
interface InfiniteViewer extends InfiniteViewerProperties { }
export default InfiniteViewer;