From 607c89160e92185815a86d479b37d099b5ce1c0a Mon Sep 17 00:00:00 2001 From: liaozip Date: Wed, 5 Aug 2026 00:03:18 +0800 Subject: [PATCH] feat: add TIFF/TIF image preview support using UTIF - Add TiffViewer component with two-phase rendering (decode then canvas render) to fix race condition where canvas ref is null during loading state - Add TypeScript type definitions for UTIF library - Integrate TiffViewer into preview-modal for .tif/.tiff file previews - Add utif@^3.1.0 dependency - Supports multi-page TIFF, compressed TIFF (LZW, Deflate, PackBits, JPEG) --- components/object/preview-modal.tsx | 17 +++- components/object/tiff-viewer.tsx | 136 ++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 15 +++ types/utif.d.ts | 16 ++++ 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 components/object/tiff-viewer.tsx create mode 100644 types/utif.d.ts diff --git a/components/object/preview-modal.tsx b/components/object/preview-modal.tsx index c9ab16b7..6aab6eda 100644 --- a/components/object/preview-modal.tsx +++ b/components/object/preview-modal.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils" import { RiFullscreenExitLine, RiFullscreenLine } from "@remixicon/react" import { PdfViewer } from "@/components/object/pdf-viewer" import { ParquetViewer } from "@/components/object/parquet-viewer" +import { TiffViewer } from "@/components/object/tiff-viewer" import Image from "next/image" const SAFE_TEXT_MIMES = [ @@ -26,7 +27,7 @@ const SAFE_TEXT_EXTENSIONS = [".txt", ".json", ".jsonl", ".ndjson", ".xml", ".cs const SAFE_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tif", ".tiff"] const ALLOWED_SIZE = 1024 * 1024 * 2 // 2MB -type PreviewMode = "text" | "image" | "pdf" | "parquet" | "sandbox" | "download" +type PreviewMode = "text" | "image" | "pdf" | "parquet" | "sandbox" | "download" | "tiff" const PARQUET_MIMES = ["application/vnd.apache.parquet", "application/x-parquet", "application/parquet"] const PARQUET_EXTENSIONS = [".parquet", ".pq"] @@ -86,6 +87,11 @@ function isParquetPreview(contentType: string, objectKey: string) { return PARQUET_EXTENSIONS.some((ext) => keyLower.endsWith(ext)) } +function isTiffPreview(objectKey: string) { + const keyLower = objectKey.toLowerCase() + return keyLower.endsWith(".tif") || keyLower.endsWith(".tiff") +} + function getFullscreenElement(doc: FullscreenDocument): Element | null { return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null } @@ -140,13 +146,16 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview const canRenderImage = hasPreviewUrl && isImagePreview(normalizedContentType, objectKey) const canRenderPdf = hasPreviewUrl && isPdfPreview(normalizedContentType) const canRenderParquet = hasPreviewUrl && isParquetPreview(normalizedContentType, objectKey) + const canRenderTiff = hasPreviewUrl && isTiffPreview(objectKey) const previewMode: PreviewMode = canRenderParquet ? "parquet" : canRenderPdf ? "pdf" - : getPreviewMode(hasPreviewUrl, canRenderText, canRenderImage) + : canRenderTiff + ? "tiff" + : getPreviewMode(hasPreviewUrl, canRenderText, canRenderImage) const isImageMode = previewMode === "image" - const isSelfScrollMode = isImageMode || previewMode === "parquet" + const isSelfScrollMode = isImageMode || previewMode === "parquet" || previewMode === "tiff" const getFormattedContent = () => { if (!isJson || !isFormatted) return textContent @@ -374,6 +383,8 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview return case "parquet": return + case "tiff": + return case "download": default: return ( diff --git a/components/object/tiff-viewer.tsx b/components/object/tiff-viewer.tsx new file mode 100644 index 00000000..8941a108 --- /dev/null +++ b/components/object/tiff-viewer.tsx @@ -0,0 +1,136 @@ +"use client" + +import * as React from "react" +import { useTranslation } from "react-i18next" +import { Spinner } from "@/components/ui/spinner" + +// UTIF types +interface UTIFModule { + decode: (buffer: ArrayBuffer) => Array<{ width: number; height: number; [key: string]: unknown }> + decodeImage: (buffer: ArrayBuffer, ifd: { width: number; height: number; [key: string]: unknown }) => void + toRGBA8: (ifd: { width: number; height: number; [key: string]: unknown }) => Uint8Array +} + +interface TiffViewerProps { + url: string + objectKey: string +} + +interface TiffImageData { + width: number + height: number + rgba: Uint8Array +} + +/** + * TiffViewer — decode TIFF/TIF images client-side and render to Canvas. + * Uses utif for decoding with dynamic import to avoid bundling for non-TIFF usage. + * Supports compressed TIFF (LZW, Deflate, PackBits, JPEG). + * + * Two-phase rendering: phase 1 decodes the image and stores the result in state + * (setLoading(false) after decode); phase 2 renders to canvas once the canvas + * element is mounted to the DOM. This avoids a race condition where the canvas + * ref is null because the component is still displaying the loading spinner. + */ +export function TiffViewer({ url, objectKey }: TiffViewerProps) { + const { t } = useTranslation() + const canvasRef = React.useRef(null) + const [loading, setLoading] = React.useState(true) + const [error, setError] = React.useState("") + const [imageData, setImageData] = React.useState(null) + + // Phase 1: fetch and decode the TIFF image + React.useEffect(() => { + let cancelled = false + const controller = new AbortController() + + async function decodeTiff() { + setLoading(true) + setError("") + setImageData(null) + + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const buffer = await response.arrayBuffer() + + if (cancelled) return + + const UTIF: UTIFModule = await import("utif") + + const ifds = UTIF.decode(buffer) + if (!ifds || ifds.length === 0) throw new Error("Invalid TIFF: no IFD found") + + UTIF.decodeImage(buffer, ifds[0]) + const rgba = UTIF.toRGBA8(ifds[0]) + + if (cancelled) return + + setImageData({ width: ifds[0].width, height: ifds[0].height, rgba }) + setLoading(false) + } catch (err: unknown) { + if (cancelled) return + const message = + err instanceof Error && err.name === "AbortError" + ? "" + : err instanceof Error + ? err.message + : String(err) + setError(message || t("Preview unavailable")) + setLoading(false) + } + } + + decodeTiff() + + return () => { + cancelled = true + controller.abort() + } + }, [url, t]) + + // Phase 2: render decoded image data to canvas (runs after canvas is in DOM) + React.useEffect(() => { + if (loading || !imageData) return + + const canvas = canvasRef.current + if (!canvas) return + + canvas.width = imageData.width + canvas.height = imageData.height + + const ctx = canvas.getContext("2d") + if (!ctx) return + + const imgData = ctx.createImageData(canvas.width, canvas.height) + imgData.data.set(imageData.rgba) + ctx.putImageData(imgData, 0, 0) + }, [loading, imageData]) + + if (loading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ {error} +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/package.json b/package.json index d8c8bfbd..58e3f870 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "ufo": "^1.6.4", + "utif": "^3.1.0", "vaul": "^1.1.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6159945..0f7234d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: ufo: specifier: ^1.6.4 version: 1.6.4 + utif: + specifier: ^3.1.0 + version: 3.1.0 vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3324,6 +3327,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3944,6 +3950,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utif@3.1.0: + resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7507,6 +7516,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8247,6 +8258,10 @@ snapshots: dependencies: react: 19.2.7 + utif@3.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} validate-npm-package-name@7.0.2: {} diff --git a/types/utif.d.ts b/types/utif.d.ts new file mode 100644 index 00000000..f6394f7b --- /dev/null +++ b/types/utif.d.ts @@ -0,0 +1,16 @@ +declare module "utif" { + interface UTIFIFD { + width: number + height: number + [key: string]: unknown + } + + interface UTIFModule { + decode: (buffer: ArrayBuffer) => UTIFIFD[] + decodeImage: (buffer: ArrayBuffer, ifd: UTIFIFD) => void + toRGBA8: (ifd: UTIFIFD) => Uint8Array + } + + const UTIF: UTIFModule + export = UTIF +}