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 = []; } }