From a331cf0eece686619fadb4d242ac09adea7b44b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kindler?= <21176681+bkindler@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:28:39 +0200 Subject: [PATCH] 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 --- MEMORY.md | 54 ++++++++++ README.md | 29 ++++++ main.js | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 9 ++ styles.css | 55 ++++++++++ 5 files changed, 417 insertions(+) create mode 100644 MEMORY.md create mode 100644 README.md create mode 100644 main.js create mode 100644 manifest.json create mode 100644 styles.css diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..cff838b --- /dev/null +++ b/MEMORY.md @@ -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: "/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..356e806 --- /dev/null +++ b/README.md @@ -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: + `/.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*. diff --git a/main.js b/main.js new file mode 100644 index 0000000..0906e85 --- /dev/null +++ b/main.js @@ -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; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..5c15ec9 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e199391 --- /dev/null +++ b/styles.css @@ -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; } +}