image file info toegevoegd bij CMD+ENTER

This commit is contained in:
kodi
2026-03-13 11:37:27 +01:00
parent 05569576a7
commit 018c3dcd94
18 changed files with 726 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
# IMAGE_VIEWER_AND_INFO_V1.md
## 1. Doel
Een volledige image viewer voegt nu directe waarde toe omdat de app al image-bestanden kan tonen in de lijst, thumbnails kent, en type-specifieke viewers heeft voor tekst, video en PDF. Voor afbeeldingen ontbreekt nog de logische volgende stap: het geselecteerde bestand volledig bekijken zonder download- of externe viewerstap.
Een kleine uitbreiding van File Info met image-specifieke metadata voegt ook waarde toe. Voor afbeeldingen zijn afmetingen vaak net zo relevant als naam, grootte en modified time. Dat helpt bij snelle selectie, kwaliteitscontrole en onderscheid tussen vergelijkbare bestanden.
Dit past goed binnen de bestaande dual-pane workflow zolang:
- openen een lichte modalactie blijft
- de browse-flow niet verandert
- de info-uitbreiding read-only en goedkoop blijft
## 2. Scope
In scope voor v1:
- volledige image viewer voor:
- `jpg`
- `jpeg`
- `png`
- `webp`
- `gif`
- `bmp`
- `avif` als browser-native rendering zonder extra complexiteit werkt
- aparte image-modal
- read-only
- standaard `fit-to-view`
- basis zoom:
- zoom in
- zoom out
- reset
- File Info uitbreiding met:
- `width`
- `height`
Niet in scope:
- edit
- crop/rotate
- slideshow
- metadata editor
- EXIF-inspectie als brede feature
- thumbnails in de viewer
- multi-image navigation
Aanbevolen v1-richting:
- `jpg/jpeg/png/webp/gif/bmp` volwaardig ondersteunen
- `avif` best-effort, zonder extra garanties
- geen extra dependency alleen om `avif` of exotische metadata te forceren
## 3. Startgedrag
Aanbevolen v1-gedrag:
- `F3` opent de image viewer bij exact 1 geselecteerd image-bestand
- de bestaande `View`-knop gebruikt dezelfde centrale type-dispatch
- gewone `Enter`-semantiek blijft intact
Concreet:
- `F3` / `View` dispatch:
- tekst -> text viewer
- video -> video viewer
- pdf -> pdf viewer
- image -> image viewer
- bij geen selectie of multi-select doet `F3` niets als `View` disabled zou zijn
- directory-open gedrag via gewone `Enter` of directorynaam blijft onaangetast
## 4. Viewer-richting
Aanbevolen v1-richting: aparte image-modal met browser-native afbeeldingselement (`img`) en lichte frontend-zoom.
Waarom:
- geen extra dependency nodig
- laag regressierisico
- goed te combineren met bestaande modalarchitectuur
- voldoende voor een bruikbare eerste viewer
Aanbevolen UX:
- afbeelding centraal in een aparte modal
- standaard `fit-to-view`
- controls:
- `Zoom in`
- `Zoom out`
- `Reset`
- sluiten via:
- `X`
- `Escape`
- overlay-click alleen meenemen als dat geen conflict geeft met zoom/pan-interactie; anders weglaten in v1
Pannen/slepen:
- optioneel in v1
- alleen toevoegen als licht en stabiel
- geen ingewikkelde canvas/viewer-stack bouwen
Aanbevolen minimalistische v1:
- CSS transform zoom
- centreren zolang mogelijk
- eventueel natuurlijke browser-scroll/pan bij grotere zoom, in plaats van custom drag-logica
## 5. Backend-impact
Aanbevolen backendrichting:
- nieuw read-only image endpoint, analoog aan PDF/video, bijvoorbeeld:
- `GET /api/files/image?path=...`
Waarom een apart endpoint beter is dan hergebruik van random file-serving:
- consistente foutmapping
- duidelijke content-type-afhandeling
- hergebruik van bestaande `path_guard`
- expliciete scheiding van concerns per viewertype
Eisen:
- padvalidatie via bestaand `path_guard`
- alleen files
- directory -> bestaande `type_conflict`
- path not found -> bestaande not-found fout
- traversal / invalid root alias / outside whitelist -> bestaande securityfouten
- streaming/serving zonder onnodige buffering
- passend `Content-Type`
Geen nieuwe backendsemantiek nodig buiten een read-only route.
## 6. Frontend-impact
Aanbevolen frontendrichting:
- aparte image-modal
- geen hergebruik van text/video/pdf modalbody
- wel dezelfde modalstructuur en focusregels als bestaande viewers
Waarom een aparte modal:
- image viewing heeft eigen interactie (fit/zoom)
- voorkomt rommelige uitzonderingslogica in de bestaande text viewer
- houdt type-dispatch helder
Focusgedrag:
- terwijl image-modal open is, geen paneelkeyboardnavigatie
- `Escape` sluit modal
- `F3` en `View` blijven via dezelfde dispatch werken
## 7. File Info uitbreiding
Aanbevolen extra velden voor image-bestanden in v1:
- `width`
- `height`
- `content_type`
Optioneel, maar niet nodig voor v1:
- kleurprofiel
- EXIF orientation
- camera metadata
- creation date uit EXIF
Aanbevolen aanpak:
- alleen goedkope metadata
- afmetingen server-side afleiden zonder zware analyse
- geen brede EXIF feature
Voor niet-image bestanden blijven `width` en `height` gewoon `null`.
## 8. Regressierisico
Belangrijkste risico's:
- view-dispatch wordt rommeliger als image niet netjes als eigen type wordt behandeld
- modalfocus kan bestaande keyboardflow blokkeren of laten lekken
- grote afbeeldingen kunnen trager laden of veel viewport-ruimte vragen
- File Info response-uitbreiding moet backward-compatible blijven
Mitigatie:
- aparte image-modal
- eigen `isImageSelection(...)` helper in dezelfde dispatchstijl als video/pdf
- geen wijziging aan gewone `Enter`
- alleen extra velden aan File Info toevoegen, geen bestaande velden wijzigen
- zoom klein en beheersbaar houden
## 9. Teststrategie
Backend golden tests:
- image endpoint success voor ondersteund imagebestand
- directory -> `type_conflict`
- path not found
- traversal blocked
- invalid root alias
- non-image blocked of duidelijke unsupported fout
- File Info success voor imagebestand met `width`/`height`
- File Info voor niet-image met `width`/`height = null`
UI smoke/regressietests:
- image-modal container aanwezig
- image viewer wiring aanwezig in `F3`/`View` dispatch
- text/video/pdf modal containers blijven aanwezig
- File Info modal blijft aanwezig
- geen extra zichtbare knop toegevoegd
Handmatige validatie:
- `F3` opent image viewer bij exact 1 image
- `View` opent dezelfde image viewer
- zoom in/out/reset werkt
- sluiten via `X` en `Escape` werkt
- gewone `Enter` blijft directory/open-semantiek houden
- File Info toont width/height voor images
- grote afbeelding blijft bruikbaar zonder layoutbreuk
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- nieuw read-only image endpoint
- aparte image-modal met browser-native `img`
- lichte zoombediening zonder externe image-viewer library
- `F3` en `View` gebruiken de bestaande centrale type-dispatch
- File Info uitbreiden met alleen goedkope image metadata:
- `width`
- `height`
- bestaand `content_type`
- `avif` alleen best-effort, zonder extra dependency of browsergarantie
Dit houdt de stap klein, veilig en consistent met de bestaande architectuur:
- viewers blijven type-specifiek
- File Info blijft read-only
- browse- en keyboardflow blijven intact
+13
View File
@@ -79,6 +79,19 @@ async def pdf(
)
@router.get("/image")
async def image(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
) -> StreamingResponse:
prepared = service.prepare_image_stream(path=path)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
@router.get("/thumbnail")
async def thumbnail(
path: str,
+2
View File
@@ -92,6 +92,8 @@ class FileInfoResponse(BaseModel):
content_type: str | None = None
owner: str | None = None
group: str | None = None
width: int | None = None
height: int | None = None
class SettingsResponse(BaseModel):
+115
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import shutil
import mimetypes
import struct
import grp
import pwd
from datetime import datetime, timezone
@@ -23,6 +24,7 @@ class FilesystemAdapter:
group = None
content_type, _ = mimetypes.guess_type(path.name)
width, height = self._image_dimensions(path) if path.is_file() else (None, None)
return {
"name": path.name,
"size": int(stat.st_size) if path.is_file() else None,
@@ -31,6 +33,8 @@ class FilesystemAdapter:
"group": group,
"content_type": content_type,
"extension": path.suffix.lower() or None,
"width": width,
"height": height,
}
def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]:
@@ -159,3 +163,114 @@ class FilesystemAdapter:
def modified_iso(path: Path) -> str:
stat = path.stat()
return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def _image_dimensions(self, path: Path) -> tuple[int | None, int | None]:
suffix = path.suffix.lower()
try:
if suffix == ".png":
return self._png_dimensions(path)
if suffix in {".jpg", ".jpeg"}:
return self._jpeg_dimensions(path)
if suffix == ".gif":
return self._gif_dimensions(path)
if suffix == ".bmp":
return self._bmp_dimensions(path)
if suffix == ".webp":
return self._webp_dimensions(path)
if suffix == ".avif":
return self._avif_dimensions(path)
except (OSError, ValueError, struct.error):
return None, None
return None, None
@staticmethod
def _png_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(24)
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
return None, None
return struct.unpack(">II", header[16:24])
@staticmethod
def _jpeg_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
if handle.read(2) != b"\xff\xd8":
return None, None
while True:
marker_prefix = handle.read(1)
if not marker_prefix:
return None, None
if marker_prefix != b"\xff":
continue
marker = handle.read(1)
while marker == b"\xff":
marker = handle.read(1)
if not marker or marker in {b"\xd8", b"\xd9"}:
return None, None
segment_length_bytes = handle.read(2)
if len(segment_length_bytes) != 2:
return None, None
segment_length = struct.unpack(">H", segment_length_bytes)[0]
if segment_length < 2:
return None, None
if marker in {b"\xc0", b"\xc1", b"\xc2", b"\xc3", b"\xc5", b"\xc6", b"\xc7", b"\xc9", b"\xca", b"\xcb", b"\xcd", b"\xce", b"\xcf"}:
payload = handle.read(5)
if len(payload) != 5:
return None, None
height, width = struct.unpack(">HH", payload[1:5])
return width, height
handle.seek(segment_length - 2, 1)
@staticmethod
def _gif_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(10)
if len(header) < 10 or header[:6] not in {b"GIF87a", b"GIF89a"}:
return None, None
width, height = struct.unpack("<HH", header[6:10])
return width, height
@staticmethod
def _bmp_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(26)
if len(header) < 26 or header[:2] != b"BM":
return None, None
width, height = struct.unpack("<ii", header[18:26])
return abs(width), abs(height)
@staticmethod
def _webp_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(64)
if len(header) < 30 or header[:4] != b"RIFF" or header[8:12] != b"WEBP":
return None, None
chunk = header[12:16]
if chunk == b"VP8 " and len(header) >= 30:
width, height = struct.unpack("<HH", header[26:30])
return width & 0x3FFF, height & 0x3FFF
if chunk == b"VP8L" and len(header) >= 25:
bits = struct.unpack("<I", header[21:25])[0]
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
return width, height
if chunk == b"VP8X" and len(header) >= 30:
width = 1 + int.from_bytes(header[24:27], "little")
height = 1 + int.from_bytes(header[27:30], "little")
return width, height
return None, None
@staticmethod
def _avif_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
data = handle.read(256 * 1024)
if b"ftypavif" not in data and b"ftypavis" not in data:
return None, None
index = data.find(b"ispe")
if index == -1 or index + 20 > len(data):
return None, None
width = int.from_bytes(data[index + 12:index + 16], "big")
height = int.from_bytes(data[index + 16:index + 20], "big")
if width <= 0 or height <= 0:
return None, None
return width, height
@@ -32,6 +32,15 @@ THUMBNAIL_CONTENT_TYPES = {
".png": "image/png",
".webp": "image/webp",
}
IMAGE_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".avif": "image/avif",
}
VIDEO_CONTENT_TYPES = {
".mp4": "video/mp4",
".mkv": "video/x-matroska",
@@ -270,6 +279,8 @@ class FileOpsService:
content_type=metadata["content_type"],
owner=metadata["owner"],
group=metadata["group"],
width=metadata["width"],
height=metadata["height"],
)
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
@@ -413,6 +424,39 @@ class FileOpsService:
"content": self._filesystem.stream_file(resolved_target.absolute),
}
def prepare_image_stream(self, path: str) -> dict:
resolved_target = self._path_guard.resolve_existing_path(path)
if resolved_target.absolute.is_dir():
raise AppError(
code="type_conflict",
message="Source must be a file",
status_code=409,
details={"path": resolved_target.relative},
)
if not resolved_target.absolute.is_file():
raise AppError(
code="type_conflict",
message="Unsupported path type for image",
status_code=409,
details={"path": resolved_target.relative},
)
content_type = self._image_content_type_for(resolved_target.absolute)
if content_type is None:
raise AppError(
code="unsupported_type",
message="File type is not supported for image viewing",
status_code=409,
details={"path": resolved_target.relative},
)
return {
"headers": {"Content-Length": str(int(resolved_target.absolute.stat().st_size))},
"content_type": content_type,
"content": self._filesystem.stream_file(resolved_target.absolute),
}
def prepare_pdf_stream(self, path: str) -> dict:
resolved_target = self._path_guard.resolve_existing_path(path)
@@ -465,6 +509,10 @@ class FileOpsService:
def _thumbnail_content_type_for(path: Path) -> str | None:
return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower())
@staticmethod
def _image_content_type_for(path: Path) -> str | None:
return IMAGE_CONTENT_TYPES.get(path.suffix.lower())
@staticmethod
def _pdf_content_type_for(path: Path) -> str | None:
return PDF_CONTENT_TYPES.get(path.suffix.lower())
@@ -0,0 +1,102 @@
from __future__ import annotations
import asyncio
import sys
import tempfile
import unittest
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_file_ops_service
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app
from backend.app.security.path_guard import PathGuard
from backend.app.services.file_ops_service import FileOpsService
PNG_1X1 = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00"
b"\xc9\xfe\x92\xef"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
class ImageApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name) / "root"
self.root.mkdir(parents=True, exist_ok=True)
path_guard = PathGuard({"storage1": str(self.root)})
service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter())
async def _override_file_ops_service() -> FileOpsService:
return service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _request(self, path: str) -> httpx.Response:
async def _run() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
return await client.get("/api/files/image", params={"path": path})
return asyncio.run(_run())
def test_image_endpoint_success(self) -> None:
(self.root / "sample.png").write_bytes(PNG_1X1)
response = self._request("storage1/sample.png")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["content-type"], "image/png")
self.assertEqual(response.headers["content-length"], str(len(PNG_1X1)))
self.assertEqual(response.content, PNG_1X1)
def test_image_directory_type_conflict(self) -> None:
(self.root / "images").mkdir()
response = self._request("storage1/images")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "type_conflict")
def test_image_path_not_found(self) -> None:
response = self._request("storage1/missing.png")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["error"]["code"], "path_not_found")
def test_image_traversal_blocked(self) -> None:
response = self._request("storage1/../etc/passwd")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
def test_image_invalid_root_alias(self) -> None:
response = self._request("unknown/sample.png")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"]["code"], "invalid_root_alias")
def test_image_non_image_blocked(self) -> None:
(self.root / "notes.txt").write_text("hello", encoding="utf-8")
response = self._request("storage1/notes.txt")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "unsupported_type")
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import base64
import sys
import tempfile
import unittest
@@ -17,6 +18,11 @@ from backend.app.security.path_guard import PathGuard
from backend.app.services.file_ops_service import FileOpsService
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
)
class FileInfoApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
@@ -59,6 +65,8 @@ class FileInfoApiGoldenTest(unittest.TestCase):
self.assertIn("modified", payload)
self.assertIn("owner", payload)
self.assertIn("group", payload)
self.assertIsNone(payload["width"])
self.assertIsNone(payload["height"])
def test_directory_info_success(self) -> None:
directory = self.root / "Media"
@@ -74,6 +82,20 @@ class FileInfoApiGoldenTest(unittest.TestCase):
self.assertIsNone(payload["size"])
self.assertEqual(payload["root"], "storage1")
self.assertIsNone(payload["extension"])
self.assertIsNone(payload["width"])
self.assertIsNone(payload["height"])
def test_image_info_has_width_and_height(self) -> None:
file_path = self.root / "pixel.png"
file_path.write_bytes(PNG_1X1)
response = self._request("storage1/pixel.png")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["width"], 1)
self.assertEqual(payload["height"], 1)
self.assertEqual(payload["content_type"], "image/png")
def test_info_path_not_found(self) -> None:
response = self._request("storage1/missing.txt")
@@ -113,6 +135,8 @@ class FileInfoApiGoldenTest(unittest.TestCase):
"content_type",
"owner",
"group",
"width",
"height",
},
)
@@ -62,6 +62,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="viewer-modal"', body)
self.assertIn('id="video-modal"', body)
self.assertIn('id="pdf-modal"', body)
self.assertIn('id="image-modal"', body)
self.assertIn('id="image-viewer-img"', body)
self.assertIn('id="image-zoom-in-btn"', body)
self.assertIn('id="image-zoom-out-btn"', body)
self.assertIn('id="image-reset-btn"', body)
self.assertIn('id="pdf-frame"', body)
self.assertIn('id="pdf-close-btn"', body)
self.assertIn('id="video-player"', body)
@@ -192,6 +197,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function openSearch()', app_js)
self.assertIn('async function submitSearch()', app_js)
self.assertIn('async function openInfo()', app_js)
self.assertIn('function imageElements()', app_js)
self.assertIn('function isImageSelection(item)', app_js)
self.assertIn('async function openImageViewer()', app_js)
self.assertIn('function isImageOpen()', app_js)
self.assertIn("`/api/files/image?", app_js)
self.assertIn('if (isImageSelection(selected)) {', app_js)
self.assertIn('document.getElementById("info-modal")', app_js)
self.assertIn("`/api/files/info?", app_js)
self.assertIn('document.getElementById("search-input")', app_js)
+146
View File
@@ -51,6 +51,12 @@ let batchMoveState = {
destinationBase: "",
count: 0,
};
let imageViewerState = {
scale: 1,
fitScale: 1,
path: null,
resizeHandler: null,
};
let settingsState = {
activeTab: "general",
logsLoaded: false,
@@ -203,6 +209,22 @@ function pdfElements() {
};
}
function imageElements() {
return {
overlay: document.getElementById("image-modal"),
title: document.getElementById("image-title"),
fileName: document.getElementById("image-file-name"),
filePath: document.getElementById("image-file-path"),
error: document.getElementById("image-error"),
viewport: document.getElementById("image-viewport"),
image: document.getElementById("image-viewer-img"),
closeButton: document.getElementById("image-close-btn"),
zoomInButton: document.getElementById("image-zoom-in-btn"),
zoomOutButton: document.getElementById("image-zoom-out-btn"),
resetButton: document.getElementById("image-reset-btn"),
};
}
function moveElements() {
return {
overlay: document.getElementById("move-popup"),
@@ -655,6 +677,32 @@ function isPdfSelection(item) {
return (item.name || "").toLowerCase().endsWith(".pdf");
}
function isImageSelection(item) {
if (!item || item.kind !== "file") {
return false;
}
const lower = (item.name || "").toLowerCase();
return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"].some((suffix) => lower.endsWith(suffix));
}
function currentImageScale() {
return Number.isFinite(imageViewerState.scale) ? imageViewerState.scale : 1;
}
function applyImageScale() {
const image = imageElements().image;
image.style.transform = `scale(${currentImageScale()})`;
}
function resetImageViewerState() {
imageViewerState = {
scale: 1,
fitScale: 1,
path: null,
resizeHandler: null,
};
}
function currentParentPath(path) {
const normalized = (path || "").trim();
if (!normalized) {
@@ -1771,6 +1819,55 @@ function closePdfViewer() {
pdf.frame.removeAttribute("src");
}
function isImageOpen() {
return !imageElements().overlay.classList.contains("hidden");
}
function fitImageToViewport() {
const image = imageElements().image;
const viewport = imageElements().viewport;
if (!image.naturalWidth || !image.naturalHeight) {
return;
}
const widthScale = viewport.clientWidth / image.naturalWidth;
const heightScale = viewport.clientHeight / image.naturalHeight;
imageViewerState.fitScale = Math.min(widthScale, heightScale, 1);
imageViewerState.scale = imageViewerState.fitScale;
applyImageScale();
}
function adjustImageZoom(multiplier) {
if (!isImageOpen()) {
return;
}
const minScale = Math.max(imageViewerState.fitScale * 0.5, 0.1);
const maxScale = Math.max(imageViewerState.fitScale * 6, 1.5);
imageViewerState.scale = Math.min(maxScale, Math.max(minScale, currentImageScale() * multiplier));
applyImageScale();
}
function resetImageZoom() {
if (!isImageOpen()) {
return;
}
imageViewerState.scale = imageViewerState.fitScale;
applyImageScale();
}
function closeImageViewer() {
const image = imageElements();
image.overlay.classList.add("hidden");
image.error.textContent = "";
image.image.removeAttribute("src");
image.image.removeAttribute("alt");
image.image.onload = null;
image.image.onerror = null;
if (imageViewerState.resizeHandler) {
window.removeEventListener("resize", imageViewerState.resizeHandler);
}
resetImageViewerState();
}
function isInfoOpen() {
return !infoElements().overlay.classList.contains("hidden");
}
@@ -1815,6 +1912,8 @@ async function openInfo() {
renderInfoField("Content type", data.content_type);
renderInfoField("Owner", data.owner);
renderInfoField("Group", data.group);
renderInfoField("Width", data.width);
renderInfoField("Height", data.height);
} catch (err) {
elements.error.textContent = err.message;
}
@@ -2116,6 +2215,36 @@ async function openPdfViewer() {
pdf.frame.src = pdfUrl;
}
async function openImageViewer() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1 || !isImageSelection(selectedItems[0])) {
return;
}
const selected = selectedItems[0];
const image = imageElements();
const imageUrl = `/api/files/image?${new URLSearchParams({ path: selected.path }).toString()}`;
closeImageViewer();
image.overlay.classList.remove("hidden");
image.title.textContent = "Image";
image.fileName.textContent = selected.name;
image.filePath.textContent = selected.path;
image.error.textContent = "";
image.image.alt = selected.name;
image.image.onload = () => {
fitImageToViewport();
image.image.onload = null;
};
image.image.onerror = () => {
image.error.textContent = "Image could not be displayed in this browser.";
image.image.onerror = null;
};
imageViewerState.path = selected.path;
imageViewerState.resizeHandler = () => fitImageToViewport();
window.addEventListener("resize", imageViewerState.resizeHandler);
image.image.src = imageUrl;
}
function videoPlaybackMessage(item) {
const lower = (item.name || "").toLowerCase();
if (lower.endsWith(".mkv")) {
@@ -2179,6 +2308,10 @@ function openViewer() {
return;
}
const selected = selectedItems[0];
if (isImageSelection(selected)) {
openImageViewer();
return;
}
if (isVideoSelection(selected)) {
openVideoViewer();
return;
@@ -2369,6 +2502,13 @@ function handleKeyboardShortcuts(event) {
}
return;
}
if (isImageOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeImageViewer();
}
return;
}
if (isVideoOpen()) {
if (event.key === "Escape") {
event.preventDefault();
@@ -2553,6 +2693,12 @@ function setupEvents() {
}
};
const image = imageElements();
image.closeButton.onclick = closeImageViewer;
image.zoomInButton.onclick = () => adjustImageZoom(1.2);
image.zoomOutButton.onclick = () => adjustImageZoom(1 / 1.2);
image.resetButton.onclick = resetImageZoom;
const search = searchElements();
search.closeButton.onclick = closeSearch;
search.overlay.onclick = (event) => {
+30
View File
@@ -626,6 +626,36 @@ button:disabled {
border: 1px solid var(--color-border);
}
.image-card {
width: min(1100px, calc(100vw - 28px));
}
.image-toolbar {
display: flex;
gap: 8px;
margin: 8px 0 8px 0;
}
.image-viewport {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
height: calc(100vh - 240px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-surface) 88%, black 12%);
}
.image-viewer-img {
max-width: none;
max-height: none;
transform-origin: center center;
transition: transform 120ms ease;
user-select: none;
}
.search-card {
width: min(680px, calc(100vw - 32px));
}
+18
View File
@@ -249,6 +249,24 @@
</div>
</div>
<div id="image-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="image-title">
<div class="popup-card viewer-card image-card">
<button id="image-close-btn" class="viewer-close" type="button" aria-label="Close image">X</button>
<h3 id="image-title">Image</h3>
<div id="image-file-name" class="popup-meta"></div>
<div id="image-file-path" class="popup-meta"></div>
<div class="image-toolbar">
<button id="image-zoom-out-btn" type="button">Zoom out</button>
<button id="image-reset-btn" type="button">Reset</button>
<button id="image-zoom-in-btn" type="button">Zoom in</button>
</div>
<div id="image-error" class="error"></div>
<div id="image-viewport" class="image-viewport">
<img id="image-viewer-img" class="image-viewer-img" alt="">
</div>
</div>
</div>
<div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<div class="popup-card viewer-card editor-card">
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>