assets_js_controllers_image-zoom-controller.js
import { Controller } from "@hotwired/stimulus";
/**
* Zoomable/pannable image controller for modal dialogs.
*
* Supports mouse-wheel zoom, click-drag pan, and touch pinch-zoom.
* Double-click resets to fit view.
*
* Targets:
* - image: The <img> element to make zoomable
*
* Values:
* - minScale: minimum zoom (default 1)
* - maxScale: maximum zoom (default 8)
*/
class ImageZoom extends Controller {
static targets = ["image"];
static values = {
minScale: { type: Number, default: 1 },
maxScale: { type: Number, default: 8 },
};
connect() {
this.scale = 1;
this.translateX = 0;
this.translateY = 0;
this.dragging = false;
this.lastX = 0;
this.lastY = 0;
this.initialPinchDistance = null;
this.initialPinchScale = 1;
const container = this.element;
container.style.overflow = "hidden";
container.style.cursor = "grab";
container.style.touchAction = "none";
const img = this.imageTarget;
img.style.transformOrigin = "0 0";
img.style.transition = "none";
img.style.userSelect = "none";
img.style.webkitUserSelect = "none";
img.draggable = false;
this._onImageLoad = this._onImageLoad.bind(this);
img.addEventListener("load", this._onImageLoad);
this._onWheel = this._onWheel.bind(this);
this._onPointerDown = this._onPointerDown.bind(this);
this._onPointerMove = this._onPointerMove.bind(this);
this._onPointerUp = this._onPointerUp.bind(this);
this._onDblClick = this._onDblClick.bind(this);
this._onTouchStart = this._onTouchStart.bind(this);
this._onTouchMove = this._onTouchMove.bind(this);
this._onTouchEnd = this._onTouchEnd.bind(this);
this._onModalShown = this._onModalShown.bind(this);
this.modalElement = this.element.closest(".modal");
if (this.modalElement) {
this.modalElement.addEventListener("shown.bs.modal", this._onModalShown);
}
container.addEventListener("wheel", this._onWheel, { passive: false });
container.addEventListener("pointerdown", this._onPointerDown);
container.addEventListener("pointermove", this._onPointerMove);
container.addEventListener("pointerup", this._onPointerUp);
container.addEventListener("pointerleave", this._onPointerUp);
container.addEventListener("dblclick", this._onDblClick);
container.addEventListener("touchstart", this._onTouchStart, { passive: false });
container.addEventListener("touchmove", this._onTouchMove, { passive: false });
container.addEventListener("touchend", this._onTouchEnd);
this._applyTransform();
}
disconnect() {
const container = this.element;
this.imageTarget.removeEventListener("load", this._onImageLoad);
container.removeEventListener("wheel", this._onWheel);
container.removeEventListener("pointerdown", this._onPointerDown);
container.removeEventListener("pointermove", this._onPointerMove);
container.removeEventListener("pointerup", this._onPointerUp);
container.removeEventListener("pointerleave", this._onPointerUp);
container.removeEventListener("dblclick", this._onDblClick);
container.removeEventListener("touchstart", this._onTouchStart);
container.removeEventListener("touchmove", this._onTouchMove);
container.removeEventListener("touchend", this._onTouchEnd);
if (this.modalElement) {
this.modalElement.removeEventListener("shown.bs.modal", this._onModalShown);
}
}
_onWheel(e) {
e.preventDefault();
const rect = this.element.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const delta = e.deltaY > 0 ? 0.9 : 1.1;
this._zoomAt(cursorX, cursorY, delta);
}
_onPointerDown(e) {
if (e.pointerType === "touch") return;
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.element.style.cursor = "grabbing";
this.element.setPointerCapture(e.pointerId);
}
_onPointerMove(e) {
if (!this.dragging || e.pointerType === "touch") return;
const dx = e.clientX - this.lastX;
const dy = e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.translateX += dx;
this.translateY += dy;
this._clampTranslation();
this._applyTransform();
}
_onPointerUp(e) {
if (e.pointerType === "touch") return;
this.dragging = false;
this.element.style.cursor = "grab";
}
_onDblClick() {
this._resetView();
}
_onImageLoad() {
this._resetView();
}
_onModalShown() {
this._resetView();
}
_resetView() {
this.scale = 1;
this.translateX = 0;
this.translateY = 0;
this._applyTransform();
}
// Touch pinch-zoom
_onTouchStart(e) {
if (e.touches.length === 2) {
e.preventDefault();
this.initialPinchDistance = this._pinchDistance(e.touches);
this.initialPinchScale = this.scale;
} else if (e.touches.length === 1 && this.scale > 1) {
e.preventDefault();
this.dragging = true;
this.lastX = e.touches[0].clientX;
this.lastY = e.touches[0].clientY;
}
}
_onTouchMove(e) {
if (e.touches.length === 2 && this.initialPinchDistance) {
e.preventDefault();
const dist = this._pinchDistance(e.touches);
const ratio = dist / this.initialPinchDistance;
const newScale = Math.min(
this.maxScaleValue,
Math.max(this.minScaleValue, this.initialPinchScale * ratio)
);
const rect = this.element.getBoundingClientRect();
const cx = ((e.touches[0].clientX + e.touches[1].clientX) / 2) - rect.left;
const cy = ((e.touches[0].clientY + e.touches[1].clientY) / 2) - rect.top;
const scaleRatio = newScale / this.scale;
this.translateX = cx - scaleRatio * (cx - this.translateX);
this.translateY = cy - scaleRatio * (cy - this.translateY);
this.scale = newScale;
this._clampTranslation();
this._applyTransform();
} else if (e.touches.length === 1 && this.dragging) {
e.preventDefault();
const dx = e.touches[0].clientX - this.lastX;
const dy = e.touches[0].clientY - this.lastY;
this.lastX = e.touches[0].clientX;
this.lastY = e.touches[0].clientY;
this.translateX += dx;
this.translateY += dy;
this._clampTranslation();
this._applyTransform();
}
}
_onTouchEnd(e) {
if (e.touches.length < 2) {
this.initialPinchDistance = null;
}
if (e.touches.length === 0) {
this.dragging = false;
}
}
_pinchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
_zoomAt(cx, cy, factor) {
const newScale = Math.min(
this.maxScaleValue,
Math.max(this.minScaleValue, this.scale * factor)
);
const ratio = newScale / this.scale;
this.translateX = cx - ratio * (cx - this.translateX);
this.translateY = cy - ratio * (cy - this.translateY);
this.scale = newScale;
this._clampTranslation();
this._applyTransform();
}
_clampTranslation() {
const img = this.imageTarget;
const container = this.element;
const cw = container.clientWidth;
const ch = container.clientHeight;
if (!cw || !ch) {
return;
}
// Scaled image dimensions based on rendered size
const renderedW = img.clientWidth * this.scale;
const renderedH = img.clientHeight * this.scale;
const baseX = renderedW < cw ? (cw - renderedW) / 2 : 0;
const baseY = renderedH < ch ? (ch - renderedH) / 2 : 0;
if (renderedW <= cw) {
this.translateX = 0;
} else {
const minX = cw - renderedW - baseX;
const maxX = -baseX;
this.translateX = Math.max(minX, Math.min(maxX, this.translateX));
}
if (renderedH <= ch) {
this.translateY = 0;
} else {
const minY = ch - renderedH - baseY;
const maxY = -baseY;
this.translateY = Math.max(minY, Math.min(maxY, this.translateY));
}
}
_applyTransform() {
const container = this.element;
const cw = container.clientWidth;
const ch = container.clientHeight;
const renderedW = this.imageTarget.clientWidth * this.scale;
const renderedH = this.imageTarget.clientHeight * this.scale;
const baseX = renderedW < cw ? (cw - renderedW) / 2 : 0;
const baseY = renderedH < ch ? (ch - renderedH) / 2 : 0;
this.imageTarget.style.transform =
`translate(${baseX + this.translateX}px, ${baseY + this.translateY}px) scale(${this.scale})`;
}
}
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["image-zoom"] = ImageZoom;