@rbuljan/zoompan
Version:
Zoomable and pannable area with scrollbars. Inspired by graphical editors like Photoshop.
571 lines (490 loc) • 19.7 kB
JavaScript
// Helper functions
const el = (sel, par) => (par || document).querySelector(sel);
const elNew = (tag, prop) => Object.assign(document.createElement(tag), prop);
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
const noop = () => { };
const pointsDistance = (x1, x2, y1, y2) => Math.hypot(x2 - x1, y2 - y1);
const dragHandler = (el, evFn = {}) => {
const onUp = (evt) => {
removeEventListener("pointermove", evFn.onMove);
removeEventListener("pointerup", onUp);
removeEventListener("pointercancel", onUp);
evFn.onUp?.(evt);
};
el.addEventListener("pointerdown", (evt) => {
evt.preventDefault();
addEventListener("pointermove", evFn.onMove);
addEventListener("pointerup", onUp);
addEventListener("pointercancel", onUp);
evFn.onDown?.(evt);
});
};
/**
* ZoomPan
* @class
*/
class ZoomPan {
/**
* @param {string|Node} selector String selector or Element, Node
* @param {object} options Customization options
*/
constructor(selector, options = {}) {
Object.assign(
this,
// Defaults
{
width: 800, // Canvas width
height: 600, // Canvas height
offsetX: 0, // Pan offset (from canvas center)
offsetY: 0,
scale: 1,
scaleOld: 1,
scaleMax: 10,
scaleMin: 0.05,
scaleFactor: 0.2,
transitionDuration: 250,
padd: 40,
panStep: 50,
fitOnInit: true,
canDrag: true,
canPinch: true,
scrollbars: true,
scrollbarsWidth: 14, // px
onPan: noop,
onPanStart: noop,
onPanEnd: noop,
onScale: noop,
onInit: noop,
onChange: noop,
},
// User options:
options,
// Overrides:
{
elParent: typeof selector === "string" ? el(selector) : selector,
pinchDistance: 0, // Distance between two pointers
isDrag: false,
isPinch: false,
});
this.init();
}
/**
* Initialization
* @return {this} instance
*/
init() {
this.elParent.classList.add("zoompan");
this.elParent.style.setProperty("--scrollbarsWidth", this.scrollbars ? this.scrollbarsWidth : 0);
// Create DIV elements viewport and canvas
this.elViewport = elNew("div", { className: "zoompan-viewport" });
this.elCanvas = elNew("div", { className: "zoompan-canvas" });
this.elViewport.append(this.elCanvas);
this.elParent.prepend(this.elViewport);
// Create scrollbars
if (this.scrollbars) {
this.elTrackX = elNew("div", { className: "zoompan-track zoompan-track-x" });
this.elThumbX = elNew("div", { className: "zoompan-thumb zoompan-thumb-x" });
this.elTrackY = elNew("div", { className: "zoompan-track zoompan-track-y" });
this.elThumbY = elNew("div", { className: "zoompan-thumb zoompan-thumb-y" });
this.elTrackX.append(this.elThumbX);
this.elTrackY.append(this.elThumbY);
this.elParent.prepend(this.elTrackX);
this.elParent.prepend(this.elTrackY);
// Horizontal scrollbar track drag:
dragHandler(this.elTrackX, {
onDown: () => {
this.isDrag = true;
this.onPanStart();
},
onUp: () => {
this.isDrag = false;
this.onPanEnd();
},
onMove: (ev) => {
const area = this.getArea();
this.panTo(this.offsetX - (area.width / this.elTrackX.offsetWidth) * ev.movementX, this.offsetY);
}
});
// Vertical scrollbar track drag:
dragHandler(this.elTrackY, {
onDown: () => {
this.isDrag = true;
this.onPanStart();
},
onUp: () => {
this.isDrag = false;
this.onPanEnd();
},
onMove: (ev) => {
const area = this.getArea();
this.panTo(this.offsetX, this.offsetY - (area.height / this.elTrackY.offsetHeight) * ev.movementY);
}
});
}
// Apply width height to canvas...
this.resize();
// ...and fit to viewport, or use option's scale and pan
if (this.fitOnInit) {
this.fit();
} else {
this.scaleTo(this.scale);
this.panTo(this.offsetX, this.offsetY);
}
// Pointers
const pointers = {
mouse: new Map(),
touch: new Map(),
};
const pointersUpdate = (ev) => pointers[ev.pointerType].set(ev.pointerId, ev);
const pointersDelete = (ev) => pointers[ev.pointerType].delete(ev.pointerId);
const handlePointer = (ev) => {
// Just keep in mind that this function can be called sequentially on
// multiple pointers-move. If you understand that, you're good to go.
const pointersType = pointers[ev.pointerType];
const pointsEvts = pointersType.values();
const pointersTot = pointersType.size;
this.isPinch = pointersTot === 2;
const pointer1 = pointsEvts.next().value;
const pointer2 = pointsEvts.next().value;
let movementX = 0;
let movementY = 0;
if (!this.isPinch) {
movementX = pointer1.movementX;
movementY = pointer1.movementY;
}
else if (this.canPinch && ev === pointer2) {
movementX = (pointer1.movementX + pointer2.movementX) / 2;
movementY = (pointer1.movementY + pointer2.movementY) / 2;
const pointM = { // Get XY of pinch center point
x: pointer1.x + (pointer2.x - pointer1.x) * 0.5,
y: pointer1.y + (pointer2.y - pointer1.y) * 0.5,
};
const pinchDistanceNew = pointsDistance(pointer2.x, pointer1.x, pointer2.y, pointer1.y);
const pinchDistanceOld = this.pinchDistance || pinchDistanceNew;
const pinchDistanceDiff = pinchDistanceNew - pinchDistanceOld;
this.pinchDistance = pinchDistanceNew;
const delta = pinchDistanceDiff * 0.025;
const newScale = this.calcScaleDelta(delta);
const { originX, originY } = this.getPointerOrigin(pointM);
this.scaleTo(newScale, originX, originY);
}
// PS: canDrag is default to true, but if one wants to use i.e: Ctrl key
// in order to drag the area, set the default to false and than manually
// change it to true on Ctrl key press.
if (this.canDrag) {
this.panTo(this.offsetX + movementX, this.offsetY + movementY);
}
};
const onStart = (ev) => {
ev.preventDefault();
pointersUpdate(ev);
this.isDrag = true;
addEventListener("pointermove", onMove);
addEventListener("pointerup", onEnd);
addEventListener("pointercancel", onEnd);
this.onPanStart(ev);
};
const onMove = (ev) => {
pointersUpdate(ev);
handlePointer(ev);
};
const onEnd = (ev) => {
pointersDelete(ev);
const pointersType = pointers[ev.pointerType];
const pointersTot = pointersType.size;
if (pointersTot < 2) {
this.pinchDistance = 0;
}
if (pointersTot === 0) {
this.isDrag = false;
removeEventListener("pointermove", onMove);
removeEventListener("pointerup", onEnd);
removeEventListener("pointercancel", onEnd);
}
this.onPanEnd();
};
this.elViewport.addEventListener("pointerdown", onStart, { passive: false });
// Fix pan on browser resize
addEventListener("resize", () => {
this.panTo(this.offsetX, this.offsetY);
});
// Emit init is done:
this.onInit();
this.onChange();
return this;
}
/**
* Get pointer origin XY from pointer position
* relative to canvas center
* @param {PointerEvent|Object} ev Event with x,y pointer coordinates of Object {x,y}
* @return {object} {originX, originY} offsets from canvas center
*/
getPointerOrigin({ x, y }) {
const vpt = this.getViewport();
const cvs = this.getCanvas();
const originX = x - vpt.x - cvs.x - cvs.width / 2;
const originY = y - vpt.y - cvs.y - cvs.height / 2;
return { originX, originY }
}
/**
* Get -1 or +1 integer delta from mousewheel
* @param {PointerEvent|Object} ev Event with deltaY of Object with the same deltaY property
* @return {number} -1 | +1
*/
getWheelDelta({ deltaY }) {
const delta = Math.sign(-deltaY);
return delta;
}
/**
* Calculate the new scale value by a given delta.
* @param {number} delta positive or negative integer
* @returns {number} new scale value calmped by the defined scaleMin/Max options
*/
calcScaleDelta(delta) {
const scale = this.scale * Math.exp(delta * this.scaleFactor);
const scaleNew = clamp(scale, this.scaleMin, this.scaleMax);
return scaleNew;
}
/**
* Apply new width and height to canvas
* (Also, update the scrollbars)
* @param {number} width
* @param {number} height
* @returns {ThisType}
*/
resize(width, height) {
this.width = width ?? this.width;
this.height = height ?? this.height;
this.elCanvas.style.width = `${this.width}px`;
this.elCanvas.style.height = `${this.height}px`;
this.updateScrollbars();
return this;
}
/**
* Fit (contain) canvas into viewport center.
* Scale to fit original size (1.0) or less with "padd" spacing
*/
fit() {
const wRatio = this.elViewport.clientWidth / (this.elCanvas.clientWidth + this.padd * 2);
const hRatio = this.elViewport.clientHeight / (this.elCanvas.clientHeight + this.padd * 2);
const fitRatio = +Math.min(1, wRatio, hRatio).toFixed(1);
this.scaleTo(fitRatio);
this.panTo(0, 0);
return this;
}
/**
* Get client size and position of viewport
* @returns {object} {width,height,x,y} of the viewport Element
*/
getViewport() {
const { width, height, x, y } = this.elViewport.getBoundingClientRect();
return { width, height, x, y };
}
/**
* Get canvas size and position relative to viewport
* @returns {object} {width,height,x,y} of the (scaled) canvas Element
*/
getCanvas() {
const vpt = this.getViewport();
const width = this.width * this.scale;
const height = this.height * this.scale;
const x = (vpt.width - width) / 2 + this.offsetX;
const y = (vpt.height - height) / 2 + this.offsetY;
return { width, height, x, y };
}
/**
* Get the immaginary area size
* PS: that area is just used to calculate the scrollbars
* and to prevent the canvas to fully exit the viewport
* (min visibility px defined by `padd`).
* @returns {object} {width,height} of the fictive area
*/
getArea() {
const vpt = this.getViewport();
const cvs = this.getCanvas();
const width = (vpt.width - this.padd) * 2 + cvs.width;
const height = (vpt.height - this.padd) * 2 + cvs.height;
return { width, height };
}
/**
* Repaint scrollbars.
* Use after the canvas changes position or scales.
* @return {this} instance
*/
updateScrollbars() {
// Ignore if scrollbars are not used
if (!this.scrollbars) return this;
const vpt = this.getViewport();
const cvs = this.getCanvas();
const area = this.getArea();
const thumbSizeX = vpt.width ** 2 / area.width;
const thumbSizeY = vpt.height ** 2 / area.height;
const thumbPosX = (vpt.width - cvs.x - this.padd) / vpt.width * thumbSizeX;
const thumbPosY = (vpt.height - cvs.y - this.padd) / vpt.height * thumbSizeY;
const widthPercent = thumbSizeX / vpt.width * 100;
const leftPercent = thumbPosX / vpt.width * 100;
const heightPercent = thumbSizeY / vpt.height * 100;
const topPercent = thumbPosY / vpt.height * 100;
const scaleDuration = this.isPinch || this.isDrag ? 0 : this.transitionDuration;
const translateDuration = this.isDrag ? 0 : this.transitionDuration;
this.elThumbX.style.transition = `width ${scaleDuration}ms, left ${translateDuration}ms`;
this.elThumbY.style.transition = `height ${scaleDuration}ms, top ${translateDuration}ms`;
this.elCanvas.addEventListener("transitionend", () => {
this.elThumbX.style.transition = `width 0, left 0`;
this.elThumbY.style.transition = `height 0, top 0`;
}, { once: true });
this.elThumbX.style.width = `${widthPercent}%`;
this.elThumbX.style.left = `${leftPercent}%`;
this.elThumbY.style.height = `${heightPercent}%`;
this.elThumbY.style.top = `${topPercent}%`;
return this;
}
/**
* Apply canvas new scale by a given delta value (i.e: +1, -1, +2, ...)
* @param {number} delta
* @return {this} instance
*/
scaleDelta(delta) {
const scaleNew = this.calcScaleDelta(delta);
this.scaleTo(scaleNew);
return this;
}
/**
* Scale canvas element up
* Alias for scaling by delta +1
* @return {this} instance
*/
scaleUp() {
this.scaleDelta(1);
return this;
}
/**
* Scale canvas element down
* Alias for scaling by delta -1
* @return {this} instance
*/
scaleDown() {
this.scaleDelta(-1);
return this;
}
/**
* Apply a new scale at a given origin point relative from canvas center
* Useful when zooming in/out at a specific "anchor" point.
* @param {number} scaleNew
* @param {number} originX Scale to X point (relative to canvas center)
* @param {number} originY Scale to Y point (relative to canvas center)
* @return {this} instance
*/
scaleTo(scaleNew = 1, originX, originY) {
this.scaleOld = this.scale;
this.scale = clamp(scaleNew, this.scaleMin, this.scaleMax);
// The default XY origin is in the canvas center,
// If the origin changed (i.e: by mouse wheel at
// coordinates-from-center) use the new scaling origin:
if (originX !== undefined && originY !== undefined) {
// Calculate the XY as if the element is in its
// original, non-scaled size:
const xOrg = originX / this.scaleOld;
const yOrg = originY / this.scaleOld;
// Calculate the scaled XY
const xNew = xOrg * scaleNew;
const yNew = yOrg * scaleNew;
// Retrieve the XY difference to be used as the change in offset:
const xDiff = originX - xNew;
const yDiff = originY - yNew;
this.panTo(this.offsetX + xDiff, this.offsetY + yDiff, false);
}
this.transform();
this.onScale();
return this;
}
/**
* Apply scale from the mouse wheel Event at the given
* pointer origin relative to canvas center.
* @param {WheelEvent} ev
* @return {this} instance
*/
scaleWheel(ev) {
ev.preventDefault();
const delta = this.getWheelDelta(ev);
const scaleNew = this.calcScaleDelta(delta);
const { originX, originY } = this.getPointerOrigin(ev);
this.scaleTo(scaleNew, originX, originY);
return this;
}
/**
* Pan the canvas element to the new XY offset values
* PS: offsets are relative to the canvas center.
* @param {number} offsetX
* @param {number} offsetY
* @param {boolean} isTransform Set to false if you're already applying transformations from the scaleTo function
* @return {this} instance
*/
panTo(offsetX, offsetY, isTransform = true) {
const vpt = this.getViewport();
const width = this.width * this.scale;
const height = this.height * this.scale;
// Clamp offsets to prevent canvas exit viewport
// (and scrollbars thumbs move out of track):
const spaceX = vpt.width / 2 + width / 2 - this.padd;
const spaceY = vpt.height / 2 + height / 2 - this.padd;
this.offsetX = clamp(offsetX, -spaceX, spaceX);
this.offsetY = clamp(offsetY, -spaceY, spaceY);
if (isTransform) {
this.transform();
}
this.onPan();
return this;
}
/**
* Trigger canvas element scale and translate.
* Also, repaint the scrollbars
* @return {this} instance
*/
transform() {
const scaleDuration = this.isPinch || this.isDrag ? 0 : this.transitionDuration;
const translateDuration = this.isDrag ? 0 : this.transitionDuration;
this.elCanvas.style.transition = `scale ${scaleDuration}ms, translate ${translateDuration}ms`;
this.elCanvas.addEventListener("transitionend", () => {
this.elCanvas.style.transition = `scale 0, translate 0`;
}, { once: true });
this.elCanvas.style.scale = this.scale;
this.elCanvas.style.translate = `${this.offsetX}px ${this.offsetY}px`;
this.updateScrollbars();
this.onChange();
return this;
}
/**
* Pan the canvas up by panStep px.
* @return {this} instance
*/
panUp() {
this.panTo(this.offsetX, this.offsetY - this.panStep);
return this;
}
/**
* Pan the canvas down by panStep px.
* @return {this} instance
*/
panDown() {
this.panTo(this.offsetX, this.offsetY + this.panStep);
return this;
}
/**
* Pan the canvas left by panStep px.
* @return {this} instance
*/
panLeft() {
this.panTo(this.offsetX - this.panStep, this.offsetY);
return this;
}
/**
* Pan the canvas right by panStep px.
* @return {this} instance
*/
panRight() {
this.panTo(this.offsetX + this.panStep, this.offsetY);
return this;
}
}
export default ZoomPan