Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions components/object/preview-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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"]
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
136 changes: 136 additions & 0 deletions components/object/tiff-viewer.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions types/utif.d.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading