From 226826f3bd98a7f8996c9beeb853a362adb67164 Mon Sep 17 00:00:00 2001 From: Thomansky <73141171+Thomansky@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:27:19 +0200 Subject: [PATCH] File Manager previews: fullscreen and zoom, image previews, double-click to open (#2990) --- backend/app/api/routes/library.py | 80 ++- backend/app/main.py | 40 +- backend/tests/integration/test_library_api.py | 25 + .../test_library_preview_thumbnail_api.py | 154 +++++ .../integration/test_security_headers.py | 81 ++- frontend/scripts/check-browser-baseline.mjs | 37 +- .../components/ImagePreviewModal.test.tsx | 235 +++++++ .../components/ModelViewerControls.test.tsx | 210 ++++++ .../components/ModelViewerModal.test.tsx | 46 +- .../components/PdfPreviewModal.test.tsx | 354 +++++++++- .../components/PreviewModalShell.test.tsx | 140 ++++ .../SpreadsheetPreviewModal.test.tsx | 126 +++- .../hooks/useElementFullscreen.test.tsx | 127 ++++ .../__tests__/pages/FileManagerPage.test.tsx | 80 +++ .../pages/FileManagerPreviewOpening.test.tsx | 335 ++++++++++ frontend/src/components/ImagePreviewModal.tsx | 306 +++++++++ frontend/src/components/ModelViewer.tsx | 60 +- frontend/src/components/ModelViewerModal.tsx | 617 +++++++++--------- frontend/src/components/PdfPreviewModal.tsx | 389 ++++++++--- frontend/src/components/PreviewModalShell.tsx | 101 +++ .../components/SpreadsheetPreviewModal.tsx | 227 +++---- frontend/src/hooks/useElementFullscreen.ts | 72 ++ frontend/src/hooks/usePreviewFullscreen.ts | 22 + frontend/src/i18n/locales/de.ts | 3 + frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/es.ts | 3 + frontend/src/i18n/locales/fr.ts | 3 + frontend/src/i18n/locales/it.ts | 3 + frontend/src/i18n/locales/ja.ts | 3 + frontend/src/i18n/locales/ko.ts | 3 + frontend/src/i18n/locales/nl.ts | 3 + frontend/src/i18n/locales/pt-BR.ts | 3 + frontend/src/i18n/locales/ru.ts | 3 + frontend/src/i18n/locales/sv.ts | 3 + frontend/src/i18n/locales/tr.ts | 3 + frontend/src/i18n/locales/uk.ts | 3 + frontend/src/i18n/locales/zh-CN.ts | 3 + frontend/src/i18n/locales/zh-TW.ts | 3 + frontend/src/pages/FileManagerPage.tsx | 202 ++++-- frontend/vite.config.ts | 44 +- 40 files changed, 3498 insertions(+), 657 deletions(-) create mode 100644 frontend/src/__tests__/components/ImagePreviewModal.test.tsx create mode 100644 frontend/src/__tests__/components/ModelViewerControls.test.tsx create mode 100644 frontend/src/__tests__/components/PreviewModalShell.test.tsx create mode 100644 frontend/src/__tests__/hooks/useElementFullscreen.test.tsx create mode 100644 frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx create mode 100644 frontend/src/components/ImagePreviewModal.tsx create mode 100644 frontend/src/components/PreviewModalShell.tsx create mode 100644 frontend/src/hooks/useElementFullscreen.ts create mode 100644 frontend/src/hooks/usePreviewFullscreen.ts diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 5f4c098fd..651e3bdae 100644 --- a/backend/app/api/routes/library.py +++ b/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.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.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"} # 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"} # 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. 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: """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 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 @@ -5455,26 +5467,48 @@ async def upload_preview_thumbnail( from PIL import Image, UnidentifiedImageError 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") - 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: 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 - 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() + if result.rowcount == 0: + thumb_path.unlink(missing_ok=True) + return ClientThumbnailResponse(updated=False) return ClientThumbnailResponse(updated=True) diff --git a/backend/app/main.py b/backend/app/main.py index 76f43365d..447080c08 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10163,10 +10163,18 @@ def _frame_ancestors(default_value: str) -> str: return f"frame-ancestors {default_value};" -# The Vite-emitted STEP preview worker chunk (#2976): src/workers/ -# stepPreview.worker.ts becomes /assets/stepPreview.worker-.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-.js +# pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url +# -> /assets/pdf.worker.min-.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$") +_PDF_WORKER_ASSET_RE = re.compile(r"^/assets/pdf\.worker\.min-[\w-]+\.js$") @app.middleware("http") @@ -10236,6 +10244,20 @@ async def security_headers_middleware(request, call_next): "object-src '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: # The streaming overlay is embedded same-origin by the URL builder's # 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 # screen to bait a click. 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"] = ( "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'; " "img-src 'self' data: blob:; " "media-src 'self' blob:; " diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index 548abf83e..ebdcb5cb4 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -1190,6 +1190,31 @@ endsolid cube""" # No thumbnail should be generated when disabled 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.integration async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session): diff --git a/backend/tests/integration/test_library_preview_thumbnail_api.py b/backend/tests/integration/test_library_preview_thumbnail_api.py index b59fe5a7d..0bdd0a8b4 100644 --- a/backend/tests/integration/test_library_preview_thumbnail_api.py +++ b/backend/tests/integration/test_library_preview_thumbnail_api.py @@ -7,6 +7,7 @@ types, and never replacing an existing thumbnail. """ import io +import zlib import pytest 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.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: @@ -22,6 +24,22 @@ def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes: 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 def isolated_storage(monkeypatch, tmp_path): """Point thumbnail storage at a throwaway directory.""" @@ -169,3 +187,139 @@ class TestPreviewThumbnailUpload: files={"thumbnail": ("preview.png", _png_bytes(), "image/png")}, ) 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 diff --git a/backend/tests/integration/test_security_headers.py b/backend/tests/integration/test_security_headers.py index 852a9de9a..6dcbd3865 100644 --- a/backend/tests/integration/test_security_headers.py +++ b/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}" -# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ───────── +# ─── #2976: WebAssembly is confined to the two preview workers ─────────── + + +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. + """ + 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() @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). +async def test_spa_csp_permits_no_kind_of_eval(async_client: AsyncClient): + """The document policy allows neither wasm compilation nor JS 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. + 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") - csp = resp.headers.get("Content-Security-Policy", "") - script_src = next( - (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}" + 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 @@ -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 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") - 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") - 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) ─── diff --git a/frontend/scripts/check-browser-baseline.mjs b/frontend/scripts/check-browser-baseline.mjs index 8709c52ea..6f5c42dae 100644 --- a/frontend/scripts/check-browser-baseline.mjs +++ b/frontend/scripts/check-browser-baseline.mjs @@ -28,11 +28,37 @@ */ 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'; 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 * needs context to tell a false positive from a real hit (regex flags, for @@ -62,17 +88,16 @@ const FORBIDDEN = [ let bundles; 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 { console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`); process.exit(1); } 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); } diff --git a/frontend/src/__tests__/components/ImagePreviewModal.test.tsx b/frontend/src/__tests__/components/ImagePreviewModal.test.tsx new file mode 100644 index 000000000..d15b1c760 --- /dev/null +++ b/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[0]> = {}) { + return render( + , + ); +} + +async function renderLoadedModal(props: Partial[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); + }); +}); diff --git a/frontend/src/__tests__/components/ModelViewerControls.test.tsx b/frontend/src/__tests__/components/ModelViewerControls.test.tsx new file mode 100644 index 000000000..6344e630d --- /dev/null +++ b/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(); + 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(); + 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); + }); +}); diff --git a/frontend/src/__tests__/components/ModelViewerModal.test.tsx b/frontend/src/__tests__/components/ModelViewerModal.test.tsx index e00c44c59..9e311ccec 100644 --- a/frontend/src/__tests__/components/ModelViewerModal.test.tsx +++ b/frontend/src/__tests__/components/ModelViewerModal.test.tsx @@ -202,11 +202,55 @@ describe('ModelViewerModal', () => { // Look for the maximize icon button const buttons = screen.getAllByRole('button'); 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(); }); }); + + 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( + + ); + 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 }).requestFullscreen; + } + }); + + it('fills the viewport as a fallback where the Fullscreen API is missing', async () => { + render( + + ); + 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', () => { diff --git a/frontend/src/__tests__/components/PdfPreviewModal.test.tsx b/frontend/src/__tests__/components/PdfPreviewModal.test.tsx index 7cbc64ffd..7b021ed22 100644 --- a/frontend/src/__tests__/components/PdfPreviewModal.test.tsx +++ b/frontend/src/__tests__/components/PdfPreviewModal.test.tsx @@ -3,25 +3,24 @@ * * 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 — - * 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 { 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 { PdfPreviewModal } from '../../components/PdfPreviewModal'; const pdfjsMocks = vi.hoisted(() => { 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(() => ({ promise: Promise.resolve({ numPages: 3, getPage }), destroy: vi.fn(), })); - return { render, getPage, getDocument }; + return { render, defaultViewport, getViewport, getPage, getDocument }; }); vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({ @@ -52,13 +51,49 @@ function renderModal(props: Partial[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[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', () => { beforeEach(() => { 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( 'fetch', 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(() => { @@ -92,6 +127,40 @@ describe('PdfPreviewModal', () => { 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((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 () => { const fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); @@ -100,4 +169,275 @@ describe('PdfPreviewModal', () => { expect(await screen.findByText(/too large to preview/)).toBeInTheDocument(); 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 }).exitFullscreen; + delete (HTMLElement.prototype as { requestFullscreen?: () => Promise }).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); + }); }); diff --git a/frontend/src/__tests__/components/PreviewModalShell.test.tsx b/frontend/src/__tests__/components/PreviewModalShell.test.tsx new file mode 100644 index 000000000..a02260e61 --- /dev/null +++ b/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 ( + Zoom in} + > +
+ body +
+
+ ); +} + +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 }).exitFullscreen; + delete (HTMLElement.prototype as { requestFullscreen?: () => Promise }).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(); + 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(); + + 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(); + 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(); + + 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(); + + 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(); + const backdrop = panelOf().parentElement as HTMLElement; + + await user.click(backdrop); + expect(mockOnClose).not.toHaveBeenCalled(); + unmount(); + + render(); + 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); + }); +}); diff --git a/frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx b/frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx index d94943871..4bc33d838 100644 --- a/frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx +++ b/frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx @@ -7,7 +7,7 @@ */ 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 * as XLSX from 'xlsx'; import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal'; @@ -111,4 +111,128 @@ describe('SpreadsheetPreviewModal', () => { expect(await screen.findByText(/too large to preview/)).toBeInTheDocument(); 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 }).exitFullscreen; + delete (HTMLElement.prototype as { requestFullscreen?: () => Promise }).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(); + }); + }); }); diff --git a/frontend/src/__tests__/hooks/useElementFullscreen.test.tsx b/frontend/src/__tests__/hooks/useElementFullscreen.test.tsx new file mode 100644 index 000000000..1f868ada7 --- /dev/null +++ b/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(null); + const { isFullscreen, toggleFullscreen, apiAvailable } = useElementFullscreen(ref); + return ( +
+ +
+ ); +} + +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 }).exitFullscreen; + delete (HTMLElement.prototype as { requestFullscreen?: () => Promise }).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(); + 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(); + 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(); + 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(); + 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(); + const panel = screen.getByTestId('panel'); + + fireEvent.click(screen.getByText('toggle')); + browserSetsFullscreen(panel); + + unmount(); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx index 6bf301b99..60da9d09a 100644 --- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx +++ b/frontend/src/__tests__/pages/FileManagerPage.test.tsx @@ -600,6 +600,86 @@ describe('FileManagerPage', () => { 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, 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(); + 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(); + 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(); + 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)', () => { diff --git a/frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx b/frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx new file mode 100644 index 000000000..d71d5e7e0 --- /dev/null +++ b/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()), + useNavigate: () => mockNavigate, +})); + +vi.mock('../../components/ModelViewerModal', () => ({ + ModelViewerModal: ({ title }: { title: string }) =>
{title}
, +})); +vi.mock('../../components/PdfPreviewModal', () => ({ + PdfPreviewModal: ({ filename }: { filename: string }) =>
{filename}
, +})); +vi.mock('../../components/SpreadsheetPreviewModal', () => ({ + SpreadsheetPreviewModal: ({ filename }: { filename: string }) => ( +
{filename}
+ ), +})); +vi.mock('../../components/ImagePreviewModal', () => ({ + ImagePreviewModal: ({ filename }: { filename: string }) =>
{filename}
, +})); +vi.mock('../../components/LibraryFileDetailsModal', () => ({ + LibraryFileDetailsModal: () =>
, +})); + +function libraryFile(overrides: Record) { + 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).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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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).mockImplementation((key: string) => + key === 'library-view-mode' ? 'list' : null, + ); + }); + + it('opens the document preview for a PDF', async () => { + const user = userEvent.setup(); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/frontend/src/components/ImagePreviewModal.tsx b/frontend/src/components/ImagePreviewModal.tsx new file mode 100644 index 000000000..02ae84e01 --- /dev/null +++ b/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(null); + const imageRef = useRef(null); + + const [src, setSrc] = useState(null); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(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(ORIGIN); + const [dragging, setDragging] = useState(false); + + const zoomRef = useRef(1); + const offsetRef = useRef(ORIGIN); + const pointersRef = useRef>(new Map()); + const pinchDistanceRef = useRef(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 : the download endpoint + // wants the bearer token, which an 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) => { + 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) => { + pointersRef.current.delete(e.pointerId); + pinchDistanceRef.current = null; + if (pointersRef.current.size === 0) setDragging(false); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + 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 ( + } + actions={ + src ? ( + <> + + + + + ) : undefined + } + > +
+ {error ? ( +

{error}

+ ) : ( + <> + {src && ( + {filename} 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 && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/ModelViewer.tsx b/frontend/src/components/ModelViewer.tsx index dad0264ba..6eb0a5080 100644 --- a/frontend/src/components/ModelViewer.tsx +++ b/frontend/src/components/ModelViewer.tsx @@ -64,6 +64,9 @@ function parseStepInWorker(buffer: ArrayBuffer): Promise { }); } +// 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. * @@ -78,6 +81,7 @@ function fitCameraToBox( camera: THREE.PerspectiveCamera, controls: OrbitControls, box: THREE.Box3, + plateDiagonal: number, padding = 1.15, ): void { const size = box.getSize(new THREE.Vector3()); @@ -92,10 +96,18 @@ function fitCameraToBox( // Keep the established three-quarter view; only the distance changes. const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize(); 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 - // 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.far = distance + radius * 4; + camera.far = controls.maxDistance + radius * 4 + plateDiagonal; camera.updateProjectionMatrix(); controls.target.copy(center); @@ -108,6 +120,11 @@ interface BuildVolume { 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 { url: string; fileType?: string; @@ -765,7 +782,7 @@ function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THRE export function ModelViewer({ url, fileType, - buildVolume = { x: 256, y: 256, z: 256 }, + buildVolume = DEFAULT_BUILD_VOLUME, filamentColors, selectedPlateId = null, className = '', @@ -786,6 +803,9 @@ export function ModelViewer({ const modelGroupRef = useRef(null); const plateRef = useRef(null); const gridRef = useRef(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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [parsedData, setParsedData] = useState(null); @@ -1023,6 +1043,10 @@ export function ModelViewer({ renderer.setSize(w, h); }; 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(() => { handleResize(); }); @@ -1030,6 +1054,7 @@ export function ModelViewer({ return () => { window.removeEventListener('resize', handleResize); + document.removeEventListener('fullscreenchange', handleResize); resizeObserver.disconnect(); cancelAnimationFrame(animationId); controls.dispose(); @@ -1148,7 +1173,8 @@ export function ModelViewer({ const finalBox = new THREE.Box3().setFromObject(group); // 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); @@ -1184,17 +1210,29 @@ export function ModelViewer({ }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]); 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) => { - 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 ( diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 7116cdd7d..4ac84d63d 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/frontend/src/components/ModelViewerModal.tsx @@ -1,9 +1,11 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; 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 { Button } from './Button'; +import { PreviewModalShell } from './PreviewModalShell'; +import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen'; import { api, withMediaToken } from '../api/client'; import { useToast } from '../contexts/ToastContext'; 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 [selectedPlateId, setSelectedPlateId] = useState(null); const [platePage, setPlatePage] = useState(0); - const [isFullscreen, setIsFullscreen] = useState(false); const [platePanelHeight, setPlatePanelHeight] = useState(null); const [isDraggingDivider, setIsDraggingDivider] = useState(false); const [hasCustomSplit, setHasCustomSplit] = useState(false); const splitContainerRef = useRef(null); const platesPanelRef = useRef(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 minPlateHeight = 160; const minViewerPx = 240; 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(() => { setLoading(true); @@ -454,339 +450,324 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on })); return ( -
+ {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })} + + ) : undefined + } + actions={ + <> + {useBambuddySlicer ? ( + } + label={t('slice.action')} + dropdownLabel={t('modelViewer.moreSlicerOptions')} + onPrimary={() => onSliceWithBambuddy?.()} + items={slicerDropdownItems} + /> + ) : canOpenInSlicer ? ( + } + // 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} + /> + ) : ( + + )} + + } > -
e.stopPropagation()} - > - {/* Header */} -
-
-

{title}

- {hasObjectCount && ( - - {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })} - - )} -
-
- {useBambuddySlicer ? ( - } - label={t('slice.action')} - dropdownLabel={t('modelViewer.moreSlicerOptions')} - onPrimary={() => onSliceWithBambuddy?.()} - items={slicerDropdownItems} - /> - ) : canOpenInSlicer ? ( - } - // 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} - /> - ) : ( - - )} - - -
+ {/* Tabs - only show if we have capabilities */} + {capabilities && ( +
+
+ )} - {/* Tabs - only show if we have capabilities */} - {capabilities && ( -
- + {/* Viewer */} +
+ {loading ? ( +
+
- )} - - {/* Viewer */} -
- {loading ? ( -
- -
- ) : activeTab === '3d' && capabilities ? ( -
- {hasMultiplePlates && ( -
-
- - {t('modelViewer.plates')} - {platesLoading && } -
-
-
-
+ {hasMultiplePlates && ( +
+
+ + {t('modelViewer.plates')} + {platesLoading && } +
+
+
+
+ + {pagedPlates.map((plate) => ( - {pagedPlates.map((plate) => ( - - ))} -
+ ))}
- {(selectedPlate || shouldPaginatePlates) && ( -
- {selectedPlate && ( -
- {t('modelViewer.plateNumber', { number: selectedPlate.index })} - {selectedPlate.print_time_seconds != null && ( - {t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })} - )} - {selectedPlate.filament_used_grams != null && ( - {selectedPlate.filament_used_grams.toFixed(1)} g - )} - {selectedPlate.filaments.length > 0 && ( - {t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })} - )} -
- )} - {shouldPaginatePlates && ( -
- {t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })} -
- - {(() => { - 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 && ( - - )} - {start > 1 && …} - {pages.map((pageNumber) => ( - - ))} - {end < totalPlatePages - 1 && …} - {end < totalPlatePages && ( - - )} - - ); - })()} - -
-
- )} -
- )}
+ {(selectedPlate || shouldPaginatePlates) && ( +
+ {selectedPlate && ( +
+ {t('modelViewer.plateNumber', { number: selectedPlate.index })} + {selectedPlate.print_time_seconds != null && ( + {t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })} + )} + {selectedPlate.filament_used_grams != null && ( + {selectedPlate.filament_used_grams.toFixed(1)} g + )} + {selectedPlate.filaments.length > 0 && ( + {t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })} + )} +
+ )} + {shouldPaginatePlates && ( +
+ {t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })} +
+ + {(() => { + 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 && ( + + )} + {start > 1 && …} + {pages.map((pageNumber) => ( + + ))} + {end < totalPlatePages - 1 && …} + {end < totalPlatePages && ( + + )} + + ); + })()} + +
+
+ )} +
+ )}
- )} - {splitFullscreen && ( -
{ - 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' - }`} - > -
-
- )} -
-
+ )} + {splitFullscreen && ( +
{ + 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' + }`} + > +
+
+ )} +
+
- ) : ( -
- {t('modelViewer.noPreview')} -
- )} -
+
+ ) : ( +
+ {t('modelViewer.noPreview')} +
+ )}
-
+ ); } diff --git a/frontend/src/components/PdfPreviewModal.tsx b/frontend/src/components/PdfPreviewModal.tsx index 300932d77..1adf0fbf0 100644 --- a/frontend/src/components/PdfPreviewModal.tsx +++ b/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 { 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 { api, getAuthToken } from '../api/client'; 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 // preview shows a notice instead of stalling the tab on a giant download. 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 { libraryFileId: number; filename: string; @@ -18,6 +44,20 @@ interface PdfPreviewModalProps { 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. function snapshotFromCanvas(source: HTMLCanvasElement): Promise { const size = 256; @@ -35,8 +75,17 @@ function snapshotFromCanvas(source: HTMLCanvasElement): Promise { 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) { const { t } = useTranslation(); + const fullscreen = usePreviewFullscreen(); + const { panelRef, isFullscreen, toggleFullscreen } = fullscreen; const containerRef = useRef(null); const canvasRef = useRef(null); const renderTaskRef = useRef(null); @@ -48,17 +97,160 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on const [doc, setDoc] = useState(null); 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 [renderZoom, setRenderZoom] = useState(1); + const [layoutVersion, setLayoutVersion] = useState(0); const [error, setError] = useState(null); 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(null); + const pointersRef = useRef>(new Map()); + const pinchDistanceRef = useRef(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(() => { 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); 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) => { + if (e.pointerType !== 'touch') return; + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + pinchDistanceRef.current = null; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + 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) => { + 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 // 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') ).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; 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; } 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) { setError(t('fileManager.preview.error')); 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. const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1); 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.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(); const task = page.render({ canvas, viewport }); @@ -158,6 +373,7 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on })().catch((err: unknown) => { // A cancelled render throws RenderingCancelledException — not an error. if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') { + console.error('[pdf-preview] render failed', err); setError(t('fileManager.preview.error')); setRendering(false); } @@ -166,89 +382,90 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on return () => { cancelled = true; }; - }, [doc, pageNum, zoom, t]); + }, [doc, pageNum, renderZoom, layoutVersion, t]); const pageCount = doc?.numPages ?? 0; + const iconButtonClass = previewIconButtonClass; return ( -
-
- {/* Header */} -
-
- -

{filename}

-
-
- {doc && pageCount > 1 && ( -
- - - {t('fileManager.preview.page', { current: pageNum, total: pageCount })} - - -
- )} - {doc && ( - <> - - - - )} - -
-
- - {/* Page */} -
- {error ? ( -
-

{error}

-
- ) : ( -
- + } + actions={ + <> + {doc && pageCount > 1 && ( +
+ + + {t('fileManager.preview.page', { current: pageNum, total: pageCount })} + +
)} - {!error && rendering && ( -
- -
+ {doc && ( + <> + + + )} -
+ + } + > + {/* Page */} +
+ {error ? ( +
+

{error}

+
+ ) : ( +
+ +
+ )} + {!error && rendering && ( +
+ +
+ )}
-
+ ); } diff --git a/frontend/src/components/PreviewModalShell.tsx b/frontend/src/components/PreviewModalShell.tsx new file mode 100644 index 000000000..51e21d12a --- /dev/null +++ b/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 ( +
+
e.stopPropagation() : undefined} + > +
+
+ {icon} +

{title}

+ {titleExtra} +
+
+ {actions} + + +
+
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/SpreadsheetPreviewModal.tsx b/frontend/src/components/SpreadsheetPreviewModal.tsx index 330654edf..66d15c20b 100644 --- a/frontend/src/components/SpreadsheetPreviewModal.tsx +++ b/frontend/src/components/SpreadsheetPreviewModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState } from 'react'; 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 { formatFileSize } from '../utils/file'; +import { PreviewModalShell } from './PreviewModalShell'; +import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen'; // 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. @@ -101,14 +103,8 @@ export function SpreadsheetPreviewModal({ useEffect(() => { 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(() => { let cancelled = false; @@ -135,15 +131,33 @@ export function SpreadsheetPreviewModal({ // papaparse is loaded on demand so it stays out of the main bundle. const Papa = (await import('papaparse')).default; const text = new TextDecoder().decode(buffer); - const result = Papa.parse(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(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 }]; } else { // SheetJS handles both XLSX and ODS; loaded on demand like papaparse. @@ -186,7 +200,8 @@ export function SpreadsheetPreviewModal({ if (blob && !cancelled) onSnapshotRef.current?.(blob); } } - })().catch(() => { + })().catch((err: unknown) => { + console.error('[sheet-preview] load failed', err); 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); return ( -
-
- {/* Header */} -
-
- -

{filename}

-
- + } + > + {/* Sheet tabs */} + {sheets && sheets.length > 1 && ( +
+ {sheets.map((s, index) => ( + + ))}
+ )} - {/* Sheet tabs */} - {sheets && sheets.length > 1 && ( -
- {sheets.map((s, index) => ( - - ))} + {/* Content */} +
+ {error ? ( +
+

{error}

- )} - - {/* Content */} -
- {error ? ( -
-

{error}

-
- ) : !sheets ? ( -
- -
- ) : !sheet || sheet.rows.length === 0 ? ( -
-

{t('fileManager.preview.emptySheet')}

-
- ) : ( - - - -
+ ) : !sheets ? ( +
+ +
+ ) : !sheet || sheet.rows.length === 0 ? ( +
+

{t('fileManager.preview.emptySheet')}

+
+ ) : ( + // min-w-full so a narrow sheet spreads across the wider panel + // instead of huddling in its top-left corner (#2976). + + + + + ))} + + + + {sheet.rows.map((row, r) => ( + + {Array.from({ length: shownCols }, (_, c) => ( - + {row[c] ?? ''} + ))} - - - {sheet.rows.map((row, r) => ( - - - {Array.from({ length: shownCols }, (_, c) => ( - - ))} - - ))} - -
+ {Array.from({ length: shownCols }, (_, c) => ( + + {columnLabel(c)} +
+ {r + 1} + - {columnLabel(c)} -
- {r + 1} - - {row[c] ?? ''} -
- )} - - - {/* Truncation notices */} - {(rowsTruncated || colsTruncated) && sheet && ( -
- {rowsTruncated && ( - {t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })} - )} - {colsTruncated && ( - {t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })} - )} -
+ ))} + +
)}
-
+ + {/* Truncation notices */} + {(rowsTruncated || colsTruncated) && sheet && ( +
+ {rowsTruncated && ( + {t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })} + )} + {colsTruncated && ( + {t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })} + )} +
+ )} + ); } diff --git a/frontend/src/hooks/useElementFullscreen.ts b/frontend/src/hooks/useElementFullscreen.ts new file mode 100644 index 000000000..adfa140bd --- /dev/null +++ b/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