@egjs/flicking
Version:
Everyday 30 million people experience. It's reliable, flexible and extendable carousel.
649 lines (543 loc) • 19 kB
text/typescript
/*
* Copyright (c) 2015 NAVER Corp.
* egjs projects are licensed under the MIT license
*/
import { ComponentEvent } from "@egjs/component";
import ImReady from "@egjs/imready";
import { ALIGN } from "../constants/values";
import Panel, { PanelOptions } from "../core/panel/Panel";
import * as ERROR from "../error/codes";
import FlickingError from "../error/FlickingError";
import { EVENTS } from "../event/names";
import Flicking, { FlickingOptions } from "../Flicking";
import { getFlickingAttached, getMinusCompensatedIndex, includes, parsePanelAlign } from "../utils";
import RenderingStrategy from "./strategy/RenderingStrategy";
/**
* Options for creating a {@link Renderer}
*/
export interface RendererOptions {
/** An {@link Flicking.align | align} value that will be applied to all panels */
align?: FlickingOptions["align"];
/** An instance of RenderingStrategy(internal module) */
strategy: RenderingStrategy;
}
/**
* Parameters for {@link Renderer.batchInsert}
* @public
*/
export interface BatchInsertParams {
/** Index to insert new panels at */
index: number;
/** An array of element or framework component with element in it */
elements: any[];
/** Whether it contains actual DOM elements. If set to true, renderer will add them to the camera element */
hasDOMInElements: boolean;
}
/**
* Parameters for {@link Renderer.batchRemove}
* @public
*/
export interface BatchRemoveParams {
/** Index of panel to remove */
index: number;
/** Number of panels to remove from index */
deleteCount: number;
/** Whether it contains actual DOM elements. If set to true, renderer will remove them from the camera element */
hasDOMInElements: boolean;
}
/**
* A component that manages {@link Panel} and its elements
* @public
*/
abstract class Renderer {
// Internal States
protected _flicking: Flicking | null;
protected _panels: Panel[];
protected _rendering: boolean;
// Options
protected _align: NonNullable<RendererOptions["align"]>;
protected _strategy: RendererOptions["strategy"];
// Internal states Getter
/**
* Array of panels
* @readonly
* @see Panel
*/
public get panels(): Panel[] {
return this._panels;
}
/**
* A boolean value indicating whether rendering is in progress
* @readonly
* @internal
*/
public get rendering(): boolean {
return this._rendering;
}
/**
* Count of panels
* @readonly
*/
public get panelCount(): number {
return this._panels.length;
}
/**
* @internal
*/
public get strategy(): RendererOptions["strategy"] {
return this._strategy;
}
// Options Getter
/**
* A {@link Panel}'s {@link Panel.align | align} value that applied to all panels
*/
public get align(): NonNullable<RendererOptions["align"]> {
return this._align;
}
// Options Setter
public set align(val: NonNullable<RendererOptions["align"]>) {
this._align = val;
const panelAlign = parsePanelAlign(val);
this._panels.forEach(panel => {
panel.align = panelAlign;
});
}
/**
* @param options - {@link RendererOptions}
*/
public constructor(options: RendererOptions) {
// Destructure options with default values
const { align = ALIGN.CENTER, strategy } = options;
this._flicking = null;
this._panels = [];
this._rendering = false;
// Bind options
this._align = align;
this._strategy = strategy;
}
/**
* Render panel elements inside the camera element
* @remarks
* This method updates the DOM to reflect the current panel state.
* @returns A Promise that resolves when rendering is complete
*/
public abstract render(): Promise<void>;
/**
* @internal
* @privateRemarks
* Collects panel elements from the camera element and creates Panel instances.
*/
protected abstract _collectPanels(): void;
/**
* @internal
* @privateRemarks
* Creates a Panel instance from an element with the given options.
*/
protected abstract _createPanel(el: any, options: Omit<PanelOptions, "elementProvider">): Panel;
/**
* Initialize Renderer
* @remarks
* This method is called automatically during {@link Flicking.init}. It collects existing panel elements.
* @param flicking - An instance of {@link Flicking}
* @returns The current instance for method chaining
*/
public init(flicking: Flicking): this {
this._flicking = flicking;
this._collectPanels();
return this;
}
/**
* Destroy Renderer and return to initial state
* @remarks
* This method clears all panel references and resets the internal state.
*/
public destroy(): void {
this._flicking = null;
this._panels = [];
}
/**
* Return the {@link Panel} at the given index. `null` if it doesn't exist.
* @remarks
* This is equivalent to accessing `Flicking.panels[index]`.
* @param index - Index of the panel to get
* @returns Panel at the given index
* @see Panel
*/
public getPanel(index: number): Panel | null {
return this._panels[index] || null;
}
public forceRenderAllPanels(): Promise<void> {
this._panels.forEach(panel => panel.markForShow());
return Promise.resolve();
}
/**
* Return Rendered Panels
* @returns Rendered Panels
*/
public getRenderedPanels(): Panel[] {
const flicking = getFlickingAttached(this._flicking);
return flicking.renderer.panels.filter(panel => panel.rendered);
}
/**
* Update all panel sizes
* @returns The current instance for method chaining
*/
public updatePanelSize(): this {
const flicking = getFlickingAttached(this._flicking);
const panels = this._panels;
if (panels.length <= 0) return this;
if (flicking.panelsPerView > 0) {
const firstPanel = panels[0];
firstPanel.resize();
this._updatePanelSizeByGrid(firstPanel, panels);
} else {
flicking.panels.forEach(panel => panel.resize());
}
return this;
}
/**
* Insert new panels at given index
* @remarks
* This will increase index of panels after by the number of panels added.
* @param items - An array of {@link BatchInsertParams}
* @throws {@link DOMManipulationErrors}
* @returns An array of inserted panels
*/
public batchInsert(...items: BatchInsertParams[]): Panel[] {
const allPanelsInserted = this.batchInsertDefer(...items);
if (allPanelsInserted.length <= 0) return [];
this.updateAfterPanelChange(allPanelsInserted, []);
return allPanelsInserted;
}
/**
* @internal
* @privateRemarks
* Defers update. Camera position & others will be updated after calling updateAfterPanelChange.
*/
public batchInsertDefer(...items: BatchInsertParams[]) {
const panels = this._panels;
const flicking = getFlickingAttached(this._flicking);
const prevFirstPanel = panels[0];
const align = parsePanelAlign(this._align);
const allPanelsInserted = items.reduce((addedPanels, item) => {
const insertingIdx = getMinusCompensatedIndex(item.index, panels.length);
const panelsPushed = panels.slice(insertingIdx);
const panelsInserted = item.elements.map((el, idx) =>
this._createPanel(el, { index: insertingIdx + idx, align, flicking })
);
panels.splice(insertingIdx, 0, ...panelsInserted);
if (item.hasDOMInElements) {
// Insert the actual elements as camera element's children
this._insertPanelElements(panelsInserted, panelsPushed[0] ?? null);
}
// Resize the newly added panels
if (flicking.panelsPerView > 0) {
const firstPanel = prevFirstPanel || panelsInserted[0].resize();
this._updatePanelSizeByGrid(firstPanel, panelsInserted);
} else {
panelsInserted.forEach(panel => panel.resize());
}
// Update panel indexes & positions
panelsPushed.forEach(panel => {
panel.increaseIndex(panelsInserted.length);
panel.updatePosition();
});
return [...addedPanels, ...panelsInserted];
}, []);
return allPanelsInserted;
}
/**
* Remove the panel at the given index
* @remarks
* This will decrease index of panels after by the number of panels removed.
* @param items - An array of {@link BatchRemoveParams}
* @throws {@link DOMManipulationErrors}
* @returns An array of removed panels
*/
public batchRemove(...items: BatchRemoveParams[]): Panel[] {
const allPanelsRemoved = this.batchRemoveDefer(...items);
if (allPanelsRemoved.length <= 0) return [];
this.updateAfterPanelChange([], allPanelsRemoved);
return allPanelsRemoved;
}
/**
* @internal
* @privateRemarks
* Defers update. Camera position & others will be updated after calling updateAfterPanelChange.
*/
public batchRemoveDefer(...items: BatchRemoveParams[]) {
const panels = this._panels;
const flicking = getFlickingAttached(this._flicking);
const { control } = flicking;
const activePanel = control.activePanel;
const allPanelsRemoved = items.reduce((removed, item) => {
const { index, deleteCount } = item;
const removingIdx = getMinusCompensatedIndex(index, panels.length);
const panelsPulled = panels.slice(removingIdx + deleteCount);
const panelsRemoved = panels.splice(removingIdx, deleteCount);
if (panelsRemoved.length <= 0) return [];
// Update panel indexes & positions
panelsPulled.forEach(panel => {
panel.decreaseIndex(panelsRemoved.length);
panel.updatePosition();
});
if (item.hasDOMInElements) {
this._removePanelElements(panelsRemoved);
}
// Remove panel elements
panelsRemoved.forEach(panel => panel.destroy());
if (includes(panelsRemoved, activePanel)) {
control.resetActive();
}
return [...removed, ...panelsRemoved];
}, []);
return allPanelsRemoved;
}
/**
* @internal
* @privateRemarks
* Updates camera, control, and triggers {@link PanelChangeEvent} after panels are added or removed.
*/
public updateAfterPanelChange(panelsAdded: Panel[], panelsRemoved: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const { camera, control } = flicking;
const panels = this._panels;
const activePanel = control.activePanel;
// Update camera & control
this._updateCameraAndControl();
if (flicking.autoResize && flicking.useResizeObserver) {
panelsAdded.forEach(panel => {
if (panel.element) {
flicking.autoResizer.observe(panel.element);
}
});
panelsRemoved.forEach(panel => {
if (panel.element) {
flicking.autoResizer.unobserve(panel.element);
}
});
}
void this.render();
if (!flicking.animating) {
if (!activePanel || activePanel.removed) {
if (panels.length <= 0) {
// All panels removed
camera.lookAt(0);
} else {
let targetIndex = activePanel?.index ?? 0;
if (targetIndex > panels.length - 1) {
targetIndex = panels.length - 1;
}
void control
.moveToPanel(panels[targetIndex], {
duration: 0
})
.catch(() => void 0);
}
} else {
void control
.moveToPanel(activePanel, {
duration: 0
})
.catch(() => void 0);
}
}
flicking.camera.updateOffset();
if (panelsAdded.length > 0 || panelsRemoved.length > 0) {
flicking.trigger(
new ComponentEvent(EVENTS.PANEL_CHANGE, {
added: panelsAdded,
removed: panelsRemoved
})
);
this.checkPanelContentsReady([...panelsAdded, ...panelsRemoved]);
}
}
/**
* @internal
* @privateRemarks
* Checks if panel contents (images/videos) are ready and triggers resize when loaded.
*/
public checkPanelContentsReady(checkingPanels: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const resizeOnContentsReady = flicking.resizeOnContentsReady;
const panels = this._panels;
if (!resizeOnContentsReady || flicking.virtualEnabled) return;
const hasContents = (panel: Panel) => panel.element && !!panel.element.querySelector("img, video");
checkingPanels = checkingPanels.filter(panel => hasContents(panel));
if (checkingPanels.length <= 0) return;
const contentsReadyChecker = new ImReady();
checkingPanels.forEach(panel => {
panel.loading = true;
});
contentsReadyChecker.on("readyElement", e => {
if (!this._flicking) {
// Renderer's destroy() is called before
contentsReadyChecker.destroy();
return;
}
const panel = checkingPanels[e.index];
const camera = flicking.camera;
const control = flicking.control;
const prevProgressInPanel = control.activePanel ? camera.getProgressInPanel(control.activePanel) : 0;
panel.loading = false;
panel.resize();
panels.slice(panel.index + 1).forEach(panelBehind => panelBehind.updatePosition());
if (!flicking.initialized) return;
camera.updateRange();
camera.updateOffset();
camera.updateAnchors();
if (control.animating) {
// TODO: Need Axes update
} else {
control.updatePosition(prevProgressInPanel);
control.updateInput();
}
});
contentsReadyChecker.on("preReady", e => {
if (this._flicking) {
void this.render();
}
if (e.readyCount === e.totalCount) {
contentsReadyChecker.destroy();
}
});
contentsReadyChecker.on("ready", () => {
if (this._flicking) {
void this.render();
}
contentsReadyChecker.destroy();
});
contentsReadyChecker.check(checkingPanels.map(panel => panel.element));
}
/**
* @internal
* @privateRemarks
* Updates camera range, anchors, and control input after panel changes.
*/
protected _updateCameraAndControl() {
const flicking = getFlickingAttached(this._flicking);
const { camera, control } = flicking;
camera.updateRange();
camera.updateOffset();
camera.updateAnchors();
camera.resetNeedPanelHistory();
control.updateInput();
}
/**
* @internal
* @privateRemarks
* Marks only visible panels for rendering when {@link FlickingOptions.renderOnlyVisible | renderOnlyVisible} is enabled.
*/
protected _showOnlyVisiblePanels(flicking: Flicking) {
const panels = flicking.renderer.panels;
const camera = flicking.camera;
const visibleIndexes = camera.visiblePanels.reduce((visibles, panel) => {
visibles[panel.index] = true;
return visibles;
}, {});
panels.forEach(panel => {
if (panel.index in visibleIndexes || panel.loading) {
panel.markForShow();
} else if (!flicking.holding) {
// During the input sequence,
// Do not remove panel elements as it won't trigger touchend event.
panel.markForHide();
}
});
}
/**
* @internal
* @privateRemarks
* Calculates and applies panel sizes when {@link FlickingOptions.panelsPerView | panelsPerView} is enabled.
*/
protected _updatePanelSizeByGrid(referencePanel: Panel, panels: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const panelsPerView = flicking.panelsPerView;
if (panelsPerView <= 0) {
throw new FlickingError(ERROR.MESSAGE.WRONG_OPTION("panelsPerView", panelsPerView), ERROR.CODE.WRONG_OPTION);
}
if (panels.length <= 0) return;
const viewportSize = flicking.camera.size;
const gap = referencePanel.margin.prev + referencePanel.margin.next;
const panelSize = (viewportSize - gap * (panelsPerView - 1)) / panelsPerView;
const panelSizeObj = flicking.horizontal ? { width: panelSize } : { height: panelSize };
const firstPanelSizeObj = {
size: panelSize,
margin: referencePanel.margin,
...(!flicking.horizontal && { height: referencePanel.height })
};
if (!flicking.noPanelStyleOverride) {
this._strategy.updatePanelSizes(flicking, panelSizeObj);
}
flicking.panels.forEach(panel => panel.resize(firstPanelSizeObj));
}
/**
* @internal
* @privateRemarks
* Removes all child elements from the camera element.
*/
protected _removeAllChildsFromCamera() {
const flicking = getFlickingAttached(this._flicking);
const cameraElement = flicking.camera.element;
// Remove other elements
while (cameraElement.firstChild) {
cameraElement.removeChild(cameraElement.firstChild);
}
}
/**
* @internal
* @privateRemarks
* Inserts panel elements into the camera element at the specified position.
*/
protected _insertPanelElements(panels: Panel[], nextSibling: Panel | null = null) {
const flicking = getFlickingAttached(this._flicking);
const camera = flicking.camera;
const cameraElement = camera.element;
const nextSiblingElement = nextSibling?.element || null;
const fragment = document.createDocumentFragment();
panels.forEach(panel => fragment.appendChild(panel.element));
// With `renderOnlyVisible`, the reference(nextSibling) panel may be detached from the camera
// element. insertBefore requires the reference to be a mounted child, so fall back to appending
// when it isn't to avoid a `NotFoundError`.
const referenceElement =
nextSiblingElement && nextSiblingElement.parentNode === cameraElement ? nextSiblingElement : null;
cameraElement.insertBefore(fragment, referenceElement);
}
/**
* @internal
* @privateRemarks
* Removes panel elements from the camera element.
*/
protected _removePanelElements(panels: Panel[]) {
const flicking = getFlickingAttached(this._flicking);
const cameraElement = flicking.camera.element;
panels.forEach(panel => {
// With `renderOnlyVisible`, non-visible panels are detached from the camera element,
// so only remove elements that are actually mounted to avoid a `removeChild` error.
if (panel.element.parentNode === cameraElement) {
cameraElement.removeChild(panel.element);
}
});
}
/**
* @internal
* @privateRemarks
* Called after rendering to apply the camera transform.
*/
protected _afterRender() {
const flicking = getFlickingAttached(this._flicking);
flicking.camera.applyTransform();
if (flicking.useCSSOrder) {
// `useCSSOrder`를 사용하는 경우 DOM은 변화가 없지만 대신 css `order`값을 주입
const renderedPanels = flicking.renderer.panels.filter(panel => panel.rendered);
this._strategy.getRenderingIndexesByOrder(flicking).forEach((domIndex, index) => {
if (renderedPanels[domIndex].element) {
// 방어 코드 추가
renderedPanels[domIndex].element.style.order = `${index}`;
}
});
}
}
}
export default Renderer;