mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
feat: add TIFF/TIF image preview support using UTIF (#194)
This commit is contained in:
@@ -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 <PdfViewer url={previewUrl} />
|
||||
case "parquet":
|
||||
return <ParquetViewer url={previewUrl} sizeBytes={objectSize} />
|
||||
case "tiff":
|
||||
return <TiffViewer url={previewUrl} objectKey={objectKey} />
|
||||
case "download":
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -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<HTMLCanvasElement | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [error, setError] = React.useState("")
|
||||
const [imageData, setImageData] = React.useState<TiffImageData | null>(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 (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
role="img"
|
||||
aria-label={objectKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Generated
+15
@@ -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: {}
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user