mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
File Manager previews: fullscreen and zoom, image previews, double-click to open (#2990)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+31
-9
@@ -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-<hash>.js. Matched
|
||||
# exactly so the eval-relaxed CSP below can never apply to any other asset.
|
||||
# The two Vite-emitted worker assets that compile WebAssembly (#2976). Both
|
||||
# patterns are anchored on the exact emitted name so the relaxed policies
|
||||
# below can never apply to any other asset.
|
||||
# src/workers/stepPreview.worker.ts -> /assets/stepPreview.worker-<hash>.js
|
||||
# pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url
|
||||
# -> /assets/pdf.worker.min-<hash>.js
|
||||
# Vite also emits a one-line chunk under the second name that only exports the
|
||||
# worker's URL. The page imports it as a module, and a module script is run
|
||||
# under the importing document's policy, never its own response's, so it
|
||||
# matching as well changes nothing.
|
||||
_STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
|
||||
_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:; "
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ───
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Tests for ImagePreviewModal (#2976).
|
||||
*
|
||||
* jsdom decodes nothing and lays nothing out, so the tests cover the modal's
|
||||
* own logic — the authenticated fetch, the size guard, zoom input and the
|
||||
* fullscreen wiring — rather than what the image looks like.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ImagePreviewModal, IMAGE_PREVIEW_MAX_BYTES } from '../../components/ImagePreviewModal';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
|
||||
},
|
||||
getAuthToken: () => 'token-123',
|
||||
}));
|
||||
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
function renderModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
|
||||
return render(
|
||||
<ImagePreviewModal
|
||||
libraryFileId={11}
|
||||
filename="plate.png"
|
||||
fileSize={2048}
|
||||
onClose={mockOnClose}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function renderLoadedModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
|
||||
const utils = renderModal(props);
|
||||
const image = (await screen.findByAltText('plate.png')) as HTMLImageElement;
|
||||
fireEvent.load(image);
|
||||
return { ...utils, image, content: screen.getByTestId('image-preview-content') };
|
||||
}
|
||||
|
||||
function scaleOf(image: HTMLImageElement): number {
|
||||
const match = /scale\(([\d.]+)\)/.exec(image.style.transform);
|
||||
return match ? Number(match[1]) : NaN;
|
||||
}
|
||||
|
||||
describe('ImagePreviewModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 })),
|
||||
);
|
||||
vi.stubGlobal('URL', {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => 'blob:image-preview'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads the file through the authenticated download URL', async () => {
|
||||
await renderLoadedModal();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('http://test/library/files/11/download', {
|
||||
headers: { Authorization: 'Bearer token-123' },
|
||||
});
|
||||
expect(screen.getByAltText('plate.png')).toHaveAttribute('src', 'blob:image-preview');
|
||||
expect(screen.getByText('plate.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refuses a file too large to hold in memory', async () => {
|
||||
renderModal({ fileSize: IMAGE_PREVIEW_MAX_BYTES + 1 });
|
||||
|
||||
expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a message when the download fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderModal();
|
||||
|
||||
expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts fitted to the window and zooms from the buttons', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { image } = await renderLoadedModal();
|
||||
|
||||
expect(scaleOf(image)).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Zoom in' }));
|
||||
expect(scaleOf(image)).toBeCloseTo(1.25, 5);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Zoom out' }));
|
||||
expect(scaleOf(image)).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it('zooms with the wheel around the pointer', async () => {
|
||||
const { image, content } = await renderLoadedModal();
|
||||
|
||||
fireEvent.wheel(content, { deltaY: -100, clientX: 40, clientY: 30 });
|
||||
await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
|
||||
|
||||
fireEvent.wheel(content, { deltaY: 100, clientX: 40, clientY: 30 });
|
||||
await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1, 5));
|
||||
});
|
||||
|
||||
it('zooms from the keyboard and resets with 0', async () => {
|
||||
const { image } = await renderLoadedModal();
|
||||
|
||||
fireEvent.keyDown(window, { key: '+' });
|
||||
fireEvent.keyDown(window, { key: '+' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.5625, 4));
|
||||
|
||||
fireEvent.keyDown(window, { key: '0' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBe(1));
|
||||
});
|
||||
|
||||
it('stops at the zoom limits', async () => {
|
||||
const { image } = await renderLoadedModal();
|
||||
|
||||
for (let i = 0; i < 20; i++) fireEvent.keyDown(window, { key: '+' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBe(8));
|
||||
|
||||
for (let i = 0; i < 30; i++) fireEvent.keyDown(window, { key: '-' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBe(0.4));
|
||||
});
|
||||
|
||||
it('offers grab-to-pan only once the image is bigger than the window', async () => {
|
||||
const { content } = await renderLoadedModal();
|
||||
|
||||
expect(content.className).not.toContain('cursor-grab');
|
||||
|
||||
fireEvent.keyDown(window, { key: '+' });
|
||||
await waitFor(() => expect(content.className).toContain('cursor-grab'));
|
||||
});
|
||||
|
||||
describe('a pointer that never reports its release', () => {
|
||||
// jsdom has no PointerEvent, and testing-library then falls back to a plain
|
||||
// Event without pointerId / pointerType / buttons.
|
||||
class FakePointerEvent extends MouseEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 0;
|
||||
this.pointerType = init.pointerType ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** jsdom lays nothing out, so the pan clamp needs real box sizes. */
|
||||
function giveBoxes(content: HTMLElement, image: HTMLImageElement) {
|
||||
Object.defineProperty(content, 'clientWidth', { configurable: true, value: 200 });
|
||||
Object.defineProperty(content, 'clientHeight', { configurable: true, value: 200 });
|
||||
Object.defineProperty(image, 'offsetWidth', { configurable: true, value: 400 });
|
||||
Object.defineProperty(image, 'offsetHeight', { configurable: true, value: 400 });
|
||||
content.setPointerCapture = vi.fn();
|
||||
}
|
||||
|
||||
function translateOf(image: HTMLImageElement): string {
|
||||
return /translate\(([^)]*)\)/.exec(image.style.transform)?.[1] ?? '';
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as { PointerEvent?: unknown }).PointerEvent;
|
||||
});
|
||||
|
||||
it('drops it when it leaves the image area, instead of panning on hover', async () => {
|
||||
const { image, content } = await renderLoadedModal();
|
||||
giveBoxes(content, image);
|
||||
|
||||
// Press at zoom 1 — no pointer capture is taken — then release somewhere
|
||||
// else, so only pointerleave arrives.
|
||||
fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerLeave(content, { pointerId: 1, pointerType: 'mouse' });
|
||||
|
||||
fireEvent.keyDown(window, { key: '+' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
|
||||
|
||||
fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
|
||||
|
||||
expect(translateOf(image)).toBe('0px, 0px');
|
||||
});
|
||||
|
||||
it('drops it on the first buttonless move, so a later touch is not read as a pinch', async () => {
|
||||
const { image, content } = await renderLoadedModal();
|
||||
giveBoxes(content, image);
|
||||
|
||||
fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
|
||||
fireEvent.keyDown(window, { key: '+' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
|
||||
|
||||
// The mouse moves back over the image with nothing held down.
|
||||
fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
|
||||
expect(translateOf(image)).toBe('0px, 0px');
|
||||
|
||||
// One finger now drags alone: it pans, it does not pinch-zoom.
|
||||
fireEvent.pointerDown(content, { pointerId: 2, pointerType: 'touch', clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(content, { pointerId: 2, pointerType: 'touch', clientX: 130, clientY: 100 });
|
||||
|
||||
expect(scaleOf(image)).toBeCloseTo(1.25, 5);
|
||||
expect(translateOf(image)).toBe('30px, 0px');
|
||||
});
|
||||
|
||||
it('still pans while the button is held', async () => {
|
||||
const { image, content } = await renderLoadedModal();
|
||||
giveBoxes(content, image);
|
||||
|
||||
fireEvent.keyDown(window, { key: '+' });
|
||||
await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
|
||||
|
||||
fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 120, clientY: 110 });
|
||||
|
||||
expect(translateOf(image)).toBe('20px, 10px');
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
await renderLoadedModal();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Zoom and resize behaviour of the 3D model viewer (#2976).
|
||||
*
|
||||
* Only what jsdom cannot provide is replaced: the WebGL renderer, the PMREM
|
||||
* environment it would bake, and OrbitControls. Scene, camera and the STL
|
||||
* loader are real, so the tests exercise the component's own framing maths.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setSize: vi.fn(),
|
||||
wheelSeen: vi.fn(),
|
||||
controlsInstances: [] as Array<{ object: THREE.Camera; domElement: HTMLElement; target: THREE.Vector3; minDistance: number; maxDistance: number }>,
|
||||
}));
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('three')>();
|
||||
class FakeWebGLRenderer {
|
||||
domElement = document.createElement('canvas');
|
||||
shadowMap = { enabled: false, type: 0 };
|
||||
toneMapping = 0;
|
||||
toneMappingExposure = 1;
|
||||
setSize(width: number, height: number) {
|
||||
mocks.setSize(width, height);
|
||||
this.domElement.style.width = `${width}px`;
|
||||
this.domElement.style.height = `${height}px`;
|
||||
}
|
||||
setPixelRatio() {}
|
||||
render() {}
|
||||
dispose() {}
|
||||
}
|
||||
class FakePMREMGenerator {
|
||||
fromScene() {
|
||||
return { texture: { dispose() {} } };
|
||||
}
|
||||
dispose() {}
|
||||
}
|
||||
return { ...actual, WebGLRenderer: FakeWebGLRenderer, PMREMGenerator: FakePMREMGenerator };
|
||||
});
|
||||
|
||||
vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
|
||||
OrbitControls: class {
|
||||
object: THREE.Camera;
|
||||
domElement: HTMLElement;
|
||||
target = new THREE.Vector3();
|
||||
enableDamping = false;
|
||||
dampingFactor = 0;
|
||||
minDistance = 0;
|
||||
maxDistance = Infinity;
|
||||
constructor(object: THREE.Camera, domElement: HTMLElement) {
|
||||
this.object = object;
|
||||
this.domElement = domElement;
|
||||
// The real controls listen for wheel on the element they are handed;
|
||||
// the fake does the same so the test can tell where events end up.
|
||||
domElement.addEventListener('wheel', mocks.wheelSeen);
|
||||
mocks.controlsInstances.push(this);
|
||||
}
|
||||
update() {}
|
||||
dispose() {
|
||||
this.domElement.removeEventListener('wheel', mocks.wheelSeen);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
getAuthToken: () => null,
|
||||
}));
|
||||
|
||||
import { ModelViewer } from '../../components/ModelViewer';
|
||||
|
||||
// Binary STL of one triangle, enough for a bounding box.
|
||||
function tinyStl(): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(84 + 50);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(80, 1, true);
|
||||
const floats = [0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 20, 10];
|
||||
floats.forEach((value, i) => view.setFloat32(84 + i * 4, value, true));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function setClientSize(element: HTMLElement, width: number, height: number) {
|
||||
Object.defineProperty(element, 'clientWidth', { configurable: true, value: width });
|
||||
Object.defineProperty(element, 'clientHeight', { configurable: true, value: height });
|
||||
}
|
||||
|
||||
describe('ModelViewer controls', () => {
|
||||
beforeEach(() => {
|
||||
mocks.setSize.mockClear();
|
||||
mocks.wheelSeen.mockClear();
|
||||
mocks.controlsInstances.length = 0;
|
||||
// jsdom lays nothing out; give every element a panel-sized box so the
|
||||
// camera gets a real aspect ratio to frame against.
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 800 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 600 });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(tinyStl(), { status: 200 })),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth;
|
||||
delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight;
|
||||
});
|
||||
|
||||
async function renderLoaded() {
|
||||
const utils = render(<ModelViewer url="/api/v1/library/files/1/download" fileType="stl" />);
|
||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
// Loaded once the zoom / reset buttons replace the spinner.
|
||||
await waitFor(() => expect(document.querySelector('.lucide-zoom-in')).not.toBeNull());
|
||||
return { ...utils, canvas, controls: mocks.controlsInstances[0] };
|
||||
}
|
||||
|
||||
it('hands the canvas that is in the DOM to OrbitControls, so wheel events reach it', async () => {
|
||||
const { canvas, controls } = await renderLoaded();
|
||||
|
||||
expect(controls.domElement).toBe(canvas);
|
||||
expect(document.body.contains(canvas)).toBe(true);
|
||||
|
||||
fireEvent.wheel(canvas, { deltaY: -100 });
|
||||
expect(mocks.wheelSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds the dolly range and keeps the far plane beyond it', async () => {
|
||||
const { controls } = await renderLoaded();
|
||||
const camera = controls.object as THREE.PerspectiveCamera;
|
||||
|
||||
const framed = camera.position.distanceTo(controls.target);
|
||||
expect(controls.maxDistance).toBeGreaterThan(framed);
|
||||
expect(Number.isFinite(controls.maxDistance)).toBe(true);
|
||||
expect(controls.minDistance).toBeGreaterThan(0);
|
||||
// Zooming all the way out must never push the model past the far plane.
|
||||
expect(camera.far).toBeGreaterThan(controls.maxDistance);
|
||||
});
|
||||
|
||||
it('zoom buttons dolly along the view axis toward the orbit target', async () => {
|
||||
const { controls } = await renderLoaded();
|
||||
const camera = controls.object as THREE.PerspectiveCamera;
|
||||
const before = camera.position.clone();
|
||||
const distanceBefore = before.distanceTo(controls.target);
|
||||
const directionBefore = before.clone().sub(controls.target).normalize();
|
||||
|
||||
fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
|
||||
|
||||
const distanceAfter = camera.position.distanceTo(controls.target);
|
||||
expect(distanceAfter).toBeCloseTo(distanceBefore * 0.8, 5);
|
||||
const directionAfter = camera.position.clone().sub(controls.target).normalize();
|
||||
expect(directionAfter.distanceTo(directionBefore)).toBeLessThan(1e-6);
|
||||
|
||||
fireEvent.click(document.querySelector('.lucide-zoom-out')!.closest('button')!);
|
||||
expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distanceBefore, 5);
|
||||
});
|
||||
|
||||
it('resets to the framed view, not a fixed pose', async () => {
|
||||
const { controls } = await renderLoaded();
|
||||
const camera = controls.object as THREE.PerspectiveCamera;
|
||||
const framedPosition = camera.position.clone();
|
||||
const framedTarget = controls.target.clone();
|
||||
|
||||
fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
|
||||
fireEvent.click(document.querySelector('.lucide-rotate-ccw')!.closest('button')!);
|
||||
|
||||
expect(camera.position.distanceTo(framedPosition)).toBeLessThan(1e-6);
|
||||
expect(controls.target.distanceTo(framedTarget)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('resizes the canvas and camera when fullscreen changes', async () => {
|
||||
const { canvas, controls } = await renderLoaded();
|
||||
const camera = controls.object as THREE.PerspectiveCamera;
|
||||
const container = canvas.parentElement as HTMLElement;
|
||||
mocks.setSize.mockClear();
|
||||
|
||||
setClientSize(container, 1920, 1080);
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
|
||||
expect(mocks.setSize).toHaveBeenCalledWith(1920, 1080);
|
||||
expect(camera.aspect).toBeCloseTo(1920 / 1080, 5);
|
||||
expect(canvas.style.width).toBe('1920px');
|
||||
});
|
||||
|
||||
it('ignores a resize to zero (hidden container) instead of collapsing the camera', async () => {
|
||||
const { canvas, controls } = await renderLoaded();
|
||||
const camera = controls.object as THREE.PerspectiveCamera;
|
||||
const container = canvas.parentElement as HTMLElement;
|
||||
const aspect = camera.aspect;
|
||||
mocks.setSize.mockClear();
|
||||
|
||||
setClientSize(container, 0, 0);
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
|
||||
expect(mocks.setSize).not.toHaveBeenCalled();
|
||||
expect(camera.aspect).toBe(aspect);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<ModelViewerModal
|
||||
archiveId={1}
|
||||
title="Test Model"
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
const viewer = await screen.findByTestId('model-viewer-area');
|
||||
|
||||
fireEvent.doubleClick(viewer);
|
||||
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
const panel = screen.getByText('Test Model').closest('.flex-col');
|
||||
expect(requestFullscreen.mock.instances[0]).toBe(panel);
|
||||
} finally {
|
||||
delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
|
||||
}
|
||||
});
|
||||
|
||||
it('fills the viewport as a fallback where the Fullscreen API is missing', async () => {
|
||||
render(
|
||||
<ModelViewerModal
|
||||
archiveId={1}
|
||||
title="Test Model"
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
const viewer = await screen.findByTestId('model-viewer-area');
|
||||
const panel = screen.getByText('Test Model').closest('.flex-col') as HTMLElement;
|
||||
// Windowed: the size every preview shares (#2976), not a per-modal max-w.
|
||||
expect(panel.className).toContain('w-[min(1800px,96vw)]');
|
||||
|
||||
fireEvent.doubleClick(viewer);
|
||||
|
||||
expect(panel.className).toContain('max-w-none');
|
||||
expect(screen.getByTitle('Exit fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('object count', () => {
|
||||
|
||||
@@ -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<Parameters<typeof PdfPreviewModal>[0]> = {})
|
||||
);
|
||||
}
|
||||
|
||||
// jsdom lays nothing out, so the fit scale bottoms out at the 0.1 floor and
|
||||
// the 600pt page is shown 60px wide at zoom 1.
|
||||
const CSS_WIDTH_AT_ZOOM_1 = 60;
|
||||
|
||||
async function renderLoadedModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
|
||||
const utils = renderModal(props);
|
||||
await screen.findByText('Page 1 of 3');
|
||||
await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
|
||||
const page = screen.getByTestId('pdf-preview-page');
|
||||
const canvas = page.querySelector('canvas') as HTMLCanvasElement;
|
||||
await waitFor(() => expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`));
|
||||
return { ...utils, page, canvas };
|
||||
}
|
||||
|
||||
function lastRenderScale(): number {
|
||||
const calls = pdfjsMocks.getViewport.mock.calls;
|
||||
return calls[calls.length - 1][0].scale;
|
||||
}
|
||||
|
||||
// A wheel event with ctrlKey, as Ctrl+wheel and a trackpad pinch both arrive.
|
||||
function wheelWithCtrl(target: Element, deltaY: number) {
|
||||
return fireEvent.wheel(target, { deltaY, ctrlKey: true, clientX: 30, clientY: 40 });
|
||||
}
|
||||
|
||||
describe('PdfPreviewModal', () => {
|
||||
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<Response>((resolve) => (deliver = resolve))),
|
||||
);
|
||||
const destroy = vi.fn();
|
||||
pdfjsMocks.getDocument.mockReturnValueOnce({ promise: new Promise(() => {}), destroy } as never);
|
||||
|
||||
const { unmount } = renderModal();
|
||||
unmount();
|
||||
deliver(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
|
||||
|
||||
await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('refuses oversized files without fetching them', async () => {
|
||||
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<void> }).exitFullscreen;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
|
||||
});
|
||||
|
||||
// The browser flips fullscreenElement and fires fullscreenchange; the
|
||||
// mocks do neither, so the test plays the browser's part.
|
||||
function enterFullscreen(panel: Element) {
|
||||
(document as { fullscreenElement: Element | null }).fullscreenElement = panel;
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
}
|
||||
|
||||
it('double-click on the page toggles fullscreen', async () => {
|
||||
const { page } = await renderLoadedModal();
|
||||
const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
|
||||
|
||||
fireEvent.doubleClick(page);
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(requestFullscreen.mock.instances[0]).toBe(panel);
|
||||
|
||||
enterFullscreen(panel);
|
||||
expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.doubleClick(page);
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers a fullscreen button in the header', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderLoadedModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-syncs when the browser leaves fullscreen on Esc', async () => {
|
||||
const { page } = await renderLoadedModal();
|
||||
const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
|
||||
|
||||
fireEvent.doubleClick(page);
|
||||
enterFullscreen(panel);
|
||||
expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
|
||||
|
||||
// Esc while fullscreen is the browser's: the modal must not close.
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
enterFullscreen(null as unknown as Element);
|
||||
expect(screen.getByRole('button', { name: 'Fullscreen' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grid thumbnail snapshot', () => {
|
||||
// jsdom has no 2D backend: the snapshot helper draws into an offscreen
|
||||
// canvas and hands the result to toBlob, so both have to be stood in for.
|
||||
function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
|
||||
const context = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() };
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
context as unknown as CanvasRenderingContext2D,
|
||||
);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
|
||||
return context;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('hands the first page to onSnapshot as a PNG', async () => {
|
||||
stubCanvas2d();
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
await renderLoadedModal({ onSnapshot });
|
||||
|
||||
await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
|
||||
const blob = onSnapshot.mock.calls[0][0] as Blob;
|
||||
expect(blob.type).toBe('image/png');
|
||||
});
|
||||
|
||||
it('sends it once, not on every re-render of page 1', async () => {
|
||||
stubCanvas2d();
|
||||
const user = userEvent.setup();
|
||||
const onSnapshot = vi.fn();
|
||||
await renderLoadedModal({ onSnapshot });
|
||||
await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next page' }));
|
||||
await screen.findByText('Page 2 of 3');
|
||||
await user.click(screen.getByRole('button', { name: 'Previous page' }));
|
||||
await screen.findByText('Page 1 of 3');
|
||||
|
||||
expect(onSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays quiet when the canvas yields no blob', async () => {
|
||||
stubCanvas2d(null);
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
await renderLoadedModal({ onSnapshot });
|
||||
await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
|
||||
|
||||
expect(onSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the raster inside the canvas area iOS Safari will back', async () => {
|
||||
// Past roughly 16.7M pixels iOS Safari hands back a blank canvas instead
|
||||
// of failing, so resolution is what gives way, not the page.
|
||||
vi.stubGlobal('devicePixelRatio', 2);
|
||||
pdfjsMocks.getViewport.mockImplementation(({ scale }: { scale: number }) => ({
|
||||
width: 30000 * scale,
|
||||
height: 30000 * scale,
|
||||
}));
|
||||
|
||||
renderModal();
|
||||
await screen.findByText('Page 1 of 3');
|
||||
await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
|
||||
|
||||
// Unclamped this would be 0.1 (fit floor) x 2 (dpr) = 6000 x 6000 px.
|
||||
const scale = lastRenderScale();
|
||||
expect(scale).toBeLessThan(0.2);
|
||||
expect(30000 * scale * (30000 * scale)).toBeLessThanOrEqual(16 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for PreviewModalShell (#2976) — the backdrop, panel size and header
|
||||
* every file preview shares.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { PreviewModalShell } from '../../components/PreviewModalShell';
|
||||
import { usePreviewFullscreen } from '../../hooks/usePreviewFullscreen';
|
||||
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
function Harness({ closeOnBackdropClick }: { closeOnBackdropClick?: boolean }) {
|
||||
const fullscreen = usePreviewFullscreen();
|
||||
return (
|
||||
<PreviewModalShell
|
||||
title="drawing.pdf"
|
||||
fullscreen={fullscreen}
|
||||
onClose={mockOnClose}
|
||||
closeOnBackdropClick={closeOnBackdropClick}
|
||||
actions={<button type="button">Zoom in</button>}
|
||||
>
|
||||
<div data-testid="preview-body" onDoubleClick={fullscreen.toggleFullscreen}>
|
||||
body
|
||||
</div>
|
||||
</PreviewModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function panelOf(): HTMLElement {
|
||||
return screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('PreviewModalShell', () => {
|
||||
const requestFullscreen = vi.fn();
|
||||
const exitFullscreen = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestFullscreen.mockResolvedValue(undefined);
|
||||
exitFullscreen.mockResolvedValue(undefined);
|
||||
Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
|
||||
Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
|
||||
Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
|
||||
delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
|
||||
delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
|
||||
});
|
||||
|
||||
// The browser flips fullscreenElement and fires fullscreenchange; the mocks
|
||||
// do neither, so the test plays the browser's part.
|
||||
function browserEntersFullscreen(panel: Element | null) {
|
||||
(document as { fullscreenElement: Element | null }).fullscreenElement = panel;
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
}
|
||||
|
||||
it('gives the panel the size every preview shares', () => {
|
||||
render(<Harness />);
|
||||
const panel = panelOf();
|
||||
|
||||
expect(panel.className).toContain('w-[min(1800px,96vw)]');
|
||||
expect(panel.className).toContain('h-[94vh]');
|
||||
expect(panel.className).toContain('rounded-lg');
|
||||
});
|
||||
|
||||
it('renders the title, the preview actions and the close button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByText('drawing.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('goes fullscreen on a double-click in the body and fills the screen', () => {
|
||||
render(<Harness />);
|
||||
const panel = panelOf();
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('preview-body'));
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(requestFullscreen.mock.instances[0]).toBe(panel);
|
||||
|
||||
browserEntersFullscreen(panel);
|
||||
expect(panel.className).toContain('max-w-none');
|
||||
expect(panel.className).not.toContain('w-[min(1800px,96vw)]');
|
||||
expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles fullscreen from the header button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
|
||||
browserEntersFullscreen(panelOf());
|
||||
await user.click(screen.getByRole('button', { name: 'Exit fullscreen' }));
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes on Escape, but leaves it to the browser while fullscreen', () => {
|
||||
render(<Harness />);
|
||||
|
||||
browserEntersFullscreen(panelOf());
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
browserEntersFullscreen(null);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes on a backdrop click only where the preview asks for it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<Harness />);
|
||||
const backdrop = panelOf().parentElement as HTMLElement;
|
||||
|
||||
await user.click(backdrop);
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
render(<Harness closeOnBackdropClick />);
|
||||
await user.click(panelOf().parentElement as HTMLElement);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A click inside the panel is not a backdrop click.
|
||||
await user.click(screen.getByTestId('preview-body'));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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<void> }).exitFullscreen;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
|
||||
});
|
||||
|
||||
it('double-click on the sheet toggles fullscreen', async () => {
|
||||
stubFetchWith('Article,Qty\nM3 screw,12\n');
|
||||
renderModal();
|
||||
await screen.findByText('M3 screw');
|
||||
const content = screen.getByTestId('spreadsheet-preview-content');
|
||||
const panel = screen.getByText('parts.csv').closest('.flex-col') as HTMLElement;
|
||||
|
||||
fireEvent.doubleClick(content);
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(requestFullscreen.mock.instances[0]).toBe(panel);
|
||||
|
||||
// The mocks do not flip fullscreenElement or fire fullscreenchange;
|
||||
// play the browser's part.
|
||||
(document as { fullscreenElement: Element | null }).fullscreenElement = panel;
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.doubleClick(content);
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers a fullscreen button in the header', async () => {
|
||||
stubFetchWith('Article,Qty\nM3 screw,12\n');
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await screen.findByText('M3 screw');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grid thumbnail snapshot', () => {
|
||||
// The snapshot is drawn into an offscreen canvas, which jsdom cannot
|
||||
// back: both the 2D context and toBlob are stood in for.
|
||||
function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
|
||||
const context = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
font: '',
|
||||
textBaseline: '',
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
};
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
context as unknown as CanvasRenderingContext2D,
|
||||
);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
|
||||
return context;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('hands the first sheet to onSnapshot as a PNG', async () => {
|
||||
const context = stubCanvas2d();
|
||||
stubFetchWith('Article,Qty\nM3 screw,12\n');
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
renderModal({ onSnapshot });
|
||||
await screen.findByText('M3 screw');
|
||||
|
||||
await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
|
||||
expect((onSnapshot.mock.calls[0][0] as Blob).type).toBe('image/png');
|
||||
// The mini table is what makes it recognisable in the grid.
|
||||
expect(context.fillText).toHaveBeenCalledWith('M3 screw', expect.any(Number), expect.any(Number), expect.any(Number));
|
||||
});
|
||||
|
||||
it('draws the first sheet that has rows, not an empty leading one', async () => {
|
||||
stubCanvas2d();
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Cover');
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Part', 'Qty'], ['Hinge', '2']]), 'Bom');
|
||||
stubFetchWith(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer);
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
renderModal({ onSnapshot, filename: 'bom.xlsx', fileType: 'xlsx' });
|
||||
// The empty Cover sheet opens first, so the tab strip is what says the
|
||||
// workbook has parsed.
|
||||
await screen.findByRole('button', { name: 'Bom' });
|
||||
|
||||
await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('stays quiet for a sheet with no rows at all', async () => {
|
||||
stubCanvas2d();
|
||||
stubFetchWith('');
|
||||
const onSnapshot = vi.fn();
|
||||
|
||||
renderModal({ onSnapshot });
|
||||
await screen.findByText('This sheet is empty');
|
||||
|
||||
expect(onSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* useElementFullscreen (#2976): Fullscreen API where the browser grants it,
|
||||
* the viewport-filling fallback everywhere else.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { useElementFullscreen } from '../../hooks/useElementFullscreen';
|
||||
|
||||
function Panel() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, toggleFullscreen, apiAvailable } = useElementFullscreen(ref);
|
||||
return (
|
||||
<div ref={ref} data-testid="panel" data-fullscreen={String(isFullscreen)} data-api={String(apiAvailable)}>
|
||||
<button onClick={toggleFullscreen}>toggle</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const requestFullscreen = vi.fn();
|
||||
const exitFullscreen = vi.fn();
|
||||
|
||||
function installApi() {
|
||||
Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
|
||||
Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
|
||||
Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
|
||||
}
|
||||
|
||||
function removeApi() {
|
||||
delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
|
||||
delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
|
||||
delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
|
||||
delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
|
||||
}
|
||||
|
||||
// The mocks neither flip fullscreenElement nor fire fullscreenchange, so the
|
||||
// tests play the browser's part.
|
||||
function browserSetsFullscreen(element: Element | null) {
|
||||
(document as { fullscreenElement: Element | null }).fullscreenElement = element;
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
}
|
||||
|
||||
describe('useElementFullscreen', () => {
|
||||
beforeEach(() => {
|
||||
requestFullscreen.mockReset().mockResolvedValue(undefined);
|
||||
exitFullscreen.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeApi();
|
||||
});
|
||||
|
||||
it('requests fullscreen on the element and follows fullscreenchange', async () => {
|
||||
installApi();
|
||||
render(<Panel />);
|
||||
const panel = screen.getByTestId('panel');
|
||||
expect(panel.dataset.api).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(requestFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(requestFullscreen.mock.instances[0]).toBe(panel);
|
||||
// Nothing changes until the browser confirms.
|
||||
expect(panel.dataset.fullscreen).toBe('false');
|
||||
|
||||
browserSetsFullscreen(panel);
|
||||
expect(panel.dataset.fullscreen).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
|
||||
browserSetsFullscreen(null);
|
||||
expect(panel.dataset.fullscreen).toBe('false');
|
||||
});
|
||||
|
||||
it('ignores another element going fullscreen', () => {
|
||||
installApi();
|
||||
render(<Panel />);
|
||||
const panel = screen.getByTestId('panel');
|
||||
|
||||
browserSetsFullscreen(document.createElement('video'));
|
||||
expect(panel.dataset.fullscreen).toBe('false');
|
||||
});
|
||||
|
||||
it('falls back to the viewport-filling layout when the request is refused', async () => {
|
||||
installApi();
|
||||
requestFullscreen.mockRejectedValue(new TypeError('Permissions check failed'));
|
||||
render(<Panel />);
|
||||
const panel = screen.getByTestId('panel');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
});
|
||||
expect(panel.dataset.fullscreen).toBe('true');
|
||||
|
||||
// Leaving the fallback never calls the API.
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(panel.dataset.fullscreen).toBe('false');
|
||||
expect(exitFullscreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back where the Fullscreen API is missing', () => {
|
||||
render(<Panel />);
|
||||
const panel = screen.getByTestId('panel');
|
||||
expect(panel.dataset.api).toBe('false');
|
||||
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(panel.dataset.fullscreen).toBe('true');
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(panel.dataset.fullscreen).toBe('false');
|
||||
});
|
||||
|
||||
it('leaves fullscreen when the panel unmounts while fullscreen', () => {
|
||||
installApi();
|
||||
const { unmount } = render(<Panel />);
|
||||
const panel = screen.getByTestId('panel');
|
||||
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
browserSetsFullscreen(panel);
|
||||
|
||||
unmount();
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof userEvent.setup>, filename: string) => {
|
||||
const card = screen.getByText(filename).closest('.group') as HTMLElement;
|
||||
const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
|
||||
await user.click(kebab);
|
||||
return card;
|
||||
};
|
||||
|
||||
it('offers Generate Thumbnail in the card menu of a PDF', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/v1/library/generate-stl-thumbnails', async ({ request }) => {
|
||||
const body = (await request.json()) as { file_ids?: number[] };
|
||||
return HttpResponse.json({
|
||||
processed: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
results: [{ file_id: body.file_ids?.[0], success: true }],
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
|
||||
|
||||
const card = await openMenu(user, 'drawing.pdf');
|
||||
await user.click(within(card).getByText('Generate Thumbnail'));
|
||||
|
||||
expect(await screen.findByText('Thumbnail generated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer it for STEP, which only the browser can render', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('part.step')).toBeInTheDocument());
|
||||
|
||||
const card = await openMenu(user, 'part.step');
|
||||
expect(within(card).queryByText('Generate Thumbnail')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the action in the list view strip of a PDF', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /list/i }));
|
||||
|
||||
// List rows are CSS grids; the row is the nearest grid ancestor.
|
||||
await waitFor(() => {
|
||||
const row = screen.getByText('drawing.pdf').closest('.grid') as HTMLElement;
|
||||
expect(within(row).getByTitle('Generate Thumbnail')).toBeInTheDocument();
|
||||
});
|
||||
const stepRow = screen.getByText('part.step').closest('.grid') as HTMLElement;
|
||||
expect(within(stepRow).queryByTitle('Generate Thumbnail')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload modal (FileUploadModal)', () => {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* How a preview is opened from the File Manager (#2976): double-click in the
|
||||
* grid and the list, the toolbar's Preview button, and the per-file menu.
|
||||
*
|
||||
* The preview modals themselves are stubbed — pdf.js, SheetJS and three.js
|
||||
* have their own tests and none of them render in jsdom.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { FileManagerPage } from '../../pages/FileManagerPage';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-router-dom')>()),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ModelViewerModal', () => ({
|
||||
ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
|
||||
}));
|
||||
vi.mock('../../components/PdfPreviewModal', () => ({
|
||||
PdfPreviewModal: ({ filename }: { filename: string }) => <div data-testid="pdf-preview-modal">{filename}</div>,
|
||||
}));
|
||||
vi.mock('../../components/SpreadsheetPreviewModal', () => ({
|
||||
SpreadsheetPreviewModal: ({ filename }: { filename: string }) => (
|
||||
<div data-testid="sheet-preview-modal">{filename}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../components/ImagePreviewModal', () => ({
|
||||
ImagePreviewModal: ({ filename }: { filename: string }) => <div data-testid="image-preview-modal">{filename}</div>,
|
||||
}));
|
||||
vi.mock('../../components/LibraryFileDetailsModal', () => ({
|
||||
LibraryFileDetailsModal: () => <div data-testid="details-modal" />,
|
||||
}));
|
||||
|
||||
function libraryFile(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
file_path: '/library/file',
|
||||
file_size: 4096,
|
||||
folder_id: null,
|
||||
thumbnail_path: null,
|
||||
print_name: null,
|
||||
print_time_seconds: null,
|
||||
print_count: 0,
|
||||
duplicate_count: 0,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockFiles = [
|
||||
libraryFile({ id: 1, filename: 'benchy.gcode.3mf', file_type: 'gcode.3mf' }),
|
||||
libraryFile({ id: 2, filename: 'bracket.stl', file_type: 'stl' }),
|
||||
libraryFile({ id: 3, filename: 'drawing.pdf', file_type: 'pdf' }),
|
||||
libraryFile({ id: 4, filename: 'parts.csv', file_type: 'csv' }),
|
||||
libraryFile({
|
||||
id: 5,
|
||||
filename: 'photo.png',
|
||||
file_type: 'png',
|
||||
tags: [{ id: 21, name: 'reference', color: '#00ae42' }],
|
||||
has_notes: true,
|
||||
}),
|
||||
libraryFile({ id: 6, filename: 'notes.md', file_type: 'md' }),
|
||||
libraryFile({ id: 7, filename: 'scan.tif', file_type: 'tif' }),
|
||||
];
|
||||
|
||||
function card(name: string): HTMLElement {
|
||||
return screen.getByText(name).closest('div.group') as HTMLElement;
|
||||
}
|
||||
|
||||
function row(name: string): HTMLElement {
|
||||
return screen.getByText(name).closest('div[class*="grid-cols-"]') as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview modals load lazily, so one opened by mistake renders a moment
|
||||
* after the event that opened it: a few hundred milliseconds for the first
|
||||
* one in a run. Absence only counts once it has had findBy's full second to
|
||||
* appear; checking straight away passed whenever no earlier test had loaded
|
||||
* that modal yet.
|
||||
*/
|
||||
async function expectNoPreviewOpened() {
|
||||
await expect(screen.findByTestId(/^(model-viewer|pdf-preview|sheet-preview|image-preview)-modal$/)).rejects.toThrow();
|
||||
}
|
||||
|
||||
describe('FileManagerPage preview opening', () => {
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
// localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
|
||||
// the view mode is programmed rather than written.
|
||||
(localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
server.use(
|
||||
http.get('/api/v1/library/folders', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles)),
|
||||
http.get('/api/v1/library/stats', () =>
|
||||
HttpResponse.json({
|
||||
total_files: mockFiles.length,
|
||||
total_folders: 0,
|
||||
total_size_bytes: 1024,
|
||||
disk_free_bytes: 1024 * 1024,
|
||||
disk_total_bytes: 2048 * 1024,
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
|
||||
http.get('/api/v1/projects/', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/archives/', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
describe('double-click in the grid', () => {
|
||||
it('opens the image preview for a PNG and leaves the selection alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
await user.dblClick(card('photo.png'));
|
||||
|
||||
expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
|
||||
// The two clicks of a double-click toggle the selection twice.
|
||||
expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the 3D viewer for an STL', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('bracket.stl');
|
||||
|
||||
await user.dblClick(card('bracket.stl'));
|
||||
|
||||
expect(await screen.findByTestId('model-viewer-modal')).toHaveTextContent('bracket.stl');
|
||||
});
|
||||
|
||||
it('sends a sliced file to the gcode viewer route', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('benchy.gcode.3mf');
|
||||
|
||||
await user.dblClick(card('benchy.gcode.3mf'));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/gcode-viewer?library_file=1');
|
||||
});
|
||||
|
||||
it('does nothing for a file with no preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('notes.md');
|
||||
|
||||
await user.dblClick(card('notes.md'));
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The card's own controls are not "the row": stopping their click is not
|
||||
// enough, because dblclick is a separate native event (#2976).
|
||||
it('ignores a double-click on the card menu button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
const imageCard = card('photo.png');
|
||||
const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
|
||||
await user.dblClick(kebab);
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
});
|
||||
|
||||
// The chip's own click toggles the tag filter and re-renders the list, so
|
||||
// the dblclick is fired directly: what is under test is whether it bubbles
|
||||
// to the card, not what the two clicks before it did.
|
||||
it('ignores a double-click on a tag chip', async () => {
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
fireEvent.doubleClick(within(card('photo.png')).getByTitle('reference'));
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
});
|
||||
|
||||
// The notes and photos indicators open the file's details; a double-click
|
||||
// on one must not open the preview on top of them.
|
||||
it('ignores a double-click on the notes indicator', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
await user.dblClick(within(card('photo.png')).getByTitle('Has notes'));
|
||||
|
||||
expect(await screen.findByTestId('details-modal')).toBeInTheDocument();
|
||||
await expectNoPreviewOpened();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the list view', () => {
|
||||
beforeEach(() => {
|
||||
(localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
|
||||
key === 'library-view-mode' ? 'list' : null,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the document preview for a PDF', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('drawing.pdf');
|
||||
|
||||
await user.dblClick(row('drawing.pdf'));
|
||||
|
||||
expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
|
||||
});
|
||||
|
||||
it('opens the spreadsheet preview for a CSV', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('parts.csv');
|
||||
|
||||
await user.dblClick(row('parts.csv'));
|
||||
|
||||
expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
|
||||
});
|
||||
|
||||
it('offers an image file the same action-strip preview button as a document', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
await user.click(within(row('photo.png')).getByTitle('Preview'));
|
||||
|
||||
expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
|
||||
});
|
||||
|
||||
it('ignores a double-click on the row action strip', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
// An impatient double-tap on Rename must not also open the preview.
|
||||
await user.dblClick(within(row('photo.png')).getByTitle('Rename'));
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
});
|
||||
|
||||
it('ignores a double-click on the row tag cell', async () => {
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
fireEvent.doubleClick(within(row('photo.png')).getByTitle('reference'));
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the toolbar Preview button', () => {
|
||||
it('appears for a single previewable selection and opens the preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
await user.click(card('photo.png'));
|
||||
const preview = await screen.findByRole('button', { name: 'Preview' });
|
||||
await user.click(preview);
|
||||
|
||||
expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
|
||||
});
|
||||
|
||||
it('stays away for a multi-file selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
await user.click(card('photo.png'));
|
||||
expect(await screen.findByRole('button', { name: 'Preview' })).toBeInTheDocument();
|
||||
|
||||
await user.click(card('drawing.pdf'));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('stays away for a file with no preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('notes.md');
|
||||
|
||||
await user.click(card('notes.md'));
|
||||
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The server thumbnails TIFF (PIL), but an <img> only decodes it on Safari,
|
||||
// so the preview is not offered rather than downloading 50 MB to fail (#2976).
|
||||
describe('a TIFF file', () => {
|
||||
it('gets no Preview entry in the card menu and no toolbar button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('scan.tif');
|
||||
|
||||
await user.click(card('scan.tif'));
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
|
||||
|
||||
const kebab = card('scan.tif').querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
|
||||
await user.click(kebab);
|
||||
expect(within(card('scan.tif')).queryByText('Preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does nothing on double-click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('scan.tif');
|
||||
|
||||
await user.dblClick(card('scan.tif'));
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('offers an image file a Preview entry in the card menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await screen.findByText('photo.png');
|
||||
|
||||
const imageCard = card('photo.png');
|
||||
const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
|
||||
await user.click(kebab);
|
||||
await user.click(within(imageCard).getByText('Preview'));
|
||||
|
||||
expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image as ImageIcon, Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { api, getAuthToken } from '../api/client';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
|
||||
import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
|
||||
|
||||
// The whole file is fetched into memory before it is shown, so a multi-hundred
|
||||
// megabyte scan gets the same notice the other previews give instead of a tab
|
||||
// that stops responding (#2976).
|
||||
export const IMAGE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const MIN_ZOOM = 0.4;
|
||||
// Higher than the PDF's ceiling: a photo or a scan is worth inspecting at the
|
||||
// pixel, and unlike a PDF page there is no sharper raster to fall back on.
|
||||
const MAX_ZOOM = 8;
|
||||
// One button press, one key press, one mouse-wheel notch.
|
||||
const ZOOM_STEP = 1.25;
|
||||
// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
|
||||
// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
|
||||
// and the pinch a smooth ramp.
|
||||
const WHEEL_DELTA_CLAMP = 30;
|
||||
const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
|
||||
|
||||
const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const ORIGIN: Point = { x: 0, y: 0 };
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
libraryFileId: number;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
|
||||
function wheelDeltaPixels(e: WheelEvent): number {
|
||||
if (e.deltaMode === 1) return e.deltaY * 16;
|
||||
if (e.deltaMode === 2) return e.deltaY * 100;
|
||||
return e.deltaY;
|
||||
}
|
||||
|
||||
export function ImagePreviewModal({ libraryFileId, filename, fileSize, onClose }: ImagePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fullscreen = usePreviewFullscreen();
|
||||
const { isFullscreen, toggleFullscreen } = fullscreen;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The image is laid out fitted to the panel; zoom and pan are a transform on
|
||||
// top of that, so zoom 1 is always "fits the window".
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [offset, setOffset] = useState<Point>(ORIGIN);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const zoomRef = useRef(1);
|
||||
const offsetRef = useRef<Point>(ORIGIN);
|
||||
const pointersRef = useRef<Map<number, Point>>(new Map());
|
||||
const pinchDistanceRef = useRef<number | null>(null);
|
||||
|
||||
// Panning past the edges would strand the image off-screen, so the offset is
|
||||
// bounded by however much of it the zoom pushes outside the viewport.
|
||||
const clampOffset = useCallback((next: Point, atZoom: number): Point => {
|
||||
const container = containerRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!container || !image) return next;
|
||||
const maxX = Math.max(0, (image.offsetWidth * atZoom - container.clientWidth) / 2);
|
||||
const maxY = Math.max(0, (image.offsetHeight * atZoom - container.clientHeight) / 2);
|
||||
return {
|
||||
x: Math.min(maxX, Math.max(-maxX, next.x)),
|
||||
y: Math.min(maxY, Math.max(-maxY, next.y)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setView = useCallback((nextZoom: number, nextOffset: Point) => {
|
||||
zoomRef.current = nextZoom;
|
||||
offsetRef.current = nextOffset;
|
||||
setZoom(nextZoom);
|
||||
setOffset(nextOffset);
|
||||
}, []);
|
||||
|
||||
/** Zoom by `factor`, keeping the point under the pointer where it is. */
|
||||
const zoomAt = useCallback(
|
||||
(factor: number, clientX?: number, clientY?: number) => {
|
||||
const current = zoomRef.current;
|
||||
const next = clampZoom(current * factor);
|
||||
if (next === current) return;
|
||||
const container = containerRef.current;
|
||||
const previous = offsetRef.current;
|
||||
let nextOffset = { x: previous.x * (next / current), y: previous.y * (next / current) };
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
// The transform grows the image around the container's centre, so an
|
||||
// anchored point is expressed relative to that centre.
|
||||
const ax = (clientX == null ? rect.left + rect.width / 2 : clientX) - (rect.left + rect.width / 2);
|
||||
const ay = (clientY == null ? rect.top + rect.height / 2 : clientY) - (rect.top + rect.height / 2);
|
||||
const k = next / current;
|
||||
nextOffset = { x: ax - (ax - previous.x) * k, y: ay - (ay - previous.y) * k };
|
||||
}
|
||||
setView(next, clampOffset(nextOffset, next));
|
||||
},
|
||||
[clampOffset, setView]
|
||||
);
|
||||
|
||||
const resetView = useCallback(() => setView(1, ORIGIN), [setView]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
setSrc(null);
|
||||
setLoaded(false);
|
||||
setError(null);
|
||||
setView(1, ORIGIN);
|
||||
|
||||
if (fileSize > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {};
|
||||
const token = getAuthToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
(async () => {
|
||||
// Fetched rather than pointed at with <img src>: the download endpoint
|
||||
// wants the bearer token, which an <img> cannot send.
|
||||
const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
})().catch((err: unknown) => {
|
||||
console.error('[image-preview] load failed', err);
|
||||
if (!cancelled) setError(t('fileManager.preview.error'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [libraryFileId, fileSize, t, setView]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault();
|
||||
zoomAt(ZOOM_STEP);
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
zoomAt(1 / ZOOM_STEP);
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
resetView();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [resetView, zoomAt]);
|
||||
|
||||
// Native listener: React registers wheel as passive, so preventDefault —
|
||||
// which keeps Ctrl+wheel from zooming the whole page — would be ignored.
|
||||
// Nothing scrolls here, so a plain wheel zooms as well.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
|
||||
zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
|
||||
};
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [zoomAt, src]);
|
||||
|
||||
// One pointer drags the image once it is bigger than the window, two fingers
|
||||
// pinch-zoom around their midpoint. `touch-action: none` on the container
|
||||
// hands both gestures here instead of to the browser's scroll / page zoom.
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
pinchDistanceRef.current = null;
|
||||
if (pointersRef.current.size === 1 && zoomRef.current > 1) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.delete(e.pointerId);
|
||||
pinchDistanceRef.current = null;
|
||||
if (pointersRef.current.size === 0) setDragging(false);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const pointers = pointersRef.current;
|
||||
const previous = pointers.get(e.pointerId);
|
||||
if (!previous) return;
|
||||
// A button released outside this container never reaches onPointerUp (no
|
||||
// capture is taken below zoom 1), and the stale entry would then pan the
|
||||
// image under a bare cursor — or count as a second finger on a hybrid
|
||||
// device, turning the next one-finger drag into a pinch (#2976).
|
||||
if (e.pointerType === 'mouse' && e.buttons === 0) {
|
||||
handlePointerEnd(e);
|
||||
return;
|
||||
}
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
|
||||
if (pointers.size === 1) {
|
||||
if (zoomRef.current <= 1) return;
|
||||
const moved = { x: offsetRef.current.x + (e.clientX - previous.x), y: offsetRef.current.y + (e.clientY - previous.y) };
|
||||
setView(zoomRef.current, clampOffset(moved, zoomRef.current));
|
||||
return;
|
||||
}
|
||||
if (pointers.size !== 2) return;
|
||||
const [a, b] = Array.from(pointers.values());
|
||||
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const last = pinchDistanceRef.current;
|
||||
pinchDistanceRef.current = distance;
|
||||
if (last != null && last > 0 && distance > 0) {
|
||||
zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
|
||||
}
|
||||
};
|
||||
|
||||
const canPan = zoom > 1;
|
||||
|
||||
return (
|
||||
<PreviewModalShell
|
||||
title={filename}
|
||||
fullscreen={fullscreen}
|
||||
onClose={onClose}
|
||||
icon={<ImageIcon className="w-5 h-5 text-bambu-green flex-shrink-0" />}
|
||||
actions={
|
||||
src ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => zoomAt(1 / ZOOM_STEP)}
|
||||
className={previewIconButtonClass}
|
||||
aria-label={t('fileManager.preview.zoomOut')}
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoomAt(ZOOM_STEP)}
|
||||
className={previewIconButtonClass}
|
||||
aria-label={t('fileManager.preview.zoomIn')}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={resetView} className={previewIconButtonClass} aria-label={t('fileManager.preview.resetZoom')}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="image-preview-content"
|
||||
className={`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${
|
||||
isFullscreen ? '' : 'rounded-b-lg'
|
||||
} ${canPan ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
>
|
||||
{error ? (
|
||||
<p className="text-bambu-gray text-center p-6">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
{src && (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
alt={filename}
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(t('fileManager.preview.error'))}
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})` }}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
/>
|
||||
)}
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PreviewModalShell>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,9 @@ function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
|
||||
});
|
||||
}
|
||||
|
||||
// How far the user may dolly out, as a multiple of the framed distance.
|
||||
const MAX_ZOOM_OUT = 8;
|
||||
|
||||
/**
|
||||
* Frame the camera on a bounding box.
|
||||
*
|
||||
@@ -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<THREE.Group | null>(null);
|
||||
const plateRef = useRef<THREE.Mesh | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
// The last framed bounds, so Reset returns to the framed view rather than
|
||||
// a fixed camera pose that ignores where the model was placed.
|
||||
const fitBoxRef = useRef<THREE.Box3 | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [parsedData, setParsedData] = useState<Parsed3MFData | null>(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 (
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [platePage, setPlatePage] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
|
||||
const [isDraggingDivider, setIsDraggingDivider] = useState(false);
|
||||
const [hasCustomSplit, setHasCustomSplit] = useState(false);
|
||||
const splitContainerRef = useRef<HTMLDivElement>(null);
|
||||
const platesPanelRef = useRef<HTMLDivElement>(null);
|
||||
// Real fullscreen where the browser offers it, the viewport-filling layout
|
||||
// below otherwise (#2976).
|
||||
const fullscreen = usePreviewFullscreen();
|
||||
const { isFullscreen, toggleFullscreen } = fullscreen;
|
||||
const dividerHeight = 10;
|
||||
const 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 (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
|
||||
onClick={onClose}
|
||||
<PreviewModalShell
|
||||
title={title}
|
||||
fullscreen={fullscreen}
|
||||
onClose={onClose}
|
||||
closeOnBackdropClick
|
||||
titleExtra={
|
||||
hasObjectCount ? (
|
||||
<span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
|
||||
{objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{useBambuddySlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<Cog className="w-4 h-4" />}
|
||||
label={t('slice.action')}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => onSliceWithBambuddy?.()}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : canOpenInSlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<ExternalLink className="w-4 h-4" />}
|
||||
// Name the slicer when it is not the configured one. That happens
|
||||
// when the configured slicer cannot take this format — an STL with
|
||||
// Bambu Studio selected — and silently handing the file to the
|
||||
// other one without saying so would be worse than the failure it
|
||||
// replaces.
|
||||
label={
|
||||
usableSlicers[0] === preferredSlicer
|
||||
? t('modelViewer.openInSlicer')
|
||||
: t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
|
||||
}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('modelViewer.openInSlicer')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
|
||||
isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
|
||||
<h2 className="text-lg font-semibold text-white truncate">{title}</h2>
|
||||
{hasObjectCount && (
|
||||
<span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
|
||||
{objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{useBambuddySlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<Cog className="w-4 h-4" />}
|
||||
label={t('slice.action')}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => onSliceWithBambuddy?.()}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : canOpenInSlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<ExternalLink className="w-4 h-4" />}
|
||||
// Name the slicer when it is not the configured one. That happens
|
||||
// when the configured slicer cannot take this format — an STL with
|
||||
// Bambu Studio selected — and silently handing the file to the
|
||||
// other one without saying so would be worse than the failure it
|
||||
// replaces.
|
||||
label={
|
||||
usableSlicers[0] === preferredSlicer
|
||||
? t('modelViewer.openInSlicer')
|
||||
: t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
|
||||
}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('modelViewer.openInSlicer')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen((prev) => !prev)}
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Tabs - only show if we have capabilities */}
|
||||
{capabilities && (
|
||||
<div className="flex border-b border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={() => capabilities.has_model && setActiveTab('3d')}
|
||||
disabled={!capabilities.has_model}
|
||||
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === '3d'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: capabilities.has_model
|
||||
? 'text-bambu-gray hover:text-white'
|
||||
: 'text-bambu-gray/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
{t('modelViewer.tabs.model')}
|
||||
{!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs - only show if we have capabilities */}
|
||||
{capabilities && (
|
||||
<div className="flex border-b border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={() => capabilities.has_model && setActiveTab('3d')}
|
||||
disabled={!capabilities.has_model}
|
||||
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === '3d'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: capabilities.has_model
|
||||
? 'text-bambu-gray hover:text-white'
|
||||
: 'text-bambu-gray/30 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
{t('modelViewer.tabs.model')}
|
||||
{!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
|
||||
</button>
|
||||
{/* Viewer */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{loading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Viewer */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{loading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
) : activeTab === '3d' && capabilities ? (
|
||||
<div
|
||||
ref={splitContainerRef}
|
||||
className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
|
||||
>
|
||||
{hasMultiplePlates && (
|
||||
<div
|
||||
ref={platesPanelRef}
|
||||
style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
|
||||
className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
{t('modelViewer.plates')}
|
||||
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
</div>
|
||||
<div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
|
||||
<div
|
||||
ref={platesViewportRef}
|
||||
className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
|
||||
>
|
||||
<div
|
||||
ref={platesGridRef}
|
||||
className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
|
||||
style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
|
||||
) : activeTab === '3d' && capabilities ? (
|
||||
<div
|
||||
ref={splitContainerRef}
|
||||
className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
|
||||
>
|
||||
{hasMultiplePlates && (
|
||||
<div
|
||||
ref={platesPanelRef}
|
||||
style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
|
||||
className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
{t('modelViewer.plates')}
|
||||
{platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
</div>
|
||||
<div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
|
||||
<div
|
||||
ref={platesViewportRef}
|
||||
className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
|
||||
>
|
||||
<div
|
||||
ref={platesGridRef}
|
||||
className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
|
||||
style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPlateId(null)}
|
||||
className={`flex items-center rounded-lg border text-left transition-colors ${
|
||||
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
||||
} ${
|
||||
selectedPlateId == null
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
||||
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
||||
}`}>
|
||||
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
|
||||
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
||||
{t('modelViewer.plateCount', { count: plates.length })}
|
||||
</p>
|
||||
</div>
|
||||
{selectedPlateId == null && (
|
||||
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
||||
)}
|
||||
</button>
|
||||
{pagedPlates.map((plate) => (
|
||||
<button
|
||||
key={plate.index}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlateId(null)}
|
||||
onClick={() => setSelectedPlateId(plate.index)}
|
||||
className={`flex items-center rounded-lg border text-left transition-colors ${
|
||||
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
||||
} ${
|
||||
selectedPlateId == null
|
||||
selectedPlateId === plate.index
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
||||
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
||||
}`}>
|
||||
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
||||
</div>
|
||||
{plate.has_thumbnail && plate.thumbnail_url ? (
|
||||
<img
|
||||
src={withMediaToken(plate.thumbnail_url)}
|
||||
alt={`Plate ${plate.index}`}
|
||||
className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
||||
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
||||
}`}>
|
||||
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
|
||||
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
|
||||
{plate.name || t('modelViewer.plateNumber', { number: plate.index })}
|
||||
</p>
|
||||
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
||||
{t('modelViewer.plateCount', { count: plates.length })}
|
||||
{t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
{selectedPlateId == null && (
|
||||
{selectedPlateId === plate.index && (
|
||||
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
||||
)}
|
||||
</button>
|
||||
{pagedPlates.map((plate) => (
|
||||
<button
|
||||
key={plate.index}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlateId(plate.index)}
|
||||
className={`flex items-center rounded-lg border text-left transition-colors ${
|
||||
splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
|
||||
} ${
|
||||
selectedPlateId === plate.index
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{plate.has_thumbnail && plate.thumbnail_url ? (
|
||||
<img
|
||||
src={withMediaToken(plate.thumbnail_url)}
|
||||
alt={`Plate ${plate.index}`}
|
||||
className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
|
||||
splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
|
||||
}`}>
|
||||
<Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
|
||||
{plate.name || t('modelViewer.plateNumber', { number: plate.index })}
|
||||
</p>
|
||||
<p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
|
||||
{t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
{selectedPlateId === plate.index && (
|
||||
<Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(selectedPlate || shouldPaginatePlates) && (
|
||||
<div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
|
||||
{selectedPlate && (
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
|
||||
{selectedPlate.print_time_seconds != null && (
|
||||
<span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
|
||||
)}
|
||||
{selectedPlate.filament_used_grams != null && (
|
||||
<span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
|
||||
)}
|
||||
{selectedPlate.filaments.length > 0 && (
|
||||
<span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldPaginatePlates && (
|
||||
<div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
|
||||
<span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={platePage === 0}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === 0
|
||||
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{t('modelViewer.pagination.prev')}
|
||||
</button>
|
||||
{(() => {
|
||||
const maxVisible = 5;
|
||||
let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
|
||||
const end = Math.min(totalPlatePages, start + maxVisible);
|
||||
if (end - start < maxVisible) {
|
||||
start = Math.max(0, end - maxVisible);
|
||||
}
|
||||
const pages = Array.from({ length: end - start }, (_, i) => start + i);
|
||||
|
||||
return (
|
||||
<>
|
||||
{start > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage(0)}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === 0
|
||||
? 'border-bambu-green text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
)}
|
||||
{start > 1 && <span className="px-1">…</span>}
|
||||
{pages.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
onClick={() => setPlatePage(pageNumber)}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === pageNumber
|
||||
? 'border-bambu-green text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{pageNumber + 1}
|
||||
</button>
|
||||
))}
|
||||
{end < totalPlatePages - 1 && <span className="px-1">…</span>}
|
||||
{end < totalPlatePages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage(totalPlatePages - 1)}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === totalPlatePages - 1
|
||||
? 'border-bambu-green text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{totalPlatePages}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
|
||||
disabled={platePage >= totalPlatePages - 1}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage >= totalPlatePages - 1
|
||||
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{t('modelViewer.pagination.next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(selectedPlate || shouldPaginatePlates) && (
|
||||
<div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
|
||||
{selectedPlate && (
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
|
||||
{selectedPlate.print_time_seconds != null && (
|
||||
<span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
|
||||
)}
|
||||
{selectedPlate.filament_used_grams != null && (
|
||||
<span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
|
||||
)}
|
||||
{selectedPlate.filaments.length > 0 && (
|
||||
<span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldPaginatePlates && (
|
||||
<div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
|
||||
<span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={platePage === 0}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === 0
|
||||
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{t('modelViewer.pagination.prev')}
|
||||
</button>
|
||||
{(() => {
|
||||
const maxVisible = 5;
|
||||
let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
|
||||
const end = Math.min(totalPlatePages, start + maxVisible);
|
||||
if (end - start < maxVisible) {
|
||||
start = Math.max(0, end - maxVisible);
|
||||
}
|
||||
const pages = Array.from({ length: end - start }, (_, i) => start + i);
|
||||
|
||||
return (
|
||||
<>
|
||||
{start > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage(0)}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === 0
|
||||
? 'border-bambu-green text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
)}
|
||||
{start > 1 && <span className="px-1">…</span>}
|
||||
{pages.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
onClick={() => setPlatePage(pageNumber)}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === pageNumber
|
||||
? 'border-bambu-green text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{pageNumber + 1}
|
||||
</button>
|
||||
))}
|
||||
{end < totalPlatePages - 1 && <span className="px-1">…</span>}
|
||||
{end < totalPlatePages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage(totalPlatePages - 1)}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage === totalPlatePages - 1
|
||||
? 'border-bambu-green text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{totalPlatePages}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
|
||||
disabled={platePage >= totalPlatePages - 1}
|
||||
className={`px-2 py-1 rounded border text-xs ${
|
||||
platePage >= totalPlatePages - 1
|
||||
? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{t('modelViewer.pagination.next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{splitFullscreen && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingDivider(true);
|
||||
setHasCustomSplit(true);
|
||||
}}
|
||||
className={`h-2 cursor-row-resize flex items-center justify-center ${
|
||||
isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
|
||||
<ModelViewer
|
||||
url={isLibrary
|
||||
? api.getLibraryFileDownloadUrl(libraryFileId!)
|
||||
: (capabilities.has_source
|
||||
? api.getSource3mfDownloadUrl(archiveId!)
|
||||
: api.getArchiveDownload(archiveId!))}
|
||||
fileType={fileType}
|
||||
buildVolume={capabilities.build_volume}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
selectedPlateId={selectedPlateId}
|
||||
className="w-full h-full"
|
||||
onSnapshot={onSnapshot}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{splitFullscreen && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingDivider(true);
|
||||
setHasCustomSplit(true);
|
||||
}}
|
||||
className={`h-2 cursor-row-resize flex items-center justify-center ${
|
||||
isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}
|
||||
data-testid="model-viewer-area"
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
<ModelViewer
|
||||
url={isLibrary
|
||||
? api.getLibraryFileDownloadUrl(libraryFileId!)
|
||||
: (capabilities.has_source
|
||||
? api.getSource3mfDownloadUrl(archiveId!)
|
||||
: api.getArchiveDownload(archiveId!))}
|
||||
fileType={fileType}
|
||||
buildVolume={capabilities.build_volume}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
selectedPlateId={selectedPlateId}
|
||||
className="w-full h-full"
|
||||
onSnapshot={onSnapshot}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
|
||||
{t('modelViewer.noPreview')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
|
||||
{t('modelViewer.noPreview')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreviewModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Blob | null> {
|
||||
const size = 256;
|
||||
@@ -35,8 +75,17 @@ function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
|
||||
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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const renderTaskRef = useRef<RenderTask | null>(null);
|
||||
@@ -48,17 +97,160 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
|
||||
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||
const [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<string | null>(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<ZoomAnchor | null>(null);
|
||||
const pointersRef = useRef<Map<number, ActivePointer>>(new Map());
|
||||
const pinchDistanceRef = useRef<number | null>(null);
|
||||
|
||||
const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (container && canvas) {
|
||||
const viewport = container.getBoundingClientRect();
|
||||
const page = canvas.getBoundingClientRect();
|
||||
// Without a pointer, keep whatever is in the middle of the viewport.
|
||||
const px = clientX == null ? viewport.width / 2 : clientX - viewport.left;
|
||||
const py = clientY == null ? viewport.height / 2 : clientY - viewport.top;
|
||||
anchorRef.current =
|
||||
page.width > 0 && page.height > 0
|
||||
? {
|
||||
fx: (px + viewport.left - page.left) / page.width,
|
||||
fy: (py + viewport.top - page.top) / page.height,
|
||||
px,
|
||||
py,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
setZoom((current) => clampZoom(current * factor));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
pinchDistanceRef.current = null;
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
const pointers = pointersRef.current;
|
||||
const previous = pointers.get(e.pointerId);
|
||||
if (!previous) return;
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
|
||||
if (pointers.size === 1) {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.scrollLeft -= e.clientX - previous.x;
|
||||
container.scrollTop -= e.clientY - previous.y;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pointers.size !== 2) return;
|
||||
const [a, b] = Array.from(pointers.values());
|
||||
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const last = pinchDistanceRef.current;
|
||||
pinchDistanceRef.current = distance;
|
||||
if (last != null && last > 0 && distance > 0) {
|
||||
zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
pointersRef.current.delete(e.pointerId);
|
||||
pinchDistanceRef.current = null;
|
||||
};
|
||||
|
||||
// Show the new zoom right away by resizing the existing raster, and keep
|
||||
// the anchored point under the pointer. The sharp re-render follows.
|
||||
useLayoutEffect(() => {
|
||||
zoomRef.current = zoom;
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const base = baseCssSizeRef.current;
|
||||
if (!canvas || !container || !base) return;
|
||||
canvas.style.width = `${base.width * zoom}px`;
|
||||
canvas.style.height = `${base.height * zoom}px`;
|
||||
const anchor = anchorRef.current;
|
||||
anchorRef.current = null;
|
||||
if (!anchor) return;
|
||||
container.scrollLeft = canvas.offsetLeft + anchor.fx * canvas.offsetWidth - anchor.px;
|
||||
container.scrollTop = canvas.offsetTop + anchor.fy * canvas.offsetHeight - anchor.py;
|
||||
}, [zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom === renderZoom) return;
|
||||
const id = window.setTimeout(() => setRenderZoom(zoom), RERENDER_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [zoom, renderZoom]);
|
||||
|
||||
// The page is fitted to the panel width, so a panel resize (fullscreen,
|
||||
// window resize) needs a fresh fit. Watching the panel rather than the
|
||||
// scroll container keeps a scrollbar appearing from re-fitting the page.
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const observer = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
|
||||
observer.observe(panel);
|
||||
return () => observer.disconnect();
|
||||
}, [panelRef]);
|
||||
|
||||
// Load the document. pdf.js is imported on demand so the viewer and its
|
||||
// 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 (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
|
||||
<h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{doc && pageCount > 1 && (
|
||||
<div className="flex items-center gap-1 mr-2">
|
||||
<button
|
||||
onClick={() => setPageNum((p) => Math.max(1, p - 1))}
|
||||
disabled={pageNum <= 1}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label={t('fileManager.preview.prevPage')}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-bambu-gray whitespace-nowrap">
|
||||
{t('fileManager.preview.page', { current: pageNum, total: pageCount })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
|
||||
disabled={pageNum >= pageCount}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label={t('fileManager.preview.nextPage')}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{doc && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
aria-label={t('fileManager.preview.zoomOut')}
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
aria-label={t('fileManager.preview.zoomIn')}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page */}
|
||||
<div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-bambu-gray text-center">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center min-w-fit">
|
||||
<canvas ref={canvasRef} className="shadow-lg" />
|
||||
<PreviewModalShell
|
||||
title={filename}
|
||||
fullscreen={fullscreen}
|
||||
onClose={onClose}
|
||||
icon={<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />}
|
||||
actions={
|
||||
<>
|
||||
{doc && pageCount > 1 && (
|
||||
<div className="flex items-center gap-1 mr-2">
|
||||
<button
|
||||
onClick={() => setPageNum((p) => Math.max(1, p - 1))}
|
||||
disabled={pageNum <= 1}
|
||||
className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
aria-label={t('fileManager.preview.prevPage')}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-bambu-gray whitespace-nowrap">
|
||||
{t('fileManager.preview.page', { current: pageNum, total: pageCount })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
|
||||
disabled={pageNum >= pageCount}
|
||||
className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
aria-label={t('fileManager.preview.nextPage')}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && rendering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
{doc && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => zoomAt(1 / ZOOM_STEP)}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('fileManager.preview.zoomOut')}
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoomAt(ZOOM_STEP)}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('fileManager.preview.zoomIn')}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Page */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="pdf-preview-page"
|
||||
className={`relative flex-1 min-h-0 overflow-auto bg-bambu-dark p-4 touch-none ${isFullscreen ? '' : 'rounded-b-lg'}`}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
>
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-bambu-gray text-center">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center min-w-fit">
|
||||
<canvas ref={canvasRef} className="shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
{!error && rendering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreviewModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Maximize2, Minimize2, X } from 'lucide-react';
|
||||
import type { PreviewFullscreen } from '../hooks/usePreviewFullscreen';
|
||||
|
||||
// One size for every file preview (#2976). Each modal used to carry its own
|
||||
// max-width (4xl/5xl/6xl at 80-85vh), which left most of a wide screen empty
|
||||
// for a window the user opened precisely to look at something closely.
|
||||
// min() keeps the panel off the edges on a laptop and stops it stretching to
|
||||
// a wall of pixels on an ultrawide.
|
||||
export const PREVIEW_PANEL_SIZE_CLASS = 'w-[min(1800px,96vw)] h-[94vh]';
|
||||
|
||||
// Header icon buttons of every preview.
|
||||
export const previewIconButtonClass =
|
||||
'p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors';
|
||||
|
||||
interface PreviewModalShellProps {
|
||||
title: string;
|
||||
fullscreen: PreviewFullscreen;
|
||||
onClose: () => void;
|
||||
/** Type icon left of the title. */
|
||||
icon?: ReactNode;
|
||||
/** Rendered after the title, e.g. the 3D viewer's object-count badge. */
|
||||
titleExtra?: ReactNode;
|
||||
/** This preview's own header buttons, left of the fullscreen/close pair. */
|
||||
actions?: ReactNode;
|
||||
/** The 3D viewer closes on a backdrop click; the document previews do not. */
|
||||
closeOnBackdropClick?: boolean;
|
||||
/** Panel content: tabs, body and footers, as direct flex children. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backdrop, panel sizing and header row shared by every file preview (#2976).
|
||||
*
|
||||
* Esc closes the modal, except while the browser owns it: in fullscreen Esc
|
||||
* leaves fullscreen and the preview stays open. Double-click-to-fullscreen is
|
||||
* wired by each modal on its own content area, because only the modal knows
|
||||
* which part of the panel is the preview.
|
||||
*/
|
||||
export function PreviewModalShell({
|
||||
title,
|
||||
fullscreen,
|
||||
onClose,
|
||||
icon,
|
||||
titleExtra,
|
||||
actions,
|
||||
closeOnBackdropClick,
|
||||
children,
|
||||
}: PreviewModalShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !document.fullscreenElement) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-2 sm:p-4'}`}
|
||||
onClick={closeOnBackdropClick ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary flex flex-col ${
|
||||
isFullscreen
|
||||
? 'w-full h-full max-w-none rounded-none'
|
||||
: `${PREVIEW_PANEL_SIZE_CLASS} max-w-full max-h-full rounded-lg`
|
||||
}`}
|
||||
onClick={closeOnBackdropClick ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{icon}
|
||||
<h2 className="text-lg font-semibold text-white truncate">{title}</h2>
|
||||
{titleExtra}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{actions}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className={previewIconButtonClass}
|
||||
aria-label={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
|
||||
title={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={onClose} className={previewIconButtonClass} aria-label={t('common.close')}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>(text, { skipEmptyLines: false });
|
||||
const all = result.data.filter((row) => Array.isArray(row));
|
||||
// A trailing newline parses as one empty row — drop trailing blanks.
|
||||
while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
|
||||
all.pop();
|
||||
}
|
||||
const totalRows = all.length;
|
||||
const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
|
||||
const rows: string[][] = [];
|
||||
let totalRows = 0;
|
||||
let totalCols = 0;
|
||||
// A trailing newline parses as one empty row, and only a *trailing*
|
||||
// run of blanks is dropped — so blanks are held back until a later
|
||||
// row proves they were interior.
|
||||
let pendingBlanks: string[][] = [];
|
||||
// Counted and capped row by row, so the whole file is never
|
||||
// materialised as rows the preview then throws away.
|
||||
const take = (row: string[]) => {
|
||||
totalRows += 1;
|
||||
totalCols = Math.max(totalCols, row.length);
|
||||
if (rows.length < MAX_ROWS) rows.push(row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
|
||||
};
|
||||
Papa.parse<string[]>(text, {
|
||||
skipEmptyLines: false,
|
||||
step: ({ data }) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
if (data.every((cell) => !cell)) {
|
||||
pendingBlanks.push(data);
|
||||
return;
|
||||
}
|
||||
pendingBlanks.forEach(take);
|
||||
pendingBlanks = [];
|
||||
take(data);
|
||||
},
|
||||
});
|
||||
parsed = [{ name: filename, rows, totalRows, totalCols }];
|
||||
} 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 (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
|
||||
<h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<PreviewModalShell
|
||||
title={filename}
|
||||
fullscreen={fullscreen}
|
||||
onClose={onClose}
|
||||
icon={<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />}
|
||||
>
|
||||
{/* Sheet tabs */}
|
||||
{sheets && sheets.length > 1 && (
|
||||
<div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
|
||||
{sheets.map((s, index) => (
|
||||
<button
|
||||
key={`${s.name}-${index}`}
|
||||
onClick={() => setActiveSheet(index)}
|
||||
className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
|
||||
index === activeSheet
|
||||
? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sheet tabs */}
|
||||
{sheets && sheets.length > 1 && (
|
||||
<div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
|
||||
{sheets.map((s, index) => (
|
||||
<button
|
||||
key={`${s.name}-${index}`}
|
||||
onClick={() => setActiveSheet(index)}
|
||||
className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
|
||||
index === activeSheet
|
||||
? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
{/* Content */}
|
||||
<div
|
||||
data-testid="spreadsheet-preview-content"
|
||||
className={`flex-1 min-h-0 overflow-auto bg-bambu-dark ${isFullscreen ? '' : 'rounded-b-lg'}`}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<p className="text-bambu-gray text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<p className="text-bambu-gray text-center">{error}</p>
|
||||
</div>
|
||||
) : !sheets ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : !sheet || sheet.rows.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
|
||||
) : !sheets ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : !sheet || sheet.rows.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
|
||||
</div>
|
||||
) : (
|
||||
// min-w-full so a narrow sheet spreads across the wider panel
|
||||
// instead of huddling in its top-left corner (#2976).
|
||||
<table className="border-collapse text-xs min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
|
||||
{Array.from({ length: shownCols }, (_, c) => (
|
||||
<th
|
||||
key={c}
|
||||
className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
|
||||
>
|
||||
{columnLabel(c)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.rows.map((row, r) => (
|
||||
<tr key={r}>
|
||||
<td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
|
||||
{r + 1}
|
||||
</td>
|
||||
{Array.from({ length: shownCols }, (_, c) => (
|
||||
<th
|
||||
<td
|
||||
key={c}
|
||||
className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
|
||||
className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
|
||||
title={row[c] || undefined}
|
||||
>
|
||||
{columnLabel(c)}
|
||||
</th>
|
||||
{row[c] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.rows.map((row, r) => (
|
||||
<tr key={r}>
|
||||
<td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
|
||||
{r + 1}
|
||||
</td>
|
||||
{Array.from({ length: shownCols }, (_, c) => (
|
||||
<td
|
||||
key={c}
|
||||
className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
|
||||
title={row[c] || undefined}
|
||||
>
|
||||
{row[c] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Truncation notices */}
|
||||
{(rowsTruncated || colsTruncated) && sheet && (
|
||||
<div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
|
||||
{rowsTruncated && (
|
||||
<span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
|
||||
)}
|
||||
{colsTruncated && (
|
||||
<span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Truncation notices */}
|
||||
{(rowsTruncated || colsTruncated) && sheet && (
|
||||
<div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
|
||||
{rowsTruncated && (
|
||||
<span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
|
||||
)}
|
||||
{colsTruncated && (
|
||||
<span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PreviewModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
|
||||
// True where the unprefixed Fullscreen API can be used on an arbitrary
|
||||
// element. iPhone Safari only offers it for <video>, and an <iframe> without
|
||||
// allowfullscreen reports fullscreenEnabled === false.
|
||||
export function fullscreenApiAvailable(): boolean {
|
||||
return (
|
||||
typeof document !== 'undefined' &&
|
||||
document.fullscreenEnabled === true &&
|
||||
typeof document.documentElement.requestFullscreen === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen for a preview panel (#2976).
|
||||
*
|
||||
* Uses the Fullscreen API on the element behind `ref` where the browser has
|
||||
* it, and otherwise a viewport-filling layout driven by the same
|
||||
* `isFullscreen` flag, so the toggle keeps working on iPhone Safari and in
|
||||
* embedded views that refuse the request (a webview without the fullscreen
|
||||
* permission rejects it even though `fullscreenEnabled` says yes). In API
|
||||
* mode the flag follows `fullscreenchange`: Esc is handled by the browser and
|
||||
* simply shows up as a change, and the panel leaves fullscreen on unmount so
|
||||
* closing the modal never strands the document in fullscreen.
|
||||
*/
|
||||
export function useElementFullscreen(ref: RefObject<HTMLElement | null>) {
|
||||
const apiAvailable = fullscreenApiAvailable();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// True while the viewport-filling fallback is what the user sees.
|
||||
const fallbackActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiAvailable) return;
|
||||
const element = ref.current;
|
||||
const sync = () => {
|
||||
if (fallbackActiveRef.current) return;
|
||||
setIsFullscreen(document.fullscreenElement != null && document.fullscreenElement === ref.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', sync);
|
||||
if (element && document.fullscreenElement === element) {
|
||||
Promise.resolve(document.exitFullscreen()).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [apiAvailable, ref]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (fallbackActiveRef.current) {
|
||||
fallbackActiveRef.current = false;
|
||||
setIsFullscreen(false);
|
||||
return;
|
||||
}
|
||||
if (apiAvailable && document.fullscreenElement) {
|
||||
Promise.resolve(document.exitFullscreen()).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const enterFallback = () => {
|
||||
fallbackActiveRef.current = true;
|
||||
setIsFullscreen(true);
|
||||
};
|
||||
if (!apiAvailable) {
|
||||
enterFallback();
|
||||
return;
|
||||
}
|
||||
Promise.resolve(element.requestFullscreen()).catch(enterFallback);
|
||||
}, [apiAvailable, ref]);
|
||||
|
||||
return { isFullscreen, toggleFullscreen, apiAvailable };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useRef, type RefObject } from 'react';
|
||||
import { useElementFullscreen } from './useElementFullscreen';
|
||||
|
||||
export interface PreviewFullscreen {
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
isFullscreen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen state for a file preview panel (#2976).
|
||||
*
|
||||
* Held by the modal rather than by `PreviewModalShell`, which renders the
|
||||
* toggle: the 3D viewer drives layout effects off `isFullscreen` — in
|
||||
* fullscreen the plate list splits off the canvas — so the flag has to be
|
||||
* readable outside the shell's own subtree.
|
||||
*/
|
||||
export function usePreviewFullscreen(): PreviewFullscreen {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, toggleFullscreen } = useElementFullscreen(panelRef);
|
||||
return { panelRef, isFullscreen, toggleFullscreen };
|
||||
}
|
||||
@@ -4054,6 +4054,9 @@ export default {
|
||||
nextPage: 'Nächste Seite',
|
||||
zoomIn: 'Vergrößern',
|
||||
zoomOut: 'Verkleinern',
|
||||
resetZoom: 'Zoom zurücksetzen',
|
||||
fullscreen: 'Vollbild',
|
||||
exitFullscreen: 'Vollbild beenden',
|
||||
emptySheet: 'Dieses Tabellenblatt ist leer',
|
||||
truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
|
||||
truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',
|
||||
|
||||
@@ -4084,6 +4084,9 @@ export default {
|
||||
nextPage: 'Next page',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomOut: 'Zoom out',
|
||||
resetZoom: 'Reset zoom',
|
||||
fullscreen: 'Fullscreen',
|
||||
exitFullscreen: 'Exit fullscreen',
|
||||
emptySheet: 'This sheet is empty',
|
||||
truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
|
||||
truncatedCols: 'Showing the first {{shown}} of {{total}} columns',
|
||||
|
||||
@@ -4056,6 +4056,9 @@ export default {
|
||||
nextPage: 'Página siguiente',
|
||||
zoomIn: 'Acercar',
|
||||
zoomOut: 'Alejar',
|
||||
resetZoom: 'Restablecer zoom',
|
||||
fullscreen: 'Pantalla completa',
|
||||
exitFullscreen: 'Salir de la pantalla completa',
|
||||
emptySheet: 'Esta hoja está vacía',
|
||||
truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
|
||||
truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',
|
||||
|
||||
@@ -4042,6 +4042,9 @@ export default {
|
||||
nextPage: 'Page suivante',
|
||||
zoomIn: 'Zoom avant',
|
||||
zoomOut: 'Zoom arrière',
|
||||
resetZoom: 'Réinitialiser le zoom',
|
||||
fullscreen: 'Plein écran',
|
||||
exitFullscreen: 'Quitter plein écran',
|
||||
emptySheet: 'Cette feuille est vide',
|
||||
truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
|
||||
truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',
|
||||
|
||||
@@ -4041,6 +4041,9 @@ export default {
|
||||
nextPage: 'Pagina successiva',
|
||||
zoomIn: 'Ingrandisci',
|
||||
zoomOut: 'Riduci',
|
||||
resetZoom: 'Reimposta zoom',
|
||||
fullscreen: 'Schermo intero',
|
||||
exitFullscreen: 'Esci da schermo intero',
|
||||
emptySheet: 'Questo foglio è vuoto',
|
||||
truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
|
||||
truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',
|
||||
|
||||
@@ -4054,6 +4054,9 @@ export default {
|
||||
nextPage: '次のページ',
|
||||
zoomIn: '拡大',
|
||||
zoomOut: '縮小',
|
||||
resetZoom: 'ズームをリセット',
|
||||
fullscreen: 'フルスクリーン',
|
||||
exitFullscreen: 'フルスクリーンを終了',
|
||||
emptySheet: 'このシートは空です',
|
||||
truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
|
||||
truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',
|
||||
|
||||
@@ -3863,6 +3863,9 @@ export default {
|
||||
nextPage: '다음 페이지',
|
||||
zoomIn: '확대',
|
||||
zoomOut: '축소',
|
||||
resetZoom: '확대/축소 초기화',
|
||||
fullscreen: '전체 화면',
|
||||
exitFullscreen: '전체 화면 종료',
|
||||
emptySheet: '이 시트는 비어 있습니다',
|
||||
truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
|
||||
truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',
|
||||
|
||||
@@ -4084,6 +4084,9 @@ export default {
|
||||
nextPage: 'Volgende pagina',
|
||||
zoomIn: 'Inzoomen',
|
||||
zoomOut: 'Uitzoomen',
|
||||
resetZoom: 'Zoom herstellen',
|
||||
fullscreen: 'Volledig scherm',
|
||||
exitFullscreen: 'Volledig scherm afsluiten',
|
||||
emptySheet: 'Dit werkblad is leeg',
|
||||
truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
|
||||
truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',
|
||||
|
||||
@@ -4041,6 +4041,9 @@ export default {
|
||||
nextPage: 'Próxima página',
|
||||
zoomIn: 'Aumentar zoom',
|
||||
zoomOut: 'Diminuir zoom',
|
||||
resetZoom: 'Redefinir zoom',
|
||||
fullscreen: 'Tela Cheia',
|
||||
exitFullscreen: 'Sair da Tela Cheia',
|
||||
emptySheet: 'Esta planilha está vazia',
|
||||
truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
|
||||
truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',
|
||||
|
||||
@@ -3855,6 +3855,9 @@ export default {
|
||||
nextPage: 'Следующая страница',
|
||||
zoomIn: 'Увеличить',
|
||||
zoomOut: 'Уменьшить',
|
||||
resetZoom: 'Сбросить масштаб',
|
||||
fullscreen: 'На весь экран',
|
||||
exitFullscreen: 'Выйти из полноэкранного режима',
|
||||
emptySheet: 'Этот лист пуст',
|
||||
truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
|
||||
truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',
|
||||
|
||||
@@ -4083,6 +4083,9 @@ errors: {
|
||||
nextPage: 'Nästa sida',
|
||||
zoomIn: 'Zooma in',
|
||||
zoomOut: 'Zooma ut',
|
||||
resetZoom: 'Återställ zoom',
|
||||
fullscreen: 'Helskärm',
|
||||
exitFullscreen: 'Avsluta helskärm',
|
||||
emptySheet: 'Det här bladet är tomt',
|
||||
truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
|
||||
truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',
|
||||
|
||||
@@ -4048,6 +4048,9 @@ export default {
|
||||
nextPage: 'Sonraki sayfa',
|
||||
zoomIn: 'Yakınlaştır',
|
||||
zoomOut: 'Uzaklaştır',
|
||||
resetZoom: 'Yakınlaştırmayı sıfırla',
|
||||
fullscreen: 'Tam ekran',
|
||||
exitFullscreen: 'Tam ekrandan çık',
|
||||
emptySheet: 'Bu sayfa boş',
|
||||
truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
|
||||
truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',
|
||||
|
||||
@@ -4081,6 +4081,9 @@ export default {
|
||||
nextPage: 'Наступна сторінка',
|
||||
zoomIn: 'Збільшити',
|
||||
zoomOut: 'Зменшити',
|
||||
resetZoom: 'Скинути масштаб',
|
||||
fullscreen: 'Повний екран',
|
||||
exitFullscreen: 'Вийти з повноекранного режиму',
|
||||
emptySheet: 'Цей аркуш порожній',
|
||||
truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
|
||||
truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',
|
||||
|
||||
@@ -4041,6 +4041,9 @@ export default {
|
||||
nextPage: '下一页',
|
||||
zoomIn: '放大',
|
||||
zoomOut: '缩小',
|
||||
resetZoom: '重置缩放',
|
||||
fullscreen: '全屏',
|
||||
exitFullscreen: '退出全屏',
|
||||
emptySheet: '此工作表为空',
|
||||
truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
|
||||
truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',
|
||||
|
||||
@@ -4041,6 +4041,9 @@ export default {
|
||||
nextPage: '下一頁',
|
||||
zoomIn: '放大',
|
||||
zoomOut: '縮小',
|
||||
resetZoom: '重設縮放',
|
||||
fullscreen: '全螢幕',
|
||||
exitFullscreen: '離開全螢幕',
|
||||
emptySheet: '此工作表是空的',
|
||||
truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
|
||||
truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
Globe,
|
||||
StickyNote,
|
||||
Camera,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type {
|
||||
@@ -97,6 +98,9 @@ const PdfPreviewModal = lazy(() =>
|
||||
const SpreadsheetPreviewModal = lazy(() =>
|
||||
import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
|
||||
);
|
||||
const ImagePreviewModal = lazy(() =>
|
||||
import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
|
||||
);
|
||||
|
||||
function isSpreadsheetType(fileType: string): boolean {
|
||||
return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
|
||||
@@ -106,6 +110,68 @@ function isStepType(fileType: string): boolean {
|
||||
return fileType === 'step' || fileType === 'stp';
|
||||
}
|
||||
|
||||
// Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
|
||||
// stores and renders a thumbnail for, and so the ones that get an image icon.
|
||||
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
|
||||
|
||||
// The subset ImagePreviewModal can actually show: it hands the bytes to an
|
||||
// <img>, and outside Safari no browser decodes TIFF. Offering the preview
|
||||
// would download up to 50 MB only to report "cannot be previewed", so TIFF
|
||||
// keeps its server-rendered thumbnail and no preview (#2976).
|
||||
const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
||||
|
||||
function isImageType(fileType: string): boolean {
|
||||
return IMAGE_TYPES.has(fileType.toLowerCase());
|
||||
}
|
||||
|
||||
function isPreviewableImageType(fileType: string): boolean {
|
||||
return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
|
||||
}
|
||||
|
||||
// Which files have a preview at all: what a double-click opens, and what the
|
||||
// toolbar's Preview button appears for (#2976). Sliced files go to the
|
||||
// full-page gcode viewer, everything else to a modal.
|
||||
function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
|
||||
const type = file.file_type;
|
||||
return (
|
||||
isSlicedLibraryFile(file) ||
|
||||
type === '3mf' ||
|
||||
type === 'stl' ||
|
||||
isStepType(type) ||
|
||||
type === 'pdf' ||
|
||||
isSpreadsheetType(type) ||
|
||||
isPreviewableImageType(type)
|
||||
);
|
||||
}
|
||||
|
||||
// Spread onto a card/row subtree that is not "the row": its own controls must
|
||||
// neither toggle the selection nor open the preview. `dblclick` is a separate
|
||||
// native event from `click`, so stopping the click alone still lets the second
|
||||
// click of a double-click reach the row's onDoubleClick (#2976).
|
||||
const stopRowActivation = {
|
||||
onClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
};
|
||||
|
||||
// Whether the preview is the 3D one, which has its own menu label.
|
||||
function isModelPreview(file: LibraryFileListItem): boolean {
|
||||
return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
|
||||
}
|
||||
|
||||
function documentPreviewIcon(fileType: string) {
|
||||
if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
|
||||
if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
|
||||
return <Image className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
// Types the server renders a thumbnail for on request, STL through trimesh
|
||||
// and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
|
||||
// applies to them. Mirrors the file types batch_generate_stl_thumbnails in
|
||||
// routes/library.py selects.
|
||||
function hasServerThumbnail(fileType: string): boolean {
|
||||
return fileType === 'stl' || fileType === 'pdf';
|
||||
}
|
||||
|
||||
// New Folder Modal
|
||||
interface NewFolderModalProps {
|
||||
parentId: number | null;
|
||||
@@ -784,8 +850,7 @@ interface FileCardProps {
|
||||
// (#3029), so offering one there would only ever fail.
|
||||
desktopSlicer: SlicerType;
|
||||
canSlice?: boolean;
|
||||
onPreview3d?: (file: LibraryFileListItem) => void;
|
||||
onPreviewDocument?: (file: LibraryFileListItem) => void;
|
||||
onPreview?: (file: LibraryFileListItem) => void;
|
||||
onRename?: (file: LibraryFileListItem) => void;
|
||||
onDetails?: (file: LibraryFileListItem) => void;
|
||||
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
|
||||
@@ -798,7 +863,7 @@ interface FileCardProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onPreviewDocument, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
||||
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
||||
// Viewport coordinates rather than a flag, because the menu is rendered by
|
||||
// `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
|
||||
// card it belongs to is only ~270px tall for a bare STL, which is shorter
|
||||
@@ -840,20 +905,12 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
|
||||
});
|
||||
}
|
||||
if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf' || isStepType(file.file_type))) {
|
||||
if (onPreview && isPreviewableLibraryFile(file)) {
|
||||
const modelPreview = isModelPreview(file);
|
||||
menuItems.push({
|
||||
label: t('fileManager.preview3d'),
|
||||
icon: <Box className="w-4 h-4" />,
|
||||
onClick: () => onPreview3d(file),
|
||||
disabled: !canPreview3d,
|
||||
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
|
||||
});
|
||||
}
|
||||
if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
|
||||
menuItems.push({
|
||||
label: t('fileManager.preview.open'),
|
||||
icon: file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />,
|
||||
onClick: () => onPreviewDocument(file),
|
||||
label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
|
||||
icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
|
||||
onClick: () => onPreview(file),
|
||||
disabled: !canPreview3d,
|
||||
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
|
||||
});
|
||||
@@ -892,7 +949,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
|
||||
});
|
||||
}
|
||||
if (onGenerateThumbnail && file.file_type === 'stl') {
|
||||
if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
|
||||
menuItems.push({
|
||||
label: t('fileManager.generateThumbnail'),
|
||||
icon: <Image className="w-4 h-4" />,
|
||||
@@ -918,6 +975,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
|
||||
}`}
|
||||
onClick={() => onSelect(file.id)}
|
||||
// Double-click opens the preview (#2976). The two clicks that precede it
|
||||
// toggle the selection twice, so the selection is left as it was.
|
||||
onDoubleClick={() => onPreview?.(file)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
|
||||
@@ -931,6 +991,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
<FileText className="w-12 h-12 text-bambu-gray/30" />
|
||||
) : isSpreadsheetType(file.file_type) ? (
|
||||
<FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
|
||||
) : isImageType(file.file_type) ? (
|
||||
<Image className="w-12 h-12 text-bambu-gray/30" />
|
||||
) : (
|
||||
<FileBox className="w-12 h-12 text-bambu-gray/30" />
|
||||
)}
|
||||
@@ -986,7 +1048,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
{/* Metadata indicators (#3077): link, notes, photos. The link opens in
|
||||
a new tab like the archive card's globe; the others open Details. */}
|
||||
{(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
|
||||
{file.external_url && (
|
||||
<a
|
||||
href={file.external_url}
|
||||
@@ -1039,7 +1101,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
</div>
|
||||
)}
|
||||
{(file.tags?.length ?? 0) > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
|
||||
{file.tags!.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
@@ -1057,7 +1119,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
||||
</div>
|
||||
|
||||
{/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
|
||||
<div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
// No open/close toggle: the menu's own outside-mousedown handler
|
||||
@@ -1130,6 +1192,7 @@ export function FileManagerPage() {
|
||||
const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
|
||||
const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
|
||||
const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
|
||||
const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
||||
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
|
||||
});
|
||||
@@ -1676,6 +1739,34 @@ export function FileManagerPage() {
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
|
||||
// The one way into a preview (#2976): the kebab entry, the action-strip
|
||||
// icon, a double-click on the card or row, and the toolbar button all end
|
||||
// up here. Sliced files open the full-page gcode viewer the archive card
|
||||
// uses; everything else opens the modal for its type. A file with no
|
||||
// preview does nothing.
|
||||
const openPreview = useCallback((file: LibraryFileListItem) => {
|
||||
if (!hasPermission('library:read')) return;
|
||||
if (isSlicedLibraryFile(file)) {
|
||||
navigate(`/gcode-viewer?library_file=${file.id}`);
|
||||
} else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
|
||||
setViewerFile(file);
|
||||
} else if (file.file_type === 'pdf') {
|
||||
setPdfPreviewFile(file);
|
||||
} else if (isSpreadsheetType(file.file_type)) {
|
||||
setSheetPreviewFile(file);
|
||||
} else if (isPreviewableImageType(file.file_type)) {
|
||||
setImagePreviewFile(file);
|
||||
}
|
||||
}, [hasPermission, navigate]);
|
||||
|
||||
// The toolbar's Preview button acts on one file, so it is offered only for
|
||||
// a single previewable selection.
|
||||
const previewSelection = useMemo(() => {
|
||||
if (!files || selectedFiles.length !== 1) return null;
|
||||
const file = files.find((f) => f.id === selectedFiles[0]);
|
||||
return file && isPreviewableLibraryFile(file) ? file : null;
|
||||
}, [files, selectedFiles]);
|
||||
|
||||
// Get sliced files from selection
|
||||
const selectedSlicedFiles = useMemo(() => {
|
||||
if (!files) return [];
|
||||
@@ -2411,6 +2502,18 @@ export function FileManagerPage() {
|
||||
</span>
|
||||
<div className="hidden sm:block flex-1" />
|
||||
<div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
|
||||
{previewSelection && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openPreview(previewSelection)}
|
||||
disabled={!hasPermission('library:read')}
|
||||
title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
|
||||
>
|
||||
<Eye className="w-4 h-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Print used to disappear the moment a second sliced file was
|
||||
selected. Selecting several is now how you say "same job,
|
||||
different printers" (#671) — one queue item, whichever
|
||||
@@ -2564,21 +2667,7 @@ export function FileManagerPage() {
|
||||
onRunPipeline={setRunPipelineFile}
|
||||
useSlicerApi={settings?.use_slicer_api ?? false}
|
||||
canSlice={canSlice()}
|
||||
onPreview3d={(f) => {
|
||||
// Sliced files (.gcode / .gcode.3mf) open the same
|
||||
// full-page gcode viewer the archive card uses, so
|
||||
// the two paths feel consistent. STL / source 3MF
|
||||
// continue to use the in-app 3D model viewer modal.
|
||||
if (isSlicedLibraryFile(f)) {
|
||||
navigate(`/gcode-viewer?library_file=${f.id}`);
|
||||
} else {
|
||||
setViewerFile(f);
|
||||
}
|
||||
}}
|
||||
onPreviewDocument={(f) => {
|
||||
if (f.file_type === 'pdf') setPdfPreviewFile(f);
|
||||
else setSheetPreviewFile(f);
|
||||
}}
|
||||
onPreview={openPreview}
|
||||
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
|
||||
onDetails={setDetailsFile}
|
||||
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
|
||||
@@ -2624,6 +2713,8 @@ export function FileManagerPage() {
|
||||
selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file.id)}
|
||||
// Double-click opens the preview (#2976), as in the grid.
|
||||
onDoubleClick={() => openPreview(file)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
@@ -2649,6 +2740,8 @@ export function FileManagerPage() {
|
||||
<FileText className="w-5 h-5 text-bambu-gray/50" />
|
||||
) : isSpreadsheetType(file.file_type) ? (
|
||||
<FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
|
||||
) : isImageType(file.file_type) ? (
|
||||
<Image className="w-5 h-5 text-bambu-gray/50" />
|
||||
) : (
|
||||
<FileBox className="w-5 h-5 text-bambu-gray/50" />
|
||||
)}
|
||||
@@ -2677,7 +2770,7 @@ export function FileManagerPage() {
|
||||
href={file.external_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...stopRowActivation}
|
||||
className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
|
||||
title={t('fileManager.details.openLink')}
|
||||
aria-label={t('fileManager.details.openLink')}
|
||||
@@ -2742,7 +2835,7 @@ export function FileManagerPage() {
|
||||
filter; minmax(0,200px) on the column lets the cell
|
||||
shrink/wrap on narrow viewports without pushing the
|
||||
Actions cell off-screen. */}
|
||||
<div className="min-w-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="min-w-0" {...stopRowActivation}>
|
||||
{!file.tags || file.tags.length === 0 ? (
|
||||
<span className="text-xs text-bambu-gray/50">-</span>
|
||||
) : (
|
||||
@@ -2763,7 +2856,7 @@ export function FileManagerPage() {
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1" {...stopRowActivation}>
|
||||
{isSlicedLibraryFile(file) && (
|
||||
<>
|
||||
<button
|
||||
@@ -2811,16 +2904,9 @@ export function FileManagerPage() {
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
|
||||
{isModelPreview(file) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!hasPermission('library:read')) return;
|
||||
if (isSlicedLibraryFile(file)) {
|
||||
navigate(`/gcode-viewer?library_file=${file.id}`);
|
||||
} else {
|
||||
setViewerFile(file);
|
||||
}
|
||||
}}
|
||||
onClick={() => openPreview(file)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
hasPermission('library:read')
|
||||
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
||||
@@ -2832,13 +2918,9 @@ export function FileManagerPage() {
|
||||
<Box className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
|
||||
{!isModelPreview(file) && isPreviewableLibraryFile(file) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!hasPermission('library:read')) return;
|
||||
if (file.file_type === 'pdf') setPdfPreviewFile(file);
|
||||
else setSheetPreviewFile(file);
|
||||
}}
|
||||
onClick={() => openPreview(file)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
hasPermission('library:read')
|
||||
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
||||
@@ -2847,7 +2929,7 @@ export function FileManagerPage() {
|
||||
title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
|
||||
disabled={!hasPermission('library:read')}
|
||||
>
|
||||
{file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
|
||||
{documentPreviewIcon(file.file_type)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -2886,7 +2968,7 @@ export function FileManagerPage() {
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
{file.file_type === 'stl' && (
|
||||
{hasServerThumbnail(file.file_type) && (
|
||||
<button
|
||||
onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
@@ -3087,7 +3169,7 @@ export function FileManagerPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(pdfPreviewFile || sheetPreviewFile) && (
|
||||
{(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
|
||||
<Suspense fallback={null}>
|
||||
{pdfPreviewFile && (
|
||||
<PdfPreviewModal
|
||||
@@ -3108,6 +3190,14 @@ export function FileManagerPage() {
|
||||
onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
|
||||
/>
|
||||
)}
|
||||
{imagePreviewFile && (
|
||||
<ImagePreviewModal
|
||||
libraryFileId={imagePreviewFile.id}
|
||||
filename={imagePreviewFile.print_name || imagePreviewFile.filename}
|
||||
fileSize={imagePreviewFile.file_size}
|
||||
onClose={() => setImagePreviewFile(null)}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
|
||||
+43
-1
@@ -1,4 +1,7 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { defineConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
@@ -6,6 +9,45 @@ import path from 'path'
|
||||
const backendPort = process.env.BACKEND_PORT || '8000'
|
||||
const backendUrl = `http://localhost:${backendPort}`
|
||||
|
||||
// pdf.js keeps these out of its bundle and fetches them at runtime (#2976):
|
||||
// CJK text needs the CMaps, non-embedded fonts the standard font files, and
|
||||
// JPEG2000/JBIG2 images and ICC colour the wasm decoders. Published under the
|
||||
// bundle's own assets directory so the existing /assets mount serves them —
|
||||
// PdfPreviewModal builds the matching URLs from its PDFJS_ASSET_BASE.
|
||||
const PDFJS_RUNTIME_DIRS = ['cmaps', 'iccs', 'standard_fonts', 'wasm']
|
||||
const PDFJS_RUNTIME_PREFIX = 'assets/pdfjs'
|
||||
|
||||
/** Publish pdf.js's runtime data directories next to the bundle. */
|
||||
function pdfjsRuntimeAssets(): Plugin {
|
||||
const require = createRequire(__filename)
|
||||
const packageDir = path.dirname(require.resolve('pdfjs-dist/package.json'))
|
||||
// Published path -> file on disk. Built once, and used as an allowlist by
|
||||
// the dev-server handler so no request can escape the package directory.
|
||||
const files = new Map<string, string>()
|
||||
for (const dir of PDFJS_RUNTIME_DIRS) {
|
||||
for (const entry of readdirSync(path.join(packageDir, dir), { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue
|
||||
files.set(`${PDFJS_RUNTIME_PREFIX}/${dir}/${entry.name}`, path.join(packageDir, dir, entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'bambuddy:pdfjs-runtime-assets',
|
||||
generateBundle() {
|
||||
for (const [fileName, source] of files) {
|
||||
this.emitFile({ type: 'asset', fileName, source: readFileSync(source) })
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const source = files.get((req.url ?? '').split('?')[0].replace(/^\//, ''))
|
||||
if (!source) return next()
|
||||
res.setHeader('Content-Type', source.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream')
|
||||
res.end(readFileSync(source))
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// Default base ('/') emits absolute asset URLs (/assets/...). Required so
|
||||
@@ -17,7 +59,7 @@ export default defineConfig({
|
||||
// fix for subpath reverse proxies (#1195, wontfix) is reverted — that
|
||||
// audience uses NPM + Cloudflare Tunnel at a real domain per the
|
||||
// documented workaround, which doesn't depend on this setting.
|
||||
plugins: [react()],
|
||||
plugins: [react(), pdfjsRuntimeAssets()],
|
||||
build: {
|
||||
outDir: '../static',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user