vanilla-js-wheel-zoom
Version:
Image resizing using mouse wheel (pinch to zoom) + drag scrollable image (as well as any HTML content)
429 lines (362 loc) • 14.8 kB
JavaScript
import {
getElementPosition,
getPageScrollLeft,
getPageScrollTop,
on,
off,
eventClientX,
eventClientY,
isTouch,
transition,
transform,
} from './toolkit';
import {
calculateAlignPoint,
calculateContentMaxShift,
calculateContentShift,
calculateCorrectPoint,
calculateViewportCenter,
} from './calculator';
import { wZoomDefaultOptions } from './default-options.js';
import DragScrollableObserver, { EVENT_DROP, EVENT_GRAB, EVENT_MOVE } from './observers/DragScrollableObserver';
import InteractionObserver, { EVENT_CLICK, EVENT_DBLCLICK, EVENT_WHEEL } from './observers/InteractionObserver';
import PinchToZoomObserver, { EVENT_PINCH_TO_ZOOM } from './observers/PinchToZoomObserver';
/**
* @class WZoom
* @param {string|HTMLElement} selectorOrHTMLElement
* @param {WZoomOptions} options
* @constructor
*/
function WZoom(selectorOrHTMLElement, options = {}) {
this._init = this._init.bind(this);
this._prepare = this._prepare.bind(this);
this._computeScale = this._computeScale.bind(this);
this._computePosition = this._computePosition.bind(this);
this._transform = this._transform.bind(this);
/** @type {WZoomContent} */
this.content = {};
if (typeof selectorOrHTMLElement === 'string') {
this.content.$element = document.querySelector(selectorOrHTMLElement);
if (!this.content.$element) {
throw `WZoom: Element with selector \`${ selectorOrHTMLElement }\` not found`;
}
} else if (selectorOrHTMLElement instanceof HTMLElement) {
this.content.$element = selectorOrHTMLElement;
} else {
throw `WZoom: \`selectorOrHTMLElement\` must be selector or HTMLElement, and not ${ {}.toString.call(selectorOrHTMLElement) }`;
}
/** @type {WZoomViewport} */
this.viewport = {};
// for viewport take just the parent
this.viewport.$element = this.content.$element.parentElement;
/** @type {WZoomOptions} */
this.options = optionsConstructor(options, wZoomDefaultOptions);
// check if we're using a touch screen
this.isTouch = isTouch();
this.direction = 1;
/** @type {AbstractObserver[]} */
this.observers = [];
if (this.options.type === 'image') {
// if the `image` has already been loaded
if (this.content.$element.complete) {
this._init();
} else {
on(this.content.$element, 'load', this._init, { once: true });
}
} else {
this._init();
}
}
WZoom.prototype = {
constructor: WZoom,
/**
* @private
*/
_init() {
const { viewport, content, options, observers } = this;
this._prepare();
this._destroyObservers();
if (options.dragScrollable === true) {
const dragScrollableObserver = new DragScrollableObserver(content.$element);
observers.push(dragScrollableObserver);
if (typeof options.onGrab === 'function') {
dragScrollableObserver.on(EVENT_GRAB, (event) => {
event.preventDefault();
options.onGrab(event, this);
});
}
if (typeof options.onDrop === 'function') {
dragScrollableObserver.on(EVENT_DROP, (event) => {
event.preventDefault();
options.onDrop(event, this);
});
}
dragScrollableObserver.on(EVENT_MOVE, (event) => {
event.preventDefault();
const { x, y } = event.data;
const contentNewLeft = content.currentLeft + x;
const contentNewTop = content.currentTop + y;
let maxAvailableLeft = (content.currentWidth - viewport.originalWidth) / 2 + content.correctX;
let maxAvailableTop = (content.currentHeight - viewport.originalHeight) / 2 + content.correctY;
// if we do not go beyond the permissible boundaries of the viewport
if (Math.abs(contentNewLeft) <= maxAvailableLeft) content.currentLeft = contentNewLeft;
// if we do not go beyond the permissible boundaries of the viewport
if (Math.abs(contentNewTop) <= maxAvailableTop) content.currentTop = contentNewTop;
this._transform(options.smoothTimeDrag);
if (typeof options.onMove === 'function') {
options.onMove(event, this);
}
});
}
const interactionObserver = new InteractionObserver(content.$element);
observers.push(interactionObserver);
if (!options.disableWheelZoom) {
if (this.isTouch) {
const pinchToZoomObserver = new PinchToZoomObserver(content.$element);
observers.push(pinchToZoomObserver);
pinchToZoomObserver.on(EVENT_PINCH_TO_ZOOM, (event) => {
const { clientX, clientY, direction } = event.data;
const scale = this._computeScale(direction);
this._computePosition(scale, clientX, clientY);
this._transform();
});
} else {
interactionObserver.on(EVENT_WHEEL, (event) => {
event.preventDefault();
const direction = options.reverseWheelDirection ? -event.deltaY : event.deltaY;
const scale = this._computeScale(direction);
this._computePosition(scale, eventClientX(event), eventClientY(event));
this._transform();
});
}
}
if (options.zoomOnClick || options.zoomOnDblClick) {
const eventType = options.zoomOnDblClick ? EVENT_DBLCLICK : EVENT_CLICK;
interactionObserver.on(eventType, (event) => {
const scale = this.direction === 1 ? content.maxScale : content.minScale;
this._computePosition(scale, eventClientX(event), eventClientY(event));
this._transform();
this.direction *= -1;
});
}
},
/**
* @private
*/
_prepare() {
const { viewport, content, options } = this;
const { left, top } = getElementPosition(viewport.$element);
viewport.originalWidth = viewport.$element.offsetWidth;
viewport.originalHeight = viewport.$element.offsetHeight;
viewport.originalLeft = left;
viewport.originalTop = top;
if (options.type === 'image') {
content.originalWidth = options.width || content.$element.naturalWidth;
content.originalHeight = options.height || content.$element.naturalHeight;
} else {
content.originalWidth = options.width || content.$element.offsetWidth;
content.originalHeight = options.height || content.$element.offsetHeight;
}
content.maxScale = options.maxScale;
content.minScale = options.minScale || Math.min(viewport.originalWidth / content.originalWidth, viewport.originalHeight / content.originalHeight, content.maxScale);
content.currentScale = content.minScale;
content.currentWidth = content.originalWidth * content.currentScale;
content.currentHeight = content.originalHeight * content.currentScale;
[ content.alignPointX, content.alignPointY ] = calculateAlignPoint(viewport, content, options.alignContent);
content.currentLeft = content.alignPointX;
content.currentTop = content.alignPointY;
// calculate indent-left and indent-top to of content from viewport borders
[ content.correctX, content.correctY ] = calculateCorrectPoint(viewport, content, options.alignContent);
if (typeof options.prepare === 'function') {
options.prepare(this);
}
this._transform();
},
/**
* @private
*/
_computeScale(direction) {
this.direction = direction < 0 ? 1 : -1;
const { minScale, maxScale, currentScale } = this.content;
const scale = currentScale * Math.pow(this.options.speed, this.direction);
if (scale <= minScale) {
this.direction = 1;
return minScale;
}
if (scale >= maxScale) {
this.direction = -1;
return maxScale;
}
return scale;
},
/**
* @param {number} scale
* @param {number} x
* @param {number} y
* @private
*/
_computePosition(scale, x, y) {
const { viewport, content, options, direction } = this;
const contentNewWidth = content.originalWidth * scale;
const contentNewHeight = content.originalHeight * scale;
const scrollLeft = getPageScrollLeft();
const scrollTop = getPageScrollTop();
// calculate the parameters along the X axis
let contentNewLeft = calculateContentShift(x, scrollLeft, viewport.originalLeft, content.currentLeft, viewport.originalWidth, contentNewWidth / content.currentWidth);
// calculate the parameters along the Y axis
let contentNewTop = calculateContentShift(y, scrollTop, viewport.originalTop, content.currentTop, viewport.originalHeight, contentNewHeight / content.currentHeight);
if (direction === -1) {
// check that the content does not go beyond the X axis
contentNewLeft = calculateContentMaxShift(options.alignContent, viewport.originalWidth, content.correctX, contentNewWidth, contentNewLeft);
// check that the content does not go beyond the Y axis
contentNewTop = calculateContentMaxShift(options.alignContent, viewport.originalHeight, content.correctY, contentNewHeight, contentNewTop);
}
if (scale === content.minScale) {
contentNewLeft = content.alignPointX;
contentNewTop = content.alignPointY;
}
content.currentWidth = contentNewWidth;
content.currentHeight = contentNewHeight;
content.currentLeft = contentNewLeft;
content.currentTop = contentNewTop;
content.currentScale = scale;
},
/**
* @param {number} smoothTime
* @private
*/
_transform(smoothTime) {
if (smoothTime === undefined) smoothTime = this.options.smoothTime;
transition(this.content.$element, smoothTime);
transform(this.content.$element, this.content.currentLeft, this.content.currentTop, this.content.currentScale);
if (typeof this.options.rescale === 'function') {
this.options.rescale(this);
}
},
/**
* todo добавить проверку на то что бы переданные координаты не выходили за пределы возможного
* @param {number} scale
* @param {Object} coordinates
* @private
*/
_zoom(scale, coordinates = {}) {
// if the coordinates are not passed, then use the coordinates of the center
if (coordinates.x === undefined || coordinates.y === undefined) {
coordinates = calculateViewportCenter(this.viewport);
}
this._computePosition(scale, coordinates.x, coordinates.y);
this._transform();
},
_destroyObservers() {
for (const observer of this.observers) {
observer.destroy();
}
},
prepare() {
this._prepare();
},
/**
* todo добавить проверку на то что бы переданный state вообще возможен для данного instance
* @param {number} top
* @param {number} left
* @param {number} scale
*/
transform(top, left, scale) {
const { content } = this;
content.currentWidth = content.originalWidth * scale;
content.currentHeight = content.originalHeight * scale;
content.currentLeft = left;
content.currentTop = top;
content.currentScale = scale;
this._transform();
},
zoomUp() {
this._zoom(this._computeScale(-1));
},
zoomDown() {
this._zoom(this._computeScale(1));
},
maxZoomUp() {
this._zoom(this.content.maxScale);
},
maxZoomDown() {
this._zoom(this.content.minScale);
},
zoomUpToPoint(coordinates) {
this._zoom(this._computeScale(-1), coordinates);
},
zoomDownToPoint(coordinates) {
this._zoom(this._computeScale(1), coordinates);
},
maxZoomUpToPoint(coordinates) {
this._zoom(this.content.maxScale, coordinates);
},
destroy() {
this.content.$element.style.removeProperty('transition');
this.content.$element.style.removeProperty('transform');
if (this.options.type === 'image') {
off(this.content.$element, 'load', this._init);
}
this._destroyObservers();
for (let key in this) {
if (this.hasOwnProperty(key)) {
this[key] = null;
}
}
}
};
/**
* @param {?WZoomOptions} targetOptions
* @param {?WZoomOptions} defaultOptions
* @returns {?WZoomOptions}
*/
function optionsConstructor(targetOptions, defaultOptions) {
const options = Object.assign({}, defaultOptions, targetOptions);
if (isTouch()) {
options.smoothTime = 0;
options.smoothTimeDrag = 0;
} else {
const smoothTime = Number(options.smoothTime);
const smoothTimeDrag = Number(options.smoothTimeDrag);
options.smoothTime = !isNaN(smoothTime) ? smoothTime : wZoomDefaultOptions.smoothTime;
options.smoothTimeDrag = !isNaN(smoothTimeDrag) ? smoothTimeDrag : options.smoothTime;
}
return options;
}
/**
* Create WZoom instance
* @param {string|HTMLElement} selectorOrHTMLElement
* @param {WZoomOptions} [options]
* @returns {WZoom}
*/
WZoom.create = function (selectorOrHTMLElement, options = {}) {
return new WZoom(selectorOrHTMLElement, options);
};
export default WZoom;
/**
* @typedef WZoomContent
* @type {Object}
* @property {HTMLElement} [$element]
* @property {number} [originalWidth]
* @property {number} [originalHeight]
* @property {number} [currentWidth]
* @property {number} [currentHeight]
* @property {number} [currentLeft]
* @property {number} [currentTop]
* @property {number} [currentScale]
* @property {number} [maxScale]
* @property {number} [minScale]
* @property {number} [alignPointX]
* @property {number} [alignPointY]
* @property {number} [correctX]
* @property {number} [correctY]
*/
/**
* @typedef WZoomViewport
* @type {Object}
* @property {HTMLElement} [$element]
* @property {number} [originalWidth]
* @property {number} [originalHeight]
* @property {number} [originalLeft]
* @property {number} [originalTop]
*/