UNPKG

@fancyapps/ui

Version:

JavaScript UI Component Library

698 lines (548 loc) 17.5 kB
import { extend } from "../../../shared/utils/extend.js"; import { Panzoom } from "../../../Panzoom/Panzoom.js"; const defaults = { // Class name for slide element indicating that content can be zoomed in canZoomInClass: "can-zoom_in", // Class name for slide element indicating that content can be zoomed out canZoomOutClass: "can-zoom_out", // Do zoom animation from thumbnail image when starting or closing fancybox zoom: true, // Animate opacity while zooming zoomOpacity: "auto", // "auto" | true | false, // Zoom animation friction zoomFriction: 0.82, // Disable zoom animation if thumbnail is visible only partly ignoreCoveredThumbnail: false, // Enable guestures touch: true, // Action to be performed when user clicks on the image click: "toggleZoom", // "toggleZoom" | "next" | "close" | null // Action to be performed when double-click event is detected on the image doubleClick: null, // "toggleZoom" | null // Action to be performed when user rotates a wheel button on a pointing device wheel: "zoom", // "zoom" | "slide" | "close" | null // How image should be resized to fit its container fit: "contain", // "contain" | "contain-w" | "cover" // Should create wrapping element around the image wrap: false, // Custom Panzoom options Panzoom: { ratio: 1, }, }; export class Image { constructor(fancybox) { this.fancybox = fancybox; for (const methodName of [ // Fancybox "onReady", "onClosing", "onDone", // Fancybox.Carousel "onPageChange", "onCreateSlide", "onRemoveSlide", // Image load/error "onImageStatusChange", ]) { this[methodName] = this[methodName].bind(this); } this.events = { ready: this.onReady, closing: this.onClosing, done: this.onDone, "Carousel.change": this.onPageChange, "Carousel.createSlide": this.onCreateSlide, "Carousel.removeSlide": this.onRemoveSlide, }; } /** * Handle `ready` event to start loading content */ onReady() { this.fancybox.Carousel.slides.forEach((slide) => { if (slide.$el) { this.setContent(slide); } }); } /** * Handle `done` event to update cursor * @param {Object} fancybox * @param {Object} slide */ onDone(fancybox, slide) { this.handleCursor(slide); } /** * Handle `closing` event to clean up all slides and to start zoom-out animation * @param {Object} fancybox */ onClosing(fancybox) { clearTimeout(this.clickTimer); this.clickTimer = null; // Remove events fancybox.Carousel.slides.forEach((slide) => { if (slide.$image) { slide.state = "destroy"; } if (slide.Panzoom) { slide.Panzoom.detachEvents(); } }); // If possible, start the zoom animation, it will interrupt the default closing process if (this.fancybox.state === "closing" && this.canZoom(fancybox.getSlide())) { this.zoomOut(); } } /** * Process `Carousel.createSlide` event to create image content * @param {Object} fancybox * @param {Object} carousel * @param {Object} slide */ onCreateSlide(fancybox, carousel, slide) { if (this.fancybox.state !== "ready") { return; } this.setContent(slide); } /** * Handle `Carousel.removeSlide` event to do clean up the slide * @param {Object} fancybox * @param {Object} carousel * @param {Object} slide */ onRemoveSlide(fancybox, carousel, slide) { if (slide.$image) { slide.$el.classList.remove(fancybox.option("Image.canZoomInClass")); slide.$image.remove(); slide.$image = null; } if (slide.Panzoom) { slide.Panzoom.destroy(); slide.Panzoom = null; } if (slide.$el && slide.$el.dataset) { delete slide.$el.dataset.imageFit; } } /** * Build DOM elements and add event listeners * @param {Object} slide */ setContent(slide) { // Check if this slide should contain an image if (slide.isDom || slide.html || (slide.type && slide.type !== "image")) { return; } if (slide.$image) { return; } slide.type = "image"; slide.state = "loading"; // * Build layout // Container const $content = document.createElement("div"); $content.style.visibility = "hidden"; // Image element const $image = document.createElement("img"); $image.addEventListener("load", (event) => { event.stopImmediatePropagation(); this.onImageStatusChange(slide); }); $image.addEventListener("error", () => { this.onImageStatusChange(slide); }); $image.src = slide.src; $image.alt = ""; $image.draggable = false; $image.classList.add("fancybox__image"); if (slide.srcset) { $image.setAttribute("srcset", slide.srcset); } if (slide.sizes) { $image.setAttribute("sizes", slide.sizes); } slide.$image = $image; const shouldWrap = this.fancybox.option("Image.wrap"); if (shouldWrap) { const $wrap = document.createElement("div"); $wrap.classList.add(typeof shouldWrap === "string" ? shouldWrap : "fancybox__image-wrap"); $wrap.appendChild($image); $content.appendChild($wrap); slide.$wrap = $wrap; } else { $content.appendChild($image); } // Set data attribute if other that default // for example, set `[data-image-fit="contain-w"]` slide.$el.dataset.imageFit = this.fancybox.option("Image.fit"); // Append content this.fancybox.setContent(slide, $content); // Display loading icon if ($image.complete || $image.error) { this.onImageStatusChange(slide); } else { this.fancybox.showLoading(slide); } } /** * Handle image state change, display error or start revealing image * @param {Object} slide */ onImageStatusChange(slide) { const $image = slide.$image; if (!$image || slide.state !== "loading") { return; } if (!($image.complete && $image.naturalWidth && $image.naturalHeight)) { this.fancybox.setError(slide, "{{IMAGE_ERROR}}"); return; } this.fancybox.hideLoading(slide); if (this.fancybox.option("Image.fit") === "contain") { this.initSlidePanzoom(slide); } // Add `wheel` and `click` event handler slide.$el.addEventListener("wheel", (event) => this.onWheel(slide, event), { passive: false }); slide.$content.addEventListener("click", (event) => this.onClick(slide, event), { passive: false }); this.revealContent(slide); } /** * Make image zoomable and draggable using Panzoom * @param {Object} slide */ initSlidePanzoom(slide) { if (slide.Panzoom) { return; } //* Initialize Panzoom slide.Panzoom = new Panzoom( slide.$el, extend(true, this.fancybox.option("Image.Panzoom", {}), { viewport: slide.$wrap, content: slide.$image, width: slide._width, height: slide._height, wrapInner: false, // Allow to select caption text textSelection: true, // Toggle gestures touch: this.fancybox.option("Image.touch"), // This will prevent click conflict with fancybox main carousel panOnlyZoomed: true, // Disable default click / wheel events as custom event listeners will replace them, // because click and wheel events should work without Panzoom click: false, wheel: false, }) ); slide.Panzoom.on("startAnimation", () => { this.fancybox.trigger("Image.startAnimation", slide); }); slide.Panzoom.on("endAnimation", () => { if (slide.state === "zoomIn") { this.fancybox.done(slide); } this.handleCursor(slide); this.fancybox.trigger("Image.endAnimation", slide); }); slide.Panzoom.on("afterUpdate", () => { this.handleCursor(slide); this.fancybox.trigger("Image.afterUpdate", slide); }); } /** * Start zoom-in animation if possible, or simply reveal content * @param {Object} slide */ revealContent(slide) { // Animate only on first run if ( this.fancybox.Carousel.prevPage === null && slide.index === this.fancybox.options.startIndex && this.canZoom(slide) ) { this.zoomIn(); } else { this.fancybox.revealContent(slide); } } /** * Get zoom info for selected slide * @param {Object} slide */ getZoomInfo(slide) { const $thumb = slide.$thumb, thumbRect = $thumb.getBoundingClientRect(), thumbWidth = thumbRect.width, thumbHeight = thumbRect.height, // contentRect = slide.$content.getBoundingClientRect(), contentWidth = contentRect.width, contentHeight = contentRect.height, // shiftedTop = contentRect.top - thumbRect.top, shiftedLeft = contentRect.left - thumbRect.left; // Check if need to update opacity let opacity = this.fancybox.option("Image.zoomOpacity"); if (opacity === "auto") { opacity = Math.abs(thumbWidth / thumbHeight - contentWidth / contentHeight) > 0.1; } return { top: shiftedTop, left: shiftedLeft, scale: contentWidth && thumbWidth ? thumbWidth / contentWidth : 1, opacity: opacity, }; } /** * Determine if it is possible to do zoom-in animation */ canZoom(slide) { const fancybox = this.fancybox, $container = fancybox.$container; if (window.visualViewport && window.visualViewport.scale !== 1) { return false; } if (slide.Panzoom && !slide.Panzoom.content.width) { return false; } if (!fancybox.option("Image.zoom") || fancybox.option("Image.fit") !== "contain") { return false; } const $thumb = slide.$thumb; if (!$thumb || slide.state === "loading") { return false; } // * Check if thumbnail image is really visible $container.classList.add("fancybox__no-click"); const rect = $thumb.getBoundingClientRect(); let rez; // Check if thumbnail image is actually visible on the screen if (this.fancybox.option("Image.ignoreCoveredThumbnail")) { const visibleTopLeft = document.elementFromPoint(rect.left + 1, rect.top + 1) === $thumb; const visibleBottomRight = document.elementFromPoint(rect.right - 1, rect.bottom - 1) === $thumb; rez = visibleTopLeft && visibleBottomRight; } else { rez = document.elementFromPoint(rect.left + rect.width * 0.5, rect.top + rect.height * 0.5) === $thumb; } $container.classList.remove("fancybox__no-click"); return rez; } /** * Perform zoom-in animation */ zoomIn() { const fancybox = this.fancybox, slide = fancybox.getSlide(), Panzoom = slide.Panzoom; const { top, left, scale, opacity } = this.getZoomInfo(slide); fancybox.trigger("reveal", slide); // Scale and move to start position Panzoom.panTo({ x: left * -1, y: top * -1, scale: scale, friction: 0, ignoreBounds: true, }); slide.$content.style.visibility = ""; slide.state = "zoomIn"; if (opacity === true) { Panzoom.on("afterTransform", (panzoom) => { if (slide.state === "zoomIn" || slide.state === "zoomOut") { panzoom.$content.style.opacity = Math.min(1, 1 - (1 - panzoom.content.scale) / (1 - scale)); } }); } // Animate back to original position Panzoom.panTo({ x: 0, y: 0, scale: 1, friction: this.fancybox.option("Image.zoomFriction"), }); } /** * Perform zoom-out animation */ zoomOut() { const fancybox = this.fancybox, slide = fancybox.getSlide(), Panzoom = slide.Panzoom; if (!Panzoom) { return; } slide.state = "zoomOut"; fancybox.state = "customClosing"; if (slide.$caption) { slide.$caption.style.visibility = "hidden"; } let friction = this.fancybox.option("Image.zoomFriction"); const animatePosition = (event) => { const { top, left, scale, opacity } = this.getZoomInfo(slide); // Increase speed on the first run if opacity is not animated if (!event && !opacity) { friction *= 0.82; } Panzoom.panTo({ x: left * -1, y: top * -1, scale, friction, ignoreBounds: true, }); // Gradually increase speed friction *= 0.98; }; // Page scrolling will cause thumbnail to change position on the display, // therefore animation end position has to be recalculated after each page scroll window.addEventListener("scroll", animatePosition); Panzoom.once("endAnimation", () => { window.removeEventListener("scroll", animatePosition); fancybox.destroy(); }); animatePosition(); } /** * Set the type of mouse cursor to indicate if content is zoomable * @param {Object} slide */ handleCursor(slide) { if (slide.type !== "image" || !slide.$el) { return; } const panzoom = slide.Panzoom; const clickAction = this.fancybox.option("Image.click", false, slide); const touchIsEnabled = this.fancybox.option("Image.touch"); const classList = slide.$el.classList; const zoomInClass = this.fancybox.option("Image.canZoomInClass"); const zoomOutClass = this.fancybox.option("Image.canZoomOutClass"); classList.remove(zoomOutClass); classList.remove(zoomInClass); if (panzoom && clickAction === "toggleZoom") { const canZoomIn = panzoom && panzoom.content.scale === 1 && panzoom.option("maxScale") - panzoom.content.scale > 0.01; if (canZoomIn) { classList.add(zoomInClass); } else if (panzoom.content.scale > 1 && !touchIsEnabled) { classList.add(zoomOutClass); } } else if (clickAction === "close") { classList.add(zoomOutClass); } } /** * Handle `wheel` event * @param {Object} slide * @param {Object} event */ onWheel(slide, event) { if (this.fancybox.state !== "ready") { return; } if (this.fancybox.trigger("Image.wheel", event) === false) { return; } switch (this.fancybox.option("Image.wheel")) { case "zoom": if (slide.state === "done") { slide.Panzoom && slide.Panzoom.zoomWithWheel(event); } break; case "close": this.fancybox.close(); break; case "slide": this.fancybox[event.deltaY < 0 ? "prev" : "next"](); break; } } /** * Handle `click` and `dblclick` events * @param {Object} slide * @param {Object} event */ onClick(slide, event) { // Check that clicks should be allowed if (this.fancybox.state !== "ready") { return; } const panzoom = slide.Panzoom; if ( panzoom && (panzoom.dragPosition.midPoint || panzoom.dragOffset.x !== 0 || panzoom.dragOffset.y !== 0 || panzoom.dragOffset.scale !== 1) ) { return; } if (this.fancybox.Carousel.Panzoom.lockAxis) { return false; } const process = (action) => { switch (action) { case "toggleZoom": event.stopPropagation(); slide.Panzoom && slide.Panzoom.zoomWithClick(event); break; case "close": this.fancybox.close(); break; case "next": event.stopPropagation(); this.fancybox.next(); break; } }; const clickAction = this.fancybox.option("Image.click"); const dblclickAction = this.fancybox.option("Image.doubleClick"); if (dblclickAction) { if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; process(dblclickAction); } else { this.clickTimer = setTimeout(() => { this.clickTimer = null; process(clickAction); }, 300); } } else { process(clickAction); } } /** * Handle `Carousel.change` event to reset zoom level for any zoomed in/out content * and to revel content of the current page * @param {Object} fancybox * @param {Object} carousel */ onPageChange(fancybox, carousel) { const currSlide = fancybox.getSlide(); carousel.slides.forEach((slide) => { if (!slide.Panzoom || slide.state !== "done") { return; } if (slide.index !== currSlide.index) { slide.Panzoom.panTo({ x: 0, y: 0, scale: 1, friction: 0.8, }); } }); } attach() { this.fancybox.on(this.events); } detach() { this.fancybox.off(this.events); } } // Expose defaults Image.defaults = defaults;