diff --git a/components/object/preview-modal.tsx b/components/object/preview-modal.tsx
index c9ab16b..6aab6ed 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 0000000..8941a10
--- /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 d8c8bfb..58e3f87 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 a615994..0f7234d 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 0000000..f6394f7
--- /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
+}