Quellcode durchsuchen

File Manager previews: fullscreen and zoom, image previews, double-click to open (#2990)

Thomansky vor 2 Tagen
Ursprung
Commit
226826f3bd
40 geänderte Dateien mit 3498 neuen und 657 gelöschten Zeilen
  1. 57 23
      backend/app/api/routes/library.py
  2. 31 9
      backend/app/main.py
  3. 25 0
      backend/tests/integration/test_library_api.py
  4. 154 0
      backend/tests/integration/test_library_preview_thumbnail_api.py
  5. 52 29
      backend/tests/integration/test_security_headers.py
  6. 31 6
      frontend/scripts/check-browser-baseline.mjs
  7. 235 0
      frontend/src/__tests__/components/ImagePreviewModal.test.tsx
  8. 210 0
      frontend/src/__tests__/components/ModelViewerControls.test.tsx
  9. 45 1
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  10. 347 7
      frontend/src/__tests__/components/PdfPreviewModal.test.tsx
  11. 140 0
      frontend/src/__tests__/components/PreviewModalShell.test.tsx
  12. 125 1
      frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx
  13. 127 0
      frontend/src/__tests__/hooks/useElementFullscreen.test.tsx
  14. 80 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  15. 335 0
      frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx
  16. 306 0
      frontend/src/components/ImagePreviewModal.tsx
  17. 49 11
      frontend/src/components/ModelViewer.tsx
  18. 299 318
      frontend/src/components/ModelViewerModal.tsx
  19. 303 86
      frontend/src/components/PdfPreviewModal.tsx
  20. 101 0
      frontend/src/components/PreviewModalShell.tsx
  21. 118 109
      frontend/src/components/SpreadsheetPreviewModal.tsx
  22. 72 0
      frontend/src/hooks/useElementFullscreen.ts
  23. 22 0
      frontend/src/hooks/usePreviewFullscreen.ts
  24. 3 0
      frontend/src/i18n/locales/de.ts
  25. 3 0
      frontend/src/i18n/locales/en.ts
  26. 3 0
      frontend/src/i18n/locales/es.ts
  27. 3 0
      frontend/src/i18n/locales/fr.ts
  28. 3 0
      frontend/src/i18n/locales/it.ts
  29. 3 0
      frontend/src/i18n/locales/ja.ts
  30. 3 0
      frontend/src/i18n/locales/ko.ts
  31. 3 0
      frontend/src/i18n/locales/nl.ts
  32. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 3 0
      frontend/src/i18n/locales/ru.ts
  34. 3 0
      frontend/src/i18n/locales/sv.ts
  35. 3 0
      frontend/src/i18n/locales/tr.ts
  36. 3 0
      frontend/src/i18n/locales/uk.ts
  37. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  38. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  39. 146 56
      frontend/src/pages/FileManagerPage.tsx
  40. 43 1
      frontend/vite.config.ts

+ 57 - 23
backend/app/api/routes/library.py

@@ -17,7 +17,7 @@ from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import distinct, func, select
+from sqlalchemy import distinct, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -813,11 +813,12 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 
 
 # File types whose thumbnails are rendered client-side and uploaded back
 # File types whose thumbnails are rendered client-side and uploaded back
-# (#2976). The server has no renderer for these formats — STEP would need
-# OpenCascade, PDF a rasteriser — so the browser posts its first preview
-# render to POST /files/{id}/preview-thumbnail instead. Kept to exactly
-# these types so the endpoint can never overwrite a server-generated
-# STL/3MF/G-code/image thumbnail.
+# (#2976). The server has no renderer for STEP (that would need OpenCascade)
+# or the spreadsheet types, so the browser posts its first preview render to
+# POST /files/{id}/preview-thumbnail instead. PDF is rendered server-side with
+# PDFium when it lands and stays here for a PDF that renderer cannot read.
+# Kept to exactly these types so the endpoint can never overwrite a
+# server-generated STL/3MF/G-code/image thumbnail.
 CLIENT_THUMBNAIL_TYPES = {"step", "stp", "pdf", "csv", "xlsx", "ods"}
 CLIENT_THUMBNAIL_TYPES = {"step", "stp", "pdf", "csv", "xlsx", "ods"}
 
 
 # Photos of the printed result (#3077): same allowlist and naming as the
 # Photos of the printed result (#3077): same allowlist and naming as the
@@ -835,6 +836,15 @@ MAX_PHOTO_BYTES = 10 * 1024 * 1024
 # 256px PNG (a few tens of KB); anything near this limit is not a thumbnail.
 # 256px PNG (a few tens of KB); anything near this limit is not a thumbnail.
 MAX_CLIENT_THUMBNAIL_BYTES = 2 * 1024 * 1024
 MAX_CLIENT_THUMBNAIL_BYTES = 2 * 1024 * 1024
 
 
+# Upper bound on the *decoded* size, checked against the header before any
+# pixels are allocated: a few-KB PNG can declare 12000x7000 and still be under
+# PIL's own decompression-bomb limit, which would be ~340 MB of RGBA.
+MAX_CLIENT_THUMBNAIL_EDGE = 2048
+
+# What the endpoint stores. The grid renders at ~256px, so anything larger is
+# downscaled rather than kept.
+STORED_CLIENT_THUMBNAIL_EDGE = 512
+
 
 
 async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
 async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
     """Generate STL thumbnails for an external folder tree in the background.
     """Generate STL thumbnails for an external folder tree in the background.
@@ -5421,9 +5431,11 @@ async def upload_preview_thumbnail(
 
 
     STEP, PDF and spreadsheet previews are rendered in the browser; the FE
     STEP, PDF and spreadsheet previews are rendered in the browser; the FE
     posts its first render here so the grid gets a thumbnail without the
     posts its first render here so the grid gets a thumbnail without the
-    server needing OpenCascade or a PDF rasteriser. Only file types in
-    ``CLIENT_THUMBNAIL_TYPES`` are accepted, and only while the file has no
-    thumbnail yet — a stored thumbnail is never replaced by this route.
+    server needing OpenCascade. A PDF normally has its PDFium thumbnail from
+    upload already, so for PDFs this only fills the gap for a file PDFium
+    could not read. Only file types in ``CLIENT_THUMBNAIL_TYPES`` are
+    accepted, and only while the file has no thumbnail yet — a stored
+    thumbnail is never replaced by this route.
     """
     """
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
@@ -5455,26 +5467,48 @@ async def upload_preview_thumbnail(
     from PIL import Image, UnidentifiedImageError
     from PIL import Image, UnidentifiedImageError
 
 
     try:
     try:
-        with Image.open(io.BytesIO(content)) as img:
-            img.load()
-            if img.format != "PNG":
+        # Image.open() reads the header only. Both checks below happen before
+        # load(), so a declared-but-never-delivered canvas is refused rather
+        # than allocated. DecompressionBombError derives straight from
+        # Exception, so it has to be named explicitly — open() itself raises
+        # it once the declared size passes PIL's own limit.
+        with Image.open(io.BytesIO(content)) as source:
+            if source.format != "PNG":
                 raise HTTPException(status_code=400, detail="Thumbnail must be a PNG image")
                 raise HTTPException(status_code=400, detail="Thumbnail must be a PNG image")
-            if img.mode not in ("RGB", "RGBA"):
-                img = img.convert("RGBA")
-            # The grid renders at ~256px; cap outliers instead of storing them.
-            if img.width > 512 or img.height > 512:
-                img.thumbnail((512, 512), Image.Resampling.LANCZOS)
-            thumbnails_dir = get_library_thumbnails_dir()
-            thumb_filename = f"{uuid.uuid4().hex}.png"
-            thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
-            img.save(thumb_path, "PNG", optimize=True)
+            if max(source.size) > MAX_CLIENT_THUMBNAIL_EDGE:
+                raise HTTPException(status_code=400, detail="Thumbnail image dimensions too large")
+            source.load()
+            img = source.convert("RGBA") if source.mode not in ("RGB", "RGBA") else source.copy()
     except HTTPException:
     except HTTPException:
         raise
         raise
-    except (UnidentifiedImageError, OSError, ValueError) as e:
+    except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError) as e:
         raise HTTPException(status_code=400, detail="Invalid thumbnail image") from e
         raise HTTPException(status_code=400, detail="Invalid thumbnail image") from e
 
 
-    file.thumbnail_path = to_relative_path(thumb_path)
+    if max(img.size) > STORED_CLIENT_THUMBNAIL_EDGE:
+        img.thumbnail((STORED_CLIENT_THUMBNAIL_EDGE, STORED_CLIENT_THUMBNAIL_EDGE), Image.Resampling.LANCZOS)
+
+    thumbnails_dir = get_library_thumbnails_dir()
+    thumb_filename = f"{uuid.uuid4().hex}.png"
+    thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
+    # Outside the decode guard on purpose: a full disk or an unwritable
+    # thumbnail directory is ours, not "Invalid thumbnail image".
+    try:
+        img.save(thumb_path, "PNG", optimize=True)
+    except OSError as e:
+        logger.error("Failed to store preview thumbnail for file %s: %s", file_id, e)
+        raise HTTPException(status_code=500, detail="Failed to store thumbnail") from e
+
+    # Two previews of the same file can reach this point together; the loser
+    # of the UPDATE takes its PNG back off disk instead of orphaning it.
+    result = await db.execute(
+        update(LibraryFile)
+        .where(LibraryFile.id == file_id, LibraryFile.thumbnail_path.is_(None))
+        .values(thumbnail_path=to_relative_path(thumb_path))
+    )
     await db.commit()
     await db.commit()
+    if result.rowcount == 0:
+        thumb_path.unlink(missing_ok=True)
+        return ClientThumbnailResponse(updated=False)
 
 
     return ClientThumbnailResponse(updated=True)
     return ClientThumbnailResponse(updated=True)
 
 

+ 31 - 9
backend/app/main.py

@@ -10163,10 +10163,18 @@ def _frame_ancestors(default_value: str) -> str:
     return f"frame-ancestors {default_value};"
     return f"frame-ancestors {default_value};"
 
 
 
 
-# The Vite-emitted STEP preview worker chunk (#2976): src/workers/
-# stepPreview.worker.ts becomes /assets/stepPreview.worker-<hash>.js. Matched
-# exactly so the eval-relaxed CSP below can never apply to any other asset.
+# The two Vite-emitted worker assets that compile WebAssembly (#2976). Both
+# patterns are anchored on the exact emitted name so the relaxed policies
+# below can never apply to any other asset.
+#   src/workers/stepPreview.worker.ts -> /assets/stepPreview.worker-<hash>.js
+#   pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url
+#                                     -> /assets/pdf.worker.min-<hash>.js
+# Vite also emits a one-line chunk under the second name that only exports the
+# worker's URL. The page imports it as a module, and a module script is run
+# under the importing document's policy, never its own response's, so it
+# matching as well changes nothing.
 _STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
 _STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
+_PDF_WORKER_ASSET_RE = re.compile(r"^/assets/pdf\.worker\.min-[\w-]+\.js$")
 
 
 
 
 @app.middleware("http")
 @app.middleware("http")
@@ -10236,6 +10244,20 @@ async def security_headers_middleware(request, call_next):
             "object-src 'none'; "
             "object-src 'none'; "
             "base-uri 'self'; " + _frame_ancestors("'none'")
             "base-uri 'self'; " + _frame_ancestors("'none'")
         )
         )
+    elif _PDF_WORKER_ASSET_RE.match(request.url.path):
+        # pdf.js decodes JPEG2000/JBIG2 images and ICC colour with WebAssembly
+        # and fetches those modules from /assets/pdfjs/wasm/ (#2976). Same CSP3
+        # rule as the STEP worker above: the policy that governs a dedicated
+        # worker is the one delivered with its own script, so the wasm compile
+        # has to be permitted here rather than on the document. Unlike the STEP
+        # worker this one needs no JS eval, so it gets 'wasm-unsafe-eval' only.
+        response.headers["Content-Security-Policy"] = (
+            "default-src 'self'; "
+            "script-src 'self' 'wasm-unsafe-eval'; "
+            "connect-src 'self'; "
+            "object-src 'none'; "
+            "base-uri 'self'; " + _frame_ancestors("'none'")
+        )
     else:
     else:
         # The streaming overlay is embedded same-origin by the URL builder's
         # The streaming overlay is embedded same-origin by the URL builder's
         # preview in Settings (#1422), so this branch allows 'self'.
         # preview in Settings (#1422), so this branch allows 'self'.
@@ -10257,14 +10279,14 @@ async def security_headers_middleware(request, call_next):
         # sidebar link's site included -- still cannot frame the consent
         # sidebar link's site included -- still cannot frame the consent
         # screen to bait a click.
         # screen to bait a click.
         embeddable_same_origin = request.url.path.startswith("/overlay/") or request.url.path == "/connect/authorize"
         embeddable_same_origin = request.url.path.startswith("/overlay/") or request.url.path == "/connect/authorize"
-        # 'wasm-unsafe-eval' permits WebAssembly compilation ONLY — it does
-        # not allow eval()/Function() for JS, unlike 'unsafe-eval'. Needed by
-        # the STEP preview, which triangulates in the browser via OpenCascade
-        # compiled to WASM (#2976). Browsers that predate the keyword ignore
-        # it and simply keep blocking wasm, so this never widens JS execution.
+        # No 'wasm-unsafe-eval' here: nothing compiles WebAssembly on the main
+        # thread. Both wasm consumers — the STEP preview and pdf.js's image
+        # decoders (#2976) — run in dedicated workers, which CSP3 governs by
+        # the policy served with their own script, so each gets it in its own
+        # branch above and the document policy stays as strict as it was.
         response.headers["Content-Security-Policy"] = (
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "default-src 'self'; "
-            f"script-src 'self' 'wasm-unsafe-eval' 'nonce-{csp_nonce}'; "
+            f"script-src 'self' 'nonce-{csp_nonce}'; "
             "style-src 'self' 'unsafe-inline'; "
             "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "media-src 'self' blob:; "

+ 25 - 0
backend/tests/integration/test_library_api.py

@@ -1190,6 +1190,31 @@ endsolid cube"""
         # No thumbnail should be generated when disabled
         # No thumbnail should be generated when disabled
         assert result["thumbnail_path"] is None
         assert result["thumbnail_path"] is None
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_image_stores_a_thumbnail(self, async_client: AsyncClient, db_session):
+        """An uploaded image gets its own grid thumbnail, so PNGs are not blank cards (#2976)."""
+        from PIL import Image
+
+        buffer = io.BytesIO()
+        Image.new("RGB", (640, 480), (0, 174, 66)).save(buffer, format="PNG")
+
+        files = {"file": ("photo.png", buffer.getvalue(), "image/png")}
+        response = await async_client.post("/api/v1/library/files", files=files)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["file_type"] == "png"
+        assert result["thumbnail_path"]
+
+        from backend.app.api.routes.library import to_absolute_path
+
+        thumb = to_absolute_path(result["thumbnail_path"])
+        assert thumb is not None and thumb.exists()
+        with Image.open(thumb) as thumbnail:
+            assert thumbnail.format == "PNG"
+            assert max(thumbnail.size) <= 256
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
     async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):

+ 154 - 0
backend/tests/integration/test_library_preview_thumbnail_api.py

@@ -7,6 +7,7 @@ types, and never replacing an existing thumbnail.
 """
 """
 
 
 import io
 import io
+import zlib
 
 
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
@@ -14,6 +15,7 @@ from PIL import Image
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
 
 
 
 
 def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
 def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
@@ -22,6 +24,22 @@ def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
     return buf.getvalue()
     return buf.getvalue()
 
 
 
 
+def _png_claiming(width: int, height: int) -> bytes:
+    """A ~70-byte PNG whose IHDR declares a canvas it never delivers.
+
+    This is the shape that costs memory: the header is what a decoder sizes
+    its buffer from, and the payload stays small enough to pass any upload cap.
+    """
+    raw = bytearray(_png_bytes(size=(1, 1)))
+    # 8-byte signature, then IHDR: length(4) type(4) data(13) crc(4).
+    ihdr = raw[8:33]
+    ihdr[8:12] = width.to_bytes(4, "big")
+    ihdr[12:16] = height.to_bytes(4, "big")
+    ihdr[21:25] = zlib.crc32(bytes(ihdr[4:21])).to_bytes(4, "big")
+    raw[8:33] = ihdr
+    return bytes(raw)
+
+
 @pytest.fixture
 @pytest.fixture
 def isolated_storage(monkeypatch, tmp_path):
 def isolated_storage(monkeypatch, tmp_path):
     """Point thumbnail storage at a throwaway directory."""
     """Point thumbnail storage at a throwaway directory."""
@@ -169,3 +187,139 @@ class TestPreviewThumbnailUpload:
             files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
             files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
         )
         )
         assert response.status_code == 404
         assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_a_canvas_it_would_have_to_allocate(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        """12000x7000 is under PIL's bomb limit and would decode for real.
+
+        A few KB of upload turns into ~340 MB of pixels, so the declared size
+        has to be refused from the header, before load() is ever reached.
+        """
+        library_file = await file_factory(file_type="step")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_claiming(12000, 7000), "image/png")},
+        )
+
+        assert response.status_code == 400
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_a_decompression_bomb_header(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        """PIL raises DecompressionBombError straight off Exception.
+
+        It is neither an OSError nor a ValueError, so it escaped the decode
+        guard and surfaced as a 500 — it is a bad request like any other.
+        """
+        library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_claiming(20000, 20000), "image/png")},
+        )
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_storage_failure_is_not_reported_as_a_bad_image(
+        self, async_client: AsyncClient, monkeypatch, file_factory, isolated_storage
+    ):
+        """A full disk is ours to own, not "Invalid thumbnail image"."""
+
+        payload = _png_bytes()
+        library_file = await file_factory(file_type="xlsx")
+
+        def _no_space(*args, **kwargs):
+            raise OSError(28, "No space left on device")
+
+        monkeypatch.setattr(Image.Image, "save", _no_space)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", payload, "image/png")},
+        )
+
+        assert response.status_code == 500
+        assert response.json()["detail"] != "Invalid thumbnail image"
+
+
+class TestPreviewThumbnailOwnership(TestOwnershipPermissionsSetup):
+    """The ownership branch of the upload route (#2976).
+
+    Reuses the shared auth setup: Operators hold library:update_own only, so
+    they are the group that can tell the two branches apart.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_upload_for_their_own_file(
+        self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 200
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_for_someone_elses_file(
+        self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 403
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_for_an_ownerless_file(
+        self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
+    ):
+        """created_by_id is NULL — an *_own permission owns nothing here."""
+        library_file = await file_factory(file_type="step", created_by_id=None)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_upload_for_an_ownerless_file(
+        self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=None)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 200

+ 52 - 29
backend/tests/integration/test_security_headers.py

@@ -316,31 +316,39 @@ async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
     assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
     assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
 
 
 
 
-# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
+# ─── #2976: WebAssembly is confined to the two preview workers ───────────
 
 
 
 
-@pytest.mark.asyncio
-@pytest.mark.integration
-async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
-    """script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
-
-    The STEP preview triangulates in the browser via OpenCascade compiled to
-    WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
-    WebAssembly.instantiate() and the preview dies with a CompileError.
-    'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
-    stay blocked, which is what the second assertion pins.
+def _script_src_tokens(resp) -> list[str]:
+    """The script-src directive of a response's CSP, split into whole tokens.
+
+    Whole tokens, because 'wasm-unsafe-eval' contains the text "unsafe-eval"
+    and a substring check would read as a pass either way.
     """
     """
-    resp = await async_client.get("/api/v1/auth/status")
     csp = resp.headers.get("Content-Security-Policy", "")
     csp = resp.headers.get("Content-Security-Policy", "")
-    script_src = next(
+    directive = next(
         (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
         (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
         "",
         "",
     )
     )
-    assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
-    # Substring check must not be fooled by 'wasm-unsafe-eval' containing
-    # "unsafe-eval" — compare whole tokens.
-    tokens = script_src.split()
-    assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
+    return directive.split()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_permits_no_kind_of_eval(async_client: AsyncClient):
+    """The document policy allows neither wasm compilation nor JS eval (#2976).
+
+    Nothing on the main thread compiles WebAssembly: the STEP preview and
+    pdf.js's image decoders both run in dedicated workers, which get their own
+    policies below. So the SPA document stays exactly as strict as it was
+    before the previews landed.
+    """
+    resp = await async_client.get("/api/v1/auth/status")
+    tokens = _script_src_tokens(resp)
+
+    assert tokens, "the SPA response must carry a script-src directive"
+    assert "'wasm-unsafe-eval'" not in tokens, f"document must not compile wasm: {tokens!r}"
+    assert "'unsafe-eval'" not in tokens, f"document must not allow JS eval: {tokens!r}"
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
@@ -355,20 +363,35 @@ async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_clien
     SPA document itself — must stay nonce-strict. Both requests 404 in the
     SPA document itself — must stay nonce-strict. Both requests 404 in the
     test checkout; the security middleware stamps headers regardless.
     test checkout; the security middleware stamps headers regardless.
     """
     """
-
-    def script_src_tokens(resp) -> list[str]:
-        csp = resp.headers.get("Content-Security-Policy", "")
-        directive = next(
-            (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
-            "",
-        )
-        return directive.split()
-
     worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
     worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
-    assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
+    assert "'unsafe-eval'" in _script_src_tokens(worker), "step worker script must be allowed to eval"
 
 
     other = await async_client.get("/assets/index-Ck9aB12c.js")
     other = await async_client.get("/assets/index-Ck9aB12c.js")
-    assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
+    assert "'unsafe-eval'" not in _script_src_tokens(other), "ordinary assets must stay eval-free"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_pdf_worker_asset_csp_allows_wasm_without_eval(async_client: AsyncClient):
+    """pdf.js's worker may compile wasm, and nothing more (#2976).
+
+    Its JPEG2000/JBIG2/ICC decoders are WebAssembly fetched from
+    /assets/pdfjs/wasm/; without 'wasm-unsafe-eval' on the worker script's own
+    response they fail to compile and those images and colour spaces silently
+    drop out. JS eval stays blocked — unlike the STEP worker, pdf.js needs
+    none.
+    """
+    worker = await async_client.get("/assets/pdf.worker.min-Ck9aB12c.js")
+    tokens = _script_src_tokens(worker)
+
+    assert "'wasm-unsafe-eval'" in tokens, f"pdf worker must be allowed to compile wasm: {tokens!r}"
+    assert "'unsafe-eval'" not in tokens, f"pdf worker must not be allowed to eval JS: {tokens!r}"
+
+    # Only the worker: pdf.js's own chunk and the modal that loads it run on
+    # the page, whose policy stays wasm-free.
+    for path in ("/assets/pdf-Ck9aB12c.js", "/assets/PdfPreviewModal-Ck9aB12c.js"):
+        other = await async_client.get(path)
+        assert "'wasm-unsafe-eval'" not in _script_src_tokens(other), f"{path} must not get the worker's policy"
 
 
 
 
 # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
 # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───

+ 31 - 6
frontend/scripts/check-browser-baseline.mjs

@@ -28,11 +28,37 @@
  */
  */
 
 
 import { readdirSync, readFileSync } from 'node:fs';
 import { readdirSync, readFileSync } from 'node:fs';
-import { join, dirname, resolve } from 'node:path';
+import { join, dirname, relative, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { fileURLToPath } from 'node:url';
 
 
 const ASSETS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
 const ASSETS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
 
 
+/**
+ * Every extension the build can emit executable JavaScript under. `.mjs`
+ * because a dependency's file imported with `?url` is copied verbatim under
+ * its own extension and bypasses `build.target` - the pdf.js worker shipped a
+ * class static block that way (#2976).
+ */
+const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs'];
+
+/**
+ * Every script under `dir`, recursively, as paths relative to ASSETS. The
+ * subdirectories matter: pdf.js's decoder fallbacks are published verbatim
+ * under assets/pdfjs/ (vite.config.ts) and run in its worker (#2976).
+ */
+function collectScripts(dir) {
+  const found = [];
+  for (const entry of readdirSync(dir, { withFileTypes: true })) {
+    const full = join(dir, entry.name);
+    if (entry.isDirectory()) {
+      found.push(...collectScripts(full));
+    } else if (SCRIPT_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
+      found.push(relative(ASSETS, full));
+    }
+  }
+  return found;
+}
+
 /**
 /**
  * Each pattern must match only real occurrences of the feature. Anything that
  * Each pattern must match only real occurrences of the feature. Anything that
  * needs context to tell a false positive from a real hit (regex flags, for
  * needs context to tell a false positive from a real hit (regex flags, for
@@ -62,17 +88,16 @@ const FORBIDDEN = [
 
 
 let bundles;
 let bundles;
 try {
 try {
-  // .mjs too: a dependency's worker imported with `?url` is copied verbatim
-  // under its own extension, bypasses `build.target`, and was invisible here -
-  // the pdf.js worker shipped a class static block that way (#2976).
-  bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
+  bundles = collectScripts(ASSETS);
 } catch {
 } catch {
   console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
   console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
   process.exit(1);
   process.exit(1);
 }
 }
 
 
 if (bundles.length === 0) {
 if (bundles.length === 0) {
-  console.error(`check-browser-baseline: no .js or .mjs files in ${ASSETS} - did the build succeed?`);
+  console.error(
+    `check-browser-baseline: no ${SCRIPT_EXTENSIONS.join('/')} files in ${ASSETS} - did the build succeed?`,
+  );
   process.exit(1);
   process.exit(1);
 }
 }
 
 

+ 235 - 0
frontend/src/__tests__/components/ImagePreviewModal.test.tsx

@@ -0,0 +1,235 @@
+/**
+ * Tests for ImagePreviewModal (#2976).
+ *
+ * jsdom decodes nothing and lays nothing out, so the tests cover the modal's
+ * own logic — the authenticated fetch, the size guard, zoom input and the
+ * fullscreen wiring — rather than what the image looks like.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ImagePreviewModal, IMAGE_PREVIEW_MAX_BYTES } from '../../components/ImagePreviewModal';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
+  },
+  getAuthToken: () => 'token-123',
+}));
+
+const mockOnClose = vi.fn();
+
+function renderModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
+  return render(
+    <ImagePreviewModal
+      libraryFileId={11}
+      filename="plate.png"
+      fileSize={2048}
+      onClose={mockOnClose}
+      {...props}
+    />,
+  );
+}
+
+async function renderLoadedModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
+  const utils = renderModal(props);
+  const image = (await screen.findByAltText('plate.png')) as HTMLImageElement;
+  fireEvent.load(image);
+  return { ...utils, image, content: screen.getByTestId('image-preview-content') };
+}
+
+function scaleOf(image: HTMLImageElement): number {
+  const match = /scale\(([\d.]+)\)/.exec(image.style.transform);
+  return match ? Number(match[1]) : NaN;
+}
+
+describe('ImagePreviewModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 })),
+    );
+    vi.stubGlobal('URL', {
+      ...URL,
+      createObjectURL: vi.fn(() => 'blob:image-preview'),
+      revokeObjectURL: vi.fn(),
+    });
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('loads the file through the authenticated download URL', async () => {
+    await renderLoadedModal();
+
+    expect(fetch).toHaveBeenCalledWith('http://test/library/files/11/download', {
+      headers: { Authorization: 'Bearer token-123' },
+    });
+    expect(screen.getByAltText('plate.png')).toHaveAttribute('src', 'blob:image-preview');
+    expect(screen.getByText('plate.png')).toBeInTheDocument();
+  });
+
+  it('refuses a file too large to hold in memory', async () => {
+    renderModal({ fileSize: IMAGE_PREVIEW_MAX_BYTES + 1 });
+
+    expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
+    expect(fetch).not.toHaveBeenCalled();
+  });
+
+  it('shows a message when the download fails', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })));
+    vi.spyOn(console, 'error').mockImplementation(() => {});
+
+    renderModal();
+
+    expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
+  });
+
+  it('starts fitted to the window and zooms from the buttons', async () => {
+    const user = userEvent.setup();
+    const { image } = await renderLoadedModal();
+
+    expect(scaleOf(image)).toBe(1);
+
+    await user.click(screen.getByRole('button', { name: 'Zoom in' }));
+    expect(scaleOf(image)).toBeCloseTo(1.25, 5);
+
+    await user.click(screen.getByRole('button', { name: 'Zoom out' }));
+    expect(scaleOf(image)).toBeCloseTo(1, 5);
+  });
+
+  it('zooms with the wheel around the pointer', async () => {
+    const { image, content } = await renderLoadedModal();
+
+    fireEvent.wheel(content, { deltaY: -100, clientX: 40, clientY: 30 });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+    fireEvent.wheel(content, { deltaY: 100, clientX: 40, clientY: 30 });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1, 5));
+  });
+
+  it('zooms from the keyboard and resets with 0', async () => {
+    const { image } = await renderLoadedModal();
+
+    fireEvent.keyDown(window, { key: '+' });
+    fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.5625, 4));
+
+    fireEvent.keyDown(window, { key: '0' });
+    await waitFor(() => expect(scaleOf(image)).toBe(1));
+  });
+
+  it('stops at the zoom limits', async () => {
+    const { image } = await renderLoadedModal();
+
+    for (let i = 0; i < 20; i++) fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(scaleOf(image)).toBe(8));
+
+    for (let i = 0; i < 30; i++) fireEvent.keyDown(window, { key: '-' });
+    await waitFor(() => expect(scaleOf(image)).toBe(0.4));
+  });
+
+  it('offers grab-to-pan only once the image is bigger than the window', async () => {
+    const { content } = await renderLoadedModal();
+
+    expect(content.className).not.toContain('cursor-grab');
+
+    fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(content.className).toContain('cursor-grab'));
+  });
+
+  describe('a pointer that never reports its release', () => {
+    // jsdom has no PointerEvent, and testing-library then falls back to a plain
+    // Event without pointerId / pointerType / buttons.
+    class FakePointerEvent extends MouseEvent {
+      pointerId: number;
+      pointerType: string;
+      constructor(type: string, init: PointerEventInit = {}) {
+        super(type, init);
+        this.pointerId = init.pointerId ?? 0;
+        this.pointerType = init.pointerType ?? '';
+      }
+    }
+
+    /** jsdom lays nothing out, so the pan clamp needs real box sizes. */
+    function giveBoxes(content: HTMLElement, image: HTMLImageElement) {
+      Object.defineProperty(content, 'clientWidth', { configurable: true, value: 200 });
+      Object.defineProperty(content, 'clientHeight', { configurable: true, value: 200 });
+      Object.defineProperty(image, 'offsetWidth', { configurable: true, value: 400 });
+      Object.defineProperty(image, 'offsetHeight', { configurable: true, value: 400 });
+      content.setPointerCapture = vi.fn();
+    }
+
+    function translateOf(image: HTMLImageElement): string {
+      return /translate\(([^)]*)\)/.exec(image.style.transform)?.[1] ?? '';
+    }
+
+    beforeEach(() => {
+      Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
+    });
+
+    afterEach(() => {
+      delete (window as { PointerEvent?: unknown }).PointerEvent;
+    });
+
+    it('drops it when it leaves the image area, instead of panning on hover', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      // Press at zoom 1 — no pointer capture is taken — then release somewhere
+      // else, so only pointerleave arrives.
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.pointerLeave(content, { pointerId: 1, pointerType: 'mouse' });
+
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
+
+      expect(translateOf(image)).toBe('0px, 0px');
+    });
+
+    it('drops it on the first buttonless move, so a later touch is not read as a pinch', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      // The mouse moves back over the image with nothing held down.
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
+      expect(translateOf(image)).toBe('0px, 0px');
+
+      // One finger now drags alone: it pans, it does not pinch-zoom.
+      fireEvent.pointerDown(content, { pointerId: 2, pointerType: 'touch', clientX: 100, clientY: 100 });
+      fireEvent.pointerMove(content, { pointerId: 2, pointerType: 'touch', clientX: 130, clientY: 100 });
+
+      expect(scaleOf(image)).toBeCloseTo(1.25, 5);
+      expect(translateOf(image)).toBe('30px, 0px');
+    });
+
+    it('still pans while the button is held', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 120, clientY: 110 });
+
+      expect(translateOf(image)).toBe('20px, 10px');
+    });
+  });
+
+  it('closes on Escape', async () => {
+    await renderLoadedModal();
+
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 210 - 0
frontend/src/__tests__/components/ModelViewerControls.test.tsx

@@ -0,0 +1,210 @@
+/**
+ * Zoom and resize behaviour of the 3D model viewer (#2976).
+ *
+ * Only what jsdom cannot provide is replaced: the WebGL renderer, the PMREM
+ * environment it would bake, and OrbitControls. Scene, camera and the STL
+ * loader are real, so the tests exercise the component's own framing maths.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import * as THREE from 'three';
+
+const mocks = vi.hoisted(() => ({
+  setSize: vi.fn(),
+  wheelSeen: vi.fn(),
+  controlsInstances: [] as Array<{ object: THREE.Camera; domElement: HTMLElement; target: THREE.Vector3; minDistance: number; maxDistance: number }>,
+}));
+
+vi.mock('three', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('three')>();
+  class FakeWebGLRenderer {
+    domElement = document.createElement('canvas');
+    shadowMap = { enabled: false, type: 0 };
+    toneMapping = 0;
+    toneMappingExposure = 1;
+    setSize(width: number, height: number) {
+      mocks.setSize(width, height);
+      this.domElement.style.width = `${width}px`;
+      this.domElement.style.height = `${height}px`;
+    }
+    setPixelRatio() {}
+    render() {}
+    dispose() {}
+  }
+  class FakePMREMGenerator {
+    fromScene() {
+      return { texture: { dispose() {} } };
+    }
+    dispose() {}
+  }
+  return { ...actual, WebGLRenderer: FakeWebGLRenderer, PMREMGenerator: FakePMREMGenerator };
+});
+
+vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
+  OrbitControls: class {
+    object: THREE.Camera;
+    domElement: HTMLElement;
+    target = new THREE.Vector3();
+    enableDamping = false;
+    dampingFactor = 0;
+    minDistance = 0;
+    maxDistance = Infinity;
+    constructor(object: THREE.Camera, domElement: HTMLElement) {
+      this.object = object;
+      this.domElement = domElement;
+      // The real controls listen for wheel on the element they are handed;
+      // the fake does the same so the test can tell where events end up.
+      domElement.addEventListener('wheel', mocks.wheelSeen);
+      mocks.controlsInstances.push(this);
+    }
+    update() {}
+    dispose() {
+      this.domElement.removeEventListener('wheel', mocks.wheelSeen);
+    }
+  },
+}));
+
+vi.mock('../../api/client', () => ({
+  getAuthToken: () => null,
+}));
+
+import { ModelViewer } from '../../components/ModelViewer';
+
+// Binary STL of one triangle, enough for a bounding box.
+function tinyStl(): ArrayBuffer {
+  const buffer = new ArrayBuffer(84 + 50);
+  const view = new DataView(buffer);
+  view.setUint32(80, 1, true);
+  const floats = [0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 20, 10];
+  floats.forEach((value, i) => view.setFloat32(84 + i * 4, value, true));
+  return buffer;
+}
+
+function setClientSize(element: HTMLElement, width: number, height: number) {
+  Object.defineProperty(element, 'clientWidth', { configurable: true, value: width });
+  Object.defineProperty(element, 'clientHeight', { configurable: true, value: height });
+}
+
+describe('ModelViewer controls', () => {
+  beforeEach(() => {
+    mocks.setSize.mockClear();
+    mocks.wheelSeen.mockClear();
+    mocks.controlsInstances.length = 0;
+    // jsdom lays nothing out; give every element a panel-sized box so the
+    // camera gets a real aspect ratio to frame against.
+    Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 800 });
+    Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 600 });
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(tinyStl(), { status: 200 })),
+    );
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth;
+    delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight;
+  });
+
+  async function renderLoaded() {
+    const utils = render(<ModelViewer url="/api/v1/library/files/1/download" fileType="stl" />);
+    const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+    // Loaded once the zoom / reset buttons replace the spinner.
+    await waitFor(() => expect(document.querySelector('.lucide-zoom-in')).not.toBeNull());
+    return { ...utils, canvas, controls: mocks.controlsInstances[0] };
+  }
+
+  it('hands the canvas that is in the DOM to OrbitControls, so wheel events reach it', async () => {
+    const { canvas, controls } = await renderLoaded();
+
+    expect(controls.domElement).toBe(canvas);
+    expect(document.body.contains(canvas)).toBe(true);
+
+    fireEvent.wheel(canvas, { deltaY: -100 });
+    expect(mocks.wheelSeen).toHaveBeenCalledTimes(1);
+  });
+
+  it('bounds the dolly range and keeps the far plane beyond it', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+
+    const framed = camera.position.distanceTo(controls.target);
+    expect(controls.maxDistance).toBeGreaterThan(framed);
+    expect(Number.isFinite(controls.maxDistance)).toBe(true);
+    expect(controls.minDistance).toBeGreaterThan(0);
+    // Zooming all the way out must never push the model past the far plane.
+    expect(camera.far).toBeGreaterThan(controls.maxDistance);
+  });
+
+  it('zoom buttons dolly along the view axis toward the orbit target', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const before = camera.position.clone();
+    const distanceBefore = before.distanceTo(controls.target);
+    const directionBefore = before.clone().sub(controls.target).normalize();
+
+    fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
+
+    const distanceAfter = camera.position.distanceTo(controls.target);
+    expect(distanceAfter).toBeCloseTo(distanceBefore * 0.8, 5);
+    const directionAfter = camera.position.clone().sub(controls.target).normalize();
+    expect(directionAfter.distanceTo(directionBefore)).toBeLessThan(1e-6);
+
+    fireEvent.click(document.querySelector('.lucide-zoom-out')!.closest('button')!);
+    expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distanceBefore, 5);
+  });
+
+  it('resets to the framed view, not a fixed pose', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const framedPosition = camera.position.clone();
+    const framedTarget = controls.target.clone();
+
+    fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
+    fireEvent.click(document.querySelector('.lucide-rotate-ccw')!.closest('button')!);
+
+    expect(camera.position.distanceTo(framedPosition)).toBeLessThan(1e-6);
+    expect(controls.target.distanceTo(framedTarget)).toBeLessThan(1e-6);
+  });
+
+  it('resizes the canvas and camera when fullscreen changes', async () => {
+    const { canvas, controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const container = canvas.parentElement as HTMLElement;
+    mocks.setSize.mockClear();
+
+    setClientSize(container, 1920, 1080);
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+
+    expect(mocks.setSize).toHaveBeenCalledWith(1920, 1080);
+    expect(camera.aspect).toBeCloseTo(1920 / 1080, 5);
+    expect(canvas.style.width).toBe('1920px');
+  });
+
+  it('ignores a resize to zero (hidden container) instead of collapsing the camera', async () => {
+    const { canvas, controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const container = canvas.parentElement as HTMLElement;
+    const aspect = camera.aspect;
+    mocks.setSize.mockClear();
+
+    setClientSize(container, 0, 0);
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+
+    expect(mocks.setSize).not.toHaveBeenCalled();
+    expect(camera.aspect).toBe(aspect);
+  });
+});

+ 45 - 1
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -202,11 +202,55 @@ describe('ModelViewerModal', () => {
         // Look for the maximize icon button
         // Look for the maximize icon button
         const buttons = screen.getAllByRole('button');
         const buttons = screen.getAllByRole('button');
         const fullscreenButton = buttons.find(
         const fullscreenButton = buttons.find(
-          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'
+          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Fullscreen'
         );
         );
         expect(fullscreenButton).toBeInTheDocument();
         expect(fullscreenButton).toBeInTheDocument();
       });
       });
     });
     });
+
+    it('double-click on the viewer requests fullscreen for the panel (#2976)', async () => {
+      const requestFullscreen = vi.fn().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+      try {
+        render(
+          <ModelViewerModal
+            archiveId={1}
+            title="Test Model"
+            onClose={mockOnClose}
+          />
+        );
+        const viewer = await screen.findByTestId('model-viewer-area');
+
+        fireEvent.doubleClick(viewer);
+
+        expect(requestFullscreen).toHaveBeenCalledTimes(1);
+        const panel = screen.getByText('Test Model').closest('.flex-col');
+        expect(requestFullscreen.mock.instances[0]).toBe(panel);
+      } finally {
+        delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+        delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+      }
+    });
+
+    it('fills the viewport as a fallback where the Fullscreen API is missing', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+      const viewer = await screen.findByTestId('model-viewer-area');
+      const panel = screen.getByText('Test Model').closest('.flex-col') as HTMLElement;
+      // Windowed: the size every preview shares (#2976), not a per-modal max-w.
+      expect(panel.className).toContain('w-[min(1800px,96vw)]');
+
+      fireEvent.doubleClick(viewer);
+
+      expect(panel.className).toContain('max-w-none');
+      expect(screen.getByTitle('Exit fullscreen')).toBeInTheDocument();
+    });
   });
   });
 
 
   describe('object count', () => {
   describe('object count', () => {

+ 347 - 7
frontend/src/__tests__/components/PdfPreviewModal.test.tsx

@@ -3,25 +3,24 @@
  *
  *
  * pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
  * pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
  * mocked at the module boundary; the tests cover the modal's own logic —
  * mocked at the module boundary; the tests cover the modal's own logic —
- * loading, page navigation, and error/size fallbacks.
+ * loading, page navigation, error/size fallbacks, zoom input and fullscreen.
  */
  */
 
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { PdfPreviewModal } from '../../components/PdfPreviewModal';
 import { PdfPreviewModal } from '../../components/PdfPreviewModal';
 
 
 const pdfjsMocks = vi.hoisted(() => {
 const pdfjsMocks = vi.hoisted(() => {
   const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
   const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
-  const getPage = vi.fn(async () => ({
-    getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
-    render,
-  }));
+  const defaultViewport = ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale });
+  const getViewport = vi.fn(defaultViewport);
+  const getPage = vi.fn(async () => ({ getViewport, render }));
   const getDocument = vi.fn(() => ({
   const getDocument = vi.fn(() => ({
     promise: Promise.resolve({ numPages: 3, getPage }),
     promise: Promise.resolve({ numPages: 3, getPage }),
     destroy: vi.fn(),
     destroy: vi.fn(),
   }));
   }));
-  return { render, getPage, getDocument };
+  return { render, defaultViewport, getViewport, getPage, getDocument };
 });
 });
 
 
 vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
 vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
@@ -52,13 +51,49 @@ function renderModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {})
   );
   );
 }
 }
 
 
+// jsdom lays nothing out, so the fit scale bottoms out at the 0.1 floor and
+// the 600pt page is shown 60px wide at zoom 1.
+const CSS_WIDTH_AT_ZOOM_1 = 60;
+
+async function renderLoadedModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
+  const utils = renderModal(props);
+  await screen.findByText('Page 1 of 3');
+  await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+  const page = screen.getByTestId('pdf-preview-page');
+  const canvas = page.querySelector('canvas') as HTMLCanvasElement;
+  await waitFor(() => expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`));
+  return { ...utils, page, canvas };
+}
+
+function lastRenderScale(): number {
+  const calls = pdfjsMocks.getViewport.mock.calls;
+  return calls[calls.length - 1][0].scale;
+}
+
+// A wheel event with ctrlKey, as Ctrl+wheel and a trackpad pinch both arrive.
+function wheelWithCtrl(target: Element, deltaY: number) {
+  return fireEvent.wheel(target, { deltaY, ctrlKey: true, clientX: 30, clientY: 40 });
+}
+
 describe('PdfPreviewModal', () => {
 describe('PdfPreviewModal', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
+    // clearAllMocks keeps implementations, but a test that swaps the page
+    // geometry would otherwise leak it into every test after it.
+    pdfjsMocks.getViewport.mockImplementation(pdfjsMocks.defaultViewport);
     vi.stubGlobal(
     vi.stubGlobal(
       'fetch',
       'fetch',
       vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
       vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
     );
     );
+    // The setup-file stub is wiped by unstubAllGlobals below; keep one here.
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
   });
   });
 
 
   afterEach(() => {
   afterEach(() => {
@@ -92,6 +127,40 @@ describe('PdfPreviewModal', () => {
     expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
     expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
   });
   });
 
 
+  it('points pdf.js at the resources it fetches at runtime', async () => {
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+
+    // Unset, CJK text has no CMaps, non-embedded fonts no font data, and the
+    // JPEG2000/JBIG2/ICC decoders no wasm — all of which fail silently.
+    expect(pdfjsMocks.getDocument).toHaveBeenCalledWith(
+      expect.objectContaining({
+        cMapUrl: '/assets/pdfjs/cmaps/',
+        iccUrl: '/assets/pdfjs/iccs/',
+        standardFontDataUrl: '/assets/pdfjs/standard_fonts/',
+        wasmUrl: '/assets/pdfjs/wasm/',
+      }),
+    );
+  });
+
+  it('destroys a loading task that resolved after the modal closed', async () => {
+    // Closing during the fetch/import window used to leave cleanup holding a
+    // null task, and the pdf.js worker it later started ran on forever.
+    let deliver: (response: Response) => void = () => {};
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(() => new Promise<Response>((resolve) => (deliver = resolve))),
+    );
+    const destroy = vi.fn();
+    pdfjsMocks.getDocument.mockReturnValueOnce({ promise: new Promise(() => {}), destroy } as never);
+
+    const { unmount } = renderModal();
+    unmount();
+    deliver(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
+
+    await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1));
+  });
+
   it('refuses oversized files without fetching them', async () => {
   it('refuses oversized files without fetching them', async () => {
     const fetchSpy = vi.fn();
     const fetchSpy = vi.fn();
     vi.stubGlobal('fetch', fetchSpy);
     vi.stubGlobal('fetch', fetchSpy);
@@ -100,4 +169,275 @@ describe('PdfPreviewModal', () => {
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(fetchSpy).not.toHaveBeenCalled();
     expect(fetchSpy).not.toHaveBeenCalled();
   });
   });
+
+  describe('zoom', () => {
+    it('zooms on Ctrl+wheel and keeps the browser from zooming the page', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      const scaleBefore = lastRenderScale();
+
+      const notPrevented = wheelWithCtrl(page, -100);
+
+      expect(notPrevented).toBe(false);
+      // Immediate feedback: the raster is scaled by CSS by exactly one step.
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+      // Then the page is re-rasterised at the new scale once the wheel settles.
+      await waitFor(() => expect(lastRenderScale()).toBeCloseTo(scaleBefore * 1.25, 5));
+      expect(pdfjsMocks.render).toHaveBeenCalledTimes(2);
+    });
+
+    it('zooms out on Ctrl+wheel down', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      wheelWithCtrl(page, 100);
+
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
+    });
+
+    it('turns a pinch (many small ctrlKey deltas) into a smooth ramp', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      for (let i = 0; i < 10; i++) wheelWithCtrl(page, -3);
+
+      const width = parseFloat(canvas.style.width);
+      expect(width).toBeGreaterThan(CSS_WIDTH_AT_ZOOM_1);
+      // Ten pixels of pinch is one wheel notch (30px clamp → one step), not ten.
+      expect(width).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+    });
+
+    it('clamps to the zoom range', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      for (let i = 0; i < 40; i++) wheelWithCtrl(page, -100);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 4, 5);
+
+      for (let i = 0; i < 40; i++) wheelWithCtrl(page, 100);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 0.4, 5);
+    });
+
+    it('zooms on a plain wheel while the page fits the viewport', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      // jsdom reports every scroll metric as 0: nothing to scroll.
+      const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
+
+      expect(notPrevented).toBe(false);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+    });
+
+    it('lets a plain wheel scroll once the page overflows the viewport', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      Object.defineProperty(page, 'scrollHeight', { configurable: true, value: 2000 });
+      Object.defineProperty(page, 'clientHeight', { configurable: true, value: 500 });
+
+      const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
+
+      expect(notPrevented).toBe(true);
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('zooms with the keyboard: + and - step, 0 resets', async () => {
+      const { canvas } = await renderLoadedModal();
+
+      fireEvent.keyDown(window, { key: '+' });
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+
+      fireEvent.keyDown(window, { key: '-' });
+      fireEvent.keyDown(window, { key: '-' });
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
+
+      fireEvent.keyDown(window, { key: '0' });
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('leaves Ctrl+plus / Ctrl+0 to the browser', async () => {
+      const { canvas } = await renderLoadedModal();
+
+      fireEvent.keyDown(window, { key: '+', ctrlKey: true });
+      fireEvent.keyDown(window, { key: '0', metaKey: true });
+
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('pinch-zooms with two touch pointers', async () => {
+      // jsdom has no PointerEvent, and testing-library then falls back to a
+      // plain Event without pointerId / pointerType.
+      class FakePointerEvent extends MouseEvent {
+        pointerId: number;
+        pointerType: string;
+        constructor(type: string, init: PointerEventInit = {}) {
+          super(type, init);
+          this.pointerId = init.pointerId ?? 0;
+          this.pointerType = init.pointerType ?? '';
+        }
+      }
+      Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
+      const { page, canvas } = await renderLoadedModal();
+
+      fireEvent.pointerDown(page, { pointerId: 1, pointerType: 'touch', clientX: 100, clientY: 100 });
+      fireEvent.pointerDown(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
+      // First move sets the reference distance, the second one spreads it.
+      fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
+      fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 300, clientY: 100 });
+
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 2, 5);
+
+      fireEvent.pointerUp(page, { pointerId: 2, pointerType: 'touch' });
+      fireEvent.pointerUp(page, { pointerId: 1, pointerType: 'touch' });
+      delete (window as { PointerEvent?: unknown }).PointerEvent;
+    });
+
+    it('still offers the zoom buttons', async () => {
+      const user = userEvent.setup();
+      const { canvas } = await renderLoadedModal();
+
+      await user.click(screen.getByRole('button', { name: 'Zoom in' }));
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+
+      await user.click(screen.getByRole('button', { name: 'Zoom out' }));
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+  });
+
+  describe('fullscreen', () => {
+    const requestFullscreen = vi.fn();
+    const exitFullscreen = vi.fn();
+
+    beforeEach(() => {
+      requestFullscreen.mockReset().mockResolvedValue(undefined);
+      exitFullscreen.mockReset().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+      Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+    });
+
+    afterEach(() => {
+      delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+      delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+      delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+      delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+    });
+
+    // The browser flips fullscreenElement and fires fullscreenchange; the
+    // mocks do neither, so the test plays the browser's part.
+    function enterFullscreen(panel: Element) {
+      (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+      act(() => {
+        document.dispatchEvent(new Event('fullscreenchange'));
+      });
+    }
+
+    it('double-click on the page toggles fullscreen', async () => {
+      const { page } = await renderLoadedModal();
+      const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(page);
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+      expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+      enterFullscreen(panel);
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      fireEvent.doubleClick(page);
+      expect(exitFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('offers a fullscreen button in the header', async () => {
+      const user = userEvent.setup();
+      await renderLoadedModal();
+
+      await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('re-syncs when the browser leaves fullscreen on Esc', async () => {
+      const { page } = await renderLoadedModal();
+      const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(page);
+      enterFullscreen(panel);
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      // Esc while fullscreen is the browser's: the modal must not close.
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).not.toHaveBeenCalled();
+
+      enterFullscreen(null as unknown as Element);
+      expect(screen.getByRole('button', { name: 'Fullscreen' })).toBeInTheDocument();
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('grid thumbnail snapshot', () => {
+    // jsdom has no 2D backend: the snapshot helper draws into an offscreen
+    // canvas and hands the result to toBlob, so both have to be stood in for.
+    function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
+      const context = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() };
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+        context as unknown as CanvasRenderingContext2D,
+      );
+      vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
+      return context;
+    }
+
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it('hands the first page to onSnapshot as a PNG', async () => {
+      stubCanvas2d();
+      const onSnapshot = vi.fn();
+
+      await renderLoadedModal({ onSnapshot });
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+      const blob = onSnapshot.mock.calls[0][0] as Blob;
+      expect(blob.type).toBe('image/png');
+    });
+
+    it('sends it once, not on every re-render of page 1', async () => {
+      stubCanvas2d();
+      const user = userEvent.setup();
+      const onSnapshot = vi.fn();
+      await renderLoadedModal({ onSnapshot });
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+
+      await user.click(screen.getByRole('button', { name: 'Next page' }));
+      await screen.findByText('Page 2 of 3');
+      await user.click(screen.getByRole('button', { name: 'Previous page' }));
+      await screen.findByText('Page 1 of 3');
+
+      expect(onSnapshot).toHaveBeenCalledTimes(1);
+    });
+
+    it('stays quiet when the canvas yields no blob', async () => {
+      stubCanvas2d(null);
+      const onSnapshot = vi.fn();
+
+      await renderLoadedModal({ onSnapshot });
+      await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+
+      expect(onSnapshot).not.toHaveBeenCalled();
+    });
+  });
+
+  it('keeps the raster inside the canvas area iOS Safari will back', async () => {
+    // Past roughly 16.7M pixels iOS Safari hands back a blank canvas instead
+    // of failing, so resolution is what gives way, not the page.
+    vi.stubGlobal('devicePixelRatio', 2);
+    pdfjsMocks.getViewport.mockImplementation(({ scale }: { scale: number }) => ({
+      width: 30000 * scale,
+      height: 30000 * scale,
+    }));
+
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+    await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+
+    // Unclamped this would be 0.1 (fit floor) x 2 (dpr) = 6000 x 6000 px.
+    const scale = lastRenderScale();
+    expect(scale).toBeLessThan(0.2);
+    expect(30000 * scale * (30000 * scale)).toBeLessThanOrEqual(16 * 1024 * 1024);
+  });
 });
 });

+ 140 - 0
frontend/src/__tests__/components/PreviewModalShell.test.tsx

@@ -0,0 +1,140 @@
+/**
+ * Tests for PreviewModalShell (#2976) — the backdrop, panel size and header
+ * every file preview shares.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PreviewModalShell } from '../../components/PreviewModalShell';
+import { usePreviewFullscreen } from '../../hooks/usePreviewFullscreen';
+
+const mockOnClose = vi.fn();
+
+function Harness({ closeOnBackdropClick }: { closeOnBackdropClick?: boolean }) {
+  const fullscreen = usePreviewFullscreen();
+  return (
+    <PreviewModalShell
+      title="drawing.pdf"
+      fullscreen={fullscreen}
+      onClose={mockOnClose}
+      closeOnBackdropClick={closeOnBackdropClick}
+      actions={<button type="button">Zoom in</button>}
+    >
+      <div data-testid="preview-body" onDoubleClick={fullscreen.toggleFullscreen}>
+        body
+      </div>
+    </PreviewModalShell>
+  );
+}
+
+function panelOf(): HTMLElement {
+  return screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+}
+
+describe('PreviewModalShell', () => {
+  const requestFullscreen = vi.fn();
+  const exitFullscreen = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    requestFullscreen.mockResolvedValue(undefined);
+    exitFullscreen.mockResolvedValue(undefined);
+    Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+    Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+    Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+    Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+  });
+
+  afterEach(() => {
+    delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+    delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+    delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+    delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+  });
+
+  // The browser flips fullscreenElement and fires fullscreenchange; the mocks
+  // do neither, so the test plays the browser's part.
+  function browserEntersFullscreen(panel: Element | null) {
+    (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+  }
+
+  it('gives the panel the size every preview shares', () => {
+    render(<Harness />);
+    const panel = panelOf();
+
+    expect(panel.className).toContain('w-[min(1800px,96vw)]');
+    expect(panel.className).toContain('h-[94vh]');
+    expect(panel.className).toContain('rounded-lg');
+  });
+
+  it('renders the title, the preview actions and the close button', async () => {
+    const user = userEvent.setup();
+    render(<Harness />);
+
+    expect(screen.getByText('drawing.pdf')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('goes fullscreen on a double-click in the body and fills the screen', () => {
+    render(<Harness />);
+    const panel = panelOf();
+
+    fireEvent.doubleClick(screen.getByTestId('preview-body'));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+    browserEntersFullscreen(panel);
+    expect(panel.className).toContain('max-w-none');
+    expect(panel.className).not.toContain('w-[min(1800px,96vw)]');
+    expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+  });
+
+  it('toggles fullscreen from the header button', async () => {
+    const user = userEvent.setup();
+    render(<Harness />);
+
+    await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+
+    browserEntersFullscreen(panelOf());
+    await user.click(screen.getByRole('button', { name: 'Exit fullscreen' }));
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes on Escape, but leaves it to the browser while fullscreen', () => {
+    render(<Harness />);
+
+    browserEntersFullscreen(panelOf());
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).not.toHaveBeenCalled();
+
+    browserEntersFullscreen(null);
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes on a backdrop click only where the preview asks for it', async () => {
+    const user = userEvent.setup();
+    const { unmount } = render(<Harness />);
+    const backdrop = panelOf().parentElement as HTMLElement;
+
+    await user.click(backdrop);
+    expect(mockOnClose).not.toHaveBeenCalled();
+    unmount();
+
+    render(<Harness closeOnBackdropClick />);
+    await user.click(panelOf().parentElement as HTMLElement);
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+
+    // A click inside the panel is not a backdrop click.
+    await user.click(screen.getByTestId('preview-body'));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 125 - 1
frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx

@@ -7,7 +7,7 @@
  */
  */
 
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import * as XLSX from 'xlsx';
 import * as XLSX from 'xlsx';
 import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal';
 import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal';
@@ -111,4 +111,128 @@ describe('SpreadsheetPreviewModal', () => {
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(fetchSpy).not.toHaveBeenCalled();
     expect(fetchSpy).not.toHaveBeenCalled();
   });
   });
+
+  describe('fullscreen', () => {
+    const requestFullscreen = vi.fn();
+    const exitFullscreen = vi.fn();
+
+    beforeEach(() => {
+      requestFullscreen.mockReset().mockResolvedValue(undefined);
+      exitFullscreen.mockReset().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+      Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+    });
+
+    afterEach(() => {
+      delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+      delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+      delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+      delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+    });
+
+    it('double-click on the sheet toggles fullscreen', async () => {
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      renderModal();
+      await screen.findByText('M3 screw');
+      const content = screen.getByTestId('spreadsheet-preview-content');
+      const panel = screen.getByText('parts.csv').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(content);
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+      expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+      // The mocks do not flip fullscreenElement or fire fullscreenchange;
+      // play the browser's part.
+      (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+      act(() => {
+        document.dispatchEvent(new Event('fullscreenchange'));
+      });
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      fireEvent.doubleClick(content);
+      expect(exitFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('offers a fullscreen button in the header', async () => {
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      const user = userEvent.setup();
+      renderModal();
+      await screen.findByText('M3 screw');
+
+      await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('grid thumbnail snapshot', () => {
+    // The snapshot is drawn into an offscreen canvas, which jsdom cannot
+    // back: both the 2D context and toBlob are stood in for.
+    function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
+      const context = {
+        fillStyle: '',
+        strokeStyle: '',
+        lineWidth: 0,
+        font: '',
+        textBaseline: '',
+        fillRect: vi.fn(),
+        beginPath: vi.fn(),
+        moveTo: vi.fn(),
+        lineTo: vi.fn(),
+        stroke: vi.fn(),
+        fillText: vi.fn(),
+      };
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+        context as unknown as CanvasRenderingContext2D,
+      );
+      vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
+      return context;
+    }
+
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it('hands the first sheet to onSnapshot as a PNG', async () => {
+      const context = stubCanvas2d();
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot });
+      await screen.findByText('M3 screw');
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+      expect((onSnapshot.mock.calls[0][0] as Blob).type).toBe('image/png');
+      // The mini table is what makes it recognisable in the grid.
+      expect(context.fillText).toHaveBeenCalledWith('M3 screw', expect.any(Number), expect.any(Number), expect.any(Number));
+    });
+
+    it('draws the first sheet that has rows, not an empty leading one', async () => {
+      stubCanvas2d();
+      const workbook = XLSX.utils.book_new();
+      XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Cover');
+      XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Part', 'Qty'], ['Hinge', '2']]), 'Bom');
+      stubFetchWith(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer);
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot, filename: 'bom.xlsx', fileType: 'xlsx' });
+      // The empty Cover sheet opens first, so the tab strip is what says the
+      // workbook has parsed.
+      await screen.findByRole('button', { name: 'Bom' });
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+    });
+
+    it('stays quiet for a sheet with no rows at all', async () => {
+      stubCanvas2d();
+      stubFetchWith('');
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot });
+      await screen.findByText('This sheet is empty');
+
+      expect(onSnapshot).not.toHaveBeenCalled();
+    });
+  });
 });
 });

+ 127 - 0
frontend/src/__tests__/hooks/useElementFullscreen.test.tsx

@@ -0,0 +1,127 @@
+/**
+ * useElementFullscreen (#2976): Fullscreen API where the browser grants it,
+ * the viewport-filling fallback everywhere else.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useRef } from 'react';
+import { useElementFullscreen } from '../../hooks/useElementFullscreen';
+
+function Panel() {
+  const ref = useRef<HTMLDivElement>(null);
+  const { isFullscreen, toggleFullscreen, apiAvailable } = useElementFullscreen(ref);
+  return (
+    <div ref={ref} data-testid="panel" data-fullscreen={String(isFullscreen)} data-api={String(apiAvailable)}>
+      <button onClick={toggleFullscreen}>toggle</button>
+    </div>
+  );
+}
+
+const requestFullscreen = vi.fn();
+const exitFullscreen = vi.fn();
+
+function installApi() {
+  Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+  Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+  Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+  Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+}
+
+function removeApi() {
+  delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+  delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+  delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+  delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+}
+
+// The mocks neither flip fullscreenElement nor fire fullscreenchange, so the
+// tests play the browser's part.
+function browserSetsFullscreen(element: Element | null) {
+  (document as { fullscreenElement: Element | null }).fullscreenElement = element;
+  act(() => {
+    document.dispatchEvent(new Event('fullscreenchange'));
+  });
+}
+
+describe('useElementFullscreen', () => {
+  beforeEach(() => {
+    requestFullscreen.mockReset().mockResolvedValue(undefined);
+    exitFullscreen.mockReset().mockResolvedValue(undefined);
+  });
+
+  afterEach(() => {
+    removeApi();
+  });
+
+  it('requests fullscreen on the element and follows fullscreenchange', async () => {
+    installApi();
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+    expect(panel.dataset.api).toBe('true');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    expect(requestFullscreen.mock.instances[0]).toBe(panel);
+    // Nothing changes until the browser confirms.
+    expect(panel.dataset.fullscreen).toBe('false');
+
+    browserSetsFullscreen(panel);
+    expect(panel.dataset.fullscreen).toBe('true');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+
+    browserSetsFullscreen(null);
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('ignores another element going fullscreen', () => {
+    installApi();
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    browserSetsFullscreen(document.createElement('video'));
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('falls back to the viewport-filling layout when the request is refused', async () => {
+    installApi();
+    requestFullscreen.mockRejectedValue(new TypeError('Permissions check failed'));
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    await act(async () => {
+      fireEvent.click(screen.getByText('toggle'));
+    });
+    expect(panel.dataset.fullscreen).toBe('true');
+
+    // Leaving the fallback never calls the API.
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('false');
+    expect(exitFullscreen).not.toHaveBeenCalled();
+  });
+
+  it('falls back where the Fullscreen API is missing', () => {
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+    expect(panel.dataset.api).toBe('false');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('true');
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('leaves fullscreen when the panel unmounts while fullscreen', () => {
+    installApi();
+    const { unmount } = render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    fireEvent.click(screen.getByText('toggle'));
+    browserSetsFullscreen(panel);
+
+    unmount();
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+  });
+});

+ 80 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -600,6 +600,86 @@ describe('FileManagerPage', () => {
         expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
         expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
       });
       });
     });
     });
+
+    // Since #2976 the server renders PDF thumbnails too, so the per-file
+    // action is offered for PDFs and stays hidden for types it cannot render.
+    describe('per-file action for PDFs', () => {
+      const pdfFile = {
+        id: 40,
+        filename: 'drawing.pdf',
+        file_path: '/library/drawing.pdf',
+        file_size: 4096,
+        file_type: 'pdf',
+        folder_id: null,
+        thumbnail_path: null,
+        print_name: null,
+        print_time_seconds: null,
+        print_count: 0,
+        duplicate_count: 0,
+        created_at: '2024-01-04T00:00:00Z',
+      };
+      const stepFile = { ...pdfFile, id: 41, filename: 'part.step', file_path: '/library/part.step', file_type: 'step' };
+
+      beforeEach(() => {
+        server.use(
+          http.get('/api/v1/library/files', () => HttpResponse.json([...mockFiles, pdfFile, stepFile])),
+        );
+      });
+
+      const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
+        const card = screen.getByText(filename).closest('.group') as HTMLElement;
+        const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+        await user.click(kebab);
+        return card;
+      };
+
+      it('offers Generate Thumbnail in the card menu of a PDF', async () => {
+        const user = userEvent.setup();
+        server.use(
+          http.post('/api/v1/library/generate-stl-thumbnails', async ({ request }) => {
+            const body = (await request.json()) as { file_ids?: number[] };
+            return HttpResponse.json({
+              processed: 1,
+              succeeded: 1,
+              failed: 0,
+              results: [{ file_id: body.file_ids?.[0], success: true }],
+            });
+          })
+        );
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
+
+        const card = await openMenu(user, 'drawing.pdf');
+        await user.click(within(card).getByText('Generate Thumbnail'));
+
+        expect(await screen.findByText('Thumbnail generated')).toBeInTheDocument();
+      });
+
+      it('does not offer it for STEP, which only the browser can render', async () => {
+        const user = userEvent.setup();
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('part.step')).toBeInTheDocument());
+
+        const card = await openMenu(user, 'part.step');
+        expect(within(card).queryByText('Generate Thumbnail')).not.toBeInTheDocument();
+      });
+
+      it('offers the action in the list view strip of a PDF', async () => {
+        const user = userEvent.setup();
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
+
+        await user.click(screen.getByRole('button', { name: /list/i }));
+
+        // List rows are CSS grids; the row is the nearest grid ancestor.
+        await waitFor(() => {
+          const row = screen.getByText('drawing.pdf').closest('.grid') as HTMLElement;
+          expect(within(row).getByTitle('Generate Thumbnail')).toBeInTheDocument();
+        });
+        const stepRow = screen.getByText('part.step').closest('.grid') as HTMLElement;
+        expect(within(stepRow).queryByTitle('Generate Thumbnail')).not.toBeInTheDocument();
+      });
+    });
   });
   });
 
 
   describe('upload modal (FileUploadModal)', () => {
   describe('upload modal (FileUploadModal)', () => {

+ 335 - 0
frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx

@@ -0,0 +1,335 @@
+/**
+ * How a preview is opened from the File Manager (#2976): double-click in the
+ * grid and the list, the toolbar's Preview button, and the per-file menu.
+ *
+ * The preview modals themselves are stubbed — pdf.js, SheetJS and three.js
+ * have their own tests and none of them render in jsdom.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { server } from '../mocks/server';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('react-router-dom')>()),
+  useNavigate: () => mockNavigate,
+}));
+
+vi.mock('../../components/ModelViewerModal', () => ({
+  ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
+}));
+vi.mock('../../components/PdfPreviewModal', () => ({
+  PdfPreviewModal: ({ filename }: { filename: string }) => <div data-testid="pdf-preview-modal">{filename}</div>,
+}));
+vi.mock('../../components/SpreadsheetPreviewModal', () => ({
+  SpreadsheetPreviewModal: ({ filename }: { filename: string }) => (
+    <div data-testid="sheet-preview-modal">{filename}</div>
+  ),
+}));
+vi.mock('../../components/ImagePreviewModal', () => ({
+  ImagePreviewModal: ({ filename }: { filename: string }) => <div data-testid="image-preview-modal">{filename}</div>,
+}));
+vi.mock('../../components/LibraryFileDetailsModal', () => ({
+  LibraryFileDetailsModal: () => <div data-testid="details-modal" />,
+}));
+
+function libraryFile(overrides: Record<string, unknown>) {
+  return {
+    file_path: '/library/file',
+    file_size: 4096,
+    folder_id: null,
+    thumbnail_path: null,
+    print_name: null,
+    print_time_seconds: null,
+    print_count: 0,
+    duplicate_count: 0,
+    created_at: '2026-01-01T00:00:00Z',
+    ...overrides,
+  };
+}
+
+const mockFiles = [
+  libraryFile({ id: 1, filename: 'benchy.gcode.3mf', file_type: 'gcode.3mf' }),
+  libraryFile({ id: 2, filename: 'bracket.stl', file_type: 'stl' }),
+  libraryFile({ id: 3, filename: 'drawing.pdf', file_type: 'pdf' }),
+  libraryFile({ id: 4, filename: 'parts.csv', file_type: 'csv' }),
+  libraryFile({
+    id: 5,
+    filename: 'photo.png',
+    file_type: 'png',
+    tags: [{ id: 21, name: 'reference', color: '#00ae42' }],
+    has_notes: true,
+  }),
+  libraryFile({ id: 6, filename: 'notes.md', file_type: 'md' }),
+  libraryFile({ id: 7, filename: 'scan.tif', file_type: 'tif' }),
+];
+
+function card(name: string): HTMLElement {
+  return screen.getByText(name).closest('div.group') as HTMLElement;
+}
+
+function row(name: string): HTMLElement {
+  return screen.getByText(name).closest('div[class*="grid-cols-"]') as HTMLElement;
+}
+
+/**
+ * The preview modals load lazily, so one opened by mistake renders a moment
+ * after the event that opened it: a few hundred milliseconds for the first
+ * one in a run. Absence only counts once it has had findBy's full second to
+ * appear; checking straight away passed whenever no earlier test had loaded
+ * that modal yet.
+ */
+async function expectNoPreviewOpened() {
+  await expect(screen.findByTestId(/^(model-viewer|pdf-preview|sheet-preview|image-preview)-modal$/)).rejects.toThrow();
+}
+
+describe('FileManagerPage preview opening', () => {
+  beforeEach(() => {
+    mockNavigate.mockClear();
+    // localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
+    // the view mode is programmed rather than written.
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json([])),
+      http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles)),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: mockFiles.length,
+          total_folders: 0,
+          total_size_bytes: 1024,
+          disk_free_bytes: 1024 * 1024,
+          disk_total_bytes: 2048 * 1024,
+        }),
+      ),
+      http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    );
+  });
+
+  describe('double-click in the grid', () => {
+    it('opens the image preview for a PNG and leaves the selection alone', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.dblClick(card('photo.png'));
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+      // The two clicks of a double-click toggle the selection twice.
+      expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
+    });
+
+    it('opens the 3D viewer for an STL', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('bracket.stl');
+
+      await user.dblClick(card('bracket.stl'));
+
+      expect(await screen.findByTestId('model-viewer-modal')).toHaveTextContent('bracket.stl');
+    });
+
+    it('sends a sliced file to the gcode viewer route', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('benchy.gcode.3mf');
+
+      await user.dblClick(card('benchy.gcode.3mf'));
+
+      expect(mockNavigate).toHaveBeenCalledWith('/gcode-viewer?library_file=1');
+    });
+
+    it('does nothing for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('notes.md');
+
+      await user.dblClick(card('notes.md'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+
+    // The card's own controls are not "the row": stopping their click is not
+    // enough, because dblclick is a separate native event (#2976).
+    it('ignores a double-click on the card menu button', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      const imageCard = card('photo.png');
+      const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.dblClick(kebab);
+
+      await expectNoPreviewOpened();
+    });
+
+    // The chip's own click toggles the tag filter and re-renders the list, so
+    // the dblclick is fired directly: what is under test is whether it bubbles
+    // to the card, not what the two clicks before it did.
+    it('ignores a double-click on a tag chip', async () => {
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      fireEvent.doubleClick(within(card('photo.png')).getByTitle('reference'));
+
+      await expectNoPreviewOpened();
+    });
+
+    // The notes and photos indicators open the file's details; a double-click
+    // on one must not open the preview on top of them.
+    it('ignores a double-click on the notes indicator', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.dblClick(within(card('photo.png')).getByTitle('Has notes'));
+
+      expect(await screen.findByTestId('details-modal')).toBeInTheDocument();
+      await expectNoPreviewOpened();
+    });
+  });
+
+  describe('the list view', () => {
+    beforeEach(() => {
+      (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+        key === 'library-view-mode' ? 'list' : null,
+      );
+    });
+
+    it('opens the document preview for a PDF', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('drawing.pdf');
+
+      await user.dblClick(row('drawing.pdf'));
+
+      expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
+    });
+
+    it('opens the spreadsheet preview for a CSV', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('parts.csv');
+
+      await user.dblClick(row('parts.csv'));
+
+      expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
+    });
+
+    it('offers an image file the same action-strip preview button as a document', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(within(row('photo.png')).getByTitle('Preview'));
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+    });
+
+    it('ignores a double-click on the row action strip', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      // An impatient double-tap on Rename must not also open the preview.
+      await user.dblClick(within(row('photo.png')).getByTitle('Rename'));
+
+      await expectNoPreviewOpened();
+    });
+
+    it('ignores a double-click on the row tag cell', async () => {
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      fireEvent.doubleClick(within(row('photo.png')).getByTitle('reference'));
+
+      await expectNoPreviewOpened();
+    });
+  });
+
+  describe('the toolbar Preview button', () => {
+    it('appears for a single previewable selection and opens the preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(card('photo.png'));
+      const preview = await screen.findByRole('button', { name: 'Preview' });
+      await user.click(preview);
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+    });
+
+    it('stays away for a multi-file selection', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(card('photo.png'));
+      expect(await screen.findByRole('button', { name: 'Preview' })).toBeInTheDocument();
+
+      await user.click(card('drawing.pdf'));
+      await waitFor(() => expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument());
+    });
+
+    it('stays away for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('notes.md');
+
+      await user.click(card('notes.md'));
+
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
+    });
+  });
+
+  // The server thumbnails TIFF (PIL), but an <img> only decodes it on Safari,
+  // so the preview is not offered rather than downloading 50 MB to fail (#2976).
+  describe('a TIFF file', () => {
+    it('gets no Preview entry in the card menu and no toolbar button', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('scan.tif');
+
+      await user.click(card('scan.tif'));
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
+
+      const kebab = card('scan.tif').querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
+      expect(within(card('scan.tif')).queryByText('Preview')).not.toBeInTheDocument();
+    });
+
+    it('does nothing on double-click', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('scan.tif');
+
+      await user.dblClick(card('scan.tif'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+  });
+
+  it('offers an image file a Preview entry in the card menu', async () => {
+    const user = userEvent.setup();
+    render(<FileManagerPage />);
+    await screen.findByText('photo.png');
+
+    const imageCard = card('photo.png');
+    const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+    await user.click(kebab);
+    await user.click(within(imageCard).getByText('Preview'));
+
+    expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+  });
+});

+ 306 - 0
frontend/src/components/ImagePreviewModal.tsx

@@ -0,0 +1,306 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Image as ImageIcon, Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
+import { api, getAuthToken } from '../api/client';
+import { formatFileSize } from '../utils/file';
+import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
+
+// The whole file is fetched into memory before it is shown, so a multi-hundred
+// megabyte scan gets the same notice the other previews give instead of a tab
+// that stops responding (#2976).
+export const IMAGE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
+
+const MIN_ZOOM = 0.4;
+// Higher than the PDF's ceiling: a photo or a scan is worth inspecting at the
+// pixel, and unlike a PDF page there is no sharper raster to fall back on.
+const MAX_ZOOM = 8;
+// One button press, one key press, one mouse-wheel notch.
+const ZOOM_STEP = 1.25;
+// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
+// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
+// and the pinch a smooth ramp.
+const WHEEL_DELTA_CLAMP = 30;
+const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
+
+const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
+interface Point {
+  x: number;
+  y: number;
+}
+
+const ORIGIN: Point = { x: 0, y: 0 };
+
+interface ImagePreviewModalProps {
+  libraryFileId: number;
+  filename: string;
+  fileSize: number;
+  onClose: () => void;
+}
+
+// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
+function wheelDeltaPixels(e: WheelEvent): number {
+  if (e.deltaMode === 1) return e.deltaY * 16;
+  if (e.deltaMode === 2) return e.deltaY * 100;
+  return e.deltaY;
+}
+
+export function ImagePreviewModal({ libraryFileId, filename, fileSize, onClose }: ImagePreviewModalProps) {
+  const { t } = useTranslation();
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
+  const containerRef = useRef<HTMLDivElement>(null);
+  const imageRef = useRef<HTMLImageElement>(null);
+
+  const [src, setSrc] = useState<string | null>(null);
+  const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  // The image is laid out fitted to the panel; zoom and pan are a transform on
+  // top of that, so zoom 1 is always "fits the window".
+  const [zoom, setZoom] = useState(1);
+  const [offset, setOffset] = useState<Point>(ORIGIN);
+  const [dragging, setDragging] = useState(false);
+
+  const zoomRef = useRef(1);
+  const offsetRef = useRef<Point>(ORIGIN);
+  const pointersRef = useRef<Map<number, Point>>(new Map());
+  const pinchDistanceRef = useRef<number | null>(null);
+
+  // Panning past the edges would strand the image off-screen, so the offset is
+  // bounded by however much of it the zoom pushes outside the viewport.
+  const clampOffset = useCallback((next: Point, atZoom: number): Point => {
+    const container = containerRef.current;
+    const image = imageRef.current;
+    if (!container || !image) return next;
+    const maxX = Math.max(0, (image.offsetWidth * atZoom - container.clientWidth) / 2);
+    const maxY = Math.max(0, (image.offsetHeight * atZoom - container.clientHeight) / 2);
+    return {
+      x: Math.min(maxX, Math.max(-maxX, next.x)),
+      y: Math.min(maxY, Math.max(-maxY, next.y)),
+    };
+  }, []);
+
+  const setView = useCallback((nextZoom: number, nextOffset: Point) => {
+    zoomRef.current = nextZoom;
+    offsetRef.current = nextOffset;
+    setZoom(nextZoom);
+    setOffset(nextOffset);
+  }, []);
+
+  /** Zoom by `factor`, keeping the point under the pointer where it is. */
+  const zoomAt = useCallback(
+    (factor: number, clientX?: number, clientY?: number) => {
+      const current = zoomRef.current;
+      const next = clampZoom(current * factor);
+      if (next === current) return;
+      const container = containerRef.current;
+      const previous = offsetRef.current;
+      let nextOffset = { x: previous.x * (next / current), y: previous.y * (next / current) };
+      if (container) {
+        const rect = container.getBoundingClientRect();
+        // The transform grows the image around the container's centre, so an
+        // anchored point is expressed relative to that centre.
+        const ax = (clientX == null ? rect.left + rect.width / 2 : clientX) - (rect.left + rect.width / 2);
+        const ay = (clientY == null ? rect.top + rect.height / 2 : clientY) - (rect.top + rect.height / 2);
+        const k = next / current;
+        nextOffset = { x: ax - (ax - previous.x) * k, y: ay - (ay - previous.y) * k };
+      }
+      setView(next, clampOffset(nextOffset, next));
+    },
+    [clampOffset, setView]
+  );
+
+  const resetView = useCallback(() => setView(1, ORIGIN), [setView]);
+
+  useEffect(() => {
+    let cancelled = false;
+    let objectUrl: string | null = null;
+    setSrc(null);
+    setLoaded(false);
+    setError(null);
+    setView(1, ORIGIN);
+
+    if (fileSize > IMAGE_PREVIEW_MAX_BYTES) {
+      setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
+      return;
+    }
+
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) headers['Authorization'] = `Bearer ${token}`;
+
+    (async () => {
+      // Fetched rather than pointed at with <img src>: the download endpoint
+      // wants the bearer token, which an <img> cannot send.
+      const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const blob = await res.blob();
+      if (cancelled) return;
+      objectUrl = URL.createObjectURL(blob);
+      setSrc(objectUrl);
+    })().catch((err: unknown) => {
+      console.error('[image-preview] load failed', err);
+      if (!cancelled) setError(t('fileManager.preview.error'));
+    });
+
+    return () => {
+      cancelled = true;
+      if (objectUrl) URL.revokeObjectURL(objectUrl);
+    };
+  }, [libraryFileId, fileSize, t, setView]);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
+      if (e.ctrlKey || e.metaKey || e.altKey) return;
+      if (e.key === '+' || e.key === '=') {
+        e.preventDefault();
+        zoomAt(ZOOM_STEP);
+      } else if (e.key === '-') {
+        e.preventDefault();
+        zoomAt(1 / ZOOM_STEP);
+      } else if (e.key === '0') {
+        e.preventDefault();
+        resetView();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [resetView, zoomAt]);
+
+  // Native listener: React registers wheel as passive, so preventDefault —
+  // which keeps Ctrl+wheel from zooming the whole page — would be ignored.
+  // Nothing scrolls here, so a plain wheel zooms as well.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+    const handleWheel = (e: WheelEvent) => {
+      e.preventDefault();
+      const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
+      zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
+    };
+    container.addEventListener('wheel', handleWheel, { passive: false });
+    return () => container.removeEventListener('wheel', handleWheel);
+  }, [zoomAt, src]);
+
+  // One pointer drags the image once it is bigger than the window, two fingers
+  // pinch-zoom around their midpoint. `touch-action: none` on the container
+  // hands both gestures here instead of to the browser's scroll / page zoom.
+  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
+    pinchDistanceRef.current = null;
+    if (pointersRef.current.size === 1 && zoomRef.current > 1) {
+      e.currentTarget.setPointerCapture(e.pointerId);
+      setDragging(true);
+    }
+  };
+
+  const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
+    pointersRef.current.delete(e.pointerId);
+    pinchDistanceRef.current = null;
+    if (pointersRef.current.size === 0) setDragging(false);
+  };
+
+  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
+    const pointers = pointersRef.current;
+    const previous = pointers.get(e.pointerId);
+    if (!previous) return;
+    // A button released outside this container never reaches onPointerUp (no
+    // capture is taken below zoom 1), and the stale entry would then pan the
+    // image under a bare cursor — or count as a second finger on a hybrid
+    // device, turning the next one-finger drag into a pinch (#2976).
+    if (e.pointerType === 'mouse' && e.buttons === 0) {
+      handlePointerEnd(e);
+      return;
+    }
+    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+    if (pointers.size === 1) {
+      if (zoomRef.current <= 1) return;
+      const moved = { x: offsetRef.current.x + (e.clientX - previous.x), y: offsetRef.current.y + (e.clientY - previous.y) };
+      setView(zoomRef.current, clampOffset(moved, zoomRef.current));
+      return;
+    }
+    if (pointers.size !== 2) return;
+    const [a, b] = Array.from(pointers.values());
+    const distance = Math.hypot(a.x - b.x, a.y - b.y);
+    const last = pinchDistanceRef.current;
+    pinchDistanceRef.current = distance;
+    if (last != null && last > 0 && distance > 0) {
+      zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
+    }
+  };
+
+  const canPan = zoom > 1;
+
+  return (
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<ImageIcon className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+      actions={
+        src ? (
+          <>
+            <button
+              onClick={() => zoomAt(1 / ZOOM_STEP)}
+              className={previewIconButtonClass}
+              aria-label={t('fileManager.preview.zoomOut')}
+            >
+              <ZoomOut className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => zoomAt(ZOOM_STEP)}
+              className={previewIconButtonClass}
+              aria-label={t('fileManager.preview.zoomIn')}
+            >
+              <ZoomIn className="w-4 h-4" />
+            </button>
+            <button onClick={resetView} className={previewIconButtonClass} aria-label={t('fileManager.preview.resetZoom')}>
+              <RotateCcw className="w-4 h-4" />
+            </button>
+          </>
+        ) : undefined
+      }
+    >
+      <div
+        ref={containerRef}
+        data-testid="image-preview-content"
+        className={`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${
+          isFullscreen ? '' : 'rounded-b-lg'
+        } ${canPan ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
+        onDoubleClick={toggleFullscreen}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={handlePointerEnd}
+        onPointerCancel={handlePointerEnd}
+        onPointerLeave={handlePointerEnd}
+      >
+        {error ? (
+          <p className="text-bambu-gray text-center p-6">{error}</p>
+        ) : (
+          <>
+            {src && (
+              <img
+                ref={imageRef}
+                src={src}
+                alt={filename}
+                draggable={false}
+                onLoad={() => setLoaded(true)}
+                onError={() => setError(t('fileManager.preview.error'))}
+                style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})` }}
+                className="max-w-full max-h-full object-contain select-none"
+              />
+            )}
+            {!loaded && (
+              <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+                <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+              </div>
+            )}
+          </>
+        )}
+      </div>
+    </PreviewModalShell>
+  );
+}

+ 49 - 11
frontend/src/components/ModelViewer.tsx

@@ -64,6 +64,9 @@ function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
   });
   });
 }
 }
 
 
+// How far the user may dolly out, as a multiple of the framed distance.
+const MAX_ZOOM_OUT = 8;
+
 /**
 /**
  * Frame the camera on a bounding box.
  * Frame the camera on a bounding box.
  *
  *
@@ -78,6 +81,7 @@ function fitCameraToBox(
   camera: THREE.PerspectiveCamera,
   camera: THREE.PerspectiveCamera,
   controls: OrbitControls,
   controls: OrbitControls,
   box: THREE.Box3,
   box: THREE.Box3,
+  plateDiagonal: number,
   padding = 1.15,
   padding = 1.15,
 ): void {
 ): void {
   const size = box.getSize(new THREE.Vector3());
   const size = box.getSize(new THREE.Vector3());
@@ -92,10 +96,18 @@ function fitCameraToBox(
   // Keep the established three-quarter view; only the distance changes.
   // Keep the established three-quarter view; only the distance changes.
   const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
   const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
   camera.position.copy(center).addScaledVector(direction, distance);
   camera.position.copy(center).addScaledVector(direction, distance);
+
+  // Zoom range relative to the framed distance. Without the upper bound the
+  // wheel dollied straight through the far plane: five or six notches out and
+  // the model, plate and grid all vanished, which reads as "zoom is broken".
+  controls.minDistance = Math.max(radius * 0.05, 0.05);
+  controls.maxDistance = distance * MAX_ZOOM_OUT;
+
   // Clip planes scaled to the subject, so a small model doesn't z-fight and a
   // Clip planes scaled to the subject, so a small model doesn't z-fight and a
-  // large one isn't sliced by the far plane.
+  // large one isn't sliced by the far plane. The far plane has to cover the
+  // whole dolly range plus the plate, whose diagonal dwarfs a small model.
   camera.near = Math.max(distance / 1000, 0.01);
   camera.near = Math.max(distance / 1000, 0.01);
-  camera.far = distance + radius * 4;
+  camera.far = controls.maxDistance + radius * 4 + plateDiagonal;
   camera.updateProjectionMatrix();
   camera.updateProjectionMatrix();
 
 
   controls.target.copy(center);
   controls.target.copy(center);
@@ -108,6 +120,11 @@ interface BuildVolume {
   z: number;
   z: number;
 }
 }
 
 
+// Module-level so the default keeps its identity: an inline default object is
+// new on every render and, as an effect dependency, would rebuild the whole
+// scene each time the component re-renders.
+const DEFAULT_BUILD_VOLUME: BuildVolume = { x: 256, y: 256, z: 256 };
+
 interface ModelViewerProps {
 interface ModelViewerProps {
   url: string;
   url: string;
   fileType?: string;
   fileType?: string;
@@ -765,7 +782,7 @@ function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THRE
 export function ModelViewer({
 export function ModelViewer({
   url,
   url,
   fileType,
   fileType,
-  buildVolume = { x: 256, y: 256, z: 256 },
+  buildVolume = DEFAULT_BUILD_VOLUME,
   filamentColors,
   filamentColors,
   selectedPlateId = null,
   selectedPlateId = null,
   className = '',
   className = '',
@@ -786,6 +803,9 @@ export function ModelViewer({
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
   const gridRef = useRef<THREE.GridHelper | null>(null);
   const gridRef = useRef<THREE.GridHelper | null>(null);
+  // The last framed bounds, so Reset returns to the framed view rather than
+  // a fixed camera pose that ignores where the model was placed.
+  const fitBoxRef = useRef<THREE.Box3 | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
@@ -1023,6 +1043,10 @@ export function ModelViewer({
       renderer.setSize(w, h);
       renderer.setSize(w, h);
     };
     };
     window.addEventListener('resize', handleResize);
     window.addEventListener('resize', handleResize);
+    // Entering or leaving fullscreen resizes the container too; the observer
+    // catches that, but only after layout — resizing on the event itself
+    // avoids one frame drawn at the old aspect.
+    document.addEventListener('fullscreenchange', handleResize);
     const resizeObserver = new ResizeObserver(() => {
     const resizeObserver = new ResizeObserver(() => {
       handleResize();
       handleResize();
     });
     });
@@ -1030,6 +1054,7 @@ export function ModelViewer({
 
 
     return () => {
     return () => {
       window.removeEventListener('resize', handleResize);
       window.removeEventListener('resize', handleResize);
+      document.removeEventListener('fullscreenchange', handleResize);
       resizeObserver.disconnect();
       resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       cancelAnimationFrame(animationId);
       controls.dispose();
       controls.dispose();
@@ -1148,7 +1173,8 @@ export function ModelViewer({
     const finalBox = new THREE.Box3().setFromObject(group);
     const finalBox = new THREE.Box3().setFromObject(group);
 
 
     // Adjust camera to fit model
     // Adjust camera to fit model
-    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
+    fitBoxRef.current = finalBox;
+    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox, Math.hypot(buildVolume.x, buildVolume.y));
 
 
     setLoading(false);
     setLoading(false);
 
 
@@ -1184,17 +1210,29 @@ export function ModelViewer({
   }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
   }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
 
 
   const resetView = () => {
   const resetView = () => {
-    if (cameraRef.current && controlsRef.current) {
-      cameraRef.current.position.set(150, 150, 150);
-      controlsRef.current.target.set(0, 50, 0);
-      controlsRef.current.update();
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!camera || !controls) return;
+    if (fitBoxRef.current) {
+      fitCameraToBox(camera, controls, fitBoxRef.current, Math.hypot(buildVolume.x, buildVolume.y));
+    } else {
+      camera.position.set(150, 150, 150);
+      controls.target.set(0, 50, 0);
+      controls.update();
     }
     }
   };
   };
 
 
+  // Dolly along the view axis, like the wheel. Scaling the camera position
+  // itself measured from the world origin, not from the orbit target at the
+  // plate centre, so each press also slid the model sideways.
   const zoom = (factor: number) => {
   const zoom = (factor: number) => {
-    if (cameraRef.current) {
-      cameraRef.current.position.multiplyScalar(factor);
-    }
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!camera || !controls) return;
+    const offset = camera.position.clone().sub(controls.target);
+    const distance = Math.min(controls.maxDistance, Math.max(controls.minDistance, offset.length() * factor));
+    camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
+    controls.update();
   };
   };
 
 
   return (
   return (

+ 299 - 318
frontend/src/components/ModelViewerModal.tsx

@@ -1,9 +1,11 @@
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
+import { ExternalLink, Box, Cog, Loader2, Layers, Check, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
 import { Button } from './Button';
 import { Button } from './Button';
+import { PreviewModalShell } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 import { api, withMediaToken } from '../api/client';
 import { api, withMediaToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
@@ -156,26 +158,20 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   const [platesLoading, setPlatesLoading] = useState(false);
   const [platesLoading, setPlatesLoading] = useState(false);
   const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
   const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
   const [platePage, setPlatePage] = useState(0);
   const [platePage, setPlatePage] = useState(0);
-  const [isFullscreen, setIsFullscreen] = useState(false);
   const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
   const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
   const [isDraggingDivider, setIsDraggingDivider] = useState(false);
   const [isDraggingDivider, setIsDraggingDivider] = useState(false);
   const [hasCustomSplit, setHasCustomSplit] = useState(false);
   const [hasCustomSplit, setHasCustomSplit] = useState(false);
   const splitContainerRef = useRef<HTMLDivElement>(null);
   const splitContainerRef = useRef<HTMLDivElement>(null);
   const platesPanelRef = useRef<HTMLDivElement>(null);
   const platesPanelRef = useRef<HTMLDivElement>(null);
+  // Real fullscreen where the browser offers it, the viewport-filling layout
+  // below otherwise (#2976).
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
   const dividerHeight = 10;
   const dividerHeight = 10;
   const minPlateHeight = 160;
   const minPlateHeight = 160;
   const minViewerPx = 240;
   const minViewerPx = 240;
   const minViewerRatio = 0.35;
   const minViewerRatio = 0.35;
 
 
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
   useEffect(() => {
   useEffect(() => {
     setLoading(true);
     setLoading(true);
 
 
@@ -454,339 +450,324 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   }));
   }));
 
 
   return (
   return (
-    <div
-      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
-      onClick={onClose}
-    >
-      <div
-        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
-          isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
-        }`}
-        onClick={(e) => e.stopPropagation()}
-      >
-        {/* Header */}
-        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
-            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
-            {hasObjectCount && (
-              <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
-                {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
-              </span>
-            )}
-          </div>
-          <div className="flex items-center gap-2">
-            {useBambuddySlicer ? (
-              <SlicerSplitButton
-                icon={<Cog className="w-4 h-4" />}
-                label={t('slice.action')}
-                dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => onSliceWithBambuddy?.()}
-                items={slicerDropdownItems}
-              />
-            ) : canOpenInSlicer ? (
-              <SlicerSplitButton
-                icon={<ExternalLink className="w-4 h-4" />}
-                // Name the slicer when it is not the configured one. That happens
-                // when the configured slicer cannot take this format — an STL with
-                // Bambu Studio selected — and silently handing the file to the
-                // other one without saying so would be worse than the failure it
-                // replaces.
-                label={
-                  usableSlicers[0] === preferredSlicer
-                    ? t('modelViewer.openInSlicer')
-                    : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
-                }
-                dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
-                items={slicerDropdownItems}
-              />
-            ) : (
-              <Button variant="secondary" size="sm" disabled>
-                <ExternalLink className="w-4 h-4" />
-                {t('modelViewer.openInSlicer')}
-              </Button>
-            )}
-            <Button
-              variant="secondary"
-              size="sm"
-              onClick={() => setIsFullscreen((prev) => !prev)}
-              title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
-            >
-              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
-            </Button>
-            <Button variant="ghost" size="sm" onClick={onClose}>
-              <X className="w-5 h-5" />
+    <PreviewModalShell
+      title={title}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      closeOnBackdropClick
+      titleExtra={
+        hasObjectCount ? (
+          <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
+            {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
+          </span>
+        ) : undefined
+      }
+      actions={
+        <>
+          {useBambuddySlicer ? (
+            <SlicerSplitButton
+              icon={<Cog className="w-4 h-4" />}
+              label={t('slice.action')}
+              dropdownLabel={t('modelViewer.moreSlicerOptions')}
+              onPrimary={() => onSliceWithBambuddy?.()}
+              items={slicerDropdownItems}
+            />
+          ) : canOpenInSlicer ? (
+            <SlicerSplitButton
+              icon={<ExternalLink className="w-4 h-4" />}
+              // Name the slicer when it is not the configured one. That happens
+              // when the configured slicer cannot take this format — an STL with
+              // Bambu Studio selected — and silently handing the file to the
+              // other one without saying so would be worse than the failure it
+              // replaces.
+              label={
+                usableSlicers[0] === preferredSlicer
+                  ? t('modelViewer.openInSlicer')
+                  : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
+              }
+              dropdownLabel={t('modelViewer.moreSlicerOptions')}
+              onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
+              items={slicerDropdownItems}
+            />
+          ) : (
+            <Button variant="secondary" size="sm" disabled>
+              <ExternalLink className="w-4 h-4" />
+              {t('modelViewer.openInSlicer')}
             </Button>
             </Button>
-          </div>
+          )}
+        </>
+      }
+    >
+      {/* Tabs - only show if we have capabilities */}
+      {capabilities && (
+        <div className="flex border-b border-bambu-dark-tertiary">
+          <button
+            onClick={() => capabilities.has_model && setActiveTab('3d')}
+            disabled={!capabilities.has_model}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === '3d'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : capabilities.has_model
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <Box className="w-4 h-4" />
+            {t('modelViewer.tabs.model')}
+            {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
+          </button>
         </div>
         </div>
+      )}
 
 
-        {/* Tabs - only show if we have capabilities */}
-        {capabilities && (
-          <div className="flex border-b border-bambu-dark-tertiary">
-            <button
-              onClick={() => capabilities.has_model && setActiveTab('3d')}
-              disabled={!capabilities.has_model}
-              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
-                activeTab === '3d'
-                  ? 'text-bambu-green border-b-2 border-bambu-green'
-                  : capabilities.has_model
-                    ? 'text-bambu-gray hover:text-white'
-                    : 'text-bambu-gray/30 cursor-not-allowed'
-              }`}
-            >
-              <Box className="w-4 h-4" />
-              {t('modelViewer.tabs.model')}
-              {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
-            </button>
+      {/* Viewer */}
+      <div className="flex-1 overflow-hidden p-4">
+        {loading ? (
+          <div className="w-full h-full flex items-center justify-center">
+            <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
           </div>
           </div>
-        )}
-
-        {/* Viewer */}
-        <div className="flex-1 overflow-hidden p-4">
-          {loading ? (
-            <div className="w-full h-full flex items-center justify-center">
-              <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
-            </div>
-          ) : activeTab === '3d' && capabilities ? (
-            <div
-              ref={splitContainerRef}
-              className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
-            >
-              {hasMultiplePlates && (
-                <div
-                  ref={platesPanelRef}
-                  style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
-                  className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
-                >
-                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
-                    <Layers className="w-4 h-4" />
-                    {t('modelViewer.plates')}
-                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
-                  </div>
-                  <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
-                      <div
-                        ref={platesViewportRef}
-                        className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
-                      >
-                      <div
-                        ref={platesGridRef}
-                        className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
-                        style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+        ) : activeTab === '3d' && capabilities ? (
+          <div
+            ref={splitContainerRef}
+            className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
+          >
+            {hasMultiplePlates && (
+              <div
+                ref={platesPanelRef}
+                style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
+                className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
+              >
+                <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                  <Layers className="w-4 h-4" />
+                  {t('modelViewer.plates')}
+                  {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                </div>
+                <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
+                    <div
+                      ref={platesViewportRef}
+                      className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
+                    >
+                    <div
+                      ref={platesGridRef}
+                      className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
+                      style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+                    >
+                      <button
+                        type="button"
+                        onClick={() => setSelectedPlateId(null)}
+                        className={`flex items-center rounded-lg border text-left transition-colors ${
+                          splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                        } ${
+                          selectedPlateId == null
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                        }`}
                       >
                       >
+                        <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                          splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                        }`}>
+                          <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                        </div>
+                        <div className="min-w-0 flex-1">
+                          <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                          <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                            {t('modelViewer.plateCount', { count: plates.length })}
+                          </p>
+                        </div>
+                        {selectedPlateId == null && (
+                          <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                        )}
+                      </button>
+                      {pagedPlates.map((plate) => (
                         <button
                         <button
+                          key={plate.index}
                           type="button"
                           type="button"
-                          onClick={() => setSelectedPlateId(null)}
+                          onClick={() => setSelectedPlateId(plate.index)}
                           className={`flex items-center rounded-lg border text-left transition-colors ${
                           className={`flex items-center rounded-lg border text-left transition-colors ${
                             splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
                             splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
                           } ${
                           } ${
-                            selectedPlateId == null
+                            selectedPlateId === plate.index
                               ? 'border-bambu-green bg-bambu-green/10'
                               ? 'border-bambu-green bg-bambu-green/10'
                               : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
                               : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
                           }`}
                           }`}
                         >
                         >
-                          <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
-                            splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
-                          }`}>
-                            <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
-                          </div>
+                          {plate.has_thumbnail && plate.thumbnail_url ? (
+                            <img
+                              src={withMediaToken(plate.thumbnail_url)}
+                              alt={`Plate ${plate.index}`}
+                              className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
+                            />
+                          ) : (
+                            <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                              splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                            }`}>
+                              <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                            </div>
+                          )}
                           <div className="min-w-0 flex-1">
                           <div className="min-w-0 flex-1">
-                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
+                              {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
+                            </p>
                             <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
                             <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
-                              {t('modelViewer.plateCount', { count: plates.length })}
+                              {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
                             </p>
                             </p>
                           </div>
                           </div>
-                          {selectedPlateId == null && (
+                          {selectedPlateId === plate.index && (
                             <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
                             <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
                           )}
                           )}
                         </button>
                         </button>
-                        {pagedPlates.map((plate) => (
-                          <button
-                            key={plate.index}
-                            type="button"
-                            onClick={() => setSelectedPlateId(plate.index)}
-                            className={`flex items-center rounded-lg border text-left transition-colors ${
-                              splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
-                            } ${
-                              selectedPlateId === plate.index
-                                ? 'border-bambu-green bg-bambu-green/10'
-                                : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
-                            }`}
-                          >
-                            {plate.has_thumbnail && plate.thumbnail_url ? (
-                              <img
-                                src={withMediaToken(plate.thumbnail_url)}
-                                alt={`Plate ${plate.index}`}
-                                className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
-                              />
-                            ) : (
-                              <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
-                                splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
-                              }`}>
-                                <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
-                              </div>
-                            )}
-                            <div className="min-w-0 flex-1">
-                              <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
-                                {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
-                              </p>
-                              <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
-                                {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
-                              </p>
-                            </div>
-                            {selectedPlateId === plate.index && (
-                              <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
-                            )}
-                          </button>
-                        ))}
-                      </div>
+                      ))}
                     </div>
                     </div>
-                    {(selectedPlate || shouldPaginatePlates) && (
-                      <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
-                        {selectedPlate && (
-                          <div className="flex items-center gap-3 whitespace-nowrap">
-                            <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
-                            {selectedPlate.print_time_seconds != null && (
-                              <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
-                            )}
-                            {selectedPlate.filament_used_grams != null && (
-                              <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
-                            )}
-                            {selectedPlate.filaments.length > 0 && (
-                              <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
-                            )}
-                          </div>
-                        )}
-                        {shouldPaginatePlates && (
-                          <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
-                            <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
-                            <div className="flex items-center gap-1">
-                              <button
-                                type="button"
-                                onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
-                                disabled={platePage === 0}
-                                className={`px-2 py-1 rounded border text-xs ${
-                                  platePage === 0
-                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
-                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                }`}
-                              >
-                                {t('modelViewer.pagination.prev')}
-                              </button>
-                              {(() => {
-                                const maxVisible = 5;
-                                let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
-                                const end = Math.min(totalPlatePages, start + maxVisible);
-                                if (end - start < maxVisible) {
-                                  start = Math.max(0, end - maxVisible);
-                                }
-                                const pages = Array.from({ length: end - start }, (_, i) => start + i);
-
-                                return (
-                                  <>
-                                    {start > 0 && (
-                                      <button
-                                        type="button"
-                                        onClick={() => setPlatePage(0)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === 0
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        1
-                                      </button>
-                                    )}
-                                    {start > 1 && <span className="px-1">…</span>}
-                                    {pages.map((pageNumber) => (
-                                      <button
-                                        key={pageNumber}
-                                        type="button"
-                                        onClick={() => setPlatePage(pageNumber)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === pageNumber
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        {pageNumber + 1}
-                                      </button>
-                                    ))}
-                                    {end < totalPlatePages - 1 && <span className="px-1">…</span>}
-                                    {end < totalPlatePages && (
-                                      <button
-                                        type="button"
-                                        onClick={() => setPlatePage(totalPlatePages - 1)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === totalPlatePages - 1
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        {totalPlatePages}
-                                      </button>
-                                    )}
-                                  </>
-                                );
-                              })()}
-                              <button
-                                type="button"
-                                onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
-                                disabled={platePage >= totalPlatePages - 1}
-                                className={`px-2 py-1 rounded border text-xs ${
-                                  platePage >= totalPlatePages - 1
-                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
-                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                }`}
-                              >
-                                {t('modelViewer.pagination.next')}
-                              </button>
-                            </div>
-                          </div>
-                        )}
-                      </div>
-                    )}
                   </div>
                   </div>
+                  {(selectedPlate || shouldPaginatePlates) && (
+                    <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
+                      {selectedPlate && (
+                        <div className="flex items-center gap-3 whitespace-nowrap">
+                          <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
+                          {selectedPlate.print_time_seconds != null && (
+                            <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
+                          )}
+                          {selectedPlate.filament_used_grams != null && (
+                            <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                          )}
+                          {selectedPlate.filaments.length > 0 && (
+                            <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
+                          )}
+                        </div>
+                      )}
+                      {shouldPaginatePlates && (
+                        <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
+                          <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
+                          <div className="flex items-center gap-1">
+                            <button
+                              type="button"
+                              onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
+                              disabled={platePage === 0}
+                              className={`px-2 py-1 rounded border text-xs ${
+                                platePage === 0
+                                  ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                              }`}
+                            >
+                              {t('modelViewer.pagination.prev')}
+                            </button>
+                            {(() => {
+                              const maxVisible = 5;
+                              let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
+                              const end = Math.min(totalPlatePages, start + maxVisible);
+                              if (end - start < maxVisible) {
+                                start = Math.max(0, end - maxVisible);
+                              }
+                              const pages = Array.from({ length: end - start }, (_, i) => start + i);
+
+                              return (
+                                <>
+                                  {start > 0 && (
+                                    <button
+                                      type="button"
+                                      onClick={() => setPlatePage(0)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === 0
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      1
+                                    </button>
+                                  )}
+                                  {start > 1 && <span className="px-1">…</span>}
+                                  {pages.map((pageNumber) => (
+                                    <button
+                                      key={pageNumber}
+                                      type="button"
+                                      onClick={() => setPlatePage(pageNumber)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === pageNumber
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      {pageNumber + 1}
+                                    </button>
+                                  ))}
+                                  {end < totalPlatePages - 1 && <span className="px-1">…</span>}
+                                  {end < totalPlatePages && (
+                                    <button
+                                      type="button"
+                                      onClick={() => setPlatePage(totalPlatePages - 1)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === totalPlatePages - 1
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      {totalPlatePages}
+                                    </button>
+                                  )}
+                                </>
+                              );
+                            })()}
+                            <button
+                              type="button"
+                              onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
+                              disabled={platePage >= totalPlatePages - 1}
+                              className={`px-2 py-1 rounded border text-xs ${
+                                platePage >= totalPlatePages - 1
+                                  ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                              }`}
+                            >
+                              {t('modelViewer.pagination.next')}
+                            </button>
+                          </div>
+                        </div>
+                      )}
+                    </div>
+                  )}
                 </div>
                 </div>
-              )}
-              {splitFullscreen && (
-                <div
-                  role="separator"
-                  aria-orientation="horizontal"
-                  onMouseDown={(event) => {
-                    event.preventDefault();
-                    setIsDraggingDivider(true);
-                    setHasCustomSplit(true);
-                  }}
-                  className={`h-2 cursor-row-resize flex items-center justify-center ${
-                    isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
-                  }`}
-                >
-                  <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
-                </div>
-              )}
-              <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
-                  <ModelViewer
-                    url={isLibrary
-                      ? api.getLibraryFileDownloadUrl(libraryFileId!)
-                      : (capabilities.has_source
-                        ? api.getSource3mfDownloadUrl(archiveId!)
-                        : api.getArchiveDownload(archiveId!))}
-                    fileType={fileType}
-                    buildVolume={capabilities.build_volume}
-                    filamentColors={capabilities.filament_colors}
-                    selectedPlateId={selectedPlateId}
-                    className="w-full h-full"
-                    onSnapshot={onSnapshot}
-                  />
               </div>
               </div>
+            )}
+            {splitFullscreen && (
+              <div
+                role="separator"
+                aria-orientation="horizontal"
+                onMouseDown={(event) => {
+                  event.preventDefault();
+                  setIsDraggingDivider(true);
+                  setHasCustomSplit(true);
+                }}
+                className={`h-2 cursor-row-resize flex items-center justify-center ${
+                  isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
+                }`}
+              >
+                <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
+              </div>
+            )}
+            <div
+              className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}
+              data-testid="model-viewer-area"
+              onDoubleClick={toggleFullscreen}
+            >
+                <ModelViewer
+                  url={isLibrary
+                    ? api.getLibraryFileDownloadUrl(libraryFileId!)
+                    : (capabilities.has_source
+                      ? api.getSource3mfDownloadUrl(archiveId!)
+                      : api.getArchiveDownload(archiveId!))}
+                  fileType={fileType}
+                  buildVolume={capabilities.build_volume}
+                  filamentColors={capabilities.filament_colors}
+                  selectedPlateId={selectedPlateId}
+                  className="w-full h-full"
+                  onSnapshot={onSnapshot}
+                />
             </div>
             </div>
-          ) : (
-            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              {t('modelViewer.noPreview')}
-            </div>
-          )}
-        </div>
+          </div>
+        ) : (
+          <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+            {t('modelViewer.noPreview')}
+          </div>
+        )}
       </div>
       </div>
-    </div>
+    </PreviewModalShell>
   );
   );
 }
 }

+ 303 - 86
frontend/src/components/PdfPreviewModal.tsx

@@ -1,14 +1,40 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
+import { ChevronLeft, ChevronRight, FileText, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
 import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs';
 import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs';
 import { api, getAuthToken } from '../api/client';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
+import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 
 
 // Fetching and parsing happen fully in the browser; beyond this size the
 // Fetching and parsing happen fully in the browser; beyond this size the
 // preview shows a notice instead of stalling the tab on a giant download.
 // preview shows a notice instead of stalling the tab on a giant download.
 export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
 export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
 
 
+const MIN_ZOOM = 0.4;
+const MAX_ZOOM = 4;
+// One button press, one key press, one mouse-wheel notch.
+const ZOOM_STEP = 1.25;
+// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
+// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
+// and the pinch a smooth ramp.
+const WHEEL_DELTA_CLAMP = 30;
+const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
+// Wheel zoom changes the displayed size at once (CSS) and re-rasterises after
+// the gesture settles, so a scroll burst costs one pdf.js render, not twenty.
+const RERENDER_DEBOUNCE_MS = 150;
+// iOS Safari refuses to back a canvas past roughly 16.7M pixels and hands back
+// a blank one instead of failing; zoom 4 on a dpr-2 screen crosses that on any
+// ordinary page. Past the cap the raster stops getting sharper, which costs
+// detail rather than the whole page.
+const MAX_CANVAS_PIXELS = 16 * 1024 * 1024;
+// pdf.js fetches its CMaps, ICC profiles, standard fonts and wasm decoders at
+// runtime instead of bundling them; vite.config.ts publishes them here. Left
+// unset, CJK text, JPEG2000/JBIG2 images and ICC colour silently fail (#2976).
+const PDFJS_ASSET_BASE = `${import.meta.env.BASE_URL}assets/pdfjs/`;
+
+const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
 interface PdfPreviewModalProps {
 interface PdfPreviewModalProps {
   libraryFileId: number;
   libraryFileId: number;
   filename: string;
   filename: string;
@@ -18,6 +44,20 @@ interface PdfPreviewModalProps {
   onSnapshot?: (blob: Blob) => void;
   onSnapshot?: (blob: Blob) => void;
 }
 }
 
 
+// Where a zoom step should keep the page still: a point on the canvas
+// (fractions) pinned to a point in the scroll viewport (pixels).
+interface ZoomAnchor {
+  fx: number;
+  fy: number;
+  px: number;
+  py: number;
+}
+
+interface ActivePointer {
+  x: number;
+  y: number;
+}
+
 // Square 256px crop of the rendered page, white-backed like a paper page.
 // Square 256px crop of the rendered page, white-backed like a paper page.
 function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
 function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
   const size = 256;
   const size = 256;
@@ -35,8 +75,17 @@ function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
   return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
   return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
 }
 }
 
 
+// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
+function wheelDeltaPixels(e: WheelEvent): number {
+  if (e.deltaMode === 1) return e.deltaY * 16;
+  if (e.deltaMode === 2) return e.deltaY * 100;
+  return e.deltaY;
+}
+
 export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
 export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const fullscreen = usePreviewFullscreen();
+  const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const renderTaskRef = useRef<RenderTask | null>(null);
   const renderTaskRef = useRef<RenderTask | null>(null);
@@ -48,17 +97,160 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
 
 
   const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
   const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
   const [pageNum, setPageNum] = useState(1);
   const [pageNum, setPageNum] = useState(1);
+  // `zoom` is what the user sees (applied as CSS size immediately);
+  // `renderZoom` trails it and drives the pdf.js raster.
   const [zoom, setZoom] = useState(1);
   const [zoom, setZoom] = useState(1);
+  const [renderZoom, setRenderZoom] = useState(1);
+  const [layoutVersion, setLayoutVersion] = useState(0);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [rendering, setRendering] = useState(true);
   const [rendering, setRendering] = useState(true);
 
 
+  const zoomRef = useRef(zoom);
+  // CSS size of the page at zoom 1, known once a page has been rendered.
+  const baseCssSizeRef = useRef<{ width: number; height: number } | null>(null);
+  const anchorRef = useRef<ZoomAnchor | null>(null);
+  const pointersRef = useRef<Map<number, ActivePointer>>(new Map());
+  const pinchDistanceRef = useRef<number | null>(null);
+
+  const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
+    const container = containerRef.current;
+    const canvas = canvasRef.current;
+    if (container && canvas) {
+      const viewport = container.getBoundingClientRect();
+      const page = canvas.getBoundingClientRect();
+      // Without a pointer, keep whatever is in the middle of the viewport.
+      const px = clientX == null ? viewport.width / 2 : clientX - viewport.left;
+      const py = clientY == null ? viewport.height / 2 : clientY - viewport.top;
+      anchorRef.current =
+        page.width > 0 && page.height > 0
+          ? {
+              fx: (px + viewport.left - page.left) / page.width,
+              fy: (py + viewport.top - page.top) / page.height,
+              px,
+              py,
+            }
+          : null;
+    }
+    setZoom((current) => clampZoom(current * factor));
+  }, []);
+
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
+      // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
+      if (e.ctrlKey || e.metaKey || e.altKey) return;
+      if (e.key === '+' || e.key === '=') {
+        e.preventDefault();
+        zoomAt(ZOOM_STEP);
+      } else if (e.key === '-') {
+        e.preventDefault();
+        zoomAt(1 / ZOOM_STEP);
+      } else if (e.key === '0') {
+        e.preventDefault();
+        anchorRef.current = null;
+        setZoom(1);
+      }
     };
     };
     window.addEventListener('keydown', handleKeyDown);
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  }, [zoomAt]);
+
+  // Wheel: Ctrl/⌘ (which is also how a trackpad pinch arrives) always zooms;
+  // a plain wheel zooms only while the page fits the viewport, because then
+  // there is nothing to scroll. Native listener: React registers wheel as
+  // passive, so preventDefault — needed to keep the browser from zooming the
+  // whole page — would be ignored there.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+    const handleWheel = (e: WheelEvent) => {
+      if (!canvasRef.current || !baseCssSizeRef.current) return;
+      const modified = e.ctrlKey || e.metaKey;
+      if (!modified) {
+        const fits =
+          container.scrollHeight <= container.clientHeight && container.scrollWidth <= container.clientWidth;
+        if (!fits) return;
+      }
+      e.preventDefault();
+      const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
+      zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
+    };
+    container.addEventListener('wheel', handleWheel, { passive: false });
+    return () => container.removeEventListener('wheel', handleWheel);
+  }, [zoomAt]);
+
+  // Touch: one finger pans the page, two fingers pinch-zoom around their
+  // midpoint. `touch-action: none` on the container hands both gestures to
+  // these handlers instead of the browser's scroll / page-zoom.
+  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
+    pinchDistanceRef.current = null;
+  };
+
+  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    const pointers = pointersRef.current;
+    const previous = pointers.get(e.pointerId);
+    if (!previous) return;
+    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+    if (pointers.size === 1) {
+      const container = containerRef.current;
+      if (container) {
+        container.scrollLeft -= e.clientX - previous.x;
+        container.scrollTop -= e.clientY - previous.y;
+      }
+      return;
+    }
+    if (pointers.size !== 2) return;
+    const [a, b] = Array.from(pointers.values());
+    const distance = Math.hypot(a.x - b.x, a.y - b.y);
+    const last = pinchDistanceRef.current;
+    pinchDistanceRef.current = distance;
+    if (last != null && last > 0 && distance > 0) {
+      zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
+    }
+  };
+
+  const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    pointersRef.current.delete(e.pointerId);
+    pinchDistanceRef.current = null;
+  };
+
+  // Show the new zoom right away by resizing the existing raster, and keep
+  // the anchored point under the pointer. The sharp re-render follows.
+  useLayoutEffect(() => {
+    zoomRef.current = zoom;
+    const canvas = canvasRef.current;
+    const container = containerRef.current;
+    const base = baseCssSizeRef.current;
+    if (!canvas || !container || !base) return;
+    canvas.style.width = `${base.width * zoom}px`;
+    canvas.style.height = `${base.height * zoom}px`;
+    const anchor = anchorRef.current;
+    anchorRef.current = null;
+    if (!anchor) return;
+    container.scrollLeft = canvas.offsetLeft + anchor.fx * canvas.offsetWidth - anchor.px;
+    container.scrollTop = canvas.offsetTop + anchor.fy * canvas.offsetHeight - anchor.py;
+  }, [zoom]);
+
+  useEffect(() => {
+    if (zoom === renderZoom) return;
+    const id = window.setTimeout(() => setRenderZoom(zoom), RERENDER_DEBOUNCE_MS);
+    return () => window.clearTimeout(id);
+  }, [zoom, renderZoom]);
+
+  // The page is fitted to the panel width, so a panel resize (fullscreen,
+  // window resize) needs a fresh fit. Watching the panel rather than the
+  // scroll container keeps a scrollbar appearing from re-fitting the page.
+  useEffect(() => {
+    const panel = panelRef.current;
+    if (!panel) return;
+    const observer = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
+    observer.observe(panel);
+    return () => observer.disconnect();
+  }, [panelRef]);
 
 
   // Load the document. pdf.js is imported on demand so the viewer and its
   // Load the document. pdf.js is imported on demand so the viewer and its
   // worker stay out of the main bundle.
   // worker stay out of the main bundle.
@@ -98,14 +290,32 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
           await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
           await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
         ).default;
         ).default;
       }
       }
-      loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
+      loadingTask = pdfjs.getDocument({
+        data: new Uint8Array(buffer),
+        cMapUrl: `${PDFJS_ASSET_BASE}cmaps/`,
+        iccUrl: `${PDFJS_ASSET_BASE}iccs/`,
+        standardFontDataUrl: `${PDFJS_ASSET_BASE}standard_fonts/`,
+        wasmUrl: `${PDFJS_ASSET_BASE}wasm/`,
+      });
+      if (cancelled) {
+        // The modal closed during the fetch/import above, so cleanup ran while
+        // `loadingTask` was still null and left this task — and its worker —
+        // running. Nothing else will destroy it.
+        loadingTask.destroy();
+        return;
+      }
       const loaded = await loadingTask.promise;
       const loaded = await loadingTask.promise;
       if (cancelled) {
       if (cancelled) {
-        // Cleanup below already ran; destroying the task tears down the doc.
+        // Cleanup ran after the assignment above, so it destroyed the task
+        // already — and with it the document.
         return;
         return;
       }
       }
       setDoc(loaded);
       setDoc(loaded);
-    })().catch(() => {
+    })().catch((err: unknown) => {
+      // The reason never reaches the UI beyond a generic line, so leave it in
+      // the console: an HTTP status, a refused worker or a parser failure each
+      // need a different fix, and "cannot be previewed" hides which one it was.
+      console.error('[pdf-preview] load failed', err);
       if (!cancelled) {
       if (!cancelled) {
         setError(t('fileManager.preview.error'));
         setError(t('fileManager.preview.error'));
         setRendering(false);
         setRendering(false);
@@ -137,11 +347,16 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
       // Fit the page width to the panel at zoom 1; render at device pixels.
       // Fit the page width to the panel at zoom 1; render at device pixels.
       const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
       const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
       const dpr = Math.min(window.devicePixelRatio || 1, 2);
       const dpr = Math.min(window.devicePixelRatio || 1, 2);
-      const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
+      const wanted = fitScale * renderZoom * dpr;
+      const pixels = baseViewport.width * wanted * (baseViewport.height * wanted);
+      const scale = pixels > MAX_CANVAS_PIXELS ? wanted * Math.sqrt(MAX_CANVAS_PIXELS / pixels) : wanted;
+      const viewport = page.getViewport({ scale });
       canvas.width = viewport.width;
       canvas.width = viewport.width;
       canvas.height = viewport.height;
       canvas.height = viewport.height;
-      canvas.style.width = `${viewport.width / dpr}px`;
-      canvas.style.height = `${viewport.height / dpr}px`;
+      baseCssSizeRef.current = { width: baseViewport.width * fitScale, height: baseViewport.height * fitScale };
+      // The displayed zoom may already be ahead of this raster.
+      canvas.style.width = `${baseCssSizeRef.current.width * zoomRef.current}px`;
+      canvas.style.height = `${baseCssSizeRef.current.height * zoomRef.current}px`;
 
 
       renderTaskRef.current?.cancel();
       renderTaskRef.current?.cancel();
       const task = page.render({ canvas, viewport });
       const task = page.render({ canvas, viewport });
@@ -158,6 +373,7 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
     })().catch((err: unknown) => {
     })().catch((err: unknown) => {
       // A cancelled render throws RenderingCancelledException — not an error.
       // A cancelled render throws RenderingCancelledException — not an error.
       if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
       if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
+        console.error('[pdf-preview] render failed', err);
         setError(t('fileManager.preview.error'));
         setError(t('fileManager.preview.error'));
         setRendering(false);
         setRendering(false);
       }
       }
@@ -166,89 +382,90 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
     return () => {
     return () => {
       cancelled = true;
       cancelled = true;
     };
     };
-  }, [doc, pageNum, zoom, t]);
+  }, [doc, pageNum, renderZoom, layoutVersion, t]);
 
 
   const pageCount = doc?.numPages ?? 0;
   const pageCount = doc?.numPages ?? 0;
+  const iconButtonClass = previewIconButtonClass;
 
 
   return (
   return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
-        {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 min-w-0">
-            <FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
-            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
-          </div>
-          <div className="flex items-center gap-2 flex-shrink-0">
-            {doc && pageCount > 1 && (
-              <div className="flex items-center gap-1 mr-2">
-                <button
-                  onClick={() => setPageNum((p) => Math.max(1, p - 1))}
-                  disabled={pageNum <= 1}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
-                  aria-label={t('fileManager.preview.prevPage')}
-                >
-                  <ChevronLeft className="w-4 h-4" />
-                </button>
-                <span className="text-sm text-bambu-gray whitespace-nowrap">
-                  {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
-                </span>
-                <button
-                  onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
-                  disabled={pageNum >= pageCount}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
-                  aria-label={t('fileManager.preview.nextPage')}
-                >
-                  <ChevronRight className="w-4 h-4" />
-                </button>
-              </div>
-            )}
-            {doc && (
-              <>
-                <button
-                  onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                  aria-label={t('fileManager.preview.zoomOut')}
-                >
-                  <ZoomOut className="w-4 h-4" />
-                </button>
-                <button
-                  onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                  aria-label={t('fileManager.preview.zoomIn')}
-                >
-                  <ZoomIn className="w-4 h-4" />
-                </button>
-              </>
-            )}
-            <button
-              onClick={onClose}
-              className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-              aria-label={t('common.close')}
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-        </div>
-
-        {/* Page */}
-        <div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
-          {error ? (
-            <div className="h-full flex items-center justify-center">
-              <p className="text-bambu-gray text-center">{error}</p>
-            </div>
-          ) : (
-            <div className="flex justify-center min-w-fit">
-              <canvas ref={canvasRef} className="shadow-lg" />
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+      actions={
+        <>
+          {doc && pageCount > 1 && (
+            <div className="flex items-center gap-1 mr-2">
+              <button
+                onClick={() => setPageNum((p) => Math.max(1, p - 1))}
+                disabled={pageNum <= 1}
+                className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
+                aria-label={t('fileManager.preview.prevPage')}
+              >
+                <ChevronLeft className="w-4 h-4" />
+              </button>
+              <span className="text-sm text-bambu-gray whitespace-nowrap">
+                {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
+              </span>
+              <button
+                onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
+                disabled={pageNum >= pageCount}
+                className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
+                aria-label={t('fileManager.preview.nextPage')}
+              >
+                <ChevronRight className="w-4 h-4" />
+              </button>
             </div>
             </div>
           )}
           )}
-          {!error && rendering && (
-            <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
+          {doc && (
+            <>
+              <button
+                onClick={() => zoomAt(1 / ZOOM_STEP)}
+                className={iconButtonClass}
+                aria-label={t('fileManager.preview.zoomOut')}
+              >
+                <ZoomOut className="w-4 h-4" />
+              </button>
+              <button
+                onClick={() => zoomAt(ZOOM_STEP)}
+                className={iconButtonClass}
+                aria-label={t('fileManager.preview.zoomIn')}
+              >
+                <ZoomIn className="w-4 h-4" />
+              </button>
+            </>
           )}
           )}
-        </div>
+        </>
+      }
+    >
+      {/* Page */}
+      <div
+        ref={containerRef}
+        data-testid="pdf-preview-page"
+        className={`relative flex-1 min-h-0 overflow-auto bg-bambu-dark p-4 touch-none ${isFullscreen ? '' : 'rounded-b-lg'}`}
+        onDoubleClick={toggleFullscreen}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={handlePointerEnd}
+        onPointerCancel={handlePointerEnd}
+        onPointerLeave={handlePointerEnd}
+      >
+        {error ? (
+          <div className="h-full flex items-center justify-center">
+            <p className="text-bambu-gray text-center">{error}</p>
+          </div>
+        ) : (
+          <div className="flex justify-center min-w-fit">
+            <canvas ref={canvasRef} className="shadow-lg" />
+          </div>
+        )}
+        {!error && rendering && (
+          <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          </div>
+        )}
       </div>
       </div>
-    </div>
+    </PreviewModalShell>
   );
   );
 }
 }

+ 101 - 0
frontend/src/components/PreviewModalShell.tsx

@@ -0,0 +1,101 @@
+import { useEffect, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Maximize2, Minimize2, X } from 'lucide-react';
+import type { PreviewFullscreen } from '../hooks/usePreviewFullscreen';
+
+// One size for every file preview (#2976). Each modal used to carry its own
+// max-width (4xl/5xl/6xl at 80-85vh), which left most of a wide screen empty
+// for a window the user opened precisely to look at something closely.
+// min() keeps the panel off the edges on a laptop and stops it stretching to
+// a wall of pixels on an ultrawide.
+export const PREVIEW_PANEL_SIZE_CLASS = 'w-[min(1800px,96vw)] h-[94vh]';
+
+// Header icon buttons of every preview.
+export const previewIconButtonClass =
+  'p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors';
+
+interface PreviewModalShellProps {
+  title: string;
+  fullscreen: PreviewFullscreen;
+  onClose: () => void;
+  /** Type icon left of the title. */
+  icon?: ReactNode;
+  /** Rendered after the title, e.g. the 3D viewer's object-count badge. */
+  titleExtra?: ReactNode;
+  /** This preview's own header buttons, left of the fullscreen/close pair. */
+  actions?: ReactNode;
+  /** The 3D viewer closes on a backdrop click; the document previews do not. */
+  closeOnBackdropClick?: boolean;
+  /** Panel content: tabs, body and footers, as direct flex children. */
+  children: ReactNode;
+}
+
+/**
+ * Backdrop, panel sizing and header row shared by every file preview (#2976).
+ *
+ * Esc closes the modal, except while the browser owns it: in fullscreen Esc
+ * leaves fullscreen and the preview stays open. Double-click-to-fullscreen is
+ * wired by each modal on its own content area, because only the modal knows
+ * which part of the panel is the preview.
+ */
+export function PreviewModalShell({
+  title,
+  fullscreen,
+  onClose,
+  icon,
+  titleExtra,
+  actions,
+  closeOnBackdropClick,
+  children,
+}: PreviewModalShellProps) {
+  const { t } = useTranslation();
+  const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !document.fullscreenElement) onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-2 sm:p-4'}`}
+      onClick={closeOnBackdropClick ? onClose : undefined}
+    >
+      <div
+        ref={panelRef}
+        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary flex flex-col ${
+          isFullscreen
+            ? 'w-full h-full max-w-none rounded-none'
+            : `${PREVIEW_PANEL_SIZE_CLASS} max-w-full max-h-full rounded-lg`
+        }`}
+        onClick={closeOnBackdropClick ? (e) => e.stopPropagation() : undefined}
+      >
+        <div className="flex items-center justify-between gap-4 p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+          <div className="flex items-center gap-2 min-w-0">
+            {icon}
+            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
+            {titleExtra}
+          </div>
+          <div className="flex items-center gap-2 flex-shrink-0">
+            {actions}
+            <button
+              onClick={toggleFullscreen}
+              className={previewIconButtonClass}
+              aria-label={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
+              title={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
+            >
+              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
+            </button>
+            <button onClick={onClose} className={previewIconButtonClass} aria-label={t('common.close')}>
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+        {children}
+      </div>
+    </div>
+  );
+}

+ 118 - 109
frontend/src/components/SpreadsheetPreviewModal.tsx

@@ -1,8 +1,10 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { FileSpreadsheet, Loader2, X } from 'lucide-react';
+import { FileSpreadsheet, Loader2 } from 'lucide-react';
 import { api, getAuthToken } from '../api/client';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
+import { PreviewModalShell } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 
 
 // Parsing an arbitrarily large workbook would freeze the tab — anything over
 // Parsing an arbitrarily large workbook would freeze the tab — anything over
 // this size (or beyond the row/column caps) falls back to a truncation notice.
 // this size (or beyond the row/column caps) falls back to a truncation notice.
@@ -101,14 +103,8 @@ export function SpreadsheetPreviewModal({
   useEffect(() => {
   useEffect(() => {
     onSnapshotRef.current = onSnapshot;
     onSnapshotRef.current = onSnapshot;
   });
   });
-
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
 
 
   useEffect(() => {
   useEffect(() => {
     let cancelled = false;
     let cancelled = false;
@@ -135,15 +131,33 @@ export function SpreadsheetPreviewModal({
         // papaparse is loaded on demand so it stays out of the main bundle.
         // papaparse is loaded on demand so it stays out of the main bundle.
         const Papa = (await import('papaparse')).default;
         const Papa = (await import('papaparse')).default;
         const text = new TextDecoder().decode(buffer);
         const text = new TextDecoder().decode(buffer);
-        const result = Papa.parse<string[]>(text, { skipEmptyLines: false });
-        const all = result.data.filter((row) => Array.isArray(row));
-        // A trailing newline parses as one empty row — drop trailing blanks.
-        while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
-          all.pop();
-        }
-        const totalRows = all.length;
-        const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
-        const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        const rows: string[][] = [];
+        let totalRows = 0;
+        let totalCols = 0;
+        // A trailing newline parses as one empty row, and only a *trailing*
+        // run of blanks is dropped — so blanks are held back until a later
+        // row proves they were interior.
+        let pendingBlanks: string[][] = [];
+        // Counted and capped row by row, so the whole file is never
+        // materialised as rows the preview then throws away.
+        const take = (row: string[]) => {
+          totalRows += 1;
+          totalCols = Math.max(totalCols, row.length);
+          if (rows.length < MAX_ROWS) rows.push(row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        };
+        Papa.parse<string[]>(text, {
+          skipEmptyLines: false,
+          step: ({ data }) => {
+            if (!Array.isArray(data)) return;
+            if (data.every((cell) => !cell)) {
+              pendingBlanks.push(data);
+              return;
+            }
+            pendingBlanks.forEach(take);
+            pendingBlanks = [];
+            take(data);
+          },
+        });
         parsed = [{ name: filename, rows, totalRows, totalCols }];
         parsed = [{ name: filename, rows, totalRows, totalCols }];
       } else {
       } else {
         // SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
         // SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
@@ -186,7 +200,8 @@ export function SpreadsheetPreviewModal({
           if (blob && !cancelled) onSnapshotRef.current?.(blob);
           if (blob && !cancelled) onSnapshotRef.current?.(blob);
         }
         }
       }
       }
-    })().catch(() => {
+    })().catch((err: unknown) => {
+      console.error('[sheet-preview] load failed', err);
       if (!cancelled) setError(t('fileManager.preview.error'));
       if (!cancelled) setError(t('fileManager.preview.error'));
     });
     });
 
 
@@ -201,105 +216,99 @@ export function SpreadsheetPreviewModal({
   const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
   const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
 
 
   return (
   return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
-        {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 min-w-0">
-            <FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
-            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-            aria-label={t('common.close')}
-          >
-            <X className="w-5 h-5" />
-          </button>
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+    >
+      {/* Sheet tabs */}
+      {sheets && sheets.length > 1 && (
+        <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
+          {sheets.map((s, index) => (
+            <button
+              key={`${s.name}-${index}`}
+              onClick={() => setActiveSheet(index)}
+              className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
+                index === activeSheet
+                  ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
+              }`}
+            >
+              {s.name}
+            </button>
+          ))}
         </div>
         </div>
+      )}
 
 
-        {/* Sheet tabs */}
-        {sheets && sheets.length > 1 && (
-          <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
-            {sheets.map((s, index) => (
-              <button
-                key={`${s.name}-${index}`}
-                onClick={() => setActiveSheet(index)}
-                className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
-                  index === activeSheet
-                    ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
-                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
-                }`}
-              >
-                {s.name}
-              </button>
-            ))}
+      {/* Content */}
+      <div
+        data-testid="spreadsheet-preview-content"
+        className={`flex-1 min-h-0 overflow-auto bg-bambu-dark ${isFullscreen ? '' : 'rounded-b-lg'}`}
+        onDoubleClick={toggleFullscreen}
+      >
+        {error ? (
+          <div className="h-full flex items-center justify-center p-6">
+            <p className="text-bambu-gray text-center">{error}</p>
           </div>
           </div>
-        )}
-
-        {/* Content */}
-        <div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
-          {error ? (
-            <div className="h-full flex items-center justify-center p-6">
-              <p className="text-bambu-gray text-center">{error}</p>
-            </div>
-          ) : !sheets ? (
-            <div className="h-full flex items-center justify-center">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
-          ) : !sheet || sheet.rows.length === 0 ? (
-            <div className="h-full flex items-center justify-center p-6">
-              <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
-            </div>
-          ) : (
-            <table className="border-collapse text-xs">
-              <thead>
-                <tr>
-                  <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+        ) : !sheets ? (
+          <div className="h-full flex items-center justify-center">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          </div>
+        ) : !sheet || sheet.rows.length === 0 ? (
+          <div className="h-full flex items-center justify-center p-6">
+            <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
+          </div>
+        ) : (
+          // min-w-full so a narrow sheet spreads across the wider panel
+          // instead of huddling in its top-left corner (#2976).
+          <table className="border-collapse text-xs min-w-full">
+            <thead>
+              <tr>
+                <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+                {Array.from({ length: shownCols }, (_, c) => (
+                  <th
+                    key={c}
+                    className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                  >
+                    {columnLabel(c)}
+                  </th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {sheet.rows.map((row, r) => (
+                <tr key={r}>
+                  <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
+                    {r + 1}
+                  </td>
                   {Array.from({ length: shownCols }, (_, c) => (
                   {Array.from({ length: shownCols }, (_, c) => (
-                    <th
+                    <td
                       key={c}
                       key={c}
-                      className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                      className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
+                      title={row[c] || undefined}
                     >
                     >
-                      {columnLabel(c)}
-                    </th>
+                      {row[c] ?? ''}
+                    </td>
                   ))}
                   ))}
                 </tr>
                 </tr>
-              </thead>
-              <tbody>
-                {sheet.rows.map((row, r) => (
-                  <tr key={r}>
-                    <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
-                      {r + 1}
-                    </td>
-                    {Array.from({ length: shownCols }, (_, c) => (
-                      <td
-                        key={c}
-                        className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
-                        title={row[c] || undefined}
-                      >
-                        {row[c] ?? ''}
-                      </td>
-                    ))}
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          )}
-        </div>
-
-        {/* Truncation notices */}
-        {(rowsTruncated || colsTruncated) && sheet && (
-          <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
-            {rowsTruncated && (
-              <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
-            )}
-            {colsTruncated && (
-              <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
-            )}
-          </div>
+              ))}
+            </tbody>
+          </table>
         )}
         )}
       </div>
       </div>
-    </div>
+
+      {/* Truncation notices */}
+      {(rowsTruncated || colsTruncated) && sheet && (
+        <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
+          {rowsTruncated && (
+            <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
+          )}
+          {colsTruncated && (
+            <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
+          )}
+        </div>
+      )}
+    </PreviewModalShell>
   );
   );
 }
 }

+ 72 - 0
frontend/src/hooks/useElementFullscreen.ts

@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
+
+// True where the unprefixed Fullscreen API can be used on an arbitrary
+// element. iPhone Safari only offers it for <video>, and an <iframe> without
+// allowfullscreen reports fullscreenEnabled === false.
+export function fullscreenApiAvailable(): boolean {
+  return (
+    typeof document !== 'undefined' &&
+    document.fullscreenEnabled === true &&
+    typeof document.documentElement.requestFullscreen === 'function'
+  );
+}
+
+/**
+ * Fullscreen for a preview panel (#2976).
+ *
+ * Uses the Fullscreen API on the element behind `ref` where the browser has
+ * it, and otherwise a viewport-filling layout driven by the same
+ * `isFullscreen` flag, so the toggle keeps working on iPhone Safari and in
+ * embedded views that refuse the request (a webview without the fullscreen
+ * permission rejects it even though `fullscreenEnabled` says yes). In API
+ * mode the flag follows `fullscreenchange`: Esc is handled by the browser and
+ * simply shows up as a change, and the panel leaves fullscreen on unmount so
+ * closing the modal never strands the document in fullscreen.
+ */
+export function useElementFullscreen(ref: RefObject<HTMLElement | null>) {
+  const apiAvailable = fullscreenApiAvailable();
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  // True while the viewport-filling fallback is what the user sees.
+  const fallbackActiveRef = useRef(false);
+
+  useEffect(() => {
+    if (!apiAvailable) return;
+    const element = ref.current;
+    const sync = () => {
+      if (fallbackActiveRef.current) return;
+      setIsFullscreen(document.fullscreenElement != null && document.fullscreenElement === ref.current);
+    };
+    document.addEventListener('fullscreenchange', sync);
+    return () => {
+      document.removeEventListener('fullscreenchange', sync);
+      if (element && document.fullscreenElement === element) {
+        Promise.resolve(document.exitFullscreen()).catch(() => {});
+      }
+    };
+  }, [apiAvailable, ref]);
+
+  const toggleFullscreen = useCallback(() => {
+    if (fallbackActiveRef.current) {
+      fallbackActiveRef.current = false;
+      setIsFullscreen(false);
+      return;
+    }
+    if (apiAvailable && document.fullscreenElement) {
+      Promise.resolve(document.exitFullscreen()).catch(() => {});
+      return;
+    }
+    const element = ref.current;
+    if (!element) return;
+    const enterFallback = () => {
+      fallbackActiveRef.current = true;
+      setIsFullscreen(true);
+    };
+    if (!apiAvailable) {
+      enterFallback();
+      return;
+    }
+    Promise.resolve(element.requestFullscreen()).catch(enterFallback);
+  }, [apiAvailable, ref]);
+
+  return { isFullscreen, toggleFullscreen, apiAvailable };
+}

+ 22 - 0
frontend/src/hooks/usePreviewFullscreen.ts

@@ -0,0 +1,22 @@
+import { useRef, type RefObject } from 'react';
+import { useElementFullscreen } from './useElementFullscreen';
+
+export interface PreviewFullscreen {
+  panelRef: RefObject<HTMLDivElement | null>;
+  isFullscreen: boolean;
+  toggleFullscreen: () => void;
+}
+
+/**
+ * Fullscreen state for a file preview panel (#2976).
+ *
+ * Held by the modal rather than by `PreviewModalShell`, which renders the
+ * toggle: the 3D viewer drives layout effects off `isFullscreen` — in
+ * fullscreen the plate list splits off the canvas — so the flag has to be
+ * readable outside the shell's own subtree.
+ */
+export function usePreviewFullscreen(): PreviewFullscreen {
+  const panelRef = useRef<HTMLDivElement>(null);
+  const { isFullscreen, toggleFullscreen } = useElementFullscreen(panelRef);
+  return { panelRef, isFullscreen, toggleFullscreen };
+}

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -4054,6 +4054,9 @@ export default {
       nextPage: 'Nächste Seite',
       nextPage: 'Nächste Seite',
       zoomIn: 'Vergrößern',
       zoomIn: 'Vergrößern',
       zoomOut: 'Verkleinern',
       zoomOut: 'Verkleinern',
+      resetZoom: 'Zoom zurücksetzen',
+      fullscreen: 'Vollbild',
+      exitFullscreen: 'Vollbild beenden',
       emptySheet: 'Dieses Tabellenblatt ist leer',
       emptySheet: 'Dieses Tabellenblatt ist leer',
       truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
       truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
       truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',
       truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -4084,6 +4084,9 @@ export default {
       nextPage: 'Next page',
       nextPage: 'Next page',
       zoomIn: 'Zoom in',
       zoomIn: 'Zoom in',
       zoomOut: 'Zoom out',
       zoomOut: 'Zoom out',
+      resetZoom: 'Reset zoom',
+      fullscreen: 'Fullscreen',
+      exitFullscreen: 'Exit fullscreen',
       emptySheet: 'This sheet is empty',
       emptySheet: 'This sheet is empty',
       truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
       truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
       truncatedCols: 'Showing the first {{shown}} of {{total}} columns',
       truncatedCols: 'Showing the first {{shown}} of {{total}} columns',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -4056,6 +4056,9 @@ export default {
       nextPage: 'Página siguiente',
       nextPage: 'Página siguiente',
       zoomIn: 'Acercar',
       zoomIn: 'Acercar',
       zoomOut: 'Alejar',
       zoomOut: 'Alejar',
+      resetZoom: 'Restablecer zoom',
+      fullscreen: 'Pantalla completa',
+      exitFullscreen: 'Salir de la pantalla completa',
       emptySheet: 'Esta hoja está vacía',
       emptySheet: 'Esta hoja está vacía',
       truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
       truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
       truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',
       truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -4042,6 +4042,9 @@ export default {
       nextPage: 'Page suivante',
       nextPage: 'Page suivante',
       zoomIn: 'Zoom avant',
       zoomIn: 'Zoom avant',
       zoomOut: 'Zoom arrière',
       zoomOut: 'Zoom arrière',
+      resetZoom: 'Réinitialiser le zoom',
+      fullscreen: 'Plein écran',
+      exitFullscreen: 'Quitter plein écran',
       emptySheet: 'Cette feuille est vide',
       emptySheet: 'Cette feuille est vide',
       truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
       truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
       truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',
       truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: 'Pagina successiva',
       nextPage: 'Pagina successiva',
       zoomIn: 'Ingrandisci',
       zoomIn: 'Ingrandisci',
       zoomOut: 'Riduci',
       zoomOut: 'Riduci',
+      resetZoom: 'Reimposta zoom',
+      fullscreen: 'Schermo intero',
+      exitFullscreen: 'Esci da schermo intero',
       emptySheet: 'Questo foglio è vuoto',
       emptySheet: 'Questo foglio è vuoto',
       truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
       truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
       truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',
       truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -4054,6 +4054,9 @@ export default {
       nextPage: '次のページ',
       nextPage: '次のページ',
       zoomIn: '拡大',
       zoomIn: '拡大',
       zoomOut: '縮小',
       zoomOut: '縮小',
+      resetZoom: 'ズームをリセット',
+      fullscreen: 'フルスクリーン',
+      exitFullscreen: 'フルスクリーンを終了',
       emptySheet: 'このシートは空です',
       emptySheet: 'このシートは空です',
       truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
       truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
       truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',
       truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -3863,6 +3863,9 @@ export default {
       nextPage: '다음 페이지',
       nextPage: '다음 페이지',
       zoomIn: '확대',
       zoomIn: '확대',
       zoomOut: '축소',
       zoomOut: '축소',
+      resetZoom: '확대/축소 초기화',
+      fullscreen: '전체 화면',
+      exitFullscreen: '전체 화면 종료',
       emptySheet: '이 시트는 비어 있습니다',
       emptySheet: '이 시트는 비어 있습니다',
       truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
       truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
       truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',
       truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',

+ 3 - 0
frontend/src/i18n/locales/nl.ts

@@ -4084,6 +4084,9 @@ export default {
       nextPage: 'Volgende pagina',
       nextPage: 'Volgende pagina',
       zoomIn: 'Inzoomen',
       zoomIn: 'Inzoomen',
       zoomOut: 'Uitzoomen',
       zoomOut: 'Uitzoomen',
+      resetZoom: 'Zoom herstellen',
+      fullscreen: 'Volledig scherm',
+      exitFullscreen: 'Volledig scherm afsluiten',
       emptySheet: 'Dit werkblad is leeg',
       emptySheet: 'Dit werkblad is leeg',
       truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
       truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
       truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',
       truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: 'Próxima página',
       nextPage: 'Próxima página',
       zoomIn: 'Aumentar zoom',
       zoomIn: 'Aumentar zoom',
       zoomOut: 'Diminuir zoom',
       zoomOut: 'Diminuir zoom',
+      resetZoom: 'Redefinir zoom',
+      fullscreen: 'Tela Cheia',
+      exitFullscreen: 'Sair da Tela Cheia',
       emptySheet: 'Esta planilha está vazia',
       emptySheet: 'Esta planilha está vazia',
       truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
       truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
       truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',
       truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -3855,6 +3855,9 @@ export default {
       nextPage: 'Следующая страница',
       nextPage: 'Следующая страница',
       zoomIn: 'Увеличить',
       zoomIn: 'Увеличить',
       zoomOut: 'Уменьшить',
       zoomOut: 'Уменьшить',
+      resetZoom: 'Сбросить масштаб',
+      fullscreen: 'На весь экран',
+      exitFullscreen: 'Выйти из полноэкранного режима',
       emptySheet: 'Этот лист пуст',
       emptySheet: 'Этот лист пуст',
       truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
       truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
       truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',
       truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',

+ 3 - 0
frontend/src/i18n/locales/sv.ts

@@ -4083,6 +4083,9 @@ errors: {
       nextPage: 'Nästa sida',
       nextPage: 'Nästa sida',
       zoomIn: 'Zooma in',
       zoomIn: 'Zooma in',
       zoomOut: 'Zooma ut',
       zoomOut: 'Zooma ut',
+      resetZoom: 'Återställ zoom',
+      fullscreen: 'Helskärm',
+      exitFullscreen: 'Avsluta helskärm',
       emptySheet: 'Det här bladet är tomt',
       emptySheet: 'Det här bladet är tomt',
       truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
       truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
       truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',
       truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -4048,6 +4048,9 @@ export default {
       nextPage: 'Sonraki sayfa',
       nextPage: 'Sonraki sayfa',
       zoomIn: 'Yakınlaştır',
       zoomIn: 'Yakınlaştır',
       zoomOut: 'Uzaklaştır',
       zoomOut: 'Uzaklaştır',
+      resetZoom: 'Yakınlaştırmayı sıfırla',
+      fullscreen: 'Tam ekran',
+      exitFullscreen: 'Tam ekrandan çık',
       emptySheet: 'Bu sayfa boş',
       emptySheet: 'Bu sayfa boş',
       truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
       truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
       truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',
       truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -4081,6 +4081,9 @@ export default {
       nextPage: 'Наступна сторінка',
       nextPage: 'Наступна сторінка',
       zoomIn: 'Збільшити',
       zoomIn: 'Збільшити',
       zoomOut: 'Зменшити',
       zoomOut: 'Зменшити',
+      resetZoom: 'Скинути масштаб',
+      fullscreen: 'Повний екран',
+      exitFullscreen: 'Вийти з повноекранного режиму',
       emptySheet: 'Цей аркуш порожній',
       emptySheet: 'Цей аркуш порожній',
       truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
       truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
       truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',
       truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: '下一页',
       nextPage: '下一页',
       zoomIn: '放大',
       zoomIn: '放大',
       zoomOut: '缩小',
       zoomOut: '缩小',
+      resetZoom: '重置缩放',
+      fullscreen: '全屏',
+      exitFullscreen: '退出全屏',
       emptySheet: '此工作表为空',
       emptySheet: '此工作表为空',
       truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
       truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
       truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',
       truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4041,6 +4041,9 @@ export default {
       nextPage: '下一頁',
       nextPage: '下一頁',
       zoomIn: '放大',
       zoomIn: '放大',
       zoomOut: '縮小',
       zoomOut: '縮小',
+      resetZoom: '重設縮放',
+      fullscreen: '全螢幕',
+      exitFullscreen: '離開全螢幕',
       emptySheet: '此工作表是空的',
       emptySheet: '此工作表是空的',
       truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
       truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
       truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',
       truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',

+ 146 - 56
frontend/src/pages/FileManagerPage.tsx

@@ -51,6 +51,7 @@ import {
   Globe,
   Globe,
   StickyNote,
   StickyNote,
   Camera,
   Camera,
+  Eye,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -97,6 +98,9 @@ const PdfPreviewModal = lazy(() =>
 const SpreadsheetPreviewModal = lazy(() =>
 const SpreadsheetPreviewModal = lazy(() =>
   import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
   import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
 );
 );
+const ImagePreviewModal = lazy(() =>
+  import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
+);
 
 
 function isSpreadsheetType(fileType: string): boolean {
 function isSpreadsheetType(fileType: string): boolean {
   return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
   return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
@@ -106,6 +110,68 @@ function isStepType(fileType: string): boolean {
   return fileType === 'step' || fileType === 'stp';
   return fileType === 'step' || fileType === 'stp';
 }
 }
 
 
+// Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
+// stores and renders a thumbnail for, and so the ones that get an image icon.
+const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
+
+// The subset ImagePreviewModal can actually show: it hands the bytes to an
+// <img>, and outside Safari no browser decodes TIFF. Offering the preview
+// would download up to 50 MB only to report "cannot be previewed", so TIFF
+// keeps its server-rendered thumbnail and no preview (#2976).
+const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
+
+function isImageType(fileType: string): boolean {
+  return IMAGE_TYPES.has(fileType.toLowerCase());
+}
+
+function isPreviewableImageType(fileType: string): boolean {
+  return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
+}
+
+// Which files have a preview at all: what a double-click opens, and what the
+// toolbar's Preview button appears for (#2976). Sliced files go to the
+// full-page gcode viewer, everything else to a modal.
+function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
+  const type = file.file_type;
+  return (
+    isSlicedLibraryFile(file) ||
+    type === '3mf' ||
+    type === 'stl' ||
+    isStepType(type) ||
+    type === 'pdf' ||
+    isSpreadsheetType(type) ||
+    isPreviewableImageType(type)
+  );
+}
+
+// Spread onto a card/row subtree that is not "the row": its own controls must
+// neither toggle the selection nor open the preview. `dblclick` is a separate
+// native event from `click`, so stopping the click alone still lets the second
+// click of a double-click reach the row's onDoubleClick (#2976).
+const stopRowActivation = {
+  onClick: (e: React.MouseEvent) => e.stopPropagation(),
+  onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
+};
+
+// Whether the preview is the 3D one, which has its own menu label.
+function isModelPreview(file: LibraryFileListItem): boolean {
+  return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
+}
+
+function documentPreviewIcon(fileType: string) {
+  if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
+  if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
+  return <Image className="w-4 h-4" />;
+}
+
+// Types the server renders a thumbnail for on request, STL through trimesh
+// and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
+// applies to them. Mirrors the file types batch_generate_stl_thumbnails in
+// routes/library.py selects.
+function hasServerThumbnail(fileType: string): boolean {
+  return fileType === 'stl' || fileType === 'pdf';
+}
+
 // New Folder Modal
 // New Folder Modal
 interface NewFolderModalProps {
 interface NewFolderModalProps {
   parentId: number | null;
   parentId: number | null;
@@ -784,8 +850,7 @@ interface FileCardProps {
   // (#3029), so offering one there would only ever fail.
   // (#3029), so offering one there would only ever fail.
   desktopSlicer: SlicerType;
   desktopSlicer: SlicerType;
   canSlice?: boolean;
   canSlice?: boolean;
-  onPreview3d?: (file: LibraryFileListItem) => void;
-  onPreviewDocument?: (file: LibraryFileListItem) => void;
+  onPreview?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onDetails?: (file: LibraryFileListItem) => void;
   onDetails?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -798,7 +863,7 @@ interface FileCardProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onPreviewDocument, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -840,20 +905,12 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
       title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
     });
     });
   }
   }
-  if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf' || isStepType(file.file_type))) {
-    menuItems.push({
-      label: t('fileManager.preview3d'),
-      icon: <Box className="w-4 h-4" />,
-      onClick: () => onPreview3d(file),
-      disabled: !canPreview3d,
-      title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
-    });
-  }
-  if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
+  if (onPreview && isPreviewableLibraryFile(file)) {
+    const modelPreview = isModelPreview(file);
     menuItems.push({
     menuItems.push({
-      label: t('fileManager.preview.open'),
-      icon: file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />,
-      onClick: () => onPreviewDocument(file),
+      label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
+      icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
+      onClick: () => onPreview(file),
       disabled: !canPreview3d,
       disabled: !canPreview3d,
       title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
       title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
     });
     });
@@ -892,7 +949,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
       onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
     });
     });
   }
   }
-  if (onGenerateThumbnail && file.file_type === 'stl') {
+  if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
     menuItems.push({
     menuItems.push({
       label: t('fileManager.generateThumbnail'),
       label: t('fileManager.generateThumbnail'),
       icon: <Image className="w-4 h-4" />,
       icon: <Image className="w-4 h-4" />,
@@ -918,6 +975,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
       }`}
       }`}
       onClick={() => onSelect(file.id)}
       onClick={() => onSelect(file.id)}
+      // Double-click opens the preview (#2976). The two clicks that precede it
+      // toggle the selection twice, so the selection is left as it was.
+      onDoubleClick={() => onPreview?.(file)}
     >
     >
       {/* Thumbnail */}
       {/* Thumbnail */}
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
@@ -931,6 +991,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           <FileText className="w-12 h-12 text-bambu-gray/30" />
           <FileText className="w-12 h-12 text-bambu-gray/30" />
         ) : isSpreadsheetType(file.file_type) ? (
         ) : isSpreadsheetType(file.file_type) ? (
           <FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
           <FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
+        ) : isImageType(file.file_type) ? (
+          <Image className="w-12 h-12 text-bambu-gray/30" />
         ) : (
         ) : (
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
         )}
         )}
@@ -986,7 +1048,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
         {/* Metadata indicators (#3077): link, notes, photos. The link opens in
         {/* Metadata indicators (#3077): link, notes, photos. The link opens in
             a new tab like the archive card's globe; the others open Details. */}
             a new tab like the archive card's globe; the others open Details. */}
         {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
         {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
-          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" onClick={(e) => e.stopPropagation()}>
+          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
             {file.external_url && (
             {file.external_url && (
               <a
               <a
                 href={file.external_url}
                 href={file.external_url}
@@ -1039,7 +1101,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           </div>
           </div>
         )}
         )}
         {(file.tags?.length ?? 0) > 0 && (
         {(file.tags?.length ?? 0) > 0 && (
-          <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
+          <div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
             {file.tags!.map((tg) => (
             {file.tags!.map((tg) => (
               <button
               <button
                 key={tg.id}
                 key={tg.id}
@@ -1057,7 +1119,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       </div>
       </div>
 
 
       {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
       {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
-      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
+      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
         <button
         <button
           onClick={(e) => {
           onClick={(e) => {
             // No open/close toggle: the menu's own outside-mousedown handler
             // No open/close toggle: the menu's own outside-mousedown handler
@@ -1130,6 +1192,7 @@ export function FileManagerPage() {
   const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
   const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
+  const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1676,6 +1739,34 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
     onError: (error: Error) => showToast(error.message, 'error'),
   });
   });
 
 
+  // The one way into a preview (#2976): the kebab entry, the action-strip
+  // icon, a double-click on the card or row, and the toolbar button all end
+  // up here. Sliced files open the full-page gcode viewer the archive card
+  // uses; everything else opens the modal for its type. A file with no
+  // preview does nothing.
+  const openPreview = useCallback((file: LibraryFileListItem) => {
+    if (!hasPermission('library:read')) return;
+    if (isSlicedLibraryFile(file)) {
+      navigate(`/gcode-viewer?library_file=${file.id}`);
+    } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
+      setViewerFile(file);
+    } else if (file.file_type === 'pdf') {
+      setPdfPreviewFile(file);
+    } else if (isSpreadsheetType(file.file_type)) {
+      setSheetPreviewFile(file);
+    } else if (isPreviewableImageType(file.file_type)) {
+      setImagePreviewFile(file);
+    }
+  }, [hasPermission, navigate]);
+
+  // The toolbar's Preview button acts on one file, so it is offered only for
+  // a single previewable selection.
+  const previewSelection = useMemo(() => {
+    if (!files || selectedFiles.length !== 1) return null;
+    const file = files.find((f) => f.id === selectedFiles[0]);
+    return file && isPreviewableLibraryFile(file) ? file : null;
+  }, [files, selectedFiles]);
+
   // Get sliced files from selection
   // Get sliced files from selection
   const selectedSlicedFiles = useMemo(() => {
   const selectedSlicedFiles = useMemo(() => {
     if (!files) return [];
     if (!files) return [];
@@ -2411,6 +2502,18 @@ export function FileManagerPage() {
                   </span>
                   </span>
                   <div className="hidden sm:block flex-1" />
                   <div className="hidden sm:block flex-1" />
                   <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
                   <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
+                    {previewSelection && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => openPreview(previewSelection)}
+                        disabled={!hasPermission('library:read')}
+                        title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
+                      >
+                        <Eye className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
+                      </Button>
+                    )}
                     {/* Print used to disappear the moment a second sliced file was
                     {/* Print used to disappear the moment a second sliced file was
                         selected. Selecting several is now how you say "same job,
                         selected. Selecting several is now how you say "same job,
                         different printers" (#671) — one queue item, whichever
                         different printers" (#671) — one queue item, whichever
@@ -2564,21 +2667,7 @@ export function FileManagerPage() {
                     onRunPipeline={setRunPipelineFile}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     canSlice={canSlice()}
                     canSlice={canSlice()}
-                    onPreview3d={(f) => {
-                      // Sliced files (.gcode / .gcode.3mf) open the same
-                      // full-page gcode viewer the archive card uses, so
-                      // the two paths feel consistent. STL / source 3MF
-                      // continue to use the in-app 3D model viewer modal.
-                      if (isSlicedLibraryFile(f)) {
-                        navigate(`/gcode-viewer?library_file=${f.id}`);
-                      } else {
-                        setViewerFile(f);
-                      }
-                    }}
-                    onPreviewDocument={(f) => {
-                      if (f.file_type === 'pdf') setPdfPreviewFile(f);
-                      else setSheetPreviewFile(f);
-                    }}
+                    onPreview={openPreview}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onDetails={setDetailsFile}
                     onDetails={setDetailsFile}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
@@ -2624,6 +2713,8 @@ export function FileManagerPage() {
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     }`}
                     onClick={() => handleFileSelect(file.id)}
                     onClick={() => handleFileSelect(file.id)}
+                    // Double-click opens the preview (#2976), as in the grid.
+                    onDoubleClick={() => openPreview(file)}
                   >
                   >
                     {/* Checkbox */}
                     {/* Checkbox */}
                     <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
                     <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
@@ -2649,6 +2740,8 @@ export function FileManagerPage() {
                                 <FileText className="w-5 h-5 text-bambu-gray/50" />
                                 <FileText className="w-5 h-5 text-bambu-gray/50" />
                               ) : isSpreadsheetType(file.file_type) ? (
                               ) : isSpreadsheetType(file.file_type) ? (
                                 <FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
                                 <FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
+                              ) : isImageType(file.file_type) ? (
+                                <Image className="w-5 h-5 text-bambu-gray/50" />
                               ) : (
                               ) : (
                                 <FileBox className="w-5 h-5 text-bambu-gray/50" />
                                 <FileBox className="w-5 h-5 text-bambu-gray/50" />
                               )}
                               )}
@@ -2677,7 +2770,7 @@ export function FileManagerPage() {
                               href={file.external_url}
                               href={file.external_url}
                               target="_blank"
                               target="_blank"
                               rel="noopener noreferrer"
                               rel="noopener noreferrer"
-                              onClick={(e) => e.stopPropagation()}
+                              {...stopRowActivation}
                               className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
                               className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
                               title={t('fileManager.details.openLink')}
                               title={t('fileManager.details.openLink')}
                               aria-label={t('fileManager.details.openLink')}
                               aria-label={t('fileManager.details.openLink')}
@@ -2742,7 +2835,7 @@ export function FileManagerPage() {
                         filter; minmax(0,200px) on the column lets the cell
                         filter; minmax(0,200px) on the column lets the cell
                         shrink/wrap on narrow viewports without pushing the
                         shrink/wrap on narrow viewports without pushing the
                         Actions cell off-screen. */}
                         Actions cell off-screen. */}
-                    <div className="min-w-0" onClick={(e) => e.stopPropagation()}>
+                    <div className="min-w-0" {...stopRowActivation}>
                       {!file.tags || file.tags.length === 0 ? (
                       {!file.tags || file.tags.length === 0 ? (
                         <span className="text-xs text-bambu-gray/50">-</span>
                         <span className="text-xs text-bambu-gray/50">-</span>
                       ) : (
                       ) : (
@@ -2763,7 +2856,7 @@ export function FileManagerPage() {
                       )}
                       )}
                     </div>
                     </div>
                     {/* Actions */}
                     {/* Actions */}
-                    <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
+                    <div className="flex items-center gap-1" {...stopRowActivation}>
                       {isSlicedLibraryFile(file) && (
                       {isSlicedLibraryFile(file) && (
                         <>
                         <>
                           <button
                           <button
@@ -2811,16 +2904,9 @@ export function FileManagerPage() {
                           <Play className="w-4 h-4" />
                           <Play className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
-                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
+                      {isModelPreview(file) && (
                         <button
                         <button
-                          onClick={() => {
-                            if (!hasPermission('library:read')) return;
-                            if (isSlicedLibraryFile(file)) {
-                              navigate(`/gcode-viewer?library_file=${file.id}`);
-                            } else {
-                              setViewerFile(file);
-                            }
-                          }}
+                          onClick={() => openPreview(file)}
                           className={`p-1.5 rounded transition-colors ${
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2832,13 +2918,9 @@ export function FileManagerPage() {
                           <Box className="w-4 h-4" />
                           <Box className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
-                      {(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
+                      {!isModelPreview(file) && isPreviewableLibraryFile(file) && (
                         <button
                         <button
-                          onClick={() => {
-                            if (!hasPermission('library:read')) return;
-                            if (file.file_type === 'pdf') setPdfPreviewFile(file);
-                            else setSheetPreviewFile(file);
-                          }}
+                          onClick={() => openPreview(file)}
                           className={`p-1.5 rounded transition-colors ${
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2847,7 +2929,7 @@ export function FileManagerPage() {
                           title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
                           title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
                           disabled={!hasPermission('library:read')}
                           disabled={!hasPermission('library:read')}
                         >
                         >
-                          {file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
+                          {documentPreviewIcon(file.file_type)}
                         </button>
                         </button>
                       )}
                       )}
                       <button
                       <button
@@ -2886,7 +2968,7 @@ export function FileManagerPage() {
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
-                      {file.file_type === 'stl' && (
+                      {hasServerThumbnail(file.file_type) && (
                         <button
                         <button
                           onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
                           onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
                           className={`p-1.5 rounded transition-colors ${
                           className={`p-1.5 rounded transition-colors ${
@@ -3087,7 +3169,7 @@ export function FileManagerPage() {
         />
         />
       )}
       )}
 
 
-      {(pdfPreviewFile || sheetPreviewFile) && (
+      {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
         <Suspense fallback={null}>
         <Suspense fallback={null}>
           {pdfPreviewFile && (
           {pdfPreviewFile && (
             <PdfPreviewModal
             <PdfPreviewModal
@@ -3108,6 +3190,14 @@ export function FileManagerPage() {
               onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
               onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
             />
             />
           )}
           )}
+          {imagePreviewFile && (
+            <ImagePreviewModal
+              libraryFileId={imagePreviewFile.id}
+              filename={imagePreviewFile.print_name || imagePreviewFile.filename}
+              fileSize={imagePreviewFile.file_size}
+              onClose={() => setImagePreviewFile(null)}
+            />
+          )}
         </Suspense>
         </Suspense>
       )}
       )}
 
 

+ 43 - 1
frontend/vite.config.ts

@@ -1,4 +1,7 @@
+import { createRequire } from 'node:module'
+import { readdirSync, readFileSync } from 'node:fs'
 import { defineConfig } from 'vite'
 import { defineConfig } from 'vite'
+import type { Plugin } from 'vite'
 import react from '@vitejs/plugin-react'
 import react from '@vitejs/plugin-react'
 import path from 'path'
 import path from 'path'
 
 
@@ -6,6 +9,45 @@ import path from 'path'
 const backendPort = process.env.BACKEND_PORT || '8000'
 const backendPort = process.env.BACKEND_PORT || '8000'
 const backendUrl = `http://localhost:${backendPort}`
 const backendUrl = `http://localhost:${backendPort}`
 
 
+// pdf.js keeps these out of its bundle and fetches them at runtime (#2976):
+// CJK text needs the CMaps, non-embedded fonts the standard font files, and
+// JPEG2000/JBIG2 images and ICC colour the wasm decoders. Published under the
+// bundle's own assets directory so the existing /assets mount serves them —
+// PdfPreviewModal builds the matching URLs from its PDFJS_ASSET_BASE.
+const PDFJS_RUNTIME_DIRS = ['cmaps', 'iccs', 'standard_fonts', 'wasm']
+const PDFJS_RUNTIME_PREFIX = 'assets/pdfjs'
+
+/** Publish pdf.js's runtime data directories next to the bundle. */
+function pdfjsRuntimeAssets(): Plugin {
+  const require = createRequire(__filename)
+  const packageDir = path.dirname(require.resolve('pdfjs-dist/package.json'))
+  // Published path -> file on disk. Built once, and used as an allowlist by
+  // the dev-server handler so no request can escape the package directory.
+  const files = new Map<string, string>()
+  for (const dir of PDFJS_RUNTIME_DIRS) {
+    for (const entry of readdirSync(path.join(packageDir, dir), { withFileTypes: true })) {
+      if (!entry.isFile()) continue
+      files.set(`${PDFJS_RUNTIME_PREFIX}/${dir}/${entry.name}`, path.join(packageDir, dir, entry.name))
+    }
+  }
+
+  return {
+    name: 'bambuddy:pdfjs-runtime-assets',
+    generateBundle() {
+      for (const [fileName, source] of files) {
+        this.emitFile({ type: 'asset', fileName, source: readFileSync(source) })
+      }
+    },
+    configureServer(server) {
+      server.middlewares.use((req, res, next) => {
+        const source = files.get((req.url ?? '').split('?')[0].replace(/^\//, ''))
+        if (!source) return next()
+        res.setHeader('Content-Type', source.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream')
+        res.end(readFileSync(source))
+      })
+    },
+  }
+}
 
 
 export default defineConfig({
 export default defineConfig({
   // Default base ('/') emits absolute asset URLs (/assets/...). Required so
   // Default base ('/') emits absolute asset URLs (/assets/...). Required so
@@ -17,7 +59,7 @@ export default defineConfig({
   // fix for subpath reverse proxies (#1195, wontfix) is reverted — that
   // fix for subpath reverse proxies (#1195, wontfix) is reverted — that
   // audience uses NPM + Cloudflare Tunnel at a real domain per the
   // audience uses NPM + Cloudflare Tunnel at a real domain per the
   // documented workaround, which doesn't depend on this setting.
   // documented workaround, which doesn't depend on this setting.
-  plugins: [react()],
+  plugins: [react(), pdfjsRuntimeAssets()],
   build: {
   build: {
     outDir: '../static',
     outDir: '../static',
     emptyOutDir: true,
     emptyOutDir: true,