'use strict'; const obsidian = require('obsidian'); const MIN_SCALE = 1; const MAX_SCALE = 10; const DOUBLE_TAP_MS = 350; class ImageFullPlugin extends obsidian.Plugin { onload() { this.lastTapTime = 0; this.lastTapTarget = null; this.openViewers = new Set(); this.registerDomEvent(document, 'dblclick', this.handleDblClick.bind(this), { capture: true }); this.registerDomEvent(document, 'touchend', this.handleTouchEnd.bind(this), { capture: true }); } onunload() { for (const closeFn of this.openViewers) closeFn(); this.openViewers.clear(); } handleDblClick(evt) { const target = evt.target; if (!(target instanceof HTMLImageElement)) return; if (!this.isEmbeddedImage(target)) return; if (target.closest('.imagefull-overlay')) return; evt.preventDefault(); evt.stopPropagation(); this.openFullscreen(target); } handleTouchEnd(evt) { if (evt.touches.length > 0) return; const target = evt.target; if (!(target instanceof HTMLImageElement)) return; if (!this.isEmbeddedImage(target)) return; if (target.closest('.imagefull-overlay')) return; const now = Date.now(); if (now - this.lastTapTime < DOUBLE_TAP_MS && this.lastTapTarget === target) { evt.preventDefault(); evt.stopPropagation(); this.lastTapTime = 0; this.lastTapTarget = null; this.openFullscreen(target); } else { this.lastTapTime = now; this.lastTapTarget = target; } } isEmbeddedImage(img) { if (!img.src) return false; return !!img.closest( '.markdown-preview-view, .markdown-rendered, .markdown-source-view, .cm-editor, .internal-embed' ); } openFullscreen(sourceImg) { const overlay = document.createElement('div'); overlay.className = 'imagefull-overlay'; const img = document.createElement('img'); img.className = 'imagefull-image'; img.src = sourceImg.currentSrc || sourceImg.src; img.alt = sourceImg.alt || ''; img.draggable = false; const hint = document.createElement('div'); hint.className = 'imagefull-hint'; hint.textContent = 'Pinch to zoom · Two-finger swipe to pan · Double-click to close'; overlay.appendChild(img); overlay.appendChild(hint); document.body.appendChild(overlay); const state = { scale: 1, tx: 0, ty: 0, pinching: false, suppressClick: false, startDist: 0, startScale: 1, startMidX: 0, startMidY: 0, startTx: 0, startTy: 0, panStartX: 0, panStartY: 0, }; const apply = () => { img.setCssProps({ '--imagefull-tx': `${state.tx}px`, '--imagefull-ty': `${state.ty}px`, '--imagefull-scale': `${state.scale}`, }); img.toggleClass('imagefull-zoomed', state.scale > 1.01); }; const reset = () => { state.scale = 1; state.tx = 0; state.ty = 0; apply(); }; const clampPan = () => { const rect = img.getBoundingClientRect(); const maxX = Math.max(window.innerWidth * 0.4, (rect.width - window.innerWidth) / 2 + 60); const maxY = Math.max(window.innerHeight * 0.4, (rect.height - window.innerHeight) / 2 + 60); state.tx = Math.max(-maxX, Math.min(maxX, state.tx)); state.ty = Math.max(-maxY, Math.min(maxY, state.ty)); }; const onTouchStart = (e) => { if (e.touches.length === 2) { e.preventDefault(); state.pinching = true; state.suppressClick = true; const t1 = e.touches[0], t2 = e.touches[1]; const dx = t1.clientX - t2.clientX; const dy = t1.clientY - t2.clientY; state.startDist = Math.hypot(dx, dy) || 1; state.startScale = state.scale; state.startMidX = (t1.clientX + t2.clientX) / 2; state.startMidY = (t1.clientY + t2.clientY) / 2; state.startTx = state.tx; state.startTy = state.ty; } }; const onTouchMove = (e) => { if (!state.pinching || e.touches.length !== 2) return; e.preventDefault(); const t1 = e.touches[0], t2 = e.touches[1]; const dx = t1.clientX - t2.clientX; const dy = t1.clientY - t2.clientY; const dist = Math.hypot(dx, dy) || 1; const newScale = Math.max( MIN_SCALE, Math.min(MAX_SCALE, state.startScale * (dist / state.startDist)) ); const midX = (t1.clientX + t2.clientX) / 2; const midY = (t1.clientY + t2.clientY) / 2; const cx = window.innerWidth / 2; const cy = window.innerHeight / 2; const k = newScale / state.startScale; state.tx = state.startTx + (midX - state.startMidX) + (state.startMidX - cx) * (1 - k); state.ty = state.startTy + (midY - state.startMidY) + (state.startMidY - cy) * (1 - k); state.scale = newScale; clampPan(); apply(); }; const onTouchEnd = (e) => { if (e.touches.length < 2) state.pinching = false; setTimeout(() => { state.suppressClick = false; }, 250); }; const onWheel = (e) => { e.preventDefault(); if (e.ctrlKey) { const delta = -e.deltaY * 0.02; const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, state.scale * (1 + delta))); if (newScale === state.scale) return; const cx = window.innerWidth / 2; const cy = window.innerHeight / 2; const k = newScale / state.scale; state.tx = (state.tx + (e.clientX - cx)) * k - (e.clientX - cx); state.ty = (state.ty + (e.clientY - cy)) * k - (e.clientY - cy); state.scale = newScale; clampPan(); apply(); } else { state.tx -= e.deltaX; state.ty -= e.deltaY; clampPan(); apply(); } }; let mouseDown = false; const onMouseDown = (e) => { if (state.scale <= 1.01) return; mouseDown = true; state.panStartX = e.clientX; state.panStartY = e.clientY; state.startTx = state.tx; state.startTy = state.ty; img.addClass('imagefull-dragging'); }; const onMouseMove = (e) => { if (!mouseDown) return; state.tx = state.startTx + (e.clientX - state.panStartX); state.ty = state.startTy + (e.clientY - state.panStartY); if (Math.abs(e.clientX - state.panStartX) + Math.abs(e.clientY - state.panStartY) > 4) { state.suppressClick = true; } clampPan(); apply(); }; const onMouseUp = () => { if (mouseDown) { mouseDown = false; img.removeClass('imagefull-dragging'); setTimeout(() => { state.suppressClick = false; }, 50); } }; const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); this.openViewers.delete(close); }; const onOverlayDblClick = () => { if (state.pinching) return; close(); }; let lastTap = 0; const onOverlayTap = (e) => { if (e.touches && e.touches.length > 0) return; if (state.pinching) { lastTap = 0; return; } if (state.suppressClick) { lastTap = 0; return; } const now = Date.now(); if (now - lastTap < DOUBLE_TAP_MS) { lastTap = 0; e.preventDefault(); close(); } else { lastTap = now; } }; const onKey = (e) => { if (e.key === 'Escape') close(); else if (e.key === '0') reset(); }; overlay.addEventListener('dblclick', onOverlayDblClick); overlay.addEventListener('wheel', onWheel, { passive: false }); overlay.addEventListener('mousedown', onMouseDown); overlay.addEventListener('mousemove', onMouseMove); overlay.addEventListener('mouseup', onMouseUp); overlay.addEventListener('mouseleave', onMouseUp); overlay.addEventListener('touchstart', onTouchStart, { passive: false }); overlay.addEventListener('touchmove', onTouchMove, { passive: false }); overlay.addEventListener('touchend', onTouchEnd); overlay.addEventListener('touchcancel', onTouchEnd); overlay.addEventListener('touchend', onOverlayTap); document.addEventListener('keydown', onKey); this.openViewers.add(close); apply(); } } module.exports = ImageFullPlugin;