bkindler_imagefull/main.js
Björn Kindler a331cf0eec Initial commit: ImageFull v1.0.0
Obsidian plugin for fullscreen image viewing:
- Double-click / double-tap to open overlay
- Two-finger pinch or trackpad pinch to zoom (up to 10x)
- Two-finger swipe or trackpad swipe to pan
- Mouse drag to pan when zoomed
- ESC closes, 0 resets zoom
2026-04-17 11:28:39 +02:00

270 lines
8.3 KiB
JavaScript

'use strict';
const obsidian = require('obsidian');
const MIN_SCALE = 1;
const MAX_SCALE = 10;
class ImageFullPlugin extends obsidian.Plugin {
async onload() {
this.lastTapTime = 0;
this.lastTapTarget = null;
this.registerDomEvent(document, 'dblclick', this.handleDblClick.bind(this), { capture: true });
this.registerDomEvent(document, 'touchend', this.handleTouchEnd.bind(this), { capture: true });
}
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 < 350 && 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 = Zoom · Zwei-Finger-Wischen = Bildausschnitt · Doppelklick = Schließen';
overlay.appendChild(img);
overlay.appendChild(hint);
document.body.appendChild(overlay);
const state = {
scale: 1,
tx: 0,
ty: 0,
pinching: false,
panning: false,
suppressClick: false,
startDist: 0,
startScale: 1,
startMidX: 0,
startMidY: 0,
startTx: 0,
startTy: 0,
panStartX: 0,
panStartY: 0,
};
const apply = () => {
img.style.transform =
`translate3d(${state.tx}px, ${state.ty}px, 0) scale(${state.scale})`;
img.classList.toggle('imagefull-zoomed', state.scale > 1.01);
};
const reset = () => {
state.scale = 1;
state.tx = 0;
state.ty = 0;
apply();
};
const clampPan = () => {
const rect = img.getBoundingClientRect();
// At scale 1: allow generous pan (40% viewport) so two-finger drag is visibly responsive.
// At higher scales: allow overflow + buffer so zoomed edges can reach the screen border.
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));
};
// --- Touch gestures (mobile / iPad): two fingers only ---
// Pinch (distance change) → Zoom · Drag (midpoint move) → Pan
// Single-finger touches are intentionally ignored so double-tap-to-close stays reliable
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;
// Two-finger translation: midpoint movement pans the image; scale-origin compensation keeps pinch centered
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;
// No auto-snap-back: where the user left the image is where it stays.
// Reset explicitly via the `0` key or by closing and reopening.
setTimeout(() => { state.suppressClick = false; }, 250);
};
// --- Trackpad (desktop) ---
// Pinch gesture arrives as wheel + ctrlKey → zoom
// Two-finger swipe arrives as wheel without ctrlKey → pan (shifts the visible image section)
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 {
// Two-finger swipe: swipe right → see right part of image (image shifts left)
state.tx -= e.deltaX;
state.ty -= e.deltaY;
clampPan();
apply();
}
};
// --- Desktop drag-to-pan when zoomed ---
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.classList.add('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.classList.remove('imagefull-dragging');
setTimeout(() => { state.suppressClick = false; }, 50);
}
};
// --- Close handlers ---
const close = () => {
overlay.remove();
document.removeEventListener('keydown', onKey);
};
const onOverlayDblClick = (e) => {
if (state.pinching || state.panning) return;
close();
};
let lastTap = 0;
const onOverlayTap = (e) => {
if (e.touches && e.touches.length > 0) return;
if (state.pinching || state.panning) { lastTap = 0; return; }
if (state.suppressClick) { lastTap = 0; return; }
const now = Date.now();
if (now - lastTap < 350) {
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);
apply();
}
onunload() {
document.querySelectorAll('.imagefull-overlay').forEach((el) => el.remove());
}
}
module.exports = ImageFullPlugin;