@egjs/flicking
Version:
Everyday 30 million people experience. It's reliable, flexible and extendable carousel.
1,595 lines (1,318 loc) • 61.4 kB
text/typescript
/**
* Copyright (c) 2015 NAVER Corp.
* egjs projects are licensed under the MIT license
*/
import Axes, { PanInput } from "@egjs/axes";
import Flicking from "../Flicking";
import Panel from "./Panel";
import PanelManager from "./PanelManager";
import StateMachine from "./StateMachine";
import MoveType from "../moves/MoveType";
import { FlickingOptions, FlickingPanel, FlickingStatus, ElementLike, EventType, TriggerCallback, NeedPanelEvent, FlickingEvent, MoveTypeObjectOption, OriginalStyle, Plugin, DestroyOption, BoundingBox } from "../types";
import { DEFAULT_VIEWPORT_CSS, DEFAULT_CAMERA_CSS, TRANSFORM, DEFAULT_OPTIONS, EVENTS, DIRECTION, STATE_TYPE, MOVE_TYPE } from "../consts";
import { clamp, applyCSS, toArray, parseArithmeticExpression, isBetween, isArray, parseElement, hasClass, restoreStyle, circulate, findIndex, getBbox } from "../utils";
import Snap from "../moves/Snap";
import FreeScroll from "../moves/FreeScroll";
export default class Viewport {
public options: FlickingOptions;
public stateMachine: StateMachine;
public panelManager: PanelManager;
public moveType: MoveType;
private flicking: Flicking;
private axes: Axes;
private panInput: PanInput | null;
private viewportElement: HTMLElement;
private cameraElement: HTMLElement;
private triggerEvent: Flicking["triggerEvent"];
private axesHandlers: { [key: string]: any };
private currentPanel: Panel | undefined;
private nearestPanel: Panel | undefined;
private visiblePanels: Panel[];
private plugins: Plugin[] = [];
private panelBboxes: { [className: string]: BoundingBox };
private state: {
size: number;
position: number;
panelMaintainRatio: number;
relativeHangerPosition: number;
positionOffset: number;
scrollArea: {
prev: number;
next: number;
};
translate: {
name: string,
has3d: boolean,
};
infiniteThreshold: number;
checkedIndexes: Array<[number, number]>;
isAdaptiveCached: boolean;
isViewportGiven: boolean;
isCameraGiven: boolean;
originalViewportStyle: OriginalStyle;
originalCameraStyle: OriginalStyle;
cachedBbox: BoundingBox | null;
};
constructor(
flicking: Flicking,
options: FlickingOptions,
triggerEvent: Flicking["triggerEvent"],
) {
this.flicking = flicking;
this.triggerEvent = triggerEvent;
this.state = {
size: 0,
position: 0,
panelMaintainRatio: 0,
relativeHangerPosition: 0,
positionOffset: 0,
scrollArea: {
prev: 0,
next: 0,
},
translate: TRANSFORM,
infiniteThreshold: 0,
checkedIndexes: [],
isAdaptiveCached: false,
isViewportGiven: false,
isCameraGiven: false,
originalViewportStyle: {
className: null,
style: null,
},
originalCameraStyle: {
className: null,
style: null,
},
cachedBbox: null,
};
this.options = options;
this.stateMachine = new StateMachine();
this.visiblePanels = [];
this.panelBboxes = {};
this.build();
}
public moveTo(
panel: Panel,
destPos: number,
eventType: EventType["CHANGE"] | EventType["RESTORE"] | "",
axesEvent: any,
duration: number = this.options.duration,
): TriggerCallback {
const state = this.state;
const currentState = this.stateMachine.getState();
const currentPosition = state.position;
const isTrusted = axesEvent
? axesEvent.isTrusted
: false;
const direction = destPos === currentPosition
? null
: destPos > currentPosition
? DIRECTION.NEXT
: DIRECTION.PREV;
let eventResult: TriggerCallback;
if (eventType === EVENTS.CHANGE) {
eventResult = this.triggerEvent(EVENTS.CHANGE, axesEvent, isTrusted, {
index: panel.getIndex(),
panel,
direction,
});
} else if (eventType === EVENTS.RESTORE) {
eventResult = this.triggerEvent(EVENTS.RESTORE, axesEvent, isTrusted);
} else {
eventResult = {
onSuccess(callback: () => void): TriggerCallback {
callback();
return this;
},
onStopped(): TriggerCallback {
return this;
},
};
}
eventResult.onSuccess(() => {
currentState.delta = 0;
currentState.lastPosition = this.getCameraPosition();
currentState.targetPanel = panel;
currentState.direction = destPos === currentPosition
? null
: destPos > currentPosition
? DIRECTION.NEXT
: DIRECTION.PREV;
if (destPos === currentPosition) {
// no move
this.nearestPanel = panel;
this.currentPanel = panel;
}
if (axesEvent && axesEvent.setTo) {
// freeScroll only occurs in release events
axesEvent.setTo({ flick: destPos }, duration);
} else {
this.axes.setTo({ flick: destPos }, duration);
}
});
return eventResult;
}
public moveCamera(pos: number, axesEvent?: any): void {
const state = this.state;
const options = this.options;
const transform = state.translate.name;
const scrollArea = state.scrollArea;
// Update position & nearestPanel
if (options.circular && !isBetween(pos, scrollArea.prev, scrollArea.next)) {
pos = circulate(pos, scrollArea.prev, scrollArea.next, false);
}
state.position = pos;
this.nearestPanel = this.findNearestPanel();
const nearestPanel = this.nearestPanel;
const originalNearestPosition = nearestPanel
? nearestPanel.getPosition()
: 0;
// From 0(panel position) to 1(panel position + panel size)
// When it's on gap area value will be (val > 1 || val < 0)
if (nearestPanel) {
const hangerPosition = this.getHangerPosition();
const panelPosition = nearestPanel.getPosition();
const panelSize = nearestPanel.getSize();
const halfGap = options.gap / 2;
// As panel's range is from panel position - half gap ~ panel pos + panel size + half gap
state.panelMaintainRatio = (hangerPosition - panelPosition + halfGap) / (panelSize + 2 * halfGap);
} else {
state.panelMaintainRatio = 0;
}
this.checkNeedPanel(axesEvent);
// Possibly modified after need panel, if it's looped
const modifiedNearestPosition = nearestPanel
? nearestPanel.getPosition()
: 0;
pos += (modifiedNearestPosition - originalNearestPosition);
state.position = pos;
this.updateVisiblePanels();
// Offset is needed to fix camera layer size in visible-only rendering mode
const posOffset = options.renderOnlyVisible
? state.positionOffset
: 0;
const moveVector = options.horizontal
? [-(pos - posOffset), 0] : [0, -(pos - posOffset)];
const moveCoord = moveVector.map(coord => `${Math.round(coord)}px`).join(", ");
this.cameraElement.style[transform] = state.translate.has3d
? `translate3d(${moveCoord}, 0px)`
: `translate(${moveCoord})`;
}
public stopCamera = (axesEvent: any): void => {
if (axesEvent && axesEvent.setTo) {
axesEvent.setTo({ flick: this.state.position }, 0);
}
this.stateMachine.transitTo(STATE_TYPE.IDLE);
}
public unCacheBbox(): void {
const state = this.state;
const options = this.options;
state.cachedBbox = null;
this.visiblePanels = [];
const viewportElement = this.viewportElement;
if (!options.horizontal) {
// Don't preserve previous width for adaptive resizing
viewportElement.style.width = "";
} else {
viewportElement.style.height = "";
}
state.isAdaptiveCached = false;
this.panelBboxes = {};
}
public resize(): void {
this.updateSize();
this.updateOriginalPanelPositions();
this.updateAdaptiveSize();
this.updateScrollArea();
this.updateClonePanels();
this.updateVisiblePanelPositions();
this.updateCameraPosition();
this.updatePlugins();
}
// Find nearest anchor from current hanger position
public findNearestPanel(): Panel | undefined {
const state = this.state;
const panelManager = this.panelManager;
const hangerPosition = this.getHangerPosition();
if (this.isOutOfBound()) {
const position = state.position;
return position <= state.scrollArea.prev
? panelManager.firstPanel()
: panelManager.lastPanel();
}
return this.findNearestPanelAt(hangerPosition);
}
public findNearestPanelAt(position: number): Panel | undefined {
const panelManager = this.panelManager;
const allPanels = panelManager.allPanels();
let minimumDistance = Infinity;
let nearestPanel: Panel | undefined;
for (const panel of allPanels) {
if (!panel) {
continue;
}
const prevPosition = panel.getPosition();
const nextPosition = prevPosition + panel.getSize();
// Use shortest distance from panel's range
const distance = isBetween(position, prevPosition, nextPosition)
? 0
: Math.min(
Math.abs(prevPosition - position),
Math.abs(nextPosition - position),
);
if (distance > minimumDistance) {
break;
} else if (distance === minimumDistance) {
const minimumAnchorDistance = Math.abs(position - nearestPanel!.getAnchorPosition());
const anchorDistance = Math.abs(position - panel.getAnchorPosition());
if (anchorDistance > minimumAnchorDistance) {
break;
}
}
minimumDistance = distance;
nearestPanel = panel;
}
return nearestPanel;
}
public findNearestIdenticalPanel(panel: Panel): Panel {
let nearest = panel;
let shortestDistance = Infinity;
const hangerPosition = this.getHangerPosition();
const identicals = panel.getIdenticalPanels();
identicals.forEach(identical => {
const anchorPosition = identical.getAnchorPosition();
const distance = Math.abs(anchorPosition - hangerPosition);
if (distance < shortestDistance) {
nearest = identical;
shortestDistance = distance;
}
});
return nearest;
}
// Find shortest camera position that distance is minimum
public findShortestPositionToPanel(panel: Panel): number {
const state = this.state;
const options = this.options;
const anchorPosition = panel.getAnchorPosition();
const hangerPosition = this.getHangerPosition();
const distance = Math.abs(hangerPosition - anchorPosition);
const scrollAreaSize = state.scrollArea.next - state.scrollArea.prev;
if (!options.circular) {
const position = anchorPosition - state.relativeHangerPosition;
return this.canSetBoundMode()
? clamp(position, state.scrollArea.prev, state.scrollArea.next)
: position;
} else {
// If going out of viewport border is more efficient way of moving, choose that position
return distance <= scrollAreaSize - distance
? anchorPosition - state.relativeHangerPosition
: anchorPosition > hangerPosition
// PREV TO NEXT
? anchorPosition - state.relativeHangerPosition - scrollAreaSize
// NEXT TO PREV
: anchorPosition - state.relativeHangerPosition + scrollAreaSize;
}
}
public findEstimatedPosition(panel: Panel): number {
const scrollArea = this.getScrollArea();
let estimatedPosition = panel.getAnchorPosition() - this.getRelativeHangerPosition();
estimatedPosition = this.canSetBoundMode()
? clamp(estimatedPosition, scrollArea.prev, scrollArea.next)
: estimatedPosition;
return estimatedPosition;
}
public addVisiblePanel(panel: Panel): void {
if (this.getVisibleIndexOf(panel) < 0) {
this.visiblePanels.push(panel);
}
}
public enable(): void {
if (!this.panInput) {
this.createPanInput();
}
}
public disable(): void {
if (this.panInput) {
this.panInput.destroy();
this.panInput = null;
this.stateMachine.transitTo(STATE_TYPE.IDLE);
}
}
public insert(index: number, element: ElementLike | ElementLike[]): FlickingPanel[] {
const lastIndex = this.panelManager.getLastIndex();
// Index should not below 0
if (index < 0 || index > lastIndex) {
return [];
}
const state = this.state;
const options = this.options;
const parsedElements = parseElement(element);
const panels = parsedElements
.map((el, idx) => new Panel(el, index + idx, this))
.slice(0, lastIndex - index + 1);
if (panels.length <= 0) {
return [];
}
const pushedIndex = this.panelManager.insert(index, panels);
// ...then calc bbox for all panels
this.resizePanels(panels);
if (!this.currentPanel) {
this.currentPanel = panels[0];
this.nearestPanel = panels[0];
const newCenterPanel = panels[0];
const newPanelPosition = this.findEstimatedPosition(newCenterPanel);
state.position = newPanelPosition;
this.updateAxesPosition(newPanelPosition);
state.panelMaintainRatio = (newCenterPanel.getRelativeAnchorPosition() + options.gap / 2) / (newCenterPanel.getSize() + options.gap);
}
// Update checked indexes in infinite mode
this.updateCheckedIndexes({ min: index, max: index });
state.checkedIndexes.forEach((indexes, idx) => {
const [min, max] = indexes;
if (index < min) {
// Push checked index
state.checkedIndexes.splice(idx, 1, [min + pushedIndex, max + pushedIndex]);
}
});
this.resize();
return panels;
}
public replace(index: number, element: ElementLike | ElementLike[]): FlickingPanel[] {
const state = this.state;
const options = this.options;
const panelManager = this.panelManager;
const lastIndex = panelManager.getLastIndex();
// Index should not below 0
if (index < 0 || index > lastIndex) {
return [];
}
const parsedElements = parseElement(element);
const panels = parsedElements
.map((el, idx) => new Panel(el, index + idx, this))
.slice(0, lastIndex - index + 1);
if (panels.length <= 0) {
return [];
}
const replacedPanels = panelManager.replace(index, panels);
replacedPanels.forEach(panel => {
const visibleIndex = this.getVisibleIndexOf(panel);
if (visibleIndex > -1) {
this.visiblePanels.splice(visibleIndex, 1);
}
});
// ...then calc bbox for all panels
this.resizePanels(panels);
const currentPanel = this.currentPanel;
const wasEmpty = !currentPanel;
if (wasEmpty) {
this.currentPanel = panels[0];
this.nearestPanel = panels[0];
const newCenterPanel = panels[0];
const newPanelPosition = this.findEstimatedPosition(newCenterPanel);
state.position = newPanelPosition;
this.updateAxesPosition(newPanelPosition);
state.panelMaintainRatio = (newCenterPanel.getRelativeAnchorPosition() + options.gap / 2) / (newCenterPanel.getSize() + options.gap);
} else if (isBetween(currentPanel!.getIndex(), index, index + panels.length - 1)) {
// Current panel is replaced
this.currentPanel = panelManager.get(currentPanel!.getIndex());
}
// Update checked indexes in infinite mode
this.updateCheckedIndexes({ min: index, max: index + panels.length - 1 });
this.resize();
return panels;
}
public remove(index: number, deleteCount: number = 1): FlickingPanel[] {
const state = this.state;
// Index should not below 0
index = Math.max(index, 0);
const panelManager = this.panelManager;
const currentIndex = this.getCurrentIndex();
const removedPanels = panelManager.remove(index, deleteCount);
if (isBetween(currentIndex, index, index + deleteCount - 1)) {
// Current panel is removed
// Use panel at removing index - 1 as new current panel if it exists
const newCurrentIndex = Math.max(index - 1, panelManager.getRange().min);
this.currentPanel = panelManager.get(newCurrentIndex);
}
// Update checked indexes in infinite mode
if (deleteCount > 0) {
// Check whether removing index will affect checked indexes
// Suppose index 0 is empty and removed index 1, then checked index 0 should be deleted and vice versa.
this.updateCheckedIndexes({ min: index - 1, max: index + deleteCount });
// Uncache visible panels to refresh panels
this.visiblePanels = [];
}
if (panelManager.getPanelCount() <= 0) {
this.currentPanel = undefined;
this.nearestPanel = undefined;
}
this.resize();
const scrollArea = state.scrollArea;
if (state.position < scrollArea.prev || state.position > scrollArea.next) {
const newPosition = circulate(state.position, scrollArea.prev, scrollArea.next, false);
this.moveCamera(newPosition);
this.updateAxesPosition(newPosition);
}
return removedPanels;
}
public updateAdaptiveSize(): void {
const state = this.state;
const options = this.options;
const horizontal = options.horizontal;
const currentPanel = this.getCurrentPanel();
if (!currentPanel) {
return;
}
const shouldApplyAdaptive = options.adaptive || !state.isAdaptiveCached;
const viewportStyle = this.viewportElement.style;
if (shouldApplyAdaptive) {
let sizeToApply: number;
if (options.adaptive) {
const panelBbox = currentPanel.getBbox();
sizeToApply = horizontal ? panelBbox.height : panelBbox.width;
} else {
// Find minimum height of panels to maximum panel size
const maximumPanelSize = this.panelManager.originalPanels().reduce((maximum, panel) => {
const panelBbox = panel.getBbox();
return Math.max(maximum, horizontal ? panelBbox.height : panelBbox.width);
}, 0);
sizeToApply = maximumPanelSize;
}
if (!state.isAdaptiveCached) {
const viewportBbox = this.updateBbox();
sizeToApply = Math.max(sizeToApply, horizontal ? viewportBbox.height : viewportBbox.width);
state.isAdaptiveCached = true;
}
const viewportSize = `${sizeToApply}px`;
if (horizontal) {
viewportStyle.height = viewportSize;
state.cachedBbox!.height = sizeToApply;
} else {
viewportStyle.width = viewportSize;
state.cachedBbox!.width = sizeToApply;
}
}
}
// Update camera position after resizing
public updateCameraPosition(): void {
const state = this.state;
const currentPanel = this.getCurrentPanel();
const cameraPosition = this.getCameraPosition();
const currentState = this.stateMachine.getState();
const isFreeScroll = this.moveType.is(MOVE_TYPE.FREE_SCROLL);
const relativeHangerPosition = this.getRelativeHangerPosition();
const halfGap = this.options.gap / 2;
if (currentState.holding || currentState.playing) {
this.updateVisiblePanels();
return;
}
let newPosition: number;
if (isFreeScroll) {
const positionBounded = this.canSetBoundMode() && (cameraPosition === state.scrollArea.prev || cameraPosition === state.scrollArea.next);
const nearestPanel = this.getNearestPanel();
// Preserve camera position if it is bound to scroll area limit
newPosition = positionBounded || !nearestPanel
? cameraPosition
: nearestPanel.getPosition() - halfGap + (nearestPanel.getSize() + 2 * halfGap) * state.panelMaintainRatio - relativeHangerPosition;
} else {
newPosition = currentPanel
? currentPanel.getAnchorPosition() - relativeHangerPosition
: cameraPosition;
}
if (this.canSetBoundMode()) {
newPosition = clamp(newPosition, state.scrollArea.prev, state.scrollArea.next);
}
// Pause & resume axes to prevent axes's "change" event triggered
// This should be done before moveCamera, as moveCamera can trigger needPanel
this.updateAxesPosition(newPosition);
this.moveCamera(newPosition);
}
public updateBbox(): BoundingBox {
const state = this.state;
const options = this.options;
const viewportElement = this.viewportElement;
if (!state.cachedBbox) {
state.cachedBbox = getBbox(viewportElement, options.useOffset);
}
return state.cachedBbox!;
}
public updatePlugins(): void {
// update for resize
this.plugins.forEach(plugin => {
plugin.update && plugin.update(this.flicking);
});
}
public destroy(option: Partial<DestroyOption>): void {
const state = this.state;
const wrapper = this.flicking.getElement();
const viewportElement = this.viewportElement;
const cameraElement = this.cameraElement;
const originalPanels = this.panelManager.originalPanels();
this.removePlugins(this.plugins);
if (!option.preserveUI) {
restoreStyle(viewportElement, state.originalViewportStyle);
restoreStyle(cameraElement, state.originalCameraStyle);
if (!state.isCameraGiven && !this.options.renderExternal) {
const topmostElement = state.isViewportGiven
? viewportElement
: wrapper;
const deletingElement = state.isViewportGiven
? cameraElement
: viewportElement;
originalPanels.forEach(panel => {
topmostElement.appendChild(panel.getElement());
});
topmostElement.removeChild(deletingElement);
}
}
this.axes.destroy();
this.panInput?.destroy();
originalPanels.forEach(panel => { panel.destroy(option); });
// release resources
for (const x in this) {
(this as any)[x] = null;
}
}
public restore(status: FlickingStatus): void {
const panels = status.panels;
const defaultIndex = this.options.defaultIndex;
const cameraElement = this.cameraElement;
const panelManager = this.panelManager;
// Restore index
cameraElement.innerHTML = panels.map(panel => panel.html).join("");
// Create panels first
this.refreshPanels();
const createdPanels = panelManager.originalPanels();
// ...then order it by its index
const orderedPanels: Panel[] = [];
panels.forEach((panel, idx) => {
const createdPanel = createdPanels[idx];
createdPanel.setIndex(panel.index);
orderedPanels[panel.index] = createdPanel;
});
panelManager.replacePanels(orderedPanels, []);
panelManager.setCloneCount(0); // No clones at this point
const panelCount = panelManager.getPanelCount();
if (panelCount > 0) {
this.currentPanel = panelManager.get(status.index)
|| panelManager.get(defaultIndex)
|| panelManager.firstPanel();
this.nearestPanel = this.currentPanel;
} else {
this.currentPanel = undefined;
this.nearestPanel = undefined;
}
this.visiblePanels = orderedPanels.filter(panel => Boolean(panel));
this.resize();
this.axes.setTo({ flick: status.position }, 0);
this.moveCamera(status.position);
}
public calcVisiblePanels(): Panel[] {
const allPanels = this.panelManager.allPanels();
if (this.options.renderOnlyVisible) {
const cameraPos = this.getCameraPosition();
const viewportSize = this.getSize();
const basePanel = this.nearestPanel!;
const getNextPanel = (panel: Panel) => {
const nextPanel = panel.nextSibling;
if (nextPanel && nextPanel.getPosition() >= panel.getPosition()) {
return nextPanel;
} else {
return null;
}
};
const getPrevPanel = (panel: Panel) => {
const prevPanel = panel.prevSibling;
if (prevPanel && prevPanel.getPosition() <= panel.getPosition()) {
return prevPanel;
} else {
return null;
}
};
const isOutOfBoundNext = (panel: Panel) => panel.getPosition() >= cameraPos + viewportSize;
const isOutOfBoundPrev = (panel: Panel) => panel.getPosition() + panel.getSize() <= cameraPos;
const getVisiblePanels = (
panel: Panel,
getNext: (panel: Panel) => Panel | null,
isOutOfViewport: (panel: Panel) => boolean,
): Panel[] => {
const visiblePanels: Panel[] = [];
let lastPanel = panel;
while (true) {
const nextPanel = getNext(lastPanel);
if (!nextPanel || isOutOfViewport(nextPanel)) {
break;
}
visiblePanels.push(nextPanel);
lastPanel = nextPanel;
}
return visiblePanels;
};
const panelCount = this.panelManager.getPanelCount();
const getAbsIndex = (panel: Panel) => panel.getIndex() + (panel.getCloneIndex() + 1) * panelCount;
const nextPanels = getVisiblePanels(basePanel, getNextPanel, isOutOfBoundNext);
const prevPanels = getVisiblePanels(basePanel, getPrevPanel, isOutOfBoundPrev);
return [basePanel, ...nextPanels, ...prevPanels].sort((panel1, panel2) => getAbsIndex(panel1) - getAbsIndex(panel2));
} else {
return allPanels.filter(panel => {
const outsetProgress = panel.getOutsetProgress();
return outsetProgress > -1 && outsetProgress < 1;
});
}
}
public getCurrentPanel(): Panel | undefined {
return this.currentPanel;
}
public getCurrentIndex(): number {
const currentPanel = this.currentPanel;
return currentPanel
? currentPanel.getIndex()
: -1;
}
public getNearestPanel(): Panel | undefined {
return this.nearestPanel;
}
// Get progress from nearest panel
public getCurrentProgress(): number {
const currentState = this.stateMachine.getState();
let nearestPanel = currentState.playing || currentState.holding
? this.nearestPanel
: this.currentPanel;
const panelManager = this.panelManager;
if (!nearestPanel) {
// There're no panels
return NaN;
}
const { prev: prevRange, next: nextRange } = this.getScrollArea();
const cameraPosition = this.getCameraPosition();
const isOutOfBound = this.isOutOfBound();
let prevPanel = nearestPanel.prevSibling;
let nextPanel = nearestPanel.nextSibling;
let hangerPosition = this.getHangerPosition();
let nearestAnchorPos = nearestPanel.getAnchorPosition();
if (
isOutOfBound
&& prevPanel
&& nextPanel
&& cameraPosition < nextRange
// On the basis of anchor, prevPanel is nearestPanel.
&& (hangerPosition - prevPanel.getAnchorPosition() < nearestAnchorPos - hangerPosition)
) {
nearestPanel = prevPanel;
nextPanel = nearestPanel.nextSibling;
prevPanel = nearestPanel.prevSibling;
nearestAnchorPos = nearestPanel.getAnchorPosition();
}
const nearestIndex = nearestPanel.getIndex() + (nearestPanel.getCloneIndex() + 1) * panelManager.getPanelCount();
const nearestSize = nearestPanel.getSize();
if (isOutOfBound) {
const relativeHangerPosition = this.getRelativeHangerPosition();
if (nearestAnchorPos > nextRange + relativeHangerPosition) {
// next bounce area: hangerPosition - relativeHangerPosition - nextRange
hangerPosition = nearestAnchorPos + hangerPosition - relativeHangerPosition - nextRange;
} else if (nearestAnchorPos < prevRange + relativeHangerPosition) {
// prev bounce area: hangerPosition - relativeHangerPosition - prevRange
hangerPosition = nearestAnchorPos + hangerPosition - relativeHangerPosition - prevRange;
}
}
const hangerIsNextToNearestPanel = hangerPosition >= nearestAnchorPos;
const gap = this.options.gap;
let basePosition = nearestAnchorPos;
let targetPosition = nearestAnchorPos;
if (hangerIsNextToNearestPanel) {
targetPosition = nextPanel
? nextPanel.getAnchorPosition()
: nearestAnchorPos + nearestSize + gap;
} else {
basePosition = prevPanel
? prevPanel.getAnchorPosition()
: nearestAnchorPos - nearestSize - gap;
}
const progressBetween = (hangerPosition - basePosition) / (targetPosition - basePosition);
const startIndex = hangerIsNextToNearestPanel
? nearestIndex
: prevPanel
? prevPanel.getIndex()
: nearestIndex - 1;
return startIndex + progressBetween;
}
// Update axes flick position without triggering event
public updateAxesPosition(position: number) {
const axes = this.axes;
axes.off();
axes.setTo({
flick: position,
}, 0);
axes.on(this.axesHandlers);
}
public getSize(): number {
return this.state.size;
}
public getScrollArea(): { prev: number, next: number } {
return this.state.scrollArea;
}
public isOutOfBound(): boolean {
const state = this.state;
const options = this.options;
const scrollArea = state.scrollArea;
return !options.circular
&& options.bound
&& (state.position <= scrollArea.prev || state.position >= scrollArea.next);
}
public canSetBoundMode(): boolean {
const options = this.options;
return options.bound && !options.circular;
}
public getViewportElement(): HTMLElement {
return this.viewportElement;
}
public getCameraElement(): HTMLElement {
return this.cameraElement;
}
public getScrollAreaSize(): number {
const scrollArea = this.state.scrollArea;
return scrollArea.next - scrollArea.prev;
}
public getRelativeHangerPosition(): number {
return this.state.relativeHangerPosition;
}
public getHangerPosition(): number {
return this.state.position + this.state.relativeHangerPosition;
}
public getCameraPosition(): number {
return this.state.position;
}
public getPositionOffset(): number {
return this.state.positionOffset;
}
public getCheckedIndexes(): Array<[number, number]> {
return this.state.checkedIndexes;
}
public getVisiblePanels(): Panel[] {
return this.visiblePanels;
}
public setCurrentPanel(panel: Panel): void {
this.currentPanel = panel;
}
public setLastIndex(index: number): void {
const currentPanel = this.currentPanel;
const panelManager = this.panelManager;
panelManager.setLastIndex(index);
if (currentPanel && currentPanel.getIndex() > index) {
this.currentPanel = panelManager.lastPanel();
}
this.resize();
}
public setVisiblePanels(panels: Panel[]): void {
this.visiblePanels = panels;
}
public connectAxesHandler(handlers: { [key: string]: (event: { [key: string]: any; }) => any }): void {
const axes = this.axes;
this.axesHandlers = handlers;
axes.on(handlers);
}
public addPlugins(plugins: Plugin | Plugin[]) {
const newPlugins = ([] as Plugin[]).concat(plugins);
newPlugins.forEach(plugin => {
plugin.init(this.flicking);
});
this.plugins = this.plugins.concat(newPlugins);
return this;
}
public removePlugins(plugins: Plugin | Plugin[]) {
const currentPlugins = this.plugins;
const removedPlugins = ([] as Plugin[]).concat(plugins);
removedPlugins.forEach(plugin => {
const index = currentPlugins.indexOf(plugin);
if (index > -1) {
currentPlugins.splice(index, 1);
}
plugin.destroy(this.flicking);
});
return this;
}
public updateCheckedIndexes(changedRange: { min: number, max: number }): void {
const state = this.state;
let removed = 0;
state.checkedIndexes.concat().forEach((indexes, idx) => {
const [min, max] = indexes;
// Can fill part of indexes in range
if (changedRange.min <= max && changedRange.max >= min) {
// Remove checked index from list
state.checkedIndexes.splice(idx - removed, 1);
removed++;
}
});
}
public appendUncachedPanelElements(panels: Panel[]): void {
const options = this.options;
const fragment = document.createDocumentFragment();
if (options.isEqualSize) {
const prevVisiblePanels = this.visiblePanels;
const equalSizeClasses = options.isEqualSize as string[]; // for readability
const cached: { [className: string]: boolean } = {};
this.visiblePanels = [];
Object.keys(this.panelBboxes).forEach(className => {
cached[className] = true;
});
panels.forEach(panel => {
const overlappedClass = panel.getOverlappedClass(equalSizeClasses);
if (overlappedClass && !cached[overlappedClass]) {
if (!options.renderExternal) {
fragment.appendChild(panel.getElement());
}
this.visiblePanels.push(panel);
cached[overlappedClass] = true;
} else if (!overlappedClass) {
if (!options.renderExternal) {
fragment.appendChild(panel.getElement());
}
this.visiblePanels.push(panel);
}
});
prevVisiblePanels.forEach(panel => {
this.addVisiblePanel(panel);
});
} else {
if (!options.renderExternal) {
panels.forEach(panel => fragment.appendChild(panel.getElement()));
}
this.visiblePanels = panels.filter(panel => Boolean(panel));
}
if (!options.renderExternal) {
this.cameraElement.appendChild(fragment);
}
}
private updateClonePanels() {
const panelManager = this.panelManager;
// Clone panels in circular mode
if (this.options.circular && panelManager.getPanelCount() > 0) {
this.clonePanels();
this.updateClonedPanelPositions();
}
panelManager.chainAllPanels();
}
private getVisibleIndexOf(panel: Panel): number {
return findIndex(this.visiblePanels, visiblePanel => visiblePanel === panel);
}
private build(): void {
this.setElements();
this.applyCSSValue();
this.setMoveType();
this.setAxesInstance();
this.refreshPanels();
this.setDefaultPanel();
this.resize();
this.moveToDefaultPanel();
}
private setElements(): void {
const state = this.state;
const options = this.options;
const wrapper = this.flicking.getElement();
const classPrefix = options.classPrefix;
const viewportCandidate = wrapper.children[0] as HTMLElement;
const hasViewportElement = viewportCandidate && hasClass(viewportCandidate, `${classPrefix}-viewport`);
const viewportElement = hasViewportElement
? viewportCandidate
: document.createElement("div");
const cameraCandidate = hasViewportElement
? viewportElement.children[0] as HTMLElement
: wrapper.children[0] as HTMLElement;
const hasCameraElement = cameraCandidate && hasClass(cameraCandidate, `${classPrefix}-camera`);
const cameraElement = hasCameraElement
? cameraCandidate
: document.createElement("div");
if (!hasCameraElement) {
cameraElement.className = `${classPrefix}-camera`;
const panelElements = hasViewportElement
? viewportElement.children
: wrapper.children;
// Make all panels to be a child of camera element
// wrapper <- viewport <- camera <- panels[1...n]
toArray(panelElements).forEach(child => {
cameraElement.appendChild(child);
});
} else {
state.originalCameraStyle = {
className: cameraElement.getAttribute("class"),
style: cameraElement.getAttribute("style"),
};
}
if (!hasViewportElement) {
viewportElement.className = `${classPrefix}-viewport`;
// Add viewport element to wrapper
wrapper.appendChild(viewportElement);
} else {
state.originalViewportStyle = {
className: viewportElement.getAttribute("class"),
style: viewportElement.getAttribute("style"),
};
}
if (!hasCameraElement || !hasViewportElement) {
viewportElement.appendChild(cameraElement);
}
this.viewportElement = viewportElement;
this.cameraElement = cameraElement;
state.isViewportGiven = hasViewportElement;
state.isCameraGiven = hasCameraElement;
}
private applyCSSValue(): void {
const options = this.options;
const viewportElement = this.viewportElement;
const cameraElement = this.cameraElement;
const viewportStyle = this.viewportElement.style;
// Set default css values for each element
applyCSS(viewportElement, DEFAULT_VIEWPORT_CSS);
applyCSS(cameraElement, DEFAULT_CAMERA_CSS);
viewportElement.style.zIndex = `${options.zIndex}`;
if (options.horizontal) {
viewportStyle.minHeight = "100%";
viewportStyle.width = "100%";
} else {
viewportStyle.minWidth = "100%";
viewportStyle.height = "100%";
}
if (options.overflow) {
viewportStyle.overflow = "visible";
}
this.panelManager = new PanelManager(this.cameraElement, options);
}
private setMoveType(): void {
const moveType = this.options.moveType as MoveTypeObjectOption;
switch (moveType.type) {
case MOVE_TYPE.SNAP:
this.moveType = new Snap(moveType.count);
break;
case MOVE_TYPE.FREE_SCROLL:
this.moveType = new FreeScroll();
break;
default:
throw new Error("moveType is not correct!");
}
}
private setAxesInstance(): void {
const state = this.state;
const options = this.options;
const scrollArea = state.scrollArea;
this.axes = new Axes({
flick: {
range: [scrollArea.prev, scrollArea.next],
circular: options.circular,
bounce: [0, 0], // will be updated in resize()
},
}, {
easing: options.panelEffect,
deceleration: options.deceleration,
interruptable: true,
});
this.createPanInput();
}
private refreshPanels(): void {
const panelManager = this.panelManager;
// Panel elements were attached to camera element by Flicking class
const panelElements = this.cameraElement.children;
// Initialize panels
const panels = toArray(panelElements).map(
(el: HTMLElement, idx: number) => new Panel(el, idx, this),
);
panelManager.replacePanels(panels, []);
this.visiblePanels = panels.filter(panel => Boolean(panel));
}
private setDefaultPanel(): void {
const options = this.options;
const panelManager = this.panelManager;
const indexRange = this.panelManager.getRange();
const index = clamp(options.defaultIndex, indexRange.min, indexRange.max);
this.currentPanel = panelManager.get(index);
}
private clonePanels() {
const state = this.state;
const options = this.options;
const panelManager = this.panelManager;
const gap = options.gap;
const viewportSize = state.size;
const firstPanel = panelManager.firstPanel();
const lastPanel = panelManager.lastPanel()!;
// There're no panels exist
if (!firstPanel) {
return;
}
// For each panels, clone itself while last panel's position + size is below viewport size
const panels = panelManager.originalPanels();
const reversedPanels = panels.concat().reverse();
const sumOriginalPanelSize = lastPanel.getPosition() + lastPanel.getSize() - firstPanel.getPosition() + gap;
const relativeAnchorPosition = firstPanel.getRelativeAnchorPosition();
const relativeHangerPosition = this.getRelativeHangerPosition();
const areaPrev = (relativeHangerPosition - relativeAnchorPosition) % sumOriginalPanelSize;
let sizeSum = 0;
let panelAtLeftBoundary!: Panel;
for (const panel of reversedPanels) {
if (!panel) {
continue;
}
sizeSum += panel.getSize() + gap;
if (sizeSum >= areaPrev) {
panelAtLeftBoundary = panel;
break;
}
}
const areaNext = (viewportSize - relativeHangerPosition + relativeAnchorPosition) % sumOriginalPanelSize;
sizeSum = 0;
let panelAtRightBoundary!: Panel;
for (const panel of panels) {
if (!panel) {
continue;
}
sizeSum += panel.getSize() + gap;
if (sizeSum >= areaNext) {
panelAtRightBoundary = panel;
break;
}
}
// Need one more set of clones on prev area of original panel 0
const needCloneOnPrev = panelAtLeftBoundary.getIndex() !== 0
&& panelAtLeftBoundary.getIndex() <= panelAtRightBoundary.getIndex();
// Visible count of panel 0 on first screen
const panel0OnFirstscreen = Math.ceil((relativeHangerPosition + firstPanel.getSize() - relativeAnchorPosition) / sumOriginalPanelSize)
+ Math.ceil((viewportSize - relativeHangerPosition + relativeAnchorPosition) / sumOriginalPanelSize)
- 1; // duplication
const cloneCount = panel0OnFirstscreen
+ (needCloneOnPrev ? 1 : 0);
const prevCloneCount = panelManager.getCloneCount();
panelManager.setCloneCount(cloneCount);
if (options.renderExternal) {
return;
}
if (cloneCount > prevCloneCount) {
// should clone more
for (let cloneIndex = prevCloneCount; cloneIndex < cloneCount; cloneIndex++) {
const clones = panels.map(origPanel => origPanel.clone(cloneIndex));
const fragment = document.createDocumentFragment();
clones.forEach(panel => fragment.appendChild(panel.getElement()));
this.cameraElement.appendChild(fragment);
this.visiblePanels.push(...clones.filter(clone => Boolean(clone)));
panelManager.insertClones(cloneIndex, 0, clones);
}
} else if (cloneCount < prevCloneCount) {
// should remove some
panelManager.removeClonesAfter(cloneCount);
}
}
private moveToDefaultPanel(): void {
const state = this.state;
const panelManager = this.panelManager;
const options = this.options;
const indexRange = this.panelManager.getRange();
const defaultIndex = clamp(options.defaultIndex, indexRange.min, indexRange.max);
const defaultPanel = panelManager.get(defaultIndex);
let defaultPosition = 0;
if (defaultPanel) {
defaultPosition = defaultPanel.getAnchorPosition() - state.relativeHangerPosition;
defaultPosition = this.canSetBoundMode()
? clamp(defaultPosition, state.scrollArea.prev, state.scrollArea.next)
: defaultPosition;
}
this.moveCamera(defaultPosition);
this.axes.setTo({ flick: defaultPosition }, 0);
}
private updateSize(): void {
const state = this.state;
const options = this.options;
const panels = this.panelManager.originalPanels()
.filter(panel => Boolean(panel));
const bbox = this.updateBbox();
const prevSize = state.size;
// Update size & hanger position
state.size = options.horizontal
? bbox.width
: bbox.height;
if (prevSize !== state.size) {
state.relativeHangerPosition = parseArithmeticExpression(options.hanger, state.size);
state.infiniteThreshold = parseArithmeticExpression(options.infiniteThreshold, state.size);
}
if (panels.length <= 0) {
return;
}
this.resizePanels(panels);
}
private updateOriginalPanelPositions(): void {
const gap = this.options.gap;
const panelManager = this.panelManager;
const firstPanel = panelManager.firstPanel();
const panels = panelManager.originalPanels();
if (!firstPanel) {
return;
}
const currentPanel = this.currentPanel!;
const nearestPanel = this.nearestPanel;
const currentState = this.stateMachine.getState();
const scrollArea = this.state.scrollArea;
// Update panel position && fit to wrapper
let nextPanelPos = firstPanel.getPosition();
let maintainingPanel: Panel = firstPanel;
if (nearestPanel) {
// We should maintain nearestPanel's position
const looped = !isBetween(currentState.lastPosition + currentState.delta, scrollArea.prev, scrollArea.next);
maintainingPanel = looped
? currentPanel
: nearestPanel;
} else if (firstPanel.getIndex() > 0) {
maintainingPanel = currentPanel;
}
const panelsBeforeMaintainPanel = panels.slice(0, maintainingPanel.getIndex() + (maintainingPanel.getCloneIndex() + 1) * panels.length);
const accumulatedSize = panelsBeforeMaintainPanel.reduce((total, panel) => {
return total + panel.getSize() + gap;
}, 0);
nextPanelPos = maintainingPanel.getPosition() - accumulatedSize;
panels.forEach(panel => {
const newPosition = nextPanelPos;
const panelSize = panel.getSize();
panel.setPosition(newPosition);
nextPanelPos += panelSize + gap;
});
if (!this.options.renderOnlyVisible) {
panels.forEach(panel => panel.setPositionCSS());
}
}
private updateClonedPanelPositions(): void {
const state = this.state;
const options = this.options;
const panelManager = this.panelManager;
const clonedPanels = panelManager.clonedPanels()
.reduce((allClones, clones) => [...allClones, ...clones], [])
.filter(panel => Boolean(panel));
const scrollArea = state.scrollArea;
const firstPanel = panelManager.firstPanel();
const lastPanel = panelManager.lastPanel()!;
if (!firstPanel) {
return;
}
const sumOriginalPanelSize = lastPanel.getPosition() + lastPanel.getSize() - firstPanel.getPosition() + options.gap;
// Locate all cloned panels linearly first
for (const panel of clonedPanels) {
const origPanel = panel.getOriginalPanel();
const cloneIndex = panel.getCloneIndex();
const cloneBasePos = sumOriginalPanelSize * (cloneIndex + 1);
const clonedPanelPos = cloneBasePos + origPanel.getPosition();
panel.setPosition(clonedPanelPos);
}
let lastReplacePosition = firstPanel.getPosition();
// reverse() pollutes original array, so copy it with concat()
for (const panel of clonedPanels.concat().reverse()) {
const panelSize = panel.getSize();
const replacePosition = lastReplacePosition - panelSize - options.gap;
if (replacePosition + panelSize <= scrollArea.prev) {
// Replace is not meaningful, as it won't be seen in current scroll area
break;
}
panel.setPosition(replacePosition);
lastReplacePosition = replacePosition;
}
if (!this.options.renderOnlyVisible) {
clonedPanels.forEach(panel => {
panel.setPositionCSS();
});
}
}
private updateVisiblePanelPositions(): void {
if (this.options.renderOnlyVisible) {
this.visiblePanels.forEach(panel => {
panel.setPositionCSS(this.state.positionOffset);
});
}
}
private updateScrollArea(): void {
const state = this.state;
const panelManager = this.panelManager;
const options = this.options;
const axes = this.axes;
// Set viewport scrollable area
const firstPanel = panelManager.firstPanel();
const lastPanel = panelManager.lastPanel() as Panel;
const relativeHangerPosition = state.relativeHangerPosition;
if (!firstPanel) {
state.scrollArea = {
prev: 0,
next: 0,
};
} else if (this.canSetBoundMode()) {
const sumOriginalPanelSize = lastPanel.getPosition() + lastPanel.getSize() - firstPanel.getPosition();
if (sumOriginalPanelSize >= state.size) {
state.scrollArea = {
prev: firstPanel.getPosition(),
next: lastPanel.getPosition() + lastPanel.getSize() - state.size,
};
} else {
// Find anchor position of set of the combined panels
const relAnchorPosOfCombined = parseArithmeticExpression(options.anchor, sumOriginalPanelSize);
const anchorPos = firstPanel.getPosition() + clamp(
relAnchorPosOfCombined,
sumOriginalPanelSize - (state.size - relativeHangerPosition),
relativeHangerPosition,
);
state.scrollArea = {
prev: anchorPos - relativeHangerPosition,
next: anchorPos - relativeHangerPosition,
};
}
} else if (options.circular) {
const sumOriginalPanelSize = lastPanel.getPosition() + lastPanel.getSize() - firstPanel.getPosition() + options.gap;
// Maximum scroll extends to first clone sequence's first panel
state.scrollArea = {
prev: firstPanel.getAnchorPosition() - relativeHangerPosition,
next: sumOriginalPanelSize + firstPanel.getAnchorPosition() - relativeHangerPosition,
};
} else {
state.scrollArea = {
prev: firstPanel.getAnchorPosition() - relativeHangerPosition,
next: lastPanel.getAnchorPosition() - relativeHangerPosition,
};
}
const viewportSize = state.size;
const bounce = options.bounce;
let parsedBounce: number[];
if (isArray(bounce)) {
parsedBounce = (bounce as string[]).map(val => parseArithmeticExpression(val, viewportSize, DEFAULT_OPTIONS.bounce as number));
} else {
const parsedVal = parseArithmeticExpression(bounce as number | string, viewportSize, DEFAULT_OPTIONS.bounce as number);
parsedBounce = [parsedVal, parsedVal];
}
// Update axes range and bounce
const flick = axes.axis.flick;
flick.range = [state.scrollArea.prev, state.scrollArea.next];
flick.bounce = parsedBounce;
}
private checkNeedPanel(axesEvent?: any): void {
const state = this.state;
const options = this.options;
const panelManager = this.panelManager;
const currentPanel = this.currentPanel;
const nearestPanel = this.nearestPanel;
const currentState = this.stateMachine.getState();
if (!options.infinite) {
return;
}
const gap = options.gap;
const infiniteThreshold = state.infiniteThreshold;
const maxLastIndex = panelManager.getLastIndex();
if (maxLastIndex < 0) {
return;
}
if (!currentPanel || !nearestPanel) {
// There're no panels
this.triggerNeedPanel({
axesEvent,
siblingPanel: null,
direction: null,
indexRange: {
min: 0,
max: maxLastIndex,
length: maxLastIndex + 1,
},
});
return;
}
const originalNearestPosition = nearestPanel.getPosition();
// Check next direction
let checkingPanel: Panel | null = !currentState.holding