import {
IMAGE_VIEWER_ZOOM_EVENT,
type ImageViewerOptions,
type ImageViewerZoomDetail,
} from './image-viewer.types';
const SELECTORS = {
content: '[data-c42-viewer-content]',
zoomIn: '[data-c42-viewer-zoom-in]',
zoomOut: '[data-c42-viewer-zoom-out]',
reset: '[data-c42-viewer-reset]',
expand: '[data-c42-viewer-expand]',
indicator: '[data-c42-viewer-indicator]',
} as const;
/**
* Headless image/content viewer with zoom (wheel + buttons), pan (drag),
* and fullscreen expand. Works on any content (images, HTML previews, etc).
*
* Markup:
* ```html
*
*
*
*
*
*
*
*
* ```
*/
export class ImageViewer {
private readonly root: HTMLElement;
private readonly content: HTMLElement;
private readonly indicator: HTMLElement | null;
private readonly minZoom: number;
private readonly maxZoom: number;
private zoom = 0;
private translateX = 0;
private translateY = 0;
private expanded = false;
private isDragging = false;
private startX = 0;
private startY = 0;
private startTX = 0;
private startTY = 0;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: ImageViewerOptions = {}) {
const content = root.querySelector(SELECTORS.content);
if (!content) {
throw new Error('[42/image-viewer] Missing [data-c42-viewer-content] element.');
}
this.root = root;
this.content = content;
this.indicator = root.querySelector(SELECTORS.indicator);
this.minZoom = options.minZoom ?? 0;
this.maxZoom = options.maxZoom ?? 7;
this.expanded = options.expanded ?? false;
this.init();
}
private init(): void {
this.root.dataset.state = this.expanded ? 'expanded' : 'normal';
this.applyTransform();
this.bindBtn(SELECTORS.zoomIn, () => this.zoomIn());
this.bindBtn(SELECTORS.zoomOut, () => this.zoomOut());
this.bindBtn(SELECTORS.reset, () => this.resetView());
this.bindBtn(SELECTORS.expand, () => this.toggleExpanded());
const onWheel = (e: Event): void => {
e.preventDefault();
const we = e as WheelEvent;
if (we.deltaY < 0) this.zoomIn();
else this.zoomOut();
};
const onMouseDown = (e: Event): void => this.startDrag(e as MouseEvent);
const onMouseMove = (e: Event): void => this.drag(e as MouseEvent);
const onMouseUp = (): void => this.stopDrag();
this.content.addEventListener('wheel', onWheel, { passive: false });
this.content.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
this.cleanups.push(
() => this.content.removeEventListener('wheel', onWheel),
() => this.content.removeEventListener('mousedown', onMouseDown),
() => document.removeEventListener('mousemove', onMouseMove),
() => document.removeEventListener('mouseup', onMouseUp),
);
}
private bindBtn(selector: string, handler: () => void): void {
const el = this.root.querySelector(selector);
if (!el) return;
el.addEventListener('click', handler);
this.cleanups.push(() => el.removeEventListener('click', handler));
}
private get scale(): number {
return Math.pow(1.2, this.zoom);
}
private applyTransform(): void {
const s = this.scale;
this.content.style.transform = `scale(${s}) translate(${this.translateX}px, ${this.translateY}px)`;
this.content.style.transformOrigin = 'center center';
this.content.style.cursor = this.zoom > 0 ? (this.isDragging ? 'grabbing' : 'grab') : 'default';
this.content.dataset.zoom = String(this.zoom);
if (this.indicator) {
this.indicator.textContent = `${Math.round(s * 100)}%`;
}
}
private constrainPosition(): void {
if (this.zoom <= 0) {
this.translateX = 0;
this.translateY = 0;
return;
}
const s = this.scale;
const rect = this.content.getBoundingClientRect();
const maxTX = Math.max(0, (rect.width * s - rect.width) / 2 / s);
const maxTY = Math.max(0, (rect.height * s - rect.height) / 2 / s);
this.translateX = Math.max(-maxTX, Math.min(maxTX, this.translateX));
this.translateY = Math.max(-maxTY, Math.min(maxTY, this.translateY));
}
private emitZoom(): void {
const detail: ImageViewerZoomDetail = {
zoom: this.zoom,
scale: this.scale,
expanded: this.expanded,
};
this.root.dispatchEvent(new CustomEvent(IMAGE_VIEWER_ZOOM_EVENT, { detail, bubbles: true }));
}
private startDrag(e: MouseEvent): void {
if (this.zoom <= 0) return;
e.preventDefault();
this.isDragging = true;
this.startX = e.clientX;
this.startY = e.clientY;
this.startTX = this.translateX;
this.startTY = this.translateY;
this.applyTransform();
}
private drag(e: MouseEvent): void {
if (!this.isDragging) return;
e.preventDefault();
const s = this.scale;
this.translateX = this.startTX + (e.clientX - this.startX) / s;
this.translateY = this.startTY + (e.clientY - this.startY) / s;
this.constrainPosition();
this.applyTransform();
}
private stopDrag(): void {
if (!this.isDragging) return;
this.isDragging = false;
this.applyTransform();
}
/* ---------- public API ---------- */
zoomIn(): void {
if (this.zoom >= this.maxZoom) return;
this.zoom = Math.min(this.zoom + 1, this.maxZoom);
this.constrainPosition();
this.applyTransform();
this.emitZoom();
}
zoomOut(): void {
if (this.zoom <= this.minZoom) return;
this.zoom = Math.max(this.zoom - 1, this.minZoom);
if (this.zoom <= 0) {
this.translateX = 0;
this.translateY = 0;
} else {
this.constrainPosition();
}
this.applyTransform();
this.emitZoom();
}
resetView(): void {
this.zoom = 0;
this.translateX = 0;
this.translateY = 0;
this.applyTransform();
this.emitZoom();
}
toggleExpanded(): void {
this.expanded = !this.expanded;
this.root.dataset.state = this.expanded ? 'expanded' : 'normal';
this.resetView();
}
get isExpanded(): boolean {
return this.expanded;
}
get currentZoom(): number {
return this.zoom;
}
get currentScale(): number {
return this.scale;
}
on(event: string, handler: (event: E) => void): () => void {
const listener = handler as EventListener;
this.root.addEventListener(event, listener);
const off = (): void => this.root.removeEventListener(event, listener);
this.cleanups.push(off);
return off;
}
destroy(): void {
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
}