retab
uifile-thumbnailsource: ok · refreshed
File Thumbnail
Render complete file thumbnails from browser files, URLs, blobs, text sources, external images, or custom preview content.
npx shadcn add "https://ui.retab.com/r/file-thumbnail.json"free
Where it goes
testimonial
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@e965/xlsx@0.20.3dompurify@^3.3.3marked@^15.0.12@kenjiuno/msgreader@^1.28.0pptxviewjs@1.1.9utif@^3.1.0docx-preview@^0.3.7
Registry dependencies
@retab/file-thumbnail-frame@retab/pdf-document-resource@retab/docx-document-resource@retab/csv@retab/xlsx-worker-protocol@retab/utils@retab/pptx-viewer
Files46 · source cached 10/8/2026, 10:47:42 AM
registry/new-york-v4/ui/file-thumbnail.tsx
"use client";
import type { ViewerSource } from "@/lib/viewer-source";
import { GeneratedFileThumbnail } from "@/components/file-thumbnail/generated-preview";
import {
FileThumbnailFrame,
FileThumbnailShimmer,
hasRenderablePreviewContent,
resolveFileThumbnailState,
} from "./file-thumbnail-frame";
import type {
FileThumbnailFrameProps,
FileThumbnailProps,
FileThumbnailShape,
FileThumbnailSize,
FileThumbnailSource,
FileThumbnailState,
ThumbnailFile,
} from "./file-thumbnail-types";
export {
FileThumbnailFrame,
FileThumbnailShimmer,
hasRenderablePreviewContent,
resolveFileThumbnailState,
};
export type {
FileThumbnailFrameProps,
FileThumbnailProps,
FileThumbnailShape,
FileThumbnailSize,
FileThumbnailSource,
FileThumbnailState,
ThumbnailFile,
};
/**
* Render a complete file thumbnail from a browser File, a viewer source, an
* externally generated image, custom preview content, or plain file metadata.
*/
export function FileThumbnail({
source,
file,
as,
anchor,
presentation = "document",
retryKey,
onError,
...frameProps
}: FileThumbnailProps) {
const resolvedSource = resolveFileThumbnailSource(source, file);
const accessibilityProps =
presentation === "decorative"
? ({
"aria-hidden": true,
role: "presentation",
} as const)
: {};
const {
previewClassName: _previewClassName,
previewContent: _previewContent,
previewImageUrl: _previewImageUrl,
onPreviewError: _onPreviewError,
state: _state,
...thumbnailProps
} = frameProps;
if (shouldUseFrame(frameProps) || !resolvedSource) {
return (
<FileThumbnailFrame
{...frameProps}
{...accessibilityProps}
file={file ?? fileFromSource(resolvedSource)}
/>
);
}
return (
<GeneratedFileThumbnail
{...thumbnailProps}
{...accessibilityProps}
source={resolvedSource}
as={as}
anchor={anchor ?? "top-left"}
retryKey={retryKey}
onError={onError}
/>
);
}
function shouldUseFrame({
previewContent,
previewImageUrl,
state,
}: Pick<
FileThumbnailProps,
"previewContent" | "previewImageUrl" | "state"
>): boolean {
return (
hasRenderablePreviewContent(previewContent) ||
Boolean(previewImageUrl) ||
state !== undefined
);
}
function resolveFileThumbnailSource(
source: FileThumbnailProps["source"],
file: FileThumbnailProps["file"],
): ViewerSource | null {
if (isFile(source)) return fileSource(source);
if (source) return source;
if (isFile(file)) return fileSource(file);
return null;
}
function isFile(value: unknown): value is File {
return typeof File !== "undefined" && value instanceof File;
}
function fileSource(file: File): ViewerSource {
return {
kind: "blob",
blob: file,
identityKey: `${file.name}-${file.size}-${file.lastModified}`,
fileName: file.name,
mimeType: file.type,
};
}
function fileFromSource(source: ViewerSource | null): ThumbnailFile {
if (!source) return { name: "file", type: "" };
return {
name: source.fileName ?? "file",
type: source.mimeType ?? "",
};
}
registry/new-york-v4/ui/file-thumbnail-types.ts
import type * as React from "react";
import type { ViewerErrorInfo } from "@/lib/viewer-errors";
import type { FileCategory, ViewerSource } from "@/lib/viewer-source";
import type { ThumbnailAnchor } from "@/components/file-thumbnail/types";
import type {
FileThumbnailFrameProps,
FileThumbnailShape,
FileThumbnailSize,
FileThumbnailState,
ThumbnailFile,
} from "./file-thumbnail-frame-types";
export type {
FileThumbnailFrameProps,
FileThumbnailShape,
FileThumbnailSize,
FileThumbnailState,
ThumbnailFile,
} from "./file-thumbnail-frame-types";
export type FileThumbnailSource = ViewerSource | File;
export interface FileThumbnailProps
extends Omit<FileThumbnailFrameProps, "file" | "onError"> {
/** File metadata renders a static shell; a browser File renders a generated preview. */
file?: ThumbnailFile | File;
/** URL, Blob/File, or inline text source for generated file thumbnails. */
source?: FileThumbnailSource;
/** Override auto-detection when name or MIME type is missing or wrong. */
as?: FileCategory;
/** Corner pinned when rendered content overflows the frame. */
anchor?: ThumbnailAnchor;
/** Hide rich preview internals from assistive tech when the parent row owns the accessible name. */
presentation?: "document" | "decorative";
/** Change to retry a failed generated thumbnail for the same source. */
retryKey?: React.Key;
/** Receives generated thumbnail failures with canonical viewer error info. */
onError?: (error: unknown, info: ViewerErrorInfo) => void;
}
components/file-thumbnail/descriptor.ts
import {
extensionOf,
resolveViewerDescriptor,
type FileCategory,
type ViewerDescriptor,
type ViewerSource,
} from "@/lib/viewer-source";
export function resolveThumbnailDescriptor({
source,
as,
}: {
source: ViewerSource;
as?: FileCategory;
}): ViewerDescriptor {
return resolveViewerDescriptor({ source, category: as });
}
export function isTiffDescriptor(descriptor: ViewerDescriptor): boolean {
const mime = descriptor.mimeType?.toLowerCase().split(";")[0].trim();
if (mime === "image/tiff" || mime === "image/tif") return true;
const extension = extensionOf(descriptor.displayName);
return extension === "tif" || extension === "tiff";
}
components/file-thumbnail/errors.tsx
"use client";
import * as React from "react";
// A failed parse updates the outer FileThumbnail state so it degrades to the
// shared fallback surface instead of an empty preview layer.
export class ThumbnailErrorBoundary extends React.Component<
{
children: React.ReactNode;
fallback: React.ReactNode;
onError: (error: unknown) => void;
},
{ failed: boolean }
> {
constructor(props: {
children: React.ReactNode;
fallback: React.ReactNode;
onError: (error: unknown) => void;
}) {
super(props);
this.state = { failed: false };
}
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
this.props.onError(error);
}
render() {
if (this.state.failed) return this.props.fallback;
return this.props.children;
}
}
components/file-thumbnail/generated-preview.tsx
"use client";
import * as React from "react";
import type { ViewerErrorInfo } from "@/lib/viewer-errors";
import { createViewerResource } from "@/lib/viewer-resource";
import type { FileCategory, ViewerSource } from "@/lib/viewer-source";
import {
FileThumbnailFrame,
type FileThumbnailFrameProps,
} from "@/components/ui/file-thumbnail-frame";
import { resolveThumbnailDescriptor } from "./descriptor";
import { getThumbnailKey, getThumbnailRenderKey } from "./keys";
import { ThumbnailClientPreview } from "./thumbnail-client-preview";
import {
DirectImageThumbnail,
isDirectImageThumbnail,
} from "./thumbnail-direct-image";
import {
createThumbnailErrorState,
type ThumbnailErrorState,
} from "./thumbnail-error-state";
import { getThumbnailOptions } from "./thumbnail-options";
import type { ThumbnailAnchor } from "./types";
export interface GeneratedFileThumbnailProps
extends Omit<FileThumbnailFrameProps, "file" | "onError"> {
source: ViewerSource;
as?: FileCategory;
anchor: ThumbnailAnchor;
retryKey?: React.Key;
onError?: (error: unknown, info: ViewerErrorInfo) => void;
}
export function GeneratedFileThumbnail({
source,
as,
className,
previewAspectRatio,
anchor,
retryKey,
onError,
...props
}: GeneratedFileThumbnailProps) {
const descriptor = resolveThumbnailDescriptor({ source, as });
const resource = React.useMemo(() => createViewerResource(source), [source]);
const thumbnailKey = getThumbnailKey({
resource,
descriptor,
options: getThumbnailOptions(descriptor),
});
const renderKey = getThumbnailRenderKey({
thumbnailKey,
anchor,
retryKey: retryKey ?? null,
});
const [errorState, setErrorState] =
React.useState<ThumbnailErrorState | null>(null);
const currentErrorState =
errorState?.renderKey === renderKey ? errorState : null;
const directUrl = resource.content.directUrl;
if (descriptor.category === "unsupported") {
return (
<FileThumbnailFrame
{...props}
file={{ name: descriptor.displayName, type: descriptor.mimeType ?? "" }}
previewAspectRatio={previewAspectRatio}
className={className}
/>
);
}
if (isDirectImageThumbnail({ descriptor, directUrl })) {
return (
<DirectImageThumbnail
descriptor={descriptor}
resource={resource}
directUrl={directUrl!}
previewAspectRatio={previewAspectRatio}
className={className}
anchor={anchor}
renderKey={renderKey}
errorState={currentErrorState}
thumbnailProps={props}
onError={(error, nextErrorState) => {
setErrorState(nextErrorState);
onError?.(error, nextErrorState.info);
}}
/>
);
}
return (
<FileThumbnailFrame
{...props}
file={{ name: descriptor.displayName, type: descriptor.mimeType ?? "" }}
previewAspectRatio={previewAspectRatio}
className={className}
state={currentErrorState ? "error" : "loaded"}
aria-label={currentErrorState?.info.userMessage}
title={currentErrorState?.info.userMessage}
data-error-domain={currentErrorState?.info.domain}
data-error-format={currentErrorState?.info.format}
data-error-kind={currentErrorState?.info.kind}
data-error-message={currentErrorState?.info.message}
previewContent={
<ThumbnailClientPreview
key={renderKey}
resource={resource}
descriptor={descriptor}
thumbnailKey={thumbnailKey}
anchor={anchor}
onError={(error) => {
const nextErrorState = createThumbnailErrorState({
renderKey,
error,
resource,
descriptor,
});
setErrorState(nextErrorState);
onError?.(error, nextErrorState.info);
}}
/>
}
/>
);
}
components/file-thumbnail/keys.ts
import type * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import type { ViewerDescriptor } from "@/lib/viewer-source";
import type { ThumbnailAnchor } from "./types";
export interface ThumbnailKeyInput {
resource: ViewerResource;
descriptor: ViewerDescriptor;
unit?: string;
options?: readonly ThumbnailOption[];
}
export interface ThumbnailRenderKeyInput {
thumbnailKey: string;
anchor: ThumbnailAnchor;
retryKey: React.Key | null;
}
export function getThumbnailKey({
resource,
descriptor,
unit = "first",
options = [],
}: ThumbnailKeyInput): string {
return [
encodePart(`resource:${resource.keys.load}`),
encodePart(`category:${descriptor.category}`),
encodePart(`unit:${unit}`),
...options.map((option) => encodePart(`option:${option}`)),
].join("");
}
export type ThumbnailOption = string & {
readonly __thumbnailOption: unique symbol;
};
export function thumbnailOption(
name: string,
value: string | number,
): ThumbnailOption {
return `${name}:${String(value)}` as ThumbnailOption;
}
export function getThumbnailRenderKey({
thumbnailKey,
anchor,
retryKey,
}: ThumbnailRenderKeyInput): string {
return [
encodePart(`thumbnail:${thumbnailKey}`),
encodePart(`anchor:${anchor}`),
encodeRetryKey(retryKey),
].join("");
}
function encodePart(value: string): string {
return `${value.length}:${value}`;
}
function encodeRetryKey(retryKey: React.Key | null): string {
if (retryKey === null) return encodePart("retry:null:");
return encodePart(`retry:${typeof retryKey}:${String(retryKey)}`);
}
components/file-thumbnail/renderer-registry.tsx
"use client";
import * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import type { FileCategory, ViewerDescriptor } from "@/lib/viewer-source";
import { isTiffDescriptor } from "./descriptor";
import { CodeThumbnail } from "./renderers/code-thumbnail";
import { CsvFirstRows } from "./renderers/csv-thumbnail";
import { DocxFirstPage } from "./renderers/docx-thumbnail";
import { HtmlFirstPage } from "./renderers/html-thumbnail";
import { ImageFirstFrame } from "./renderers/image-thumbnail";
import { MarkdownFirstPage } from "./renderers/markdown-thumbnail";
import { MsgEmailThumbnail } from "./renderers/msg-thumbnail";
import { PdfFirstPage } from "./renderers/pdf-thumbnail";
import { PptxFirstSlide } from "./renderers/pptx-thumbnail";
import { TextThumbnail } from "./renderers/text-thumbnail";
import { TiffFirstPage } from "./renderers/tiff-thumbnail";
import { XlsxFirstSheet } from "./renderers/xlsx-thumbnail";
import { isCodeThumbnailDescriptor } from "./thumbnail-code";
import type { ThumbnailAnchor } from "./types";
type DocumentRenderer = (props: {
resource: ViewerResource;
descriptor: ViewerDescriptor;
thumbnailKey: string;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) => React.ReactNode;
const DOCUMENT_RENDERERS: Record<
Exclude<FileCategory, "unsupported">,
DocumentRenderer
> = {
pdf: ({ resource, anchor }) => (
<PdfFirstPage resource={resource} anchor={anchor} />
),
xlsx: ({ resource, thumbnailKey }) => (
<XlsxFirstSheet resource={resource} thumbnailKey={thumbnailKey} />
),
pptx: ({ resource, thumbnailKey, anchor }) => (
<PptxFirstSlide
resource={resource}
thumbnailKey={thumbnailKey}
anchor={anchor}
/>
),
docx: ({ resource }) => <DocxFirstPage resource={resource} />,
image: ({ resource, descriptor, thumbnailKey, anchor, onError }) =>
isTiffDescriptor(descriptor) ? (
<TiffFirstPage
resource={resource}
thumbnailKey={thumbnailKey}
anchor={anchor}
onError={onError}
/>
) : (
<ImageFirstFrame resource={resource} anchor={anchor} onError={onError} />
),
csv: ({ resource, thumbnailKey }) => (
<CsvFirstRows resource={resource} thumbnailKey={thumbnailKey} />
),
markdown: ({ resource, thumbnailKey }) => (
<MarkdownFirstPage resource={resource} thumbnailKey={thumbnailKey} />
),
html: ({ resource, thumbnailKey }) => (
<HtmlFirstPage resource={resource} thumbnailKey={thumbnailKey} />
),
email: ({ resource, thumbnailKey }) => (
<MsgEmailThumbnail resource={resource} thumbnailKey={thumbnailKey} />
),
text: ({ resource, descriptor, thumbnailKey }) =>
isCodeThumbnailDescriptor(descriptor) ? (
<CodeThumbnail resource={resource} thumbnailKey={thumbnailKey} />
) : (
<TextThumbnail resource={resource} thumbnailKey={thumbnailKey} />
),
};
export function FirstThumbnailUnit({
resource,
descriptor,
thumbnailKey,
anchor,
onError,
}: {
resource: ViewerResource;
descriptor: ViewerDescriptor;
thumbnailKey: string;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
if (descriptor.category === "unsupported") return null;
const render = DOCUMENT_RENDERERS[descriptor.category];
return render({ resource, descriptor, thumbnailKey, anchor, onError });
}
components/file-thumbnail/thumbnail-cache.ts
import { registerThumbnailTestReset } from "./thumbnail-test-reset";
export interface ThumbnailCacheEntry<T> {
promise: Promise<T>;
status: "pending" | "resolved" | "rejected";
value?: T;
onResolve?: (value: T) => void;
}
export interface ThumbnailCacheStore<T> {
get(key: string): ThumbnailCacheEntry<T> | undefined;
set(key: string, entry: ThumbnailCacheEntry<T>): void;
delete(key: string): boolean;
clear?(): void;
prune?(): void;
}
export interface ThumbnailArtifactCacheOptions<T> {
maxEntries: number;
dispose?: (value: T) => void;
}
export interface ThumbnailArtifactCache<T> extends ThumbnailCacheStore<T> {
readonly size: number;
clear(): void;
prune(): void;
}
export function cachedThumbnailResource<T>(
cache: ThumbnailCacheStore<T>,
key: string,
load: () => Promise<T>,
): Promise<T> {
const cached = cache.get(key);
if (cached) {
if (cached.status === "rejected") {
cache.delete(key);
}
return cached.promise;
}
const entry: ThumbnailCacheEntry<T> = {
status: "pending",
promise: load().then(
(value) => {
entry.status = "resolved";
entry.value = value;
entry.onResolve?.(value);
cache.prune?.();
return value;
},
(error) => {
entry.status = "rejected";
throw error;
},
),
};
cache.set(key, entry);
return entry.promise;
}
export function createThumbnailArtifactCache<T>({
maxEntries,
dispose,
}: ThumbnailArtifactCacheOptions<T>): ThumbnailArtifactCache<T> {
const entries = new Map<string, ThumbnailCacheEntry<T>>();
const cache: ThumbnailArtifactCache<T> = {
get size() {
return entries.size;
},
get(key) {
const entry = entries.get(key);
if (!entry) return undefined;
entries.delete(key);
entries.set(key, entry);
return entry;
},
set(key, entry) {
entries.set(key, entry);
entry.onResolve = (value) => {
if (entries.get(key) !== entry) dispose?.(value);
};
cache.prune?.();
},
delete(key) {
const entry = entries.get(key);
if (!entry) return false;
entries.delete(key);
disposeCacheEntry(entry, dispose);
return true;
},
clear() {
for (const entry of entries.values()) {
disposeCacheEntry(entry, dispose);
}
entries.clear();
},
prune() {
while (entries.size > maxEntries) {
const evicted = evictOldestResolvedEntry(entries, dispose);
if (!evicted) return;
}
},
};
registerThumbnailTestReset(() => cache.clear?.());
return cache;
}
function evictOldestResolvedEntry<T>(
entries: Map<string, ThumbnailCacheEntry<T>>,
dispose?: (value: T) => void,
): boolean {
for (const [key, entry] of entries) {
if (entry.status !== "pending") {
entries.delete(key);
disposeCacheEntry(entry, dispose);
return true;
}
}
return false;
}
function disposeCacheEntry<T>(
entry: ThumbnailCacheEntry<T>,
dispose?: (value: T) => void,
) {
if (entry.status === "resolved" && entry.value !== undefined) {
dispose?.(entry.value);
}
}
components/file-thumbnail/thumbnail-client-preview.tsx
"use client";
import * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import type { ViewerDescriptor } from "@/lib/viewer-source";
import { FileThumbnailShimmer } from "@/components/ui/file-thumbnail-frame";
import { useIsClient } from "@/components/ui/use-is-client";
import { ThumbnailErrorBoundary } from "./errors";
import { FirstThumbnailUnit } from "./renderer-registry";
import { useThumbnailInView } from "./thumbnail-in-view";
import type { ThumbnailAnchor } from "./types";
export function ThumbnailClientPreview({
resource,
descriptor,
thumbnailKey,
anchor,
onError,
}: {
resource: ViewerResource;
descriptor: ViewerDescriptor;
thumbnailKey: string;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
const isClient = useIsClient();
const { ref: inViewRef, seen: isSeen } = useThumbnailInView();
return (
<div ref={inViewRef} className="absolute inset-0">
{isClient && isSeen ? (
<ThumbnailErrorBoundary fallback={null} onError={onError}>
<React.Suspense fallback={<FileThumbnailShimmer />}>
<FirstThumbnailUnit
resource={resource}
descriptor={descriptor}
thumbnailKey={thumbnailKey}
anchor={anchor}
onError={onError}
/>
</React.Suspense>
</ThumbnailErrorBoundary>
) : (
<FileThumbnailShimmer />
)}
</div>
);
}
components/file-thumbnail/thumbnail-decode-queue.ts
import { registerThumbnailTestReset } from "./thumbnail-test-reset";
const MAX_CONCURRENT_DECODES = 3;
let activeDecodes = 0;
let decodeGeneration = 0;
const decodeQueue: Array<() => void> = [];
function acquireThumbnailDecodeSlot(): Promise<() => void> {
return new Promise((resolve) => {
const generation = decodeGeneration;
const grant = () => {
if (generation !== decodeGeneration) return;
activeDecodes++;
let hasReleased = false;
resolve(() => {
if (hasReleased) return;
hasReleased = true;
if (generation !== decodeGeneration) return;
activeDecodes--;
decodeQueue.shift()?.();
});
};
if (activeDecodes < MAX_CONCURRENT_DECODES) grant();
else decodeQueue.push(grant);
});
}
export async function withThumbnailDecodeSlot<T>(
fn: () => Promise<T>,
): Promise<T> {
const release = await acquireThumbnailDecodeSlot();
try {
return await fn();
} finally {
release();
}
}
export function getThumbnailDecodeQueueSnapshot() {
return {
activeDecodes,
queuedDecodes: decodeQueue.length,
maxConcurrentDecodes: MAX_CONCURRENT_DECODES,
};
}
function resetThumbnailDecodeQueueForTests() {
decodeGeneration++;
activeDecodes = 0;
decodeQueue.splice(0);
}
registerThumbnailTestReset(resetThumbnailDecodeQueueForTests);
components/file-thumbnail/thumbnail-direct-image.tsx
"use client";
import type { ViewerResource } from "@/lib/viewer-resource";
import type { ViewerDescriptor } from "@/lib/viewer-source";
import {
FileThumbnailFrame,
type FileThumbnailFrameProps,
} from "@/components/ui/file-thumbnail-frame";
import { isTiffDescriptor } from "./descriptor";
import {
createThumbnailErrorState,
type ThumbnailErrorState,
} from "./thumbnail-error-state";
import { createThumbnailImageLoadError } from "./thumbnail-errors";
import { ANCHOR_OBJECT_POSITION, type ThumbnailAnchor } from "./types";
export function isDirectImageThumbnail({
descriptor,
directUrl,
}: {
descriptor: ViewerDescriptor;
directUrl?: string | null;
}): boolean {
return (
descriptor.category === "image" &&
!isTiffDescriptor(descriptor) &&
Boolean(directUrl)
);
}
export function DirectImageThumbnail({
descriptor,
resource,
directUrl,
previewAspectRatio,
className,
anchor,
renderKey,
errorState,
thumbnailProps,
onError,
}: {
descriptor: ViewerDescriptor;
resource: ViewerResource;
directUrl: string;
previewAspectRatio?: number;
className?: string;
anchor: ThumbnailAnchor;
renderKey: string;
errorState: ThumbnailErrorState | null;
thumbnailProps?: Omit<FileThumbnailFrameProps, "file" | "onError">;
onError?: (error: unknown, errorState: ThumbnailErrorState) => void;
}) {
const failedDirectImage =
errorState?.info.format === "image" ? errorState : null;
return (
<FileThumbnailFrame
{...thumbnailProps}
file={{ name: descriptor.displayName, type: descriptor.mimeType ?? "" }}
previewImageUrl={directUrl}
previewAspectRatio={previewAspectRatio}
className={className}
previewClassName={ANCHOR_OBJECT_POSITION[anchor]}
state={failedDirectImage ? "error" : "loaded"}
aria-label={failedDirectImage?.info.userMessage}
title={failedDirectImage?.info.userMessage}
data-error-domain={failedDirectImage?.info.domain}
data-error-format={failedDirectImage?.info.format}
data-error-kind={failedDirectImage?.info.kind}
data-error-message={failedDirectImage?.info.message}
onPreviewError={() => {
const error = createThumbnailImageLoadError();
const nextErrorState = createThumbnailErrorState({
renderKey,
error,
resource,
descriptor,
});
onError?.(error, nextErrorState);
}}
/>
);
}
components/file-thumbnail/thumbnail-error-state.ts
import { toViewerErrorInfo, type ViewerErrorInfo } from "@/lib/viewer-errors";
import type { ViewerResource } from "@/lib/viewer-resource";
import type { ViewerDescriptor } from "@/lib/viewer-source";
import { thumbnailCategoryFormat } from "./thumbnail-errors";
export interface ThumbnailErrorState {
renderKey: string;
info: ViewerErrorInfo;
}
export function createThumbnailErrorState({
renderKey,
error,
resource,
descriptor,
}: {
renderKey: string;
error: unknown;
resource: ViewerResource;
descriptor: ViewerDescriptor;
}): ThumbnailErrorState {
return {
renderKey,
info: toViewerErrorInfo(error, {
format: thumbnailCategoryFormat(descriptor.category),
sourceKind: resource.sourceKind,
canDownload: !resource.originalDownload.isDisabled,
}),
};
}
components/file-thumbnail/thumbnail-errors.ts
import {
isResourceError,
ViewerFormatError,
type ViewerFormat,
} from "@/lib/viewer-errors";
import type { FileCategory } from "@/lib/viewer-source";
export async function withThumbnailFormatError<T>(
category: FileCategory,
kind:
| "decode_failed"
| "load_failed"
| "parse_failed"
| "render_failed"
| "unknown",
fileName: string,
message: string,
load: () => Promise<T>,
): Promise<T> {
try {
return await load();
} catch (error) {
if (isResourceError(error)) throw error;
throw new ViewerFormatError({
format: thumbnailCategoryFormat(category),
kind,
message: `${message}: ${fileName}`,
cause: error,
});
}
}
export function createThumbnailImageLoadError(): ViewerFormatError {
return new ViewerFormatError({
format: "image",
kind: "load_failed",
message: "Could not load image preview.",
});
}
export function thumbnailCategoryFormat(category: FileCategory): ViewerFormat {
if (category === "markdown" || category === "html" || category === "email") {
return "text";
}
if (category === "unsupported") return "file";
return category;
}
components/file-thumbnail/thumbnail-in-view.ts
"use client";
import * as React from "react";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
export function useThumbnailInView() {
const [seen, setSeen] = React.useState(false);
const [node, setNode] = React.useState<HTMLElement | null>(null);
const seenRef = React.useRef(false);
const ref = React.useCallback((el: HTMLElement | null) => setNode(el), []);
useKeyedMountEffect(joinEffectKey([node]), () => {
if (!node || seenRef.current) return;
if (typeof IntersectionObserver === "undefined") {
seenRef.current = true;
setSeen(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
seenRef.current = true;
setSeen(true);
observer.disconnect();
}
},
{ rootMargin: "300px" },
);
observer.observe(node);
return () => observer.disconnect();
});
return { ref, seen };
}
components/file-thumbnail/thumbnail-limits.ts
export const TEXT_THUMBNAIL_MAX_BYTES = 64 * 1024;
export const CSV_THUMBNAIL_MAX_ROWS = 16;
export const CSV_THUMBNAIL_MAX_COLUMNS = 6;
export const XLSX_THUMBNAIL_MAX_ROWS = 16;
export const XLSX_THUMBNAIL_MAX_COLUMNS = 6;
export const TIFF_THUMBNAIL_TARGET_WIDTH = 320;
export const TEXT_THUMBNAIL_CACHE_MAX_ENTRIES = 96;
components/file-thumbnail/thumbnail-options.ts
import type { ViewerDescriptor } from "@/lib/viewer-source";
import { isTiffDescriptor } from "./descriptor";
import { thumbnailOption, type ThumbnailOption } from "./keys";
import {
CSV_THUMBNAIL_MAX_COLUMNS,
CSV_THUMBNAIL_MAX_ROWS,
TEXT_THUMBNAIL_MAX_BYTES,
TIFF_THUMBNAIL_TARGET_WIDTH,
XLSX_THUMBNAIL_MAX_COLUMNS,
XLSX_THUMBNAIL_MAX_ROWS,
} from "./thumbnail-limits";
export function getThumbnailOptions(
descriptor: ViewerDescriptor,
): ThumbnailOption[] {
switch (descriptor.category) {
case "text":
case "markdown":
case "html":
return [thumbnailOption("text-max-bytes", TEXT_THUMBNAIL_MAX_BYTES)];
case "csv":
return [
thumbnailOption("text-max-bytes", TEXT_THUMBNAIL_MAX_BYTES),
thumbnailOption("csv-max-rows", CSV_THUMBNAIL_MAX_ROWS),
thumbnailOption("csv-max-columns", CSV_THUMBNAIL_MAX_COLUMNS),
];
case "xlsx":
return [
thumbnailOption("xlsx-max-rows", XLSX_THUMBNAIL_MAX_ROWS),
thumbnailOption("xlsx-max-columns", XLSX_THUMBNAIL_MAX_COLUMNS),
];
case "image":
return isTiffDescriptor(descriptor)
? [thumbnailOption("tiff-target-width", TIFF_THUMBNAIL_TARGET_WIDTH)]
: [];
default:
return [];
}
}
components/file-thumbnail/thumbnail-profile.ts
import type { ThumbnailFileMeta } from "./thumbnail-text";
/**
* Profiling helper: logs `[thumb] <label> <ms>` when enabled. Gated on a global
* so it costs nothing in normal use; the profiler sets it before navigation.
*/
export async function timedThumbnail<T>(
label: string,
fn: () => Promise<T>,
): Promise<T> {
const on =
typeof globalThis !== "undefined" &&
(globalThis as { __THUMB_PROFILE__?: boolean }).__THUMB_PROFILE__;
if (!on) return fn();
const t0 = performance.now();
try {
return await fn();
} finally {
console.log(`[thumb] ${label} ${(performance.now() - t0).toFixed(1)}ms`);
}
}
export function shortName(meta: ThumbnailFileMeta): string {
return meta.fileName;
}
components/file-thumbnail/thumbnail-resource.ts
import { registerThumbnailTestReset } from "./thumbnail-test-reset";
export function useThumbnailResource<T>(promise: Promise<T>): T {
const record = getThumbnailResourceRecord(promise);
if (record.status === "pending") throw record.promise;
if (record.status === "rejected") throw record.error;
return record.value as T;
}
let thumbnailResourceRecords = new WeakMap<
Promise<unknown>,
ThumbnailResourceRecord<unknown>
>();
function getThumbnailResourceRecord<T>(
promise: Promise<T>,
): ThumbnailResourceRecord<T> {
const cached = thumbnailResourceRecords.get(promise) as
| ThumbnailResourceRecord<T>
| undefined;
if (cached) return cached;
const record: ThumbnailResourceRecord<T> = {
promise,
status: "pending",
};
promise.then(
(value) => {
record.status = "resolved";
record.value = value;
},
(error) => {
record.status = "rejected";
record.error = error;
},
);
thumbnailResourceRecords.set(promise, record);
return record;
}
interface ThumbnailResourceRecord<T> {
promise: Promise<T>;
status: "pending" | "resolved" | "rejected";
value?: T;
error?: unknown;
}
function resetThumbnailResourceRecordsForTests() {
thumbnailResourceRecords = new WeakMap();
}
registerThumbnailTestReset(resetThumbnailResourceRecordsForTests);
components/file-thumbnail/thumbnail-test-reset.ts
const resetHandlers = new Set<() => void>();
export function registerThumbnailTestReset(handler: () => void): () => void {
resetHandlers.add(handler);
return () => {
resetHandlers.delete(handler);
};
}
export function clearThumbnailCachesForTests() {
for (const reset of [...resetHandlers]) reset();
}
components/file-thumbnail/thumbnail-text.ts
import {
isAbortError,
isResourceError,
ResourceError,
} from "@/lib/viewer-errors";
import type {
ViewerContentBytes,
ViewerContentIdentity,
ViewerContentRange,
ViewerContentStream,
} from "@/lib/viewer-resource";
import type { ViewerSource } from "@/lib/viewer-source";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "./thumbnail-cache";
import {
TEXT_THUMBNAIL_CACHE_MAX_ENTRIES,
TEXT_THUMBNAIL_MAX_BYTES,
} from "./thumbnail-limits";
import { shortName, timedThumbnail } from "./thumbnail-profile";
export interface ThumbnailFileMeta {
fileName: string;
mimeType?: string;
sourceKind: ViewerSource["kind"];
}
export type ThumbnailTextContent = ViewerContentIdentity &
ViewerContentRange &
ViewerContentStream;
export type ThumbnailBytesContent = ViewerContentIdentity & ViewerContentBytes;
const textCache = createThumbnailArtifactCache<string>({
maxEntries: TEXT_THUMBNAIL_CACHE_MAX_ENTRIES,
});
export function getThumbnailText(
meta: ThumbnailFileMeta,
content: ThumbnailTextContent,
thumbnailKey: string,
): Promise<string> {
return cachedThumbnailResource(textCache, thumbnailKey, () =>
timedThumbnail(`text:fetch ${shortName(meta)}`, async () => {
if (meta.sourceKind === "url" || content.sourceKind === "url") {
try {
return await readThumbnailTextRange(content, "no-store");
} catch (error) {
if (shouldReadTextStreamPrefix(error)) {
return readThumbnailTextStreamPrefix(content, "no-store");
}
throw error;
}
}
return readThumbnailTextRange(content);
}),
);
}
export function thumbnailFileMeta({
fileName,
mimeType,
sourceKind,
}: ThumbnailFileMeta): ThumbnailFileMeta {
return { fileName, mimeType, sourceKind };
}
function shouldReadTextStreamPrefix(error: unknown): boolean {
const candidate = resourceErrorCandidate(error);
return Boolean(
candidate &&
(candidate.kind === "invalid_range" ||
(candidate.kind === "http_error" && candidate.status === 416)),
);
}
function resourceErrorCandidate(error: unknown):
| {
kind?: unknown;
status?: unknown;
}
| undefined {
if (!error || typeof error !== "object") return undefined;
return error as { kind?: unknown; status?: unknown };
}
async function readThumbnailTextRange(
content: ThumbnailTextContent,
cache?: RequestCache,
) {
const rangeRequest = {
start: 0,
end: TEXT_THUMBNAIL_MAX_BYTES - 1,
};
const range = cache
? await content.readRange(rangeRequest, { cache })
: await content.readRange(rangeRequest);
return new TextDecoder().decode(range.buffer);
}
async function readThumbnailTextStreamPrefix(
content: ThumbnailTextContent,
cache?: RequestCache,
) {
const stream = cache
? await content.readStream({ cache })
: await content.readStream();
const reader = stream.getReader();
const decoder = new TextDecoder();
let remainingBytes = TEXT_THUMBNAIL_MAX_BYTES;
let text = "";
try {
while (remainingBytes > 0) {
const { done, value } = await reader.read();
if (done) return text + decoder.decode();
const chunk =
value.byteLength > remainingBytes
? value.slice(0, remainingBytes)
: value;
text += decoder.decode(chunk, { stream: true });
remainingBytes -= chunk.byteLength;
if (chunk.byteLength < value.byteLength || remainingBytes === 0) {
cancelThumbnailTextReader(reader);
return text + decoder.decode();
}
}
cancelThumbnailTextReader(reader);
return text + decoder.decode();
} catch (error) {
throw createThumbnailTextReadError(error);
}
}
function createThumbnailTextReadError(error: unknown): ResourceError {
if (isResourceError(error)) return error;
if (isAbortError(error)) {
return new ResourceError({
kind: "aborted",
message: "Resource load was aborted.",
cause: error,
});
}
return new ResourceError({
kind: "fetch_failed",
message: "Could not read this resource.",
cause: error,
});
}
function cancelThumbnailTextReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
) {
try {
void reader.cancel().catch(() => {});
} catch {
/* The preview prefix is already available; cancellation is best-effort. */
}
}
components/file-thumbnail/thumbnail-worker-client.ts
import { registerThumbnailTestReset } from "./thumbnail-test-reset";
export interface ThumbnailWorkerMessage {
id: number;
}
export interface ThumbnailWorkerClientOptions<Request, Response> {
createWorker: () => Worker;
resolve: (response: Response) => unknown;
reject: (response: Response) => string | Error;
}
export interface ThumbnailWorkerRequest<Request> {
request: Request;
transfer?: Transferable[];
}
export interface ThumbnailWorkerClient<Request, Response> {
request<T = unknown>(input: ThumbnailWorkerRequest<Request>): Promise<T>;
reset(): void;
pendingCount(): number;
}
export function createThumbnailWorkerClient<
Request extends ThumbnailWorkerMessage,
Response extends ThumbnailWorkerMessage,
>({
createWorker,
resolve,
reject,
}: ThumbnailWorkerClientOptions<Request, Response>): ThumbnailWorkerClient<
Omit<Request, "id">,
Response
> {
let worker: Worker | null = null;
let requestId = 0;
const pending = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
const getWorker = () => {
if (!worker) {
const nextWorker = createWorker();
worker = nextWorker;
nextWorker.onmessage = (event: MessageEvent<Response>) => {
if (worker !== nextWorker) return;
const response = event.data;
const entry = pending.get(response.id);
if (!entry) return;
pending.delete(response.id);
try {
const value = resolve(response);
if (value !== undefined) entry.resolve(value);
else entry.reject(toWorkerError(reject(response)));
} catch (error) {
entry.reject(toWorkerError(error));
}
};
nextWorker.onerror = (event) => {
if (worker !== nextWorker) return;
nextWorker.terminate();
worker = null;
requestId = 0;
rejectPendingRequests(
event.error instanceof Error
? event.error
: new Error(event.message || "Thumbnail worker failed."),
);
};
}
return worker;
};
const rejectPendingRequests = (error: Error) => {
for (const entry of pending.values()) entry.reject(error);
pending.clear();
};
const reset = () => {
worker?.terminate();
worker = null;
requestId = 0;
rejectPendingRequests(new Error("Thumbnail worker reset."));
};
const client: ThumbnailWorkerClient<Omit<Request, "id">, Response> = {
request<T = unknown>(
input: ThumbnailWorkerRequest<Omit<Request, "id">>,
): Promise<T> {
const { request, transfer } = input;
const id = ++requestId;
return new Promise<T>((resolveRequest, rejectRequest) => {
pending.set(id, {
resolve: (value) => resolveRequest(value as T),
reject: rejectRequest,
});
try {
getWorker().postMessage({ id, ...request }, transfer ?? []);
} catch (error) {
pending.delete(id);
rejectRequest(toWorkerError(error));
}
});
},
reset,
pendingCount: () => pending.size,
};
registerThumbnailTestReset(reset);
return client;
}
function toWorkerError(error: unknown): Error {
if (error instanceof Error) return error;
return new Error(String(error || "Thumbnail worker failed."));
}
components/file-thumbnail/types.ts
/**
* Which corner of a generated preview stays pinned when its content is larger
* than the thumbnail frame.
*/
export type ThumbnailAnchor = "top-left" | "top-right" | "bottom-left";
/** Absolute-position classes that pin a covered element to its anchor corner. */
export const ANCHOR_CORNER: Record<ThumbnailAnchor, string> = {
"top-left": "top-0 left-0",
"top-right": "top-0 right-0",
"bottom-left": "bottom-0 left-0",
};
/** `object-position` classes for the <img> cover path (raster images). */
export const ANCHOR_OBJECT_POSITION: Record<ThumbnailAnchor, string> = {
"top-left": "object-left-top",
"top-right": "object-right-top",
"bottom-left": "object-left-bottom",
};
components/file-thumbnail/renderers/csv-thumbnail.tsx
"use client";
import * as React from "react";
import { inferCsvDialect, parseCsv } from "@/lib/csv";
import type { ViewerResource } from "@/lib/viewer-resource";
import { GridTable } from "@/components/file-thumbnail/renderers/layout";
import {
CSV_THUMBNAIL_MAX_COLUMNS,
CSV_THUMBNAIL_MAX_ROWS,
} from "@/components/file-thumbnail/thumbnail-limits";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
getThumbnailText,
thumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
export function CsvFirstRows({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const raw = useThumbnailResource(
getThumbnailText(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
const rows = React.useMemo(() => {
const dialect = inferCsvDialect({
fileName: resource.fileName,
mimeType: resource.mimeType,
});
const table = parseCsv(raw, dialect);
const header = table.columns.length
? [table.columns.slice(0, CSV_THUMBNAIL_MAX_COLUMNS)]
: [];
return header.concat(
table.rows
.slice(0, CSV_THUMBNAIL_MAX_ROWS - header.length)
.map((row) => row.slice(0, CSV_THUMBNAIL_MAX_COLUMNS)),
);
}, [raw, resource.fileName, resource.mimeType]);
return <GridTable rows={rows} headerRow />;
}
components/file-thumbnail/renderers/docx-thumbnail.tsx
"use client";
import * as React from "react";
import { getDocxDocumentResource } from "@/lib/docx-document-resource";
import type { ViewerResource } from "@/lib/viewer-resource";
import { renderCachedDocxPreview } from "@/components/ui/docx-viewer-render";
import {
Surface,
useElementWidth,
} from "@/components/file-thumbnail/renderers/layout";
import { withThumbnailDecodeSlot } from "@/components/file-thumbnail/thumbnail-decode-queue";
import { withThumbnailFormatError } from "@/components/file-thumbnail/thumbnail-errors";
import {
shortName,
timedThumbnail,
} from "@/components/file-thumbnail/thumbnail-profile";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
const DOCX_PAGE_W = 816; // US Letter at 96dpi
const DOCX_THUMBNAIL_PAGE_STYLE = {
backgroundColor: "white",
color: "black",
colorScheme: "light",
} satisfies React.CSSProperties;
export function DocxFirstPage({ resource }: { resource: ViewerResource }) {
const bytes = useThumbnailResource(getDocxDocumentResource(resource.content));
const { ref: frameRef, width: frameWidth } = useElementWidth();
const [renderError, setRenderError] = React.useState<unknown>(null);
const renderRef = React.useCallback(
(el: HTMLDivElement | null) => {
if (!el) return;
let active = true;
void withThumbnailDecodeSlot(() =>
withThumbnailFormatError(
"docx",
"render_failed",
resource.fileName,
"Failed to render DOCX thumbnail",
() =>
timedThumbnail(`docx:render ${shortName(resource)}`, async () => {
if (!active) return;
const { renderHost } = await renderCachedDocxPreview({
buffer: () => bytes.slice(0),
cacheKey: resource.content.key,
getScale: () => 1,
});
if (!active) return;
commitDocxThumbnailRender(el, renderHost);
}),
),
).catch((error) => {
if (active) setRenderError(error);
});
return () => {
active = false;
};
},
[bytes, resource],
);
const scale = frameWidth ? frameWidth / DOCX_PAGE_W : null;
if (renderError) throw renderError;
return (
<Surface>
<div ref={frameRef} className="absolute inset-0 overflow-hidden bg-white">
<div
className="absolute top-0 left-0 origin-top-left [&_.docx-wrapper]:!bg-transparent [&_.docx-wrapper]:!p-0 [&_section.docx]:!mb-0 [&_section.docx]:!bg-white [&_section.docx]:!text-black [&_section.docx]:!shadow-none"
style={{
...DOCX_THUMBNAIL_PAGE_STYLE,
width: DOCX_PAGE_W,
transform: scale ? `scale(${scale})` : undefined,
visibility: scale ? "visible" : "hidden",
}}
>
<div ref={renderRef} />
</div>
</div>
</Surface>
);
}
function commitDocxThumbnailRender(host: HTMLElement, renderHost: HTMLElement) {
const wrapper = renderHost.querySelector<HTMLElement>(".docx-wrapper");
const firstPage = renderHost.querySelector<HTMLElement>(
".docx-wrapper > section.docx",
);
if (!wrapper || !firstPage) {
throw new Error("DOCX render produced no pages.");
}
const staticNodes = Array.from(renderHost.childNodes).filter(
(node) => node !== wrapper,
);
wrapper.replaceChildren(firstPage);
host.replaceChildren(...staticNodes, wrapper);
}
components/file-thumbnail/renderers/html-thumbnail.tsx
"use client";
import * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import { IframeDoc } from "@/components/file-thumbnail/renderers/layout";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
getThumbnailText,
thumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
export function HtmlFirstPage({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const html = useThumbnailResource(
getThumbnailText(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
return <IframeDoc html={html} />;
}
components/file-thumbnail/renderers/image-thumbnail.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import type { ViewerContentBlob, ViewerResource } from "@/lib/viewer-resource";
import { FileThumbnailShimmer } from "@/components/ui/file-thumbnail-frame";
import { useObjectUrl } from "@/components/file-thumbnail/renderers/use-object-url";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "@/components/file-thumbnail/thumbnail-cache";
import { createThumbnailImageLoadError } from "@/components/file-thumbnail/thumbnail-errors";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import type { ThumbnailAnchor } from "@/components/file-thumbnail/types";
import { ANCHOR_CORNER } from "@/components/file-thumbnail/types";
const IMAGE_BLOB_THUMBNAIL_CACHE_MAX_ENTRIES = 96;
const imageBlobCache = createThumbnailArtifactCache<Blob>({
maxEntries: IMAGE_BLOB_THUMBNAIL_CACHE_MAX_ENTRIES,
});
function getImageBlob(content: ViewerContentBlob): Promise<Blob> {
return cachedThumbnailResource(imageBlobCache, content.key, () =>
content.readBlob(),
);
}
export function ImageFirstFrame({
resource,
anchor,
onError,
}: {
resource: ViewerResource;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
const directUrl = resource.content.directUrl;
if (directUrl) {
return (
<ImageUrlPreview url={directUrl} anchor={anchor} onError={onError} />
);
}
return (
<ImageBlobPreview
content={resource.content}
anchor={anchor}
onError={onError}
/>
);
}
function ImageUrlPreview({
url,
anchor,
onError,
}: {
url: string;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
return (
<div className="absolute inset-0 overflow-hidden bg-white">
<img
src={url}
alt=""
loading="lazy"
decoding="async"
className={cn("absolute block w-full", ANCHOR_CORNER[anchor])}
onError={() => onError(createThumbnailImageLoadError())}
/>
</div>
);
}
function ImageBlobPreview({
content,
anchor,
onError,
}: {
content: ViewerContentBlob;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
const blob = useThumbnailResource(getImageBlob(content));
const url = useObjectUrl(blob);
if (!url) return <FileThumbnailShimmer />;
return <ImageUrlPreview url={url} anchor={anchor} onError={onError} />;
}
components/file-thumbnail/renderers/layout.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/** Centered, clipped white surface that hosts a rendered first unit. */
export function Surface({ children }: { children: React.ReactNode }) {
return (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden bg-white">
{children}
</div>
);
}
export function useElementWidth() {
const [width, setWidth] = React.useState<number | null>(null);
const ref = React.useCallback((el: HTMLElement | null) => {
if (!el) return;
setWidth(el.clientWidth);
const observer = new ResizeObserver((entries) => {
const nextWidth = entries[0]?.contentRect.width;
if (nextWidth) setWidth(nextWidth);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, width };
}
const GRID_COL_W = 46;
/** Internal-gridline table shared by the XLSX and CSV previews. */
export function GridTable({
rows,
headerRow,
}: {
rows: string[][];
headerRow?: boolean;
}) {
const colCount = Math.max(1, ...rows.map((r) => r.length));
return (
<div className="absolute inset-0 overflow-hidden bg-white text-slate-700 dark:bg-slate-950 dark:text-slate-300">
<table
className="border-collapse leading-tight"
style={{
width: colCount * GRID_COL_W,
tableLayout: "fixed",
fontSize: 7,
}}
>
<tbody>
{rows.map((row, r) => (
<tr key={r}>
{Array.from({ length: colCount }, (_, c) => (
<td
key={c}
className={cn(
"truncate border-r border-b border-slate-200 px-1 py-0.5 last:border-r-0 dark:border-slate-800",
headerRow && r === 0
? "bg-slate-50 font-semibold text-slate-900 dark:bg-slate-900/70 dark:text-slate-100"
: "text-slate-700 dark:text-slate-300",
)}
>
{row[c] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* Renders an HTML string into a fixed-size (square) sandboxed iframe and scales
* it to the measured frame width, so the top of the page fills the tile. The
* iframe is inert (no scripts, no pointer events).
*/
export function IframeDoc({ html }: { html: string }) {
const { ref, width } = useElementWidth();
const BASE = 820;
const scale = width ? width / BASE : null;
return (
<div ref={ref} className="absolute inset-0 overflow-hidden bg-white">
<iframe
srcDoc={html}
title=""
sandbox=""
scrolling="no"
tabIndex={-1}
aria-hidden
className="pointer-events-none origin-top-left border-0"
style={{
width: BASE,
height: BASE,
transform: scale ? `scale(${scale})` : undefined,
visibility: scale ? "visible" : "hidden",
}}
/>
</div>
);
}
components/file-thumbnail/renderers/markdown-thumbnail.tsx
"use client";
import * as React from "react";
import type * as DOMPurify from "dompurify";
import type * as Marked from "marked";
import type { ViewerResource } from "@/lib/viewer-resource";
import { IframeDoc } from "@/components/file-thumbnail/renderers/layout";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "@/components/file-thumbnail/thumbnail-cache";
import { withThumbnailFormatError } from "@/components/file-thumbnail/thumbnail-errors";
import {
shortName,
timedThumbnail,
} from "@/components/file-thumbnail/thumbnail-profile";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
getThumbnailText,
thumbnailFileMeta,
type ThumbnailFileMeta,
type ThumbnailTextContent,
} from "@/components/file-thumbnail/thumbnail-text";
let mdLibs: Promise<[typeof Marked, typeof DOMPurify]> | null = null;
function loadMarkdown() {
if (!mdLibs) mdLibs = Promise.all([import("marked"), import("dompurify")]);
return mdLibs;
}
const MARKDOWN_THUMBNAIL_CACHE_MAX_ENTRIES = 64;
const markdownBodyCache = createThumbnailArtifactCache<string>({
maxEntries: MARKDOWN_THUMBNAIL_CACHE_MAX_ENTRIES,
});
type MarkdownThumbnailColorScheme = "light" | "dark";
function getMarkdownBody(
meta: ThumbnailFileMeta,
content: ThumbnailTextContent,
thumbnailKey: string,
): Promise<string> {
return cachedThumbnailResource(markdownBodyCache, thumbnailKey, () =>
withThumbnailFormatError(
"markdown",
"render_failed",
meta.fileName,
"Failed to render markdown thumbnail",
() =>
timedThumbnail(`markdown:total ${shortName(meta)}`, async () => {
const [text, [{ marked }, DOMPurifyMod]] = await Promise.all([
getThumbnailText(meta, content, thumbnailKey),
loadMarkdown(),
]);
const purifier = DOMPurifyMod as unknown as {
default?: { sanitize?: (html: string) => string };
sanitize?: (html: string) => string;
};
const sanitize = purifier.default?.sanitize ?? purifier.sanitize;
if (!sanitize) throw new Error("DOMPurify sanitize unavailable");
return sanitize(await marked.parse(text));
}),
),
);
}
function createMarkdownDoc(
body: string,
colorScheme: MarkdownThumbnailColorScheme,
) {
return `<!doctype html><html data-color-scheme="${colorScheme}"><head><meta charset="utf-8"><style>
:root{color-scheme:light;--md-bg:#ffffff;--md-fg:#0f172a;--md-muted:#f1f5f9;--md-border:#e2e8f0;--md-quote:#475569;--md-link:#4f46e5}
:root[data-color-scheme="dark"]{color-scheme:dark;--md-bg:#111113;--md-fg:#e4e4e7;--md-muted:#1f2937;--md-border:#3f3f46;--md-quote:#a1a1aa;--md-link:#a5b4fc}
html,body{min-height:100%;background:var(--md-bg)}
body{margin:0;padding:18px;font:14px/1.6 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;color:var(--md-fg)}
h1{font-size:1.6em;margin:.1em 0 .5em;border-bottom:1px solid var(--md-border);padding-bottom:.25em}
h2{font-size:1.3em;margin:1em 0 .4em;border-bottom:1px solid var(--md-border);padding-bottom:.25em}
h3{font-size:1.1em;margin:1em 0 .3em}
p,ul,ol{margin:0 0 .8em}ul,ol{padding-left:1.4em}
code{font-family:ui-monospace,SFMono-Regular,monospace;background:var(--md-muted);padding:.1em .35em;border-radius:4px;font-size:.85em}
pre{background:var(--md-muted);padding:12px;border-radius:8px;overflow:hidden}pre code{background:none;padding:0}
table{border-collapse:collapse;width:100%}td,th{border:1px solid var(--md-border);padding:4px 8px;text-align:left}
a{color:var(--md-link)}blockquote{margin:0 0 .8em;padding-left:12px;border-left:3px solid var(--md-border);color:var(--md-quote)}
</style></head><body>${body}</body></html>`;
}
function useMarkdownThumbnailColorScheme(): MarkdownThumbnailColorScheme {
return React.useSyncExternalStore(
subscribeMarkdownThumbnailColorScheme,
getMarkdownThumbnailColorScheme,
() => "light",
);
}
function subscribeMarkdownThumbnailColorScheme(onChange: () => void) {
if (
typeof document === "undefined" ||
typeof MutationObserver === "undefined"
) {
return () => {};
}
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}
function getMarkdownThumbnailColorScheme(): MarkdownThumbnailColorScheme {
if (
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
) {
return "dark";
}
return "light";
}
export function MarkdownFirstPage({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const colorScheme = useMarkdownThumbnailColorScheme();
const body = useThumbnailResource(
getMarkdownBody(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
return <IframeDoc html={createMarkdownDoc(body, colorScheme)} />;
}
components/file-thumbnail/renderers/msg-thumbnail.tsx
"use client";
import * as React from "react";
import type MsgReader from "@kenjiuno/msgreader";
import type { FieldsData } from "@kenjiuno/msgreader";
import type {
ViewerContentBytes,
ViewerContentIdentity,
ViewerResource,
} from "@/lib/viewer-resource";
import { IframeDoc } from "@/components/file-thumbnail/renderers/layout";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "@/components/file-thumbnail/thumbnail-cache";
import { withThumbnailFormatError } from "@/components/file-thumbnail/thumbnail-errors";
import {
TEXT_THUMBNAIL_CACHE_MAX_ENTRIES,
TEXT_THUMBNAIL_MAX_BYTES,
} from "@/components/file-thumbnail/thumbnail-limits";
import {
shortName,
timedThumbnail,
} from "@/components/file-thumbnail/thumbnail-profile";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
type MsgReaderConstructor = new (
arrayBuffer: ArrayBuffer | DataView,
) => MsgReader;
type MsgThumbnailContent = ViewerContentIdentity & ViewerContentBytes;
let msgReaderLib: Promise<{ default: MsgReaderConstructor }> | null = null;
const msgHtmlCache = createThumbnailArtifactCache<string>({
maxEntries: TEXT_THUMBNAIL_CACHE_MAX_ENTRIES,
});
function loadMsgReader() {
if (!msgReaderLib) {
msgReaderLib = import("@kenjiuno/msgreader") as Promise<{
default: MsgReaderConstructor;
}>;
}
return msgReaderLib;
}
export function MsgEmailThumbnail({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const html = useThumbnailResource(
getMsgThumbnailHtml(resource, resource.content, thumbnailKey),
);
return <IframeDoc html={html} />;
}
function getMsgThumbnailHtml(
meta: Pick<ViewerResource, "fileName" | "sourceKind">,
content: MsgThumbnailContent,
thumbnailKey: string,
): Promise<string> {
return cachedThumbnailResource(msgHtmlCache, thumbnailKey, () =>
withThumbnailFormatError(
"email",
"parse_failed",
meta.fileName,
"Failed to parse MSG thumbnail",
() =>
timedThumbnail(`msg:parse ${shortName(meta)}`, async () => {
const bytes = await content.readBytes();
const { default: MsgReaderClass } = await loadMsgReader();
const reader = new MsgReaderClass(bytes.slice(0));
const fields = reader.getFileData();
if (fields.error) throw new Error(fields.error);
return msgFieldsToHtml(fields);
}),
),
);
}
export function msgFieldsToHtml(
fields: Pick<FieldsData, "body" | "bodyHtml" | "html">,
) {
const bodyHtml = cleanMsgHtml(fields.bodyHtml);
if (bodyHtml) return bodyHtml;
if (fields.html?.byteLength) {
const html = cleanMsgHtml(decodeMsgHtmlBytes(fields.html));
if (html) return html;
}
const body = fields.body?.trim();
if (body) return plainTextEmailHtml(body);
throw new Error("MSG file does not contain a previewable email body.");
}
export function decodeMsgHtmlBytes(bytes: Uint8Array) {
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
return new TextDecoder("utf-16le").decode(bytes.subarray(2));
}
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
return new TextDecoder("utf-16be").decode(bytes.subarray(2));
}
if (looksLikeUtf16Le(bytes)) return new TextDecoder("utf-16le").decode(bytes);
return new TextDecoder("utf-8").decode(bytes);
}
function looksLikeUtf16Le(bytes: Uint8Array) {
const sampleLength = Math.min(bytes.byteLength, TEXT_THUMBNAIL_MAX_BYTES);
if (sampleLength < 4) return false;
let oddNulls = 0;
let evenNulls = 0;
for (let index = 0; index < sampleLength; index += 1) {
if (bytes[index] !== 0) continue;
if (index % 2 === 0) evenNulls += 1;
else oddNulls += 1;
}
return oddNulls > evenNulls * 4 && oddNulls > sampleLength / 8;
}
function cleanMsgHtml(html: string | null | undefined) {
return html?.replace(/\0+$/g, "").trim() ?? "";
}
function plainTextEmailHtml(text: string) {
return `<!doctype html><html><head><meta charset="utf-8" /></head><body><pre style="margin:0;white-space:pre-wrap;font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;">${escapeHtml(text)}</pre></body></html>`;
}
function escapeHtml(text: string) {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
components/file-thumbnail/renderers/pdf-thumbnail.tsx
"use client";
import * as React from "react";
import type { PdfDocumentProxy } from "@/lib/pdf-document-types";
import {
getPdfDocumentResource,
getPdfPageResource,
} from "@/lib/pdf-document-resource";
import { cn } from "@/lib/utils";
import type { ViewerResource } from "@/lib/viewer-resource";
import { withThumbnailFormatError } from "@/components/file-thumbnail/thumbnail-errors";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import type { ThumbnailAnchor } from "@/components/file-thumbnail/types";
import { ANCHOR_CORNER } from "@/components/file-thumbnail/types";
import { getPdfCanvasPixelSize } from "@/components/ui/pdf-viewer-canvas";
import {
readPdfRenderedPageCache,
usePdfRenderedPageCache,
writePdfRenderedPageCache,
type PdfRenderedPageSignature,
} from "@/components/ui/pdf-viewer-render-cache";
const PDF_FIRST_PAGE_THUMBNAIL_WIDTH = 520;
// Page 1 via pdfjs, reusing the PdfViewer's cached document.
export function PdfFirstPage({
resource,
anchor,
}: {
resource: ViewerResource;
anchor: ThumbnailAnchor;
}) {
const doc = useThumbnailResource(
getPdfDocumentResource(resource.content),
) as PdfDocumentProxy;
const renderCache = usePdfRenderedPageCache(doc);
const page = useThumbnailResource(getPdfPageResource(doc, 1));
const [renderError, setRenderError] = React.useState<unknown>(null);
const baseViewport = React.useMemo(
() => page.getViewport({ scale: 1 }),
[page],
);
const viewport = React.useMemo(() => {
return page.getViewport({
scale: PDF_FIRST_PAGE_THUMBNAIL_WIDTH / baseViewport.width,
});
}, [baseViewport.width, page]);
const dpr =
(typeof window !== "undefined" ? window.devicePixelRatio : 1) || 1;
if (renderError) throw renderError;
const canvasRef = React.useCallback(
(canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const renderSignature = {
documentKey: resource.content.key,
pageNumber: 1,
scale: PDF_FIRST_PAGE_THUMBNAIL_WIDTH / baseViewport.width,
rotation: 0,
devicePixelRatio: dpr,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
} satisfies PdfRenderedPageSignature;
const canvasWidth = getPdfCanvasPixelSize(viewport.width, dpr);
const canvasHeight = getPdfCanvasPixelSize(viewport.height, dpr);
const cachedPage = readPdfRenderedPageCache(renderCache, renderSignature);
resizeCanvas(canvas, canvasWidth, canvasHeight);
if (cachedPage) {
drawCanvasImage(context, cachedPage.canvas, canvasWidth, canvasHeight);
return;
}
let active = true;
let didFinishPdfRender = false;
let task: ReturnType<typeof page.render>;
try {
task = page.render({
canvas,
canvasContext: context,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
});
} catch (error) {
void normalizePdfThumbnailRenderError(
resource.fileName,
Promise.reject(error),
).catch((normalizedError: unknown) => {
if (active) setRenderError(normalizedError);
});
return () => {
active = false;
};
}
void normalizePdfThumbnailRenderError(
resource.fileName,
task.promise,
).then(
() => {
if (!active) return;
didFinishPdfRender = true;
writePdfRenderedPageCache({
cache: renderCache,
rendered: renderSignature,
sourceCanvas: canvas,
});
},
(error: unknown) => {
didFinishPdfRender = true;
if (active) setRenderError(error);
},
);
return () => {
active = false;
if (!didFinishPdfRender) task.cancel();
};
},
[
baseViewport.width,
dpr,
page,
renderCache,
resource.content.key,
resource.fileName,
viewport,
],
);
return (
<div className="absolute inset-0 overflow-hidden bg-white">
<canvas
ref={canvasRef}
className={cn("absolute block w-full", ANCHOR_CORNER[anchor])}
/>
</div>
);
}
function normalizePdfThumbnailRenderError(
fileName: string,
promise: Promise<void>,
) {
return withThumbnailFormatError(
"pdf",
"render_failed",
fileName,
"Failed to render PDF thumbnail",
() => promise,
);
}
function resizeCanvas(
canvas: HTMLCanvasElement,
width: number,
height: number,
) {
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
}
function drawCanvasImage(
context: CanvasRenderingContext2D,
image: HTMLCanvasElement,
width: number,
height: number,
) {
if (typeof context.setTransform === "function") {
context.setTransform(1, 0, 0, 1, 0, 0);
}
if (typeof context.drawImage === "function") {
context.drawImage(image, 0, 0, width, height);
}
}
components/file-thumbnail/renderers/pptx-thumbnail.tsx
"use client";
import * as React from "react";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { cn } from "@/lib/utils";
import type { ViewerResource } from "@/lib/viewer-resource";
import { getPptxSource } from "@/components/ui/pptx-viewer-source";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "@/components/file-thumbnail/thumbnail-cache";
import { withThumbnailDecodeSlot } from "@/components/file-thumbnail/thumbnail-decode-queue";
import { withThumbnailFormatError } from "@/components/file-thumbnail/thumbnail-errors";
import {
shortName,
timedThumbnail,
} from "@/components/file-thumbnail/thumbnail-profile";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
thumbnailFileMeta,
type ThumbnailBytesContent,
type ThumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
import type { ThumbnailAnchor } from "@/components/file-thumbnail/types";
import { ANCHOR_CORNER } from "@/components/file-thumbnail/types";
interface PptxFirstSlideSource {
render: (canvas: HTMLCanvasElement, scale: number) => Promise<void>;
baseWidth: number;
baseHeight: number;
dispose?: () => void;
}
const pptxCache = createThumbnailArtifactCache<PptxFirstSlideSource>({
maxEntries: 4,
dispose: (source) => source.dispose?.(),
});
function getPptxFirstSlide(
meta: ThumbnailFileMeta,
content: ThumbnailBytesContent,
thumbnailKey: string,
): Promise<PptxFirstSlideSource> {
return cachedThumbnailResource(pptxCache, thumbnailKey, () =>
withThumbnailDecodeSlot(() =>
withThumbnailFormatError(
"pptx",
"parse_failed",
meta.fileName,
"Failed to parse presentation thumbnail",
() =>
timedThumbnail(`pptx:total ${shortName(meta)}`, async () => {
const source = await getPptxSource(content);
const release = source.retain();
const render = async (canvas: HTMLCanvasElement, scale: number) => {
await withThumbnailFormatError(
"pptx",
"render_failed",
meta.fileName,
"Failed to render presentation thumbnail",
async () => {
const result = await source.renderSlide({
canvas,
renderScale: scale,
slideIndex: 0,
priority: {
distanceFromReadingMarker: 0,
isCurrentSlide: true,
isInViewport: true,
isScrollLead: true,
},
});
if (result.status === "failed") throw result.error;
},
);
};
return {
render,
baseWidth: source.baseSize.width,
baseHeight: source.baseSize.height,
dispose: release,
};
}),
),
),
);
}
export function PptxFirstSlide({
resource,
thumbnailKey,
anchor,
}: {
resource: ViewerResource;
thumbnailKey: string;
anchor: ThumbnailAnchor;
}) {
useMountEffect(() => {
const preload = () => {
void import("@/components/ui/pptx-viewer").then((module) => {
module.preloadPptxViewer();
});
};
if (typeof window.requestIdleCallback === "function") {
const id = window.requestIdleCallback(preload);
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(preload, 0);
return () => window.clearTimeout(id);
});
const source = useThumbnailResource(
getPptxFirstSlide(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
const [renderError, setRenderError] = React.useState<unknown>(null);
const baseW = source.baseWidth || 960;
const baseH = source.baseHeight || 720;
const FILL_PX = 1024;
const scale = FILL_PX / Math.min(baseW, baseH);
if (renderError) throw renderError;
const canvasRef = React.useCallback(
(canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
let active = true;
source.render(canvas, scale).catch((error: unknown) => {
if (active) setRenderError(error);
});
return () => {
active = false;
};
},
[source, scale],
);
const landscape = baseW >= baseH;
return (
<div className="absolute inset-0 overflow-hidden bg-white">
<canvas
ref={canvasRef}
className={cn(
"absolute block",
ANCHOR_CORNER[anchor],
landscape ? "h-full w-auto max-w-none" : "h-auto w-full",
)}
style={{ aspectRatio: `${baseW} / ${baseH}` }}
/>
</div>
);
}
components/file-thumbnail/renderers/text-thumbnail.tsx
"use client";
import * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
getThumbnailText,
thumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
const TEXT_THUMBNAIL_MAX_PARAGRAPHS = 12;
const TEXT_THUMBNAIL_FONT_SIZE = 7;
const TEXT_THUMBNAIL_LINE_HEIGHT = 1.55;
export function TextThumbnail({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const raw = useThumbnailResource(
getThumbnailText(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
const paragraphs = React.useMemo(() => proseThumbnailParagraphs(raw), [raw]);
if (!paragraphs.length) return <EmptyTextThumbnail />;
return (
<div
data-slot="text-thumbnail"
className="absolute inset-0 overflow-hidden bg-white p-3 font-sans text-slate-700 dark:bg-slate-950 dark:text-slate-300"
style={{
fontSize: TEXT_THUMBNAIL_FONT_SIZE,
lineHeight: TEXT_THUMBNAIL_LINE_HEIGHT,
}}
>
<div className="space-y-1.5">
{paragraphs.map((paragraph, i) => (
<p key={i} className="text-pretty break-words">
{paragraph}
</p>
))}
</div>
</div>
);
}
function EmptyTextThumbnail() {
return (
<div
aria-label="Empty text file"
data-slot="text-thumbnail-empty"
className="absolute inset-0 bg-white dark:bg-slate-950"
/>
);
}
function proseThumbnailParagraphs(text: string) {
return text
.replace(/\r\n?/g, "\n")
.trim()
.split(/\n{2,}/)
.map((paragraph) => paragraph.replace(/\s*\n\s*/g, " ").trim())
.filter(Boolean)
.slice(0, TEXT_THUMBNAIL_MAX_PARAGRAPHS);
}
components/file-thumbnail/renderers/tiff-thumbnail.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import type { ViewerResource } from "@/lib/viewer-resource";
import { FileThumbnailShimmer } from "@/components/ui/file-thumbnail-frame";
import { useObjectUrl } from "@/components/file-thumbnail/renderers/use-object-url";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "@/components/file-thumbnail/thumbnail-cache";
import { withThumbnailDecodeSlot } from "@/components/file-thumbnail/thumbnail-decode-queue";
import {
createThumbnailImageLoadError,
withThumbnailFormatError,
} from "@/components/file-thumbnail/thumbnail-errors";
import { TIFF_THUMBNAIL_TARGET_WIDTH } from "@/components/file-thumbnail/thumbnail-limits";
import {
shortName,
timedThumbnail,
} from "@/components/file-thumbnail/thumbnail-profile";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
thumbnailFileMeta,
type ThumbnailBytesContent,
type ThumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
import {
createThumbnailWorkerClient,
type ThumbnailWorkerMessage,
} from "@/components/file-thumbnail/thumbnail-worker-client";
import type { ThumbnailAnchor } from "@/components/file-thumbnail/types";
import { ANCHOR_CORNER } from "@/components/file-thumbnail/types";
interface TiffWorkerRequest extends ThumbnailWorkerMessage {
buffer: ArrayBuffer;
targetWidth: number;
}
interface TiffWorkerReply extends ThumbnailWorkerMessage {
id: number;
ok: boolean;
blob?: Blob;
error?: string;
}
const tiffWorkerClient = createThumbnailWorkerClient<
TiffWorkerRequest,
TiffWorkerReply
>({
createWorker: () =>
new Worker(new URL("../../file-thumbnail-tiff.worker", import.meta.url)),
resolve: (response) =>
response.ok && response.blob ? response.blob : undefined,
reject: (response) => response.error ?? "TIFF decode failed",
});
function decodeTiffInWorker(buffer: ArrayBuffer): Promise<Blob> {
return tiffWorkerClient.request<Blob>({
request: { buffer, targetWidth: TIFF_THUMBNAIL_TARGET_WIDTH },
transfer: [buffer],
});
}
const tiffCache = createThumbnailArtifactCache<Blob>({ maxEntries: 48 });
function getTiffFirstPageBlob(
meta: ThumbnailFileMeta,
content: ThumbnailBytesContent,
thumbnailKey: string,
): Promise<Blob> {
return cachedThumbnailResource(tiffCache, thumbnailKey, () =>
withThumbnailDecodeSlot(() =>
withThumbnailFormatError(
"image",
"decode_failed",
meta.fileName,
"Failed to decode TIFF thumbnail",
() =>
timedThumbnail(`tiff:total ${shortName(meta)}`, async () => {
const buf = await timedThumbnail("tiff:fetch", () =>
content.readBytes(),
);
return timedThumbnail("tiff:worker-decode", () =>
decodeTiffInWorker(buf),
);
}),
),
),
);
}
export function TiffFirstPage({
resource,
thumbnailKey,
anchor,
onError,
}: {
resource: ViewerResource;
thumbnailKey: string;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
const blob = useThumbnailResource(
getTiffFirstPageBlob(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
return <TiffBlobImage blob={blob} anchor={anchor} onError={onError} />;
}
function TiffBlobImage({
blob,
anchor,
onError,
}: {
blob: Blob;
anchor: ThumbnailAnchor;
onError: (error: unknown) => void;
}) {
const url = useObjectUrl(blob);
if (!url) return <FileThumbnailShimmer />;
return (
<div className="absolute inset-0 overflow-hidden bg-white">
<img
src={url}
alt=""
className={cn("absolute block w-full", ANCHOR_CORNER[anchor])}
onError={() => onError(createThumbnailImageLoadError())}
/>
</div>
);
}
components/file-thumbnail/renderers/use-object-url.ts
"use client";
import * as React from "react";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
export function useObjectUrl(blob: Blob | null): string | null {
const [url, setUrl] = React.useState<string | null>(null);
useKeyedMountEffect(joinEffectKey([blob]), () => {
if (!blob) {
setUrl(null);
return;
}
const nextUrl = URL.createObjectURL(blob);
setUrl(nextUrl);
return () => URL.revokeObjectURL(nextUrl);
});
return url;
}
components/file-thumbnail/renderers/xlsx-thumbnail.tsx
"use client";
import * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import { GridTable } from "@/components/file-thumbnail/renderers/layout";
import {
cachedThumbnailResource,
createThumbnailArtifactCache,
} from "@/components/file-thumbnail/thumbnail-cache";
import { withThumbnailDecodeSlot } from "@/components/file-thumbnail/thumbnail-decode-queue";
import { withThumbnailFormatError } from "@/components/file-thumbnail/thumbnail-errors";
import {
XLSX_THUMBNAIL_MAX_COLUMNS,
XLSX_THUMBNAIL_MAX_ROWS,
} from "@/components/file-thumbnail/thumbnail-limits";
import {
shortName,
timedThumbnail,
} from "@/components/file-thumbnail/thumbnail-profile";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
thumbnailFileMeta,
type ThumbnailBytesContent,
type ThumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
import {
createThumbnailWorkerClient,
type ThumbnailWorkerMessage,
} from "@/components/file-thumbnail/thumbnail-worker-client";
interface XlsxPreview {
rows: string[][];
}
interface XlsxWorkerRequest extends ThumbnailWorkerMessage {
buffer: ArrayBuffer;
maxRows: number;
maxCols: number;
}
interface XlsxWorkerReply extends ThumbnailWorkerMessage {
id: number;
ok: boolean;
rows?: string[][];
error?: string;
}
const xlsxWorkerClient = createThumbnailWorkerClient<
XlsxWorkerRequest,
XlsxWorkerReply
>({
createWorker: () =>
new Worker(new URL("../../file-thumbnail-xlsx.worker", import.meta.url)),
resolve: (response) =>
response.ok && response.rows ? response.rows : undefined,
reject: (response) => response.error ?? "XLSX parse failed",
});
function parseXlsxInWorker(buffer: ArrayBuffer): Promise<string[][]> {
return xlsxWorkerClient.request<string[][]>({
request: {
buffer,
maxRows: XLSX_THUMBNAIL_MAX_ROWS,
maxCols: XLSX_THUMBNAIL_MAX_COLUMNS,
},
transfer: [buffer],
});
}
const xlsxCache = createThumbnailArtifactCache<XlsxPreview>({
maxEntries: 64,
});
function getXlsxPreview(
meta: ThumbnailFileMeta,
content: ThumbnailBytesContent,
thumbnailKey: string,
): Promise<XlsxPreview> {
return cachedThumbnailResource(xlsxCache, thumbnailKey, () =>
withThumbnailDecodeSlot(() =>
timedThumbnail(`xlsx:total ${shortName(meta)}`, async () => {
const rows = await withThumbnailFormatError(
"xlsx",
"parse_failed",
meta.fileName,
"Failed to parse spreadsheet thumbnail",
async () => {
const buf = await content.readBytes();
return timedThumbnail("xlsx:worker-parse", () =>
parseXlsxInWorker(buf),
);
},
);
return { rows };
}),
),
);
}
export function XlsxFirstSheet({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const { rows } = useThumbnailResource(
getXlsxPreview(thumbnailFileMeta(resource), resource.content, thumbnailKey),
);
return <GridTable rows={rows} />;
}
components/file-thumbnail-tiff.worker.ts
// Decodes the FIRST page of a TIFF and downscales it to a thumbnail-sized PNG,
// entirely off the main thread. UTIF's decode, the RGBA conversion, the resize,
// and the PNG encode are all synchronous CPU — running them here keeps a grid of
// TIFF thumbnails from janking the UI. We only ever touch ifds[0], so a 200-page
// scan costs the same as a one-page one.
import UTIF from "utif";
interface Ifd {
t256?: number[];
t257?: number[];
width?: number;
height?: number;
}
interface Utif {
decode(buf: ArrayBuffer): Ifd[];
decodeImage(buf: ArrayBuffer, ifd: Ifd): void;
toRGBA8(ifd: Ifd): Uint8Array;
}
const utif = UTIF as Utif;
interface Req {
id: number;
buffer: ArrayBuffer;
targetWidth: number;
}
const ctx = self as unknown as Worker;
ctx.onmessage = async (event: MessageEvent<Req>) => {
const { id, buffer, targetWidth } = event.data;
try {
const ifds = utif.decode(buffer);
if (!ifds.length) throw new Error("TIFF has no frames");
const ifd = ifds[0];
utif.decodeImage(buffer, ifd);
const rgba = utif.toRGBA8(ifd);
const width = ifd.t256?.[0] ?? ifd.width ?? 0;
const height = ifd.t257?.[0] ?? ifd.height ?? 0;
if (!width || !height) throw new Error("TIFF has no dimensions");
const dw = Math.min(width, targetWidth);
const dh = Math.max(1, Math.round((dw / width) * height));
const pixels = new Uint8ClampedArray(rgba.length);
pixels.set(rgba);
const source = new ImageData(pixels, width, height);
const bitmap = await createImageBitmap(source, {
resizeWidth: dw,
resizeHeight: dh,
resizeQuality: "high",
});
const canvas = new OffscreenCanvas(dw, dh);
const c = canvas.getContext("2d");
if (!c) throw new Error("No 2d context");
c.drawImage(bitmap, 0, 0);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/png" });
ctx.postMessage({ id, ok: true, blob });
} catch (err) {
ctx.postMessage({
id,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
};
components/file-thumbnail-xlsx.worker.ts
// Parses the first rows of a spreadsheet's first sheet off the main thread.
// XLSX.read is fully synchronous CPU and was the largest UI-thread block of the
// thumbnail grid; running it here keeps the page responsive. `sheetRows` stops
// the parser early so a 100k-row workbook costs the same as a 16-row one.
import * as XLSX from "@e965/xlsx";
import { isSpreadsheetContainer } from "@/lib/xlsx-worker-protocol";
interface Req {
id: number;
buffer: ArrayBuffer;
maxRows: number;
maxCols: number;
}
const ctx = self as unknown as Worker;
ctx.onmessage = (event: MessageEvent<Req>) => {
const { id, buffer, maxRows, maxCols } = event.data;
try {
// SheetJS sniffs arbitrary bytes into a stub sheet; reject non-spreadsheet
// input so a mislabeled file yields no thumbnail rather than junk cells.
if (!isSpreadsheetContainer(buffer)) {
throw new Error("File is not a recognized spreadsheet (.xlsx or .xls).");
}
const wb = XLSX.read(new Uint8Array(buffer), {
type: "array",
sheetRows: maxRows,
});
const ws = wb.Sheets[wb.SheetNames[0]];
const grid = XLSX.utils.sheet_to_json<string[]>(ws, {
header: 1,
blankrows: false,
defval: "",
raw: false,
});
const rows = grid
.slice(0, maxRows)
.map((r) =>
Array.isArray(r) ? r.slice(0, maxCols).map((c) => `${c ?? ""}`) : [],
);
ctx.postMessage({ id, ok: true, rows });
} catch (err) {
ctx.postMessage({
id,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
};
components/file-thumbnail/renderers/code-thumbnail.tsx
"use client";
import * as React from "react";
import type { ViewerResource } from "@/lib/viewer-resource";
import { formatCodeThumbnailText } from "@/components/file-thumbnail/thumbnail-code";
import { useThumbnailResource } from "@/components/file-thumbnail/thumbnail-resource";
import {
getThumbnailText,
thumbnailFileMeta,
} from "@/components/file-thumbnail/thumbnail-text";
const CODE_THUMBNAIL_MAX_LINES = 60;
const CODE_THUMBNAIL_FONT_SIZE = 5;
const CODE_THUMBNAIL_LINE_HEIGHT = 1.5;
const CODE_THUMBNAIL_LINE_NUMBER_WIDTH_CLASS = "w-2.5";
export function CodeThumbnail({
resource,
thumbnailKey,
}: {
resource: ViewerResource;
thumbnailKey: string;
}) {
const raw = useThumbnailResource(
getThumbnailText(
thumbnailFileMeta(resource),
resource.content,
thumbnailKey,
),
);
const text = React.useMemo(
() => formatCodeThumbnailText(raw, resource.fileName, resource.mimeType),
[raw, resource.fileName, resource.mimeType],
);
const lines = React.useMemo(
() =>
text
.replace(/\r?\n$/, "")
.split(/\r\n?|\n/)
.slice(0, CODE_THUMBNAIL_MAX_LINES),
[text],
);
return (
<div
data-slot="code-thumbnail"
className="bg-code text-code-foreground absolute inset-0 overflow-hidden"
>
<div
aria-hidden
className={`absolute inset-y-0 left-0 ${CODE_THUMBNAIL_LINE_NUMBER_WIDTH_CLASS} bg-code-highlight`}
/>
<div
className="relative font-mono"
style={{
fontSize: CODE_THUMBNAIL_FONT_SIZE,
lineHeight: CODE_THUMBNAIL_LINE_HEIGHT,
}}
>
{lines.map((line, i) => (
<div key={i} className="flex">
<span
className={`${CODE_THUMBNAIL_LINE_NUMBER_WIDTH_CLASS} text-code-number shrink-0 pr-px text-right select-none`}
>
{i + 1}
</span>
<span className="pl-0.5 whitespace-pre">{line || " "}</span>
</div>
))}
</div>
</div>
);
}
components/file-thumbnail/thumbnail-code.ts
import type { ViewerDescriptor } from "@/lib/viewer-source";
import { extensionOf } from "@/lib/viewer-source";
const CODE_THUMBNAIL_EXTENSIONS = new Set([
"bash",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"env",
"go",
"graphql",
"h",
"ini",
"java",
"js",
"json",
"json5",
"jsonl",
"jsx",
"kt",
"less",
"log",
"lua",
"mjs",
"ndjson",
"php",
"pl",
"proto",
"py",
"r",
"rb",
"rs",
"scala",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
export function isCodeThumbnailDescriptor(
descriptor: ViewerDescriptor,
): boolean {
const extension = extensionOf(descriptor.displayName);
if (extension && CODE_THUMBNAIL_EXTENSIONS.has(extension)) return true;
const mime = descriptor.mimeType?.toLowerCase().split(";")[0].trim();
return (
mime === "application/json" ||
mime === "application/xml" ||
mime === "text/javascript" ||
mime === "application/javascript" ||
Boolean(mime?.endsWith("+json") || mime?.endsWith("+xml"))
);
}
export function formatCodeThumbnailText(
text: string,
fileName: string,
mimeType?: string,
) {
if (!isStrictJson(fileName, mimeType)) return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
function isStrictJson(fileName: string, mimeType?: string) {
const extension = extensionOf(fileName);
if (extension === "json") return true;
if (
extension === "json5" ||
extension === "jsonl" ||
extension === "ndjson"
) {
return false;
}
const mime = mimeType?.toLowerCase().split(";")[0].trim();
return mime === "application/json" || Boolean(mime?.endsWith("+json"));
}
registry/new-york-v4/ui/utif.d.ts
// Ambient shim for the `utif` package, which ships no type declarations
// (and has no @types/utif on npm). shadcn's registry build strips leading
// comments from source files, so an inline `@ts-expect-error` above the import
// does not survive into the published item — a real declaration is required for
// a fresh consumer install to typecheck. The decode workers cast the default
// export to their own local shapes, so an opaque `any` default is sufficient.
declare module "utif" {
const UTIF: any;
export default UTIF;
}
registry/new-york-v4/ui/docx-viewer-core.ts
import type * as DocxPreview from "docx-preview";
import {
isViewerFormatError,
ViewerFormatError,
type ViewerFormatErrorMapperOptions,
} from "@/lib/viewer-errors";
export const DOCX_MIN_SCALE = 0.1;
export const DOCX_MAX_SCALE = 5;
export const DOCX_ZOOM_STEP = 1.2;
export const DEFAULT_DOCX_PAGE_WIDTH = 816;
export const DEFAULT_DOCX_PAGE_HEIGHT = 1056;
export const DOCX_RENDER_OPTIONS: Partial<DocxPreview.Options> = {
inWrapper: true,
breakPages: true,
ignoreLastRenderedPageBreak: false,
experimental: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
};
export const DOCX_SCOPED_STYLES = `
[data-slot="docx-viewer"] .docx-wrapper {
background: transparent;
padding: 0;
gap: 1rem;
}
[data-slot="docx-viewer"] .docx-wrapper > section.docx {
margin-bottom: 0;
box-shadow: 0 0 0 1px var(--border), 0 1px 2px 0 rgb(0 0 0 / 0.05);
}`;
export function clampDocxScale(value: number) {
return clamp(value, DOCX_MIN_SCALE, DOCX_MAX_SCALE);
}
export function normalizeDocxScale(value: number | null | undefined) {
if (value == null) return null;
if (Number.isNaN(value)) return 1;
return clampDocxScale(value);
}
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function positivePixel(value: number) {
return Number.isFinite(value) && value > 0 ? value : null;
}
export function toDocxFormatError(
error: unknown,
options: ViewerFormatErrorMapperOptions,
): ViewerFormatError {
if (isViewerFormatError(error)) return error;
return new ViewerFormatError({
format: "docx",
kind: options.kind,
message: options.message,
cause: error,
});
}
registry/new-york-v4/ui/docx-viewer-layout.ts
import { clamp } from "./docx-viewer-core";
export const DOCX_PAGE_GAP_PX = 16;
export const DOCX_VIEWER_PADDING_PX = 16;
export const DOCX_READING_MARKER_RATIO = 0.2;
export const DOCX_VIRTUAL_OVERSCAN_PX = 1000;
export const DOCX_FALLBACK_VIEWPORT_HEIGHT_PX = 768;
export interface DocxPageMetric {
pageNumber: number;
width: number;
height: number;
top: number;
bottom: number;
}
export interface DocxPageLayout {
pages: readonly DocxPageMetric[];
totalHeight: number;
}
export interface DocxPageWindow {
afterHeight: number;
beforeHeight: number;
endIndex: number;
renderedBottom: number;
renderedHeight: number;
renderedTop: number;
startIndex: number;
stickyOffset: number;
}
export type DocxReadingAnchor =
| {
kind: "top";
}
| {
kind: "page";
pageNumber: number;
yPercent: number;
};
export function createDocxPageLayout(
sizes: readonly (readonly [number, number])[],
gap = DOCX_PAGE_GAP_PX,
): DocxPageLayout {
let top = 0;
const pages = sizes.map(([width, height], index) => {
const page: DocxPageMetric = {
pageNumber: index + 1,
width,
height,
top,
bottom: top + height,
};
top = page.bottom + gap;
return page;
});
return {
pages,
totalHeight: pages.length ? pages[pages.length - 1]!.bottom : 0,
};
}
export function findDocxPageByMarker({
layout,
scale,
scrollTop,
viewportHeight,
}: {
layout: DocxPageLayout | null;
scale: number;
scrollTop: number;
viewportHeight: number;
}) {
const pages = layout?.pages;
if (!pages?.length) return null;
const marker = scrollTop + viewportHeight * DOCX_READING_MARKER_RATIO;
const y = Math.max(0, (marker - DOCX_VIEWER_PADDING_PX) / safeScale(scale));
let low = 0;
let high = pages.length - 1;
let current = pages[0]!;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const page = pages[mid]!;
if (page.top <= y) {
current = page;
low = mid + 1;
} else {
high = mid - 1;
}
}
return current;
}
export function captureDocxReadingAnchorFromLayout({
layout,
scale,
scrollTop,
viewportHeight,
}: {
layout: DocxPageLayout | null;
scale: number;
scrollTop: number;
viewportHeight: number;
}): DocxReadingAnchor {
if (scrollTop <= 0) return { kind: "top" };
const page = findDocxPageByMarker({
layout,
scale,
scrollTop,
viewportHeight,
});
if (!page || page.height <= 0) return { kind: "top" };
const marker = scrollTop + viewportHeight * DOCX_READING_MARKER_RATIO;
const y = Math.max(0, (marker - DOCX_VIEWER_PADDING_PX) / safeScale(scale));
return {
kind: "page",
pageNumber: page.pageNumber,
yPercent: clamp((y - page.top) / page.height, 0, 1),
};
}
export function restoreDocxReadingAnchorFromLayout({
anchor,
layout,
maxScrollTop,
scale,
viewportHeight,
}: {
anchor: DocxReadingAnchor;
layout: DocxPageLayout | null;
maxScrollTop: number;
scale: number;
viewportHeight: number;
}) {
if (anchor.kind === "top") return 0;
const page = layout?.pages[anchor.pageNumber - 1];
if (!page || page.height <= 0) return null;
const y = (page.top + page.height * anchor.yPercent) * safeScale(scale);
const marker = viewportHeight * DOCX_READING_MARKER_RATIO;
return clamp(DOCX_VIEWER_PADDING_PX + y - marker, 0, maxScrollTop);
}
export function createDocxPageWindowFromScroll({
layout,
overscanPx = DOCX_VIRTUAL_OVERSCAN_PX,
scale,
scrollTop,
viewportHeight,
}: {
layout: DocxPageLayout | null;
overscanPx?: number;
scale: number;
scrollTop: number;
viewportHeight: number;
}): DocxPageWindow {
const pages = layout?.pages;
if (!layout || !pages?.length) {
return emptyDocxPageWindow();
}
const z = safeScale(scale);
const viewHeight = safeViewportHeight(viewportHeight) / z;
const overscan = Math.max(0, overscanPx) / z;
const viewTop = Math.max(0, (scrollTop - DOCX_VIEWER_PADDING_PX) / z);
const windowTop = Math.max(0, viewTop - overscan);
const windowBottom = Math.min(
layout.totalHeight,
viewTop + viewHeight + overscan,
);
let startIndex = pages.findIndex((page) => page.bottom >= windowTop);
if (startIndex === -1) startIndex = pages.length - 1;
let endIndex = startIndex;
while (
endIndex < pages.length &&
pages[endIndex]!.top <= Math.max(windowBottom, pages[startIndex]!.top)
) {
endIndex += 1;
}
if (endIndex === startIndex)
endIndex = Math.min(pages.length, startIndex + 1);
return createDocxPageWindowFromRange({
endIndex,
layout,
startIndex,
viewportHeight: viewHeight,
});
}
export function createDocxPageWindowForPage({
layout,
pageIndex,
scale,
viewportHeight,
}: {
layout: DocxPageLayout | null;
pageIndex: number;
scale: number;
viewportHeight: number;
}): DocxPageWindow {
const pages = layout?.pages;
if (!layout || !pages?.length) return emptyDocxPageWindow();
const safePageIndex = clamp(Math.floor(pageIndex), 0, pages.length - 1);
return createDocxPageWindowFromRange({
endIndex: safePageIndex + 1,
layout,
startIndex: safePageIndex,
viewportHeight: safeViewportHeight(viewportHeight) / safeScale(scale),
});
}
function createDocxPageWindowFromRange({
endIndex,
layout,
startIndex,
viewportHeight,
}: {
endIndex: number;
layout: DocxPageLayout;
startIndex: number;
viewportHeight: number;
}): DocxPageWindow {
const pages = layout.pages;
const start = clamp(Math.floor(startIndex), 0, pages.length);
const end = clamp(Math.ceil(endIndex), start, pages.length);
const first = pages[start];
const last = pages[end - 1];
if (!first || !last) return emptyDocxPageWindow();
const renderedTop = first.top;
const renderedBottom = last.bottom;
const renderedHeight = renderedBottom - renderedTop;
return {
afterHeight: Math.max(0, layout.totalHeight - renderedBottom),
beforeHeight: renderedTop,
endIndex: end,
renderedBottom,
renderedHeight,
renderedTop,
startIndex: start,
stickyOffset: -Math.max(0, renderedHeight - Math.max(1, viewportHeight)),
};
}
function emptyDocxPageWindow(): DocxPageWindow {
return {
afterHeight: 0,
beforeHeight: 0,
endIndex: 0,
renderedBottom: 0,
renderedHeight: 0,
renderedTop: 0,
startIndex: 0,
stickyOffset: 0,
};
}
function safeViewportHeight(value: number) {
return Number.isFinite(value) && value > 0
? value
: DOCX_FALLBACK_VIEWPORT_HEIGHT_PX;
}
function safeScale(scale: number) {
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
registry/new-york-v4/ui/docx-viewer-render-cache.ts
import { lruGet, lruSet } from "./viewer-lru-cache";
export const DOCX_RENDER_CACHE_MAX_ENTRIES = 4;
export type DocxPageSize = readonly [number, number];
export interface DocxRenderCacheEntry {
pageSizes: readonly DocxPageSize[];
renderHost: HTMLElement;
}
export interface DocxRenderCacheHit {
pageSizes: readonly DocxPageSize[];
renderHost: HTMLElement;
}
const UNSAFE_DOCX_RENDER_CACHE_SELECTOR =
"audio, canvas, embed, iframe, object, video";
const docxRenderCache = new Map<string, DocxRenderCacheEntry>();
const pendingDocxRenderCache = new Map<
string,
Promise<DocxRenderCacheEntry | null>
>();
export function readDocxRenderCache(key: string): DocxRenderCacheHit | null {
const entry = lruGet(docxRenderCache, key);
return entry ? cloneDocxRenderCacheEntry(entry) : null;
}
export function readPendingDocxRenderCache(
key: string,
): Promise<DocxRenderCacheEntry | null> | null {
return pendingDocxRenderCache.get(key) ?? null;
}
export function writePendingDocxRenderCache(
key: string,
promise: Promise<DocxRenderCacheEntry | null>,
) {
pendingDocxRenderCache.set(key, promise);
void promise
.finally(() => {
if (pendingDocxRenderCache.get(key) === promise) {
pendingDocxRenderCache.delete(key);
}
})
.catch(() => undefined);
}
export function writeDocxRenderCache({
key,
pageSizes,
renderHost,
}: {
key: string;
pageSizes: readonly DocxPageSize[];
renderHost: HTMLElement;
}): DocxRenderCacheEntry | null {
if (
pageSizes.length === 0 ||
!renderHost.querySelector(".docx-wrapper > section.docx")
) {
return null;
}
if (!canCacheDocxRenderHost(renderHost)) return null;
const cachedRenderHost = renderHost.cloneNode(true);
if (!(cachedRenderHost instanceof HTMLElement)) return null;
const entry: DocxRenderCacheEntry = {
pageSizes,
renderHost: cachedRenderHost,
};
lruSet(docxRenderCache, key, entry, undefined, DOCX_RENDER_CACHE_MAX_ENTRIES);
return entry;
}
export function cloneDocxRenderCacheEntry(
entry: DocxRenderCacheEntry,
): DocxRenderCacheHit {
return {
pageSizes: entry.pageSizes,
renderHost: entry.renderHost.cloneNode(true) as HTMLElement,
};
}
export function resetDocxRenderCacheForTests() {
docxRenderCache.clear();
pendingDocxRenderCache.clear();
}
function canCacheDocxRenderHost(renderHost: HTMLElement) {
return !renderHost.querySelector(UNSAFE_DOCX_RENDER_CACHE_SELECTOR);
}
registry/new-york-v4/ui/docx-viewer-render.ts
import type * as DocxPreview from "docx-preview";
import { ViewerFormatError } from "@/lib/viewer-errors";
import {
DEFAULT_DOCX_PAGE_HEIGHT,
DEFAULT_DOCX_PAGE_WIDTH,
DOCX_RENDER_OPTIONS,
positivePixel,
} from "./docx-viewer-core";
import {
createDocxPageLayout,
type DocxPageLayout,
type DocxPageWindow,
} from "./docx-viewer-layout";
import {
readDocxRenderCache,
readPendingDocxRenderCache,
writeDocxRenderCache,
writePendingDocxRenderCache,
type DocxPageSize,
type DocxRenderCacheHit,
} from "./docx-viewer-render-cache";
let docxPromise: Promise<typeof DocxPreview> | null = null;
export interface DocxRenderedDocument {
after: HTMLDivElement;
before: HTMLDivElement;
mountedEnd: number;
mountedStart: number;
pageLayout: DocxPageLayout;
pages: readonly HTMLElement[];
sticky: HTMLElement;
wrapper: HTMLElement;
}
type DocxRenderBuffer = ArrayBuffer | (() => ArrayBuffer);
export function loadDocxPreview() {
if (!docxPromise) {
docxPromise = import("docx-preview").catch((error) => {
docxPromise = null;
throw error;
});
}
return docxPromise;
}
export async function renderDocxPreview(
buffer: ArrayBuffer,
docxPreviewPromise = loadDocxPreview(),
) {
const { renderAsync } = await docxPreviewPromise;
const renderHost = document.createElement("div");
await renderAsync(buffer, renderHost, undefined, DOCX_RENDER_OPTIONS);
return renderHost;
}
export async function renderCachedDocxPreview({
buffer,
cacheKey,
docxPreviewPromise,
getScale,
}: {
buffer: DocxRenderBuffer;
cacheKey: string;
docxPreviewPromise?: Promise<typeof DocxPreview>;
getScale: () => number;
}): Promise<DocxRenderCacheHit> {
const cached = readDocxRenderCache(cacheKey);
if (cached) return cached;
const pending = readPendingDocxRenderCache(cacheKey);
if (pending) {
const pendingEntry = await pending;
if (pendingEntry) {
return {
pageSizes: pendingEntry.pageSizes,
renderHost: pendingEntry.renderHost.cloneNode(true) as HTMLElement,
};
}
}
const renderPromise = renderDocxPreview(
resolveDocxRenderBuffer(buffer),
docxPreviewPromise ?? loadDocxPreview(),
).then((renderHost) => {
const pageSizes = collectDocxPageSizes(renderHost, getScale());
const cacheEntry = writeDocxRenderCache({
key: cacheKey,
pageSizes,
renderHost,
});
return { cacheEntry, pageSizes, renderHost };
});
writePendingDocxRenderCache(
cacheKey,
renderPromise.then((result) => result.cacheEntry),
);
const result = await renderPromise;
return {
pageSizes: result.pageSizes,
renderHost: result.renderHost,
};
}
function resolveDocxRenderBuffer(buffer: DocxRenderBuffer) {
return typeof buffer === "function" ? buffer() : buffer;
}
export function commitDocxRender({
host,
pageSizes,
renderHost,
scale,
}: {
host: HTMLElement;
pageSizes?: readonly DocxPageSize[];
renderHost: HTMLElement;
scale: number;
}) {
const wrapper = renderHost.querySelector<HTMLElement>(".docx-wrapper");
if (!wrapper) {
throw new ViewerFormatError({
format: "docx",
kind: "render_failed",
message: "DOCX render produced no pages.",
});
}
const pages = Array.from(
renderHost.querySelectorAll<HTMLElement>(".docx-wrapper > section.docx"),
);
if (!pages.length) {
throw new ViewerFormatError({
format: "docx",
kind: "render_failed",
message: "DOCX render produced no pages.",
});
}
const z = scale || 1;
const sizes =
pageSizes && pageSizes.length === pages.length
? pageSizes
: pages.map((el) => pageSize(el, z));
pages.forEach((el, i) => {
el.dataset.pageNumber = String(i + 1);
el.style.contentVisibility = "auto";
const [width, height] = sizes[i]!;
el.style.containIntrinsicSize = `${width}px ${height}px`;
});
const pageLayout = createDocxPageLayout(sizes);
const before = document.createElement("div");
const after = document.createElement("div");
before.dataset.slot = "docx-sticky-before-buffer";
wrapper.dataset.slot = "docx-sticky-window";
after.dataset.slot = "docx-sticky-after-buffer";
before.setAttribute("aria-hidden", "true");
after.setAttribute("aria-hidden", "true");
wrapper.style.position = "sticky";
wrapper.style.left = "0";
wrapper.style.overflow = "visible";
wrapper.style.width = "100%";
const staticNodes = Array.from(renderHost.childNodes).filter(
(node) => node !== wrapper,
);
wrapper.replaceChildren();
host.replaceChildren(...staticNodes, before, wrapper, after);
const virtualDocument: DocxRenderedDocument = {
after,
before,
mountedEnd: 0,
mountedStart: 0,
pageLayout,
pages,
sticky: wrapper,
wrapper,
};
return {
numPages: pages.length,
pageWidth: pages.length ? sizes[0][0] : null,
pageLayout,
virtualDocument,
};
}
export function projectDocxPages(
document: DocxRenderedDocument,
window: DocxPageWindow,
) {
document.before.style.height = `${window.beforeHeight}px`;
document.after.style.height = `${window.afterHeight}px`;
document.sticky.style.top = `${window.stickyOffset}px`;
document.sticky.style.bottom = `${window.stickyOffset}px`;
document.sticky.style.height = `${window.renderedHeight}px`;
const start = window.startIndex;
const end = window.endIndex;
if (start === document.mountedStart && end === document.mountedEnd) return;
if (
document.mountedEnd <= document.mountedStart ||
end <= start ||
end <= document.mountedStart ||
start >= document.mountedEnd
) {
document.wrapper.replaceChildren(...document.pages.slice(start, end));
document.mountedStart = start;
document.mountedEnd = end;
return;
}
for (
let index = document.mountedStart;
index < Math.min(start, document.mountedEnd);
index += 1
) {
document.pages[index]?.remove();
}
for (
let index = Math.max(end, document.mountedStart);
index < document.mountedEnd;
index += 1
) {
document.pages[index]?.remove();
}
if (start < document.mountedStart) {
const fragment = globalThis.document.createDocumentFragment();
for (let index = start; index < document.mountedStart; index += 1) {
const page = document.pages[index];
if (page) fragment.append(page);
}
document.wrapper.insertBefore(fragment, document.wrapper.firstChild);
}
if (end > document.mountedEnd) {
const fragment = globalThis.document.createDocumentFragment();
for (
let index = Math.max(document.mountedEnd, start);
index < end;
index += 1
) {
const page = document.pages[index];
if (page) fragment.append(page);
}
document.wrapper.append(fragment);
}
document.mountedStart = start;
document.mountedEnd = end;
}
function collectDocxPageSizes(
renderHost: HTMLElement,
scale: number,
): readonly DocxPageSize[] {
const pages = Array.from(
renderHost.querySelectorAll<HTMLElement>(".docx-wrapper > section.docx"),
);
const z = scale || 1;
return pages.map((el) => pageSize(el, z));
}
function pageSize(el: HTMLElement, scale: number) {
const styledWidth = positivePixel(Math.round(cssLengthToPx(el.style.width)));
const styledHeight = positivePixel(
Math.round(
cssLengthToPx(el.style.height) || cssLengthToPx(el.style.minHeight),
),
);
if (styledWidth && styledHeight) {
return [styledWidth, styledHeight] as const;
}
const r = el.getBoundingClientRect();
const width = positivePixel(Math.round(r.width / scale));
const height = positivePixel(Math.round(r.height / scale));
return [
styledWidth ?? width ?? DEFAULT_DOCX_PAGE_WIDTH,
styledHeight ?? height ?? DEFAULT_DOCX_PAGE_HEIGHT,
] as const;
}
function cssLengthToPx(value: string) {
const match = value.trim().match(/^(-?\d*\.?\d+)(px|pt|in|cm|mm|pc)?$/i);
if (!match) return 0;
const amount = Number(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return 0;
const unit = (match[2] ?? "px").toLowerCase();
if (unit === "px") return amount;
if (unit === "pt") return (amount * 96) / 72;
if (unit === "in") return amount * 96;
if (unit === "cm") return (amount * 96) / 2.54;
if (unit === "mm") return (amount * 96) / 25.4;
if (unit === "pc") return amount * 16;
return 0;
}
registry/new-york-v4/ui/pdf-viewer-canvas.ts
export function getPdfCanvasPixelSize(
cssSize: number,
devicePixelRatio: number,
) {
if (
!Number.isFinite(cssSize) ||
!Number.isFinite(devicePixelRatio) ||
cssSize <= 0 ||
devicePixelRatio <= 0
) {
return 1;
}
return Math.max(1, Math.floor(cssSize * devicePixelRatio));
}
registry/new-york-v4/ui/pdf-viewer-render-cache.ts
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
export const PDF_RENDERED_PAGE_CACHE_MAX_ENTRIES = 16;
export const PDF_RENDERED_PAGE_CACHE_MAX_PIXELS = 24_000_000;
// Only close lower-resolution renders of the same page may be stretched as
// resize previews; thumbnail-resolution bitmaps below this ratio would blur.
export const PDF_RENDERED_PAGE_PREVIEW_MIN_SCALE_RATIO = 0.5;
export type PdfRenderedPageSignature = {
documentKey: string;
pageNumber: number;
scale: number;
rotation: number;
devicePixelRatio: number;
viewportWidth: number;
viewportHeight: number;
};
export type PdfRenderedPageCache = {
clock: number;
entries: Map<string, PdfRenderedPageCacheEntry>;
resetKey: unknown;
};
export type PdfRenderedPageCacheEntry = PdfRenderedPageSignature & {
canvas: HTMLCanvasElement;
lastUsed: number;
pixels: number;
};
type PdfRenderedPageCacheRecord = {
cache: PdfRenderedPageCache;
retainCount: number;
resetKey: unknown;
};
const objectCacheRecords = new WeakMap<object, PdfRenderedPageCacheRecord>();
const primitiveCacheRecords = new Map<unknown, PdfRenderedPageCacheRecord>();
export function createPdfRenderedPageCache(
resetKey: unknown,
): PdfRenderedPageCache {
return {
clock: 0,
entries: new Map(),
resetKey,
};
}
export function usePdfRenderedPageCache(resetKey: unknown) {
const record = getPdfRenderedPageCacheRecord(resetKey);
useKeyedMountEffect(joinEffectKey([record]), () => {
record.retainCount += 1;
return () => releasePdfRenderedPageCacheRecord(record);
});
return record.cache;
}
export function readPdfRenderedPageCache(
cache: PdfRenderedPageCache | undefined,
requested: PdfRenderedPageSignature,
) {
if (!cache) return null;
let bestEntry: PdfRenderedPageCacheEntry | null = null;
for (const entry of cache.entries.values()) {
if (!doesRenderedPageSatisfyRequest(entry, requested)) continue;
if (
!bestEntry ||
entry.pixels < bestEntry.pixels ||
(entry.pixels === bestEntry.pixels && entry.lastUsed > bestEntry.lastUsed)
) {
bestEntry = entry;
}
}
if (!bestEntry) return null;
cache.clock += 1;
bestEntry.lastUsed = cache.clock;
return bestEntry;
}
export function readPdfRenderedPageCachePreview(
cache: PdfRenderedPageCache | undefined,
requested: PdfRenderedPageSignature,
) {
if (!cache) return null;
let bestEntry: PdfRenderedPageCacheEntry | null = null;
for (const entry of cache.entries.values()) {
if (!doesRenderedPageMatchPreview(entry, requested)) continue;
if (!bestEntry || entry.pixels > bestEntry.pixels) {
bestEntry = entry;
}
}
if (!bestEntry) return null;
cache.clock += 1;
bestEntry.lastUsed = cache.clock;
return bestEntry;
}
export function writePdfRenderedPageCache({
cache,
rendered,
sourceCanvas,
}: {
cache: PdfRenderedPageCache | undefined;
rendered: PdfRenderedPageSignature;
sourceCanvas: HTMLCanvasElement;
}) {
if (!cache || sourceCanvas.width <= 0 || sourceCanvas.height <= 0) return;
const canvas = document.createElement("canvas");
canvas.width = sourceCanvas.width;
canvas.height = sourceCanvas.height;
const context = canvas.getContext("2d");
if (!context || typeof context.drawImage !== "function") return;
context.drawImage(sourceCanvas, 0, 0);
cache.clock += 1;
const key = getPdfRenderedPageCacheKey(rendered);
cache.entries.set(key, {
...rendered,
canvas,
lastUsed: cache.clock,
pixels: canvas.width * canvas.height,
});
evictPdfRenderedPageCache(cache, key);
}
export function clearPdfRenderedPageCache(cache: PdfRenderedPageCache) {
cache.clock = 0;
cache.entries.clear();
}
function evictPdfRenderedPageCache(
cache: PdfRenderedPageCache,
retainedKey: string,
) {
let totalPixels = getTotalCachedPixels(cache);
while (
cache.entries.size > PDF_RENDERED_PAGE_CACHE_MAX_ENTRIES ||
totalPixels > PDF_RENDERED_PAGE_CACHE_MAX_PIXELS
) {
const evicted = findPdfRenderedPageCacheEviction(cache, retainedKey);
if (!evicted) return;
totalPixels -= evicted.entry.pixels;
cache.entries.delete(evicted.key);
}
}
function findPdfRenderedPageCacheEviction(
cache: PdfRenderedPageCache,
retainedKey: string,
) {
let oldest: { key: string; entry: PdfRenderedPageCacheEntry } | null = null;
for (const [key, entry] of cache.entries) {
if (key === retainedKey) continue;
if (!oldest || entry.lastUsed < oldest.entry.lastUsed) {
oldest = { key, entry };
}
}
return oldest;
}
function getTotalCachedPixels(cache: PdfRenderedPageCache) {
let pixels = 0;
for (const entry of cache.entries.values()) {
pixels += entry.pixels;
}
return pixels;
}
function doesRenderedPageSatisfyRequest(
rendered: PdfRenderedPageSignature,
requested: PdfRenderedPageSignature,
) {
return (
rendered.documentKey === requested.documentKey &&
rendered.pageNumber === requested.pageNumber &&
rendered.rotation === requested.rotation &&
rendered.scale >= requested.scale &&
rendered.viewportWidth >= requested.viewportWidth &&
rendered.viewportHeight >= requested.viewportHeight &&
hasMatchingPageAspectRatio(rendered, requested) &&
rendered.devicePixelRatio >= requested.devicePixelRatio
);
}
function doesRenderedPageMatchPreview(
rendered: PdfRenderedPageSignature,
requested: PdfRenderedPageSignature,
) {
return (
rendered.documentKey === requested.documentKey &&
rendered.pageNumber === requested.pageNumber &&
rendered.rotation === requested.rotation &&
rendered.viewportWidth >=
requested.viewportWidth * PDF_RENDERED_PAGE_PREVIEW_MIN_SCALE_RATIO &&
rendered.viewportHeight >=
requested.viewportHeight * PDF_RENDERED_PAGE_PREVIEW_MIN_SCALE_RATIO &&
hasMatchingPageAspectRatio(rendered, requested)
);
}
function getPdfRenderedPageCacheKey(rendered: PdfRenderedPageSignature) {
return [
rendered.documentKey,
rendered.pageNumber,
rendered.scale,
rendered.rotation,
rendered.devicePixelRatio,
rendered.viewportWidth,
rendered.viewportHeight,
].join(":");
}
function getPdfRenderedPageCacheRecord(
resetKey: unknown,
): PdfRenderedPageCacheRecord {
const records = isObjectCacheKey(resetKey)
? objectCacheRecords
: primitiveCacheRecords;
const cached = records.get(resetKey as never);
if (cached) return cached;
const record: PdfRenderedPageCacheRecord = {
cache: createPdfRenderedPageCache(resetKey),
retainCount: 0,
resetKey,
};
records.set(resetKey as never, record);
return record;
}
function releasePdfRenderedPageCacheRecord(record: PdfRenderedPageCacheRecord) {
record.retainCount = Math.max(0, record.retainCount - 1);
if (record.retainCount > 0) return;
clearPdfRenderedPageCache(record.cache);
if (isObjectCacheKey(record.resetKey)) {
objectCacheRecords.delete(record.resetKey);
} else {
primitiveCacheRecords.delete(record.resetKey);
}
}
function isObjectCacheKey(value: unknown): value is object {
return (
(typeof value === "object" && value !== null) || typeof value === "function"
);
}
function hasMatchingPageAspectRatio(
rendered: PdfRenderedPageSignature,
requested: PdfRenderedPageSignature,
) {
const renderedRatio = rendered.viewportWidth / rendered.viewportHeight;
const requestedRatio = requested.viewportWidth / requested.viewportHeight;
return Math.abs(renderedRatio - requestedRatio) < 0.0001;
}