mirror of
https://github.com/bkindler/imagefull.git
synced 2026-07-22 06:05:53 +00:00
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
This commit is contained in:
commit
a331cf0eec
5 changed files with 417 additions and 0 deletions
54
MEMORY.md
Normal file
54
MEMORY.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# plugin-imagefull — Hot Summary
|
||||
|
||||
## Dev
|
||||
```yaml
|
||||
type: obsidian-plugin
|
||||
id: imagefull
|
||||
version: 1.0.0
|
||||
stack: vanilla JS (CommonJS) + CSS
|
||||
build: none (main.js direkt, kein Bundler)
|
||||
files:
|
||||
- manifest.json
|
||||
- main.js # Plugin-Klasse + Overlay-Logik
|
||||
- styles.css # Overlay-Styles + Transform
|
||||
- README.md
|
||||
target: desktop + mobile (iPad, iPhone)
|
||||
minAppVersion: 1.4.0
|
||||
```
|
||||
|
||||
## Feature-Set
|
||||
| Eingabe | Verhalten |
|
||||
|------------------------|-----------------------------------------|
|
||||
| Doppelklick / Doppeltipp auf eingebettetes Bild | Öffnet Fullscreen-Overlay |
|
||||
| Doppelklick / Doppeltipp auf Overlay | Schließt (außer während Pinch/Pan) |
|
||||
| Two-Finger-Pinch (Touch) | Zoom 1× – 10× um Pinch-Zentrum |
|
||||
| Two-Finger-Drag (Touch) | Pan / Bildausschnitt verschieben |
|
||||
| Trackpad-Pinch / Wheel | Zoom um Cursor-Position |
|
||||
| Maus-Drag | Pan im gezoomten Zustand (Desktop) |
|
||||
| ESC | Schließt |
|
||||
| `0` | Reset Zoom |
|
||||
|
||||
## Architektur
|
||||
- Globaler `dblclick`-Handler (capture) auf `document` + `touchend`-Handler mit 350ms-Doppeltipp-Fenster für Mobile
|
||||
- Einfacher Klick bleibt unangetastet → keine Konflikte mit anderen Image-Interaktionen
|
||||
- Embed-Erkennung via `.markdown-preview-view, .markdown-rendered, .markdown-source-view, .cm-editor, .internal-embed`
|
||||
- Transform: `translate3d(tx, ty, 0) scale(s)` · `will-change: transform`
|
||||
- `clampPan()` begrenzt Pan auf sichtbaren Bereich + 40px Puffer
|
||||
- `suppressClick` verhindert Close-Klick direkt nach Geste
|
||||
|
||||
## Deployment
|
||||
```yaml
|
||||
installed_at: "<Vault>/.obsidian/plugins/imagefull/"
|
||||
active_vaults:
|
||||
- Sprecherakademie # /Users/bjoernkindler/Library/Mobile Documents/iCloud~md~obsidian/Documents/Sprecherakademie
|
||||
replaces: obsidian-image-toolkit # vom User in Sprecherakademie ersetzt
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Kein Build-Schritt — Änderungen an `main.js` direkt wirksam (Plugin reload)
|
||||
- Bei Konflikt mit anderen Image-Plugins: eins deaktivieren
|
||||
- `passive: false` bei touchmove + wheel notwendig für preventDefault
|
||||
|
||||
## L2
|
||||
- Obsidian Plugin API: https://docs.obsidian.md/Home
|
||||
- Embed-DOM-Klassen variieren zwischen Reading/Live-Preview — beide Root-Selektoren abdecken
|
||||
29
README.md
Normal file
29
README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# ImageFull — Obsidian Plugin
|
||||
|
||||
Klick auf ein eingebettetes Bild → Vollansicht. Zwei-Finger-Geste zum Zoomen. Klick schließt.
|
||||
|
||||
## Features
|
||||
|
||||
- **Doppelklick / Doppeltipp auf Bild** (in Lese- oder Live-Preview-Modus) öffnet Vollbild-Overlay
|
||||
- **Zwei-Finger-Pinch** (Touch + Trackpad) zoomt stufenlos bis 10×
|
||||
- **Zwei-Finger-Drag** verschiebt den Bildausschnitt (Pan)
|
||||
- **Doppelklick / Doppeltipp** auf das Overlay schließt die Ansicht
|
||||
- **Mausrad / Scroll** zoomt am Desktop zusätzlich
|
||||
- **Drag im gezoomten Zustand** verschiebt den Ausschnitt
|
||||
- `ESC` schließt · `0` setzt Zoom zurück
|
||||
- Funktioniert auf Desktop, iPad und Mobile
|
||||
|
||||
## Installation (manuell)
|
||||
|
||||
1. Kopiere den Ordner `plugin-imagefull` nach:
|
||||
`<Vault>/.obsidian/plugins/imagefull/`
|
||||
2. Stelle sicher, dass diese Dateien dort liegen:
|
||||
- `manifest.json`
|
||||
- `main.js`
|
||||
- `styles.css`
|
||||
3. Obsidian → Einstellungen → Community-Plugins → ImageFull aktivieren
|
||||
|
||||
## Entwicklung
|
||||
|
||||
Das Plugin ist als reines `main.js` ausgeliefert und benötigt keinen Build-Schritt.
|
||||
Änderungen an `main.js` / `styles.css` wirksam über: *Community-Plugins → Reload*.
|
||||
270
main.js
Normal file
270
main.js
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
'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;
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "imagefull",
|
||||
"name": "ImageFull",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Klicke auf ein eingebettetes Bild für Vollansicht. Zwei-Finger-Geste zoomt hinein. Weiterer Klick schließt.",
|
||||
"author": "Björn Kindler",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
55
styles.css
Normal file
55
styles.css
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
.imagefull-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: default;
|
||||
animation: imagefull-fade 140ms ease-out;
|
||||
}
|
||||
|
||||
.imagefull-image {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
transform-origin: center center;
|
||||
transition: transform 60ms linear;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: auto;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.imagefull-image.imagefull-zoomed {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.imagefull-image.imagefull-dragging {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.imagefull-hint {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-interface);
|
||||
animation: imagefull-hint-fade 4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes imagefull-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes imagefull-hint-fade {
|
||||
0%, 60% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
Loading…
Reference in a new issue