@egjs/conveyer
Version:
Conveyer adds Drag gestures to your Native Scroll.
703 lines (650 loc) • 23.9 kB
text/typescript
/**
* egjs-conveyer
* Copyright (c) 2022-present NAVER Corp.
* MIT license
*/
import Axes, { OnChange, OnHold, PanInput, WheelInput } from "@egjs/axes";
import Component from "@egjs/component";
import { diff } from "@egjs/children-differ";
import { IS_IE } from "./browser";
import { ReactiveSubscribe, Reactive, Ref } from "@cfcs/core";
import {
ConveyerEvents, ConveyerItem,
ConveyerOptions, FindItemOptions, ConveyerReactiveState,
ScrollIntoViewOptions,
} from "./types";
import { instanceOfElement, isIntersection, isString } from "./utils";
/**
* Conveyer adds Drag gestures to your Native Scroll.
* @ko Conveyer는 네이티브 스크롤에 드래그 제스처를 추가합니다.
* @extends Component
* @support {"ie": "9+(with polyfill)", "ch" : "latest", "ff" : "latest", "sf" : "latest", "edge" : "latest", "ios" : "7+", "an" : "4.X+"}
* @example
```html
<div class="items">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
<script>
import Conveyer from "@egjs/conveyer";
const conveyer = new Conveyer(".items");
</script>
```
*/
class Conveyer extends Component<ConveyerEvents> {
protected _scrollAreaElement: HTMLElement;
protected _axes: Axes | null = null;
protected _items: ConveyerItem[] = [];
protected _size = 0;
protected _scrollSize = 0;
protected _options: ConveyerOptions;
protected _animateParam: {
expectedPos: number;
offset: number;
} | null = null;
private _resizeObserver: ResizeObserver | null = null;
private _scrollTimer = 0;
private _isWheelScroll = false;
private _isDragScroll = false;
private _isAnimationScroll = false;
private _scrollArea: string | HTMLElement | Ref<HTMLElement>;
private _panInput: PanInput | null = null;
private _wheelInput: WheelInput | null = null;
/**
* Whether the scroll has reached the start.
* @ko 스크롤이 시작에 닿았는지 여부.
* @name Conveyer#isReachStart
* @type {boolean}
* @default true
* @readonly
* @example
* ```js
* import { Conveyer } from "@egjs/conveyer";
*
* const conveyer = new Conveyer(".container");
*
* conveyer.isReachStart
* ```
*/
private _isReachStart = true;
/**
* Whether the scroll has reached the end.
* @ko 스크롤이 끝에 닿았는지 여부.
* @name Conveyer#isReachEnd
* @type {boolean}
* @default false
* @readonly
* @example
* ```js
* import { Conveyer } from "@egjs/conveyer";
*
* const conveyer = new Conveyer(".container");
*
* conveyer.isReachEnd
* ```
*/
private _isReachEnd = false;
/**
* the scroll position of the container
* @ko 컨테이너의 스크롤 위치
* @name Conveyer#scrollPos
* @type {number}
* @default 0
* @readonly
* @example
* ```js
* import { Conveyer } from "@egjs/conveyer";
*
* const conveyer = new Conveyer(".container");
*
* conveyer.scrollPos
* ```
*/
protected _pos = 0;
/**
* @param - A base element for a module <ko>모듈을 적용할 기준 엘리먼트</ko>
* @param - The option object of the InfiniteGrid module <ko>eg.InfiniteGrid 모듈의 옵션 객체</ko>
*/
constructor(scrollArea: string | HTMLElement | Ref<HTMLElement>, options: ConveyerOptions = {}) {
super();
this._options = {
horizontal: true,
useDrag: true,
useSideWheel: false,
autoInit: true,
boundaryMargin: 0,
scrollDebounce: 100,
useResizeObserver: false,
...options,
};
this._scrollArea = scrollArea;
if (this._options.autoInit) {
this.init();
}
}
/**
* Finds an element for that direction.
* @ko 해당 방향에 대해 엘리먼트를 찾는다.]
* @see {@link /docs/examples/Methods direction's example} page for detailed information
* @param - direction of the element. "start" and "end" find inside. "prev" and "next" find outside. <ko>엘리먼트의 방향. "start", "end"는 안쪽으로 찾는다. "prev", "next"는 바깥쪽으로 찾는다.</ko>
* @param - Options for the `findElement` method. <ko>findElement 메서드의 옵션</ko>
* @example
* <p align="center">
* <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView1.png" height="200" />
* </p>
* <p align="center">
* <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView2.png" height="210" />
* </p>
*/
public findElement(direction: "start" | "end" | "prev" | "next", options: FindItemOptions = {}) {
return this.findItem(direction, options)?.element || null;
}
/**
* Finds an item for an element or its direction.
* @ko 엘리먼트 또는 해당 방향에 대해 아이템을 찾는다.
* @see {@link /docs/examples/Methods direction's example} page for detailed information
* @param - direction of the element. "start" and "end" find inside. "prev" and "next" find outside. <ko>엘리먼트의 방향. "start", "end"는 안쪽으로 찾는다. "prev", "next"는 바깥쪽으로 찾는다.</ko>
* @param - Options for the `findItem` method. <ko>`findItem` 메서드의 옵션</ko>
* @example
* <p align="center">
* <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView1.png" height="200" />
* </p>
* <p align="center">
* <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView2.png" height="210" />
* </p>
*/
public findItem(
target: HTMLElement | "start" | "end" | "prev" | "next",
options: FindItemOptions = {},
): ConveyerItem | null {
const pos = this._pos;
const scrollSize = this._scrollSize;
const size = this._size;
const hitTest = options?.hitTest ?? 1;
const items = [...this._items];
const length = items.length;
const endPos = pos + size;
const sibling = options.sibling;
const intersection = options.intersection;
const startVirtualItem = { pos: 0, size: 0 };
const endVirtualItem = { pos: scrollSize, size: 0 };
if (items[0].pos > 0) {
items.unshift(startVirtualItem);
}
if (length && items[length - 1].pos + items[length - 1].size < scrollSize) {
items.push(endVirtualItem);
}
let selectedItem: ConveyerItem | undefined;
if (target === "start") {
if (pos < 0) {
return null;
}
const selectedItems = items.filter(item => {
const itemSize = item.size;
const dist = item.pos - pos;
const dist2 = dist + itemSize;
return (dist >= 0)
|| (dist2 > 0 && isIntersection(dist, dist2, "end", intersection))
|| (dist2 >= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest));
});
selectedItem = (selectedItems[0] === startVirtualItem && selectedItems[1]) || selectedItems[0];
} else if (target === "end") {
if (pos > scrollSize - size) {
return null;
}
const selectedItems = items.filter(item => {
const itemSize = item.size;
const dist = item.pos + itemSize - endPos;
const dist2 = dist - itemSize;
return dist <= 0
|| (dist2 < 0 && isIntersection(dist2, dist, "start", intersection))
|| (dist2 <= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest));
}).reverse();
selectedItem = (selectedItems[0] === endVirtualItem && selectedItems[1]) || selectedItems[0];
} else if (target === "prev") {
selectedItem = items.filter(item => {
const itemSize = item.size;
const dist = item.pos + itemSize - pos;
const dist2 = dist - itemSize;
return dist <= 0
|| (dist2 < 0 && isIntersection(dist2, dist, "start", intersection))
|| (dist2 <= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest));
}).reverse()[0] || startVirtualItem;
} else if (target === "next") {
selectedItem = items.filter(item => {
const itemSize = item.size;
const dist = item.pos - endPos;
const dist2 = dist + itemSize;
return dist >= 0
|| (dist2 > 0 && isIntersection(dist, dist2, "end", intersection))
|| (dist2 >= 0 && (!itemSize || Math.abs(dist2) / itemSize >= hitTest));
})[0] || endVirtualItem;
} else {
return this._getItem(target);
}
if (sibling && selectedItem) {
const selectedIndex = items.indexOf(selectedItem);
if (selectedIndex > -1) {
selectedItem = items[selectedIndex + sibling];
}
}
return selectedItem || null;
}
/**
* Scrolls an element or an item in that direction into the view.
* @ko 엘리먼트나 해당 방향에 있는 아이템을 뷰안으로 스크롤을 한다.
* @see {@link /docs/examples/Methods target's example} page for detailed information
* @param - direction of the element. "start" and "end" find inside. "prev" and "next" find outside. <ko>엘리먼트의 방향. "start", "end"는 안쪽으로 찾는다. "prev", "next"는 바깥쪽으로 찾는다.</ko>
* @param - Options for the `scrollIntoView` method. <ko>`scrollIntoView` 메서드의 옵션</ko>
* @example
* <p align="center">
* <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView1.png" height="200" />
* </p>
* <p align="center">
* <img src="https://naver.github.io/egjs-conveyer/img/scrollIntoView2.png" height="210" />
* </p>
*/
public scrollIntoView(target: HTMLElement | "start" | "end" | "prev" | "next", options: ScrollIntoViewOptions = {}) {
let item = this.findItem(target, options);
if (!item) {
return;
}
const duration = options.duration || 0;
let nextScrollPos = this._getNextScrollPos(item, options);
if (isString(target) && options.excludeStand && nextScrollPos === this._pos) {
const selectedIndex = this._items.indexOf(item);
if (selectedIndex === -1) {
return;
}
const sibling = target === "start" || target === "prev" ? -1 : 1;
item = this._items[selectedIndex + sibling];
if (!item) {
return;
}
nextScrollPos = this._getNextScrollPos(item, options);
}
this.scrollBy(nextScrollPos - this._pos, duration);
}
/**
* Scrolls by the given position amount.
* @ko 주어진 위치 양만큼 스크롤한다.
* @param - Amount of position to scroll by. <ko>스크롤할 위치의 양.</ko>
* @param - Duration to scroll by that position. <ko>해당 위치만큼 스크롤하는 시간</ko>
*/
public scrollBy(pos: number, duration = 0) {
this._createAnimationParam(pos);
this._axes!.setBy({ scroll: -pos }, duration);
}
/**
* Scroll to the given position.
* @ko 주어진 위치로 스크롤한다.
* @param - Amount of position to scroll to. <ko>스크롤할 위치의 양.</ko>
* @param - Duration to scroll to that position. <ko>해당 위치로 스크롤하는 시간</ko>
*/
public scrollTo(pos: number, duration = 0) {
this.scrollBy(pos - this._pos, duration);
}
/**
* Set the items directly to the Conveyer.
* @ko Conveyer에 아이템들을 직접 설정한다.
* @param - Items to set on Conveyer <ko>Conveyer에 설정할 아이템들</ko>
*/
public setItems(items: ConveyerItem[]) {
this._items = items;
}
/**
* Update the position and size information of items.
* @ko 아이템들의 포지션, 사이즈 정보를 업데이트 한다.
*/
public updateItems() {
const scrollAreaElement = this._scrollAreaElement;
const itemSelector = this._options.itemSelector;
const resizeObserver = this._resizeObserver;
const prevItemElements = this._items.map(item => item.element!);
const itemElements = [].slice.call(
itemSelector ? scrollAreaElement.querySelectorAll(itemSelector) : scrollAreaElement.children,
);
this.setItems(itemElements.map((el) => this._getItem(el)));
if (resizeObserver) {
const changed = diff(prevItemElements, itemElements);
const removed = changed.removed;
const added = changed.added;
removed.forEach((index) => resizeObserver.unobserve(changed.prevList[index]));
added.forEach((index) => resizeObserver.observe(changed.list[index]));
}
}
/**
* Update container size and scroll size.
* @ko 컨테이너의 크기, 스크롤 사이즈를 업데이트 한다.
*/
public updateContainer() {
const scrollAreaElement = this._scrollAreaElement;
const horizontal = this._options.horizontal;
const size = horizontal ? scrollAreaElement.clientWidth : scrollAreaElement.clientHeight;
let scrollSize = horizontal ? scrollAreaElement.scrollWidth : scrollAreaElement.scrollHeight;
// check decimal point
if (IS_IE && scrollSize === size + 1) {
const style = getComputedStyle(scrollAreaElement);
const boxSizing = style.boxSizing;
const borderSize = horizontal
? (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0)
: (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0);
const cssSize = parseFloat(horizontal ? style.width : style.height) || 0;
const contentSize = cssSize - (boxSizing === "border-box" ? borderSize : 0);
// 0 < 0.5 && 0.5 < 1
if (size < contentSize && contentSize < size + 1) {
scrollSize = size;
}
}
this._size = size;
this._scrollSize = scrollSize;
this._refreshScroll();
this._onScroll();
}
/**
* Updating containers and items.
* @ko 컨테이너와 아이템들을 업데이트 한다.
* @method
*/
public update = () => {
this.updateItems();
this.updateContainer();
}
/**
* Enables PanInput and WheelInput operations in mouse case.
* @ko mouse 케이스에서 PanInput, WheelInput의 동작을 활성화한다.
*/
public enableInput() {
this._panInput?.enable();
this._wheelInput?.enable();
}
/**
* Disables PanInput and WheelInput operations in mouse case.
* @ko mouse 케이스에서 PanInput, WheelInput의 동작을 비활성화한다.
*/
public disableInput() {
this._panInput?.disable();
this._wheelInput?.disable();
}
/**
* If you use the autoInit option as false, you can initialize it directly through the init method.
* @ko autoInit 옵션을 false로 사용하는 경우 직접 init 메서드를 통해 초기화 할 수 있다.
*/
public init() {
if (this._axes) {
return;
}
const scrollArea = this._scrollArea;
let el!: HTMLElement;
if (isString(scrollArea)) {
el = document.querySelector<HTMLElement>(scrollArea)!;
} else if (instanceOfElement(scrollArea)) {
el = scrollArea;
} else if ("value" in scrollArea || "current" in scrollArea) {
el = scrollArea.value! || scrollArea.current!;
}
if (!el) {
return;
}
this._scrollAreaElement = el;
let isDrag = false;
const scrollAreaElement = this._scrollAreaElement;
const options = this._options;
const axes = new Axes({
scroll: {
range: [-Infinity, Infinity],
},
}, {
deceleration: 0.005,
round: 1,
nested: options.nested,
}, {
scroll: 0,
});
let isHold = false;
axes.on({
"hold": e => {
isHold = true;
isDrag = false;
const nativeEvent = this._getNativeEvent(e);
if (!nativeEvent) {
return;
}
if (options.preventDefault) {
nativeEvent.preventDefault();
}
},
"change": e => {
const nativeEvent = this._getNativeEvent(e);
const animateParam = this._animateParam;
if (options.useSideWheel && this._isMixedWheel(nativeEvent)) {
return;
}
this._isWheelScroll = !!nativeEvent && nativeEvent.type === "wheel";
this._isDragScroll = !!nativeEvent && !this._isWheelScroll;
this._isAnimationScroll = !this._isWheelScroll && !isHold;
isDrag = true;
const scroll = e.delta.scroll;
if (!e.isTrusted && animateParam) {
animateParam.expectedPos -= scroll;
if (options.horizontal) {
scrollAreaElement.scrollLeft = animateParam.expectedPos + animateParam.offset;
} else {
scrollAreaElement.scrollTop = animateParam.expectedPos + animateParam.offset;
}
} else {
this._animateParam = null;
if (options.horizontal) {
scrollAreaElement.scrollLeft -= scroll;
} else {
scrollAreaElement.scrollTop -= scroll;
}
}
if (options.nested) {
this._checkNestedMove(nativeEvent);
}
},
"release": e => {
if (!isDrag) {
e.setTo({ ...e.depaPos }, 0);
}
isHold = false;
isDrag = false;
},
});
this._axes = axes;
if (options.useDrag) {
this._panInput = new PanInput(scrollAreaElement, {
preventClickOnDrag: options.preventClickOnDrag,
preventDefaultOnDrag: options.preventDefaultOnDrag,
inputType: ["mouse"],
touchAction: "auto",
});
axes.connect(options.horizontal ? ["scroll", ""] : ["", "scroll"], this._panInput);
}
if (options.useSideWheel) {
this._wheelInput = new WheelInput(scrollAreaElement, {
useNormalized: false,
});
axes.connect(options.horizontal ? ["scroll", ""] : ["", "scroll"], this._wheelInput);
}
if (options.useResizeObserver && window.ResizeObserver) {
this._resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const items = this._items;
const length = items.length;
let index = length;
entries.forEach((entry: ResizeObserverEntry) => {
if (entry.target !== this._scrollAreaElement) {
for (let i = 0; i < length; i++) {
if (items[i].element === entry.target) {
index = Math.min(index, i);
break;
}
}
}
});
for (let i = index; i < length; i++) {
items[i] = this._getItem(items[i].element!);
}
this.updateContainer();
});
}
this.update();
this._resizeObserver?.observe(scrollAreaElement);
scrollAreaElement.addEventListener("scroll", this._onScroll);
window.addEventListener("resize", this.update);
}
/**
* Releases the instnace and events.
* @ko 인스턴스와 이벤트를 해제한다.
*/
public destroy() {
this._axes?.destroy();
this.unsubscribe();
this._scrollAreaElement?.removeEventListener("scroll", this._onScroll);
this._resizeObserver?.disconnect();
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.update);
}
this.off();
this._panInput = null;
this._wheelInput = null;
this._axes = null;
this._resizeObserver = null;
}
private _refreshScroll() {
const horizontal = this._options.horizontal;
const scrollAreaElement = this._scrollAreaElement;
this._pos = Math.min(
this._scrollSize - this._size,
horizontal ? scrollAreaElement.scrollLeft : scrollAreaElement.scrollTop,
);
}
private _getItem(element: HTMLElement): ConveyerItem {
const horizontal = this._options.horizontal;
return {
element,
pos: horizontal ? element.offsetLeft : element.offsetTop,
size: horizontal ? element.offsetWidth : element.offsetHeight,
};
}
private _getNativeEvent(e: OnHold | OnChange) {
return e?.inputEvent?.srcEvent ? e.inputEvent?.srcEvent : e?.inputEvent;
}
private _getNextScrollPos(item: ConveyerItem, options: ScrollIntoViewOptions) {
const size = this._size;
const scrollSize = this._scrollSize;
const align = options.align || "start";
const padding = options.offset || 0;
const itemPos = item!.pos;
const itemSize = item!.size;
let scrollPos = 0;
if (align === "start") {
scrollPos = itemPos - padding;
} else if (align === "end") {
scrollPos = itemPos + itemSize - size + padding;
} else if (align === "center") {
scrollPos = itemPos + itemSize / 2 - size / 2 + padding;
}
scrollPos = Math.max(0, Math.min(scrollPos, scrollSize - size));
return scrollPos;
}
private _isMixedWheel(nativeEvent: any) {
return !!nativeEvent && nativeEvent?.type === "wheel" && nativeEvent?.deltaX && nativeEvent?.deltaY;
}
private _checkNestedMove(nativeEvent: any) {
if (this.isReachStart || this.isReachEnd) {
nativeEvent.__childrenAxesAlreadyChanged = false;
}
}
private _onScroll = (e?: any) => {
if (e) {
this._debounceScroll();
}
this._refreshScroll();
const size = this._size;
const scrollSize = this._scrollSize;
const pos = this._pos;
const boundaryMargin = this._options.boundaryMargin ?? 0;
// enter start
if (pos <= boundaryMargin && this.isReachStart !== true) {
this._isReachStart = true;
/**
* This event is fired when scroll reach start.
* @ko 스크롤이 앞에 닿으면 발생하는 이벤트이다.
* @event Conveyer#reachStart
*/
this.trigger("reachStart");
} else if (pos > boundaryMargin && this.isReachStart !== false) {
this._isReachStart = false;
/**
* This event is fired when scroll leave start.
* @ko 스크롤이 앞에서 떠나면 발생하는 이벤트이다.
* @event Conveyer#leaveStart
*/
this.trigger("leaveStart");
}
// enter end
if (scrollSize - size - pos < 1 + boundaryMargin && this.isReachEnd !== true) {
this._isReachEnd = true;
/**
* This event is fired when scroll reach end.
* @ko 스크롤이 끝에 닿으면 발생하는 이벤트이다.
* @event Conveyer#reachEnd
*/
this.trigger("reachEnd");
} else if (!(scrollSize - size - pos < 1 + boundaryMargin) && this.isReachEnd !== false) {
this._isReachEnd = false;
/**
* This event is fired when scroll leave end.
* @ko 스크롤이 끝에서 떠나면 발생하는 이벤트이다.
* @event Conveyer#leaveEnd
*/
this.trigger("leaveEnd");
}
}
private _debounceScroll() {
if (!this._scrollTimer) {
/**
* This event is fired when begin scroll.
* @ko 스크롤이 시작하면 발생하는 이벤트이다.
* @event Conveyer#beginScroll
*/
this.trigger("beginScroll");
}
window.clearTimeout(this._scrollTimer);
this._scrollTimer = window.setTimeout(() => {
const isWheelScroll = this._isWheelScroll;
const isDragScroll = this._isDragScroll;
const isAnimationScroll = this._isAnimationScroll;
this._scrollTimer = 0;
/**
* This event is fired when finish scroll.
* @ko 스크롤이 끝나면 발생하는 이벤트이다.
* @event Conveyer#finishScroll
* @param {OnFinishScroll} e - The object of data to be sent to an event <ko>이벤트에 전달되는 데이터 객체</ko>
*/
this.trigger("finishScroll", {
isWheelScroll,
isDragScroll,
isAnimationScroll,
isTrusted: isWheelScroll || isDragScroll || !isAnimationScroll,
});
this._isWheelScroll = false;
this._isDragScroll = false;
this._isAnimationScroll = false;
}, this._options.scrollDebounce);
}
private _createAnimationParam(pos: number) {
// Save a decimal point before starting the animation
// and in case of animation (isTrusted: false), add the offset and scroll.
this._animateParam = {
expectedPos: this._pos,
offset: pos % 1,
};
}
}
interface Conveyer extends ConveyerReactiveState, ReactiveSubscribe<ConveyerReactiveState> { }
export default Conveyer;