@fancyapps/ui
Version:
JavaScript UI Component Library
698 lines (548 loc) • 17.5 kB
JavaScript
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;