UNPKG

photoswipe

Version:
502 lines (422 loc) 13.7 kB
/** @typedef {import('../photoswipe.js').default} PhotoSwipe */ /** @typedef {import('../photoswipe.js').Point} Point */ /** * @typedef {_SlideData & Record<string, any>} SlideData * @typedef {Object} _SlideData * @prop {HTMLElement=} element thumbnail element * @prop {string=} src image URL * @prop {string=} srcset image srcset * @prop {number=} w image width (deprecated) * @prop {number=} h image height (deprecated) * @prop {number=} width image width * @prop {number=} height image height * @prop {string=} msrc placeholder image URL that's displayed before large image is loaded * @prop {string=} alt image alt text * @prop {boolean=} thumbCropped whether thumbnail is cropped client-side or not * @prop {string=} html html content of a slide * @prop {'image' | 'html' | string} [type] slide type */ import { createElement, setTransform, equalizePoints, roundPoint, toTransformString, clamp, } from '../util/util.js'; import PanBounds from './pan-bounds.js'; import ZoomLevel from './zoom-level.js'; import { getPanAreaSize } from '../util/viewport-size.js'; /** * Renders and allows to control a single slide */ class Slide { /** * @param {SlideData} data * @param {number} index * @param {PhotoSwipe} pswp */ constructor(data, index, pswp) { this.data = data; this.index = index; this.pswp = pswp; this.isActive = (index === pswp.currIndex); this.currentResolution = 0; /** @type {Point} */ this.panAreaSize = {}; this.isFirstSlide = (this.isActive && !pswp.opener.isOpen); this.zoomLevels = new ZoomLevel(pswp.options, data, index, pswp); this.pswp.dispatch('gettingData', { slide: this, data: this.data, index }); this.pan = { x: 0, y: 0 }; this.content = this.pswp.contentLoader.getContentBySlide(this); this.container = createElement('pswp__zoom-wrap'); this.currZoomLevel = 1; /** @type {number} */ this.width = this.content.width; /** @type {number} */ this.height = this.content.height; this.bounds = new PanBounds(this); this.prevDisplayedWidth = -1; this.prevDisplayedHeight = -1; this.pswp.dispatch('slideInit', { slide: this }); } /** * If this slide is active/current/visible * * @param {boolean} isActive */ setIsActive(isActive) { if (isActive && !this.isActive) { // slide just became active this.activate(); } else if (!isActive && this.isActive) { // slide just became non-active this.deactivate(); } } /** * Appends slide content to DOM * * @param {HTMLElement} holderElement */ append(holderElement) { this.holderElement = holderElement; this.container.style.transformOrigin = '0 0'; // Slide appended to DOM if (!this.data) { return; } this.calculateSize(); this.load(); this.updateContentSize(); this.appendHeavy(); this.holderElement.appendChild(this.container); this.zoomAndPanToInitial(); this.pswp.dispatch('firstZoomPan', { slide: this }); this.applyCurrentZoomPan(); this.pswp.dispatch('afterSetContent', { slide: this }); if (this.isActive) { this.activate(); } } load() { this.content.load(); this.pswp.dispatch('slideLoad', { slide: this }); } /** * Append "heavy" DOM elements * * This may depend on a type of slide, * but generally these are large images. */ appendHeavy() { const { pswp } = this; const appendHeavyNearby = true; // todo // Avoid appending heavy elements during animations if (this.heavyAppended || !pswp.opener.isOpen || pswp.mainScroll.isShifted() || (!this.isActive && !appendHeavyNearby)) { return; } if (this.pswp.dispatch('appendHeavy', { slide: this }).defaultPrevented) { return; } this.heavyAppended = true; this.content.append(); this.pswp.dispatch('appendHeavyContent', { slide: this }); } /** * Triggered when this slide is active (selected). * * If it's part of opening/closing transition - * activate() will trigger after the transition is ended. */ activate() { this.isActive = true; this.appendHeavy(); this.content.activate(); this.pswp.dispatch('slideActivate', { slide: this }); } /** * Triggered when this slide becomes inactive. * * Slide can become inactive only after it was active. */ deactivate() { this.isActive = false; this.content.deactivate(); if (this.currZoomLevel !== this.zoomLevels.initial) { // allow filtering this.calculateSize(); } // reset zoom level this.currentResolution = 0; this.zoomAndPanToInitial(); this.applyCurrentZoomPan(); this.updateContentSize(); this.pswp.dispatch('slideDeactivate', { slide: this }); } /** * The slide should destroy itself, it will never be used again. * (unbind all events and destroy internal components) */ destroy() { this.content.hasSlide = false; this.content.remove(); this.container.remove(); this.pswp.dispatch('slideDestroy', { slide: this }); } resize() { if (this.currZoomLevel === this.zoomLevels.initial || !this.isActive) { // Keep initial zoom level if it was before the resize, // as well as when this slide is not active // Reset position and scale to original state this.calculateSize(); this.currentResolution = 0; this.zoomAndPanToInitial(); this.applyCurrentZoomPan(); this.updateContentSize(); } else { // readjust pan position if it's beyond the bounds this.calculateSize(); this.bounds.update(this.currZoomLevel); this.panTo(this.pan.x, this.pan.y); } } /** * Apply size to current slide content, * based on the current resolution and scale. * * @param {boolean=} force if size should be updated even if dimensions weren't changed */ updateContentSize(force) { // Use initial zoom level // if resolution is not defined (user didn't zoom yet) const scaleMultiplier = this.currentResolution || this.zoomLevels.initial; if (!scaleMultiplier) { return; } const width = Math.round(this.width * scaleMultiplier) || this.pswp.viewportSize.x; const height = Math.round(this.height * scaleMultiplier) || this.pswp.viewportSize.y; if (!this.sizeChanged(width, height) && !force) { return; } this.content.setDisplayedSize(width, height); } /** * @param {number} width * @param {number} height */ sizeChanged(width, height) { if (width !== this.prevDisplayedWidth || height !== this.prevDisplayedHeight) { this.prevDisplayedWidth = width; this.prevDisplayedHeight = height; return true; } return false; } getPlaceholderElement() { if (this.content.placeholder) { return this.content.placeholder.element; } } /** * Zoom current slide image to... * * @param {number} destZoomLevel Destination zoom level. * @param {{ x?: number; y?: number }} centerPoint * Transform origin center point, or false if viewport center should be used. * @param {number | false} [transitionDuration] Transition duration, may be set to 0. * @param {boolean=} ignoreBounds Minimum and maximum zoom levels will be ignored. * @return {boolean=} Returns true if animated. */ zoomTo(destZoomLevel, centerPoint, transitionDuration, ignoreBounds) { const { pswp } = this; if (!this.isZoomable() || pswp.mainScroll.isShifted()) { return; } pswp.dispatch('beforeZoomTo', { destZoomLevel, centerPoint, transitionDuration }); // stop all pan and zoom transitions pswp.animations.stopAllPan(); // if (!centerPoint) { // centerPoint = pswp.getViewportCenterPoint(); // } const prevZoomLevel = this.currZoomLevel; if (!ignoreBounds) { destZoomLevel = clamp(destZoomLevel, this.zoomLevels.min, this.zoomLevels.max); } // if (transitionDuration === undefined) { // transitionDuration = this.pswp.options.zoomAnimationDuration; // } this.setZoomLevel(destZoomLevel); this.pan.x = this.calculateZoomToPanOffset('x', centerPoint, prevZoomLevel); this.pan.y = this.calculateZoomToPanOffset('y', centerPoint, prevZoomLevel); roundPoint(this.pan); const finishTransition = () => { this._setResolution(destZoomLevel); this.applyCurrentZoomPan(); }; if (!transitionDuration) { finishTransition(); } else { pswp.animations.startTransition({ isPan: true, name: 'zoomTo', target: this.container, transform: this.getCurrentTransform(), onComplete: finishTransition, duration: transitionDuration, easing: pswp.options.easing }); } } /** * @param {{ x?: number, y?: number }} [centerPoint] */ toggleZoom(centerPoint) { this.zoomTo( this.currZoomLevel === this.zoomLevels.initial ? this.zoomLevels.secondary : this.zoomLevels.initial, centerPoint, this.pswp.options.zoomAnimationDuration ); } /** * Updates zoom level property and recalculates new pan bounds, * unlike zoomTo it does not apply transform (use applyCurrentZoomPan) * * @param {number} currZoomLevel */ setZoomLevel(currZoomLevel) { this.currZoomLevel = currZoomLevel; this.bounds.update(this.currZoomLevel); } /** * Get pan position after zoom at a given `point`. * * Always call setZoomLevel(newZoomLevel) beforehand to recalculate * pan bounds according to the new zoom level. * * @param {'x' | 'y'} axis * @param {{ x?: number; y?: number }} [point] * point based on which zoom is performed, usually refers to the current mouse position, * if false - viewport center will be used. * @param {number=} prevZoomLevel Zoom level before new zoom was applied. */ calculateZoomToPanOffset(axis, point, prevZoomLevel) { const totalPanDistance = this.bounds.max[axis] - this.bounds.min[axis]; if (totalPanDistance === 0) { return this.bounds.center[axis]; } if (!point) { point = this.pswp.getViewportCenterPoint(); } const zoomFactor = this.currZoomLevel / prevZoomLevel; return this.bounds.correctPan( axis, (this.pan[axis] - point[axis]) * zoomFactor + point[axis] ); } /** * Apply pan and keep it within bounds. * * @param {number} panX * @param {number} panY */ panTo(panX, panY) { this.pan.x = this.bounds.correctPan('x', panX); this.pan.y = this.bounds.correctPan('y', panY); this.applyCurrentZoomPan(); } /** * If the slide in the current state can be panned by the user */ isPannable() { return this.width && (this.currZoomLevel > this.zoomLevels.fit); } /** * If the slide can be zoomed */ isZoomable() { return this.width && this.content.isZoomable(); } /** * Apply transform and scale based on * the current pan position (this.pan) and zoom level (this.currZoomLevel) */ applyCurrentZoomPan() { this._applyZoomTransform(this.pan.x, this.pan.y, this.currZoomLevel); if (this === this.pswp.currSlide) { this.pswp.dispatch('zoomPanUpdate', { slide: this }); } } zoomAndPanToInitial() { this.currZoomLevel = this.zoomLevels.initial; // pan according to the zoom level this.bounds.update(this.currZoomLevel); equalizePoints(this.pan, this.bounds.center); this.pswp.dispatch('initialZoomPan', { slide: this }); } /** * Set translate and scale based on current resolution * * @param {number} x * @param {number} y * @param {number} zoom */ _applyZoomTransform(x, y, zoom) { zoom /= this.currentResolution || this.zoomLevels.initial; setTransform(this.container, x, y, zoom); } calculateSize() { const { pswp } = this; equalizePoints( this.panAreaSize, getPanAreaSize(pswp.options, pswp.viewportSize, this.data, this.index) ); this.zoomLevels.update(this.width, this.height, this.panAreaSize); pswp.dispatch('calcSlideSize', { slide: this }); } getCurrentTransform() { const scale = this.currZoomLevel / (this.currentResolution || this.zoomLevels.initial); return toTransformString(this.pan.x, this.pan.y, scale); } /** * Set resolution and re-render the image. * * For example, if the real image size is 2000x1500, * and resolution is 0.5 - it will be rendered as 1000x750. * * Image with zoom level 2 and resolution 0.5 is * the same as image with zoom level 1 and resolution 1. * * Used to optimize animations and make * sure that browser renders image in highest quality. * Also used by responsive images to load the correct one. * * @param {number} newResolution */ _setResolution(newResolution) { if (newResolution === this.currentResolution) { return; } this.currentResolution = newResolution; this.updateContentSize(); this.pswp.dispatch('resolutionChanged'); } } export default Slide;