/* * Copyright (c) 2015 NAVER Corp. * egjs projects are licensed under the MIT license */ import { ComponentEvent } from "@egjs/component"; import ImReady from "@egjs/imready"; import Flicking, { FlickingOptions } from "../Flicking"; import Panel, { PanelOptions } from "../core/panel/Panel"; import FlickingError from "../core/FlickingError"; import { ALIGN, EVENTS } from "../const/external"; import * as ERROR from "../const/error"; import { getFlickingAttached, getMinusCompensatedIndex, includes, parsePanelAlign } from "../utils"; import RenderingStrategy from "./strategy/RenderingStrategy"; export interface RendererOptions { align?: FlickingOptions["align"]; strategy: RenderingStrategy; } /** * A component that manages {@link Panel} and its elements * @ko {@link Panel}과 그 엘리먼트들을 관리하는 컴포넌트 */ abstract class Renderer { // Internal States protected _flicking: Flicking | null; protected _panels: Panel[]; protected _rendering: boolean; // Options protected _align: NonNullable; protected _strategy: RendererOptions["strategy"]; // Internal states Getter /** * Array of panels * @ko 전체 패널들의 배열 * @type {Panel[]} * @readonly * @see Panel */ public get panels() { return this._panels; } /** * A boolean value indicating whether rendering is in progress * @ko 현재 렌더링이 시작되어 끝나기 전까지의 상태인지의 여부 * @type {boolean} * @readonly * @internal */ public get rendering() { return this._rendering; } /** * Count of panels * @ko 전체 패널의 개수 * @type {number} * @readonly */ public get panelCount() { return this._panels.length; } /** * @internal */ public get strategy() { return this._strategy; } // Options Getter /** * A {@link Panel}'s {@link Panel#align align} value that applied to all panels * @ko {@link Panel}에 공통적으로 적용할 {@link Panel#align align} 값 * @type {Constants.ALIGN | string | number} */ public get align() { return this._align; } // Options Setter public set align(val: NonNullable) { this._align = val; const panelAlign = parsePanelAlign(val); this._panels.forEach(panel => { panel.align = panelAlign; }); } /** * @param {object} options An options object옵션 오브젝트 * @param {Constants.ALIGN | string | number} [options.align="center"] An {@link Flicking#align align} value that will be applied to all panels전체 패널에 적용될 {@link Flicking#align align} 값 * @param {object} [options.strategy] An instance of RenderingStrategy(internal module)RenderingStrategy의 인스턴스(내부 모듈) */ public constructor({ align = ALIGN.CENTER, strategy }: RendererOptions) { this._flicking = null; this._panels = []; this._rendering = false; // Bind options this._align = align; this._strategy = strategy; } /** * Render panel elements inside the camera element * @ko 패널 엘리먼트들을 카메라 엘리먼트 내부에 렌더링합니다 * @method * @abstract * @memberof Renderer * @instance * @name render * @chainable * @return {this} */ public abstract render(): Promise; protected abstract _collectPanels(): void; protected abstract _createPanel(el: any, options: Omit): Panel; /** * Initialize Renderer * @ko Renderer를 초기화합니다 * @param {Flicking} flicking An instance of {@link Flicking}Flicking의 인스턴스 * @chainable * @return {this} */ public init(flicking: Flicking): this { this._flicking = flicking; this._collectPanels(); return this; } /** * Destroy Renderer and return to initial state * @ko Renderer를 초기 상태로 되돌립니다 * @return {void} */ public destroy(): void { this._flicking = null; this._panels = []; } /** * Return the {@link Panel} at the given index. `null` if it doesn't exists. * @ko 주어진 인덱스에 해당하는 {@link Panel}을 반환합니다. 주어진 인덱스에 해당하는 패널이 존재하지 않을 경우 `null`을 반환합니다. * @return {Panel | null} Panel at the given index주어진 인덱스에 해당하는 패널 * @see Panel */ public getPanel(index: number): Panel | null { return this._panels[index] || null; } public forceRenderAllPanels(): Promise { this._panels.forEach(panel => panel.markForShow()); return Promise.resolve(); } /** * Update all panel sizes * @ko 모든 패널의 크기를 업데이트합니다 * @chainable * @return {this} */ 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 * This will increase index of panels after by the number of panels added * @ko 주어진 인덱스에 새로운 패널들을 추가합니다 * 해당 인덱스보다 같거나 큰 인덱스를 가진 기존 패널들은 추가한 패널의 개수만큼 인덱스가 증가합니다. * @param {Array} items An array of items to insert추가할 아이템들의 배열 * @param {number} [items.index] Index to insert new panels at새로 패널들을 추가할 인덱스 * @param {any[]} [items.elements] An array of element or framework component with element in it엘리먼트의 배열 혹은 프레임워크에서 엘리먼트를 포함한 컴포넌트들의 배열 * @param {boolean} [items.hasDOMInElements] Whether it contains actual DOM elements. If set to true, renderer will add them to the camera element내부에 실제 DOM 엘리먼트들을 포함하고 있는지 여부. true로 설정할 경우, 렌더러는 해당 엘리먼트들을 카메라 엘리먼트 내부에 추가합니다 * @return {Panel[]} An array of prepended panels추가된 패널들의 배열 */ public batchInsert(...items: Array<{ index: number; elements: any[]; hasDOMInElements: boolean; }>): Panel[] { const allPanelsInserted = this.batchInsertDefer(...items); if (allPanelsInserted.length <= 0) return []; this.updateAfterPanelChange(allPanelsInserted, []); return allPanelsInserted; } /** * Defers update * camera position & others will be updated after calling updateAfterPanelChange * @internal */ public batchInsertDefer(...items: Array<{ index: number; elements: any[]; hasDOMInElements: boolean; }>) { 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 * This will decrease index of panels after by the number of panels removed * @ko 주어진 인덱스의 패널을 제거합니다 * 해당 인덱스보다 큰 인덱스를 가진 기존 패널들은 제거한 패널의 개수만큼 인덱스가 감소합니다 * @param {Array} items An array of items to remove제거할 아이템들의 배열 * @param {number} [items.index] Index of panel to remove제거할 패널의 인덱스 * @param {number} [items.deleteCount=1] Number of panels to remove from index`index` 이후로 제거할 패널의 개수 * @param {boolean} [items.hasDOMInElements=1] Whether it contains actual DOM elements. If set to true, renderer will remove them from the camera element내부에 실제 DOM 엘리먼트들을 포함하고 있는지 여부. true로 설정할 경우, 렌더러는 해당 엘리먼트들을 카메라 엘리먼트 내부에서 제거합니다 * @return An array of removed panels제거된 패널들의 배열 */ public batchRemove(...items: Array<{ index: number; deleteCount: number; hasDOMInElements: boolean; }>): Panel[] { const allPanelsRemoved = this.batchRemoveDefer(...items); if (allPanelsRemoved.length <= 0) return []; this.updateAfterPanelChange([], allPanelsRemoved); return allPanelsRemoved; } /** * Defers update * camera position & others will be updated after calling updateAfterPanelChange * @internal */ public batchRemoveDefer(...items: Array<{ index: number; deleteCount: number; hasDOMInElements: boolean; }>) { 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 */ 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(); 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 */ 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)); } protected _updateCameraAndControl() { const flicking = getFlickingAttached(this._flicking); const { camera, control } = flicking; camera.updateRange(); camera.updateOffset(); camera.updateAnchors(); camera.resetNeedPanelHistory(); control.updateInput(); } 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(); } }); } 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)); } protected _removeAllChildsFromCamera() { const flicking = getFlickingAttached(this._flicking); const cameraElement = flicking.camera.element; // Remove other elements while (cameraElement.firstChild) { cameraElement.removeChild(cameraElement.firstChild); } } 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)); cameraElement.insertBefore(fragment, nextSiblingElement); } protected _removePanelElements(panels: Panel[]) { const flicking = getFlickingAttached(this._flicking); const cameraElement = flicking.camera.element; panels.forEach(panel => { cameraElement.removeChild(panel.element); }); } } export default Renderer;