mirror of
https://github.com/bkindler/imagefull.git
synced 2026-07-22 06:05:53 +00:00
- Replace img.style.transform with setCssProps/CSS custom properties (lint-compliant) - Switch classList to Obsidian toggleClass/addClass/removeClass helpers - Remove unused async from onload, remove dead panning state - Track open viewers so onunload closes all overlays cleanly - Translate hint text and description to English for directory - Add LICENSE (MIT), README, .gitignore, package.json, eslint config - Stop tracking MEMORY.md (dev-only artifact)
263 lines
7.4 KiB
JavaScript
263 lines
7.4 KiB
JavaScript
'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;
|