File preview component
File previews for apps. Images with zoom and filmstrip, code with search and outline, a spreadsheet grid, a paged PDF with search and a quick look.
Open any file without leaving the app
A file preview saves people a download just to see what is inside. These five viewers open images, code, tables, documents and mixed attachments right in your app, each with the tools that kind of file needs and full keyboard control.
Image viewer puts the picture on a dark stage with zoom, pan, rotation and swipe, a filmstrip you steer with the arrow keys and the details beside it, below on phones. Code file highlights code with line numbers, search with hits, a symbol outline, line selection with a link and copy or download. Spreadsheet shows a grid with sticky headers, cell selection by keyboard and mouse, sum and average of the range, sorting, a row filter and sheet tabs. PDF document pages through with thumbnails and contents, page jump, zoom, full text search and print. Quick look lays mixed attachments out as drawn tiles, Space opens each one large, for images, PDF, code, tables, audio, archives and text.
Open the preview where the file is listed, in a chat, a ticket or a folder, and keep the download one click away. Let Space and Escape open and close, people know that from their desktop. Show file name, size and type in every viewer, then nobody wonders what they are looking at.
Variants
Details
What File preview brings along, read from the registry and the source.
- Category
- Files
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/file-preview.tsx
- Brings along
- utilskitbuttoniconcode-blockdialog
- npm packages
- None
- Languages
- English and German, 82 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FilePreviewProps interface from components/ui/file-preview.tsx, with the comments from the code.
variantoptionalFilePreviewVariantimage-viewercodespreadsheetdocumentquick-look
filesoptionalPreviewFile[]image-viewer, quick-look: the set to page through
fileoptionalPreviewFilecode, spreadsheet, document: the one file on screen
startoptionalnumberimage-viewer: index to start with
titleoptionalstringA heading above the preview, e.g. the folder or the message the files belong to
subtitleoptionalstringpathoptionalstring[]code: folders above the file, shown as a path
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on download, share and open. Without it the block downloads what it can and simulates the rest.
Built-in texts
File preview writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 82 texts
| Key | English | German |
|---|---|---|
| of | English {n} of {total} | German {n} von {total} |
| previous | English Previous file | German Vorherige Datei |
| next | English Next file | German Nächste Datei |
| zoomIn | English Zoom in | German Vergrößern |
| zoomOut | English Zoom out | German Verkleinern |
| fit | English Fit to screen | German An Fenster anpassen |
| fitShort | English Fit | German Passend |
| rotate | English Rotate | German Drehen |
| details | English Details | German Details |
| hideDetails | English Hide details | German Details ausblenden |
| download | English Download | German Herunterladen |
| share | English Share | German Teilen |
| open | English Open | German Öffnen |
| close | English Close preview | German Vorschau schließen |
| files | English Files | German Dateien |
| viewer | English Image viewer. Arrow keys switch files, plus and minus zoom, zero fits the image. | German Bildbetrachter. Pfeiltasten wechseln die Datei, Plus und Minus zoomen, Null passt das Bild ein. |
| dimensions | English Dimensions | German Abmessungen |
| size | English Size | German Größe |
| modified | English Modified | German Geändert |
| owner | English Owner | German Besitzer |
| type | English Type | German Typ |
| pixels | English {w} × {h} px | German {w} × {h} px |
| showing | English Showing {name}, {n} of {total} | German {name} wird angezeigt, {n} von {total} |
| zoomAt | English Zoom {v} | German Zoom {v} |
| search | English Search in file | German In Datei suchen |
| noHits | English No matches | German Keine Treffer |
| hits | English {n} of {total} | German {n} von {total} |
| prevHit | English Previous match | German Vorheriger Treffer |
| nextHit | English Next match | German Nächster Treffer |
| wrap | English Wrap lines | German Zeilen umbrechen |
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| lines | English {n} lines | German {n} Zeilen |
| line | English Line {n} | German Zeile {n} |
| lineSel | English Line {n} selected | German Zeile {n} ausgewählt |
| linesSel | English Lines {a} to {b} selected | German Zeilen {a} bis {b} ausgewählt |
| copyLink | English Copy link to lines | German Link zu den Zeilen kopieren |
| linkCopied | English Link copied | German Link kopiert |
| outline | English Outline | German Gliederung |
| jump | English Jump to | German Springen zu |
| position | English Ln {l}, Col 1 | German Z. {l}, Sp. 1 |
| codeArea | English Code of {name} | German Code von {name} |
| spaces | English Spaces 2 | German Leerzeichen 2 |
| sheets | English Sheets | German Tabellenblätter |
| rows | English {n} rows | German {n} Zeilen |
| cols | English {n} columns | German {n} Spalten |
| filterRows | English Filter rows | German Zeilen filtern |
| shownRows | English {n} of {total} rows | German {n} von {total} Zeilen |
| sortBy | English Sort by {col} | German Nach {col} sortieren |
| sum | English Sum | German Summe |
| average | English Average | German Mittelwert |
| count | English Count | German Anzahl |
| cell | English Cell | German Zelle |
| grid | English Table {name}. Arrow keys move, shift and arrows select a range. | German Tabelle {name}. Pfeiltasten bewegen, Umschalt und Pfeile wählen einen Bereich. |
| selected | English {n} cells selected | German {n} Zellen ausgewählt |
| downloadCsv | English Download CSV | German Als CSV laden |
| pages | English Pages | German Seiten |
| page | English Page {n} | German Seite {n} |
| pageOf | English of {total} | German von {total} |
| goToPage | English Go to page | German Zur Seite |
| contents | English Contents | German Inhalt |
| zoom | English Zoom | German Zoom |
| English Print | German Drucken | |
| thumbs | English Page thumbnails | German Seitenvorschau |
| noOutline | English This document has no headings. | German Dieses Dokument hat keine Überschriften. |
| play | English Play | German Abspielen |
| pause | English Pause | German Pausieren |
| seek | English Playback position | German Wiedergabeposition |
| archive | English {n} files, {size} unpacked | German {n} Dateien, entpackt {size} |
| preview | English Preview {name} | German Vorschau von {name} |
| gridHint | English Space or Enter opens the preview, arrow keys move between files. | German Leertaste oder Eingabe öffnet die Vorschau, Pfeiltasten wechseln die Datei. |
| quickLook | English Quick look | German Schnellansicht |
| downloaded | English {name} is downloading | German {name} wird heruntergeladen |
| shared | English Share link for {name} is ready | German Freigabelink für {name} ist bereit |
| opened | English {name} opens | German {name} wird geöffnet |
| image | English Image | German Bild |
| English PDF document | German PDF-Dokument | |
| code | English Code | German Code |
| sheet | English Spreadsheet | German Tabelle |
| audio | English Audio | German Audio |
| archiveKind | English Archive | German Archiv |
| text | English Text | German Text |
Accessibility
Read from the source of the component.
- File preview handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Page up, Page down and Space itself.
- Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
- Sets aria-colcount, aria-controls, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-orientation, aria-pressed, aria-roledescription, aria-rowcount, aria-rowindex, aria-selected, aria-sort, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles grid, gridcell, group, listbox, option, region, slider, status, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 2,151 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { tokenize, type CodeLanguage } from '@/components/ui/code-block';
import { DialogMotionStyle, rememberTrigger, scrim, useModalMotion } from '@/components/ui/dialog';
export type FilePreviewVariant = 'image-viewer' | 'code' | 'spreadsheet' | 'document' | 'quick-look';
export type PreviewKind = 'image' | 'pdf' | 'code' | 'sheet' | 'audio' | 'archive' | 'text';
export interface PreviewColumn {
label: string;
/** How the values of this column are shown. Dates are ISO strings, percent values 0 to 1. */
type?: 'text' | 'number' | 'currency' | 'percent' | 'date';
/** ISO currency for type currency, default EUR */
currency?: string;
}
export interface PreviewSheet {
name: string;
columns: PreviewColumn[];
rows: (string | number | null)[][];
}
/** One block on a drawn document page */
export interface PageBlock {
type: 'title' | 'meta' | 'heading' | 'text' | 'list' | 'table' | 'chart' | 'quote';
text?: string;
/** list items, chart labels */
items?: string[];
/** table: first row is the header */
rows?: string[][];
/** chart: one value per label */
values?: number[];
}
export interface PreviewPage {
blocks: PageBlock[];
}
export interface PreviewFile {
id: string;
name: string;
kind: PreviewKind;
/** Bytes */
size: number;
/** ISO date and time */
modified?: string;
owner?: string;
/** image: pixels */
width?: number;
height?: number;
/** image: the picture itself, a drawn scene or an <img> */
media?: React.ReactNode;
/** code */
code?: string;
language?: CodeLanguage;
/** sheet */
sheets?: PreviewSheet[];
/** pdf */
pages?: PreviewPage[];
/** audio: seconds */
duration?: number;
/** archive: the files inside */
entries?: { name: string; size: number }[];
/** text: paragraphs */
text?: string[];
}
export interface FilePreviewProps {
variant?: FilePreviewVariant;
/** image-viewer, quick-look: the set to page through */
files?: PreviewFile[];
/** code, spreadsheet, document: the one file on screen */
file?: PreviewFile;
/** image-viewer: index to start with */
start?: number;
/** A heading above the preview, e.g. the folder or the message the files belong to */
title?: string;
subtitle?: string;
/** code: folders above the file, shown as a path */
path?: string[];
/** Called on download, share and open. Without it the block downloads what it can and simulates the rest. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
of: '{n} of {total}',
previous: 'Previous file',
next: 'Next file',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
fit: 'Fit to screen',
fitShort: 'Fit',
rotate: 'Rotate',
details: 'Details',
hideDetails: 'Hide details',
download: 'Download',
share: 'Share',
open: 'Open',
close: 'Close preview',
files: 'Files',
viewer: 'Image viewer. Arrow keys switch files, plus and minus zoom, zero fits the image.',
dimensions: 'Dimensions',
size: 'Size',
modified: 'Modified',
owner: 'Owner',
type: 'Type',
pixels: '{w} × {h} px',
showing: 'Showing {name}, {n} of {total}',
zoomAt: 'Zoom {v}',
search: 'Search in file',
noHits: 'No matches',
hits: '{n} of {total}',
prevHit: 'Previous match',
nextHit: 'Next match',
wrap: 'Wrap lines',
copy: 'Copy',
copied: 'Copied',
lines: '{n} lines',
line: 'Line {n}',
lineSel: 'Line {n} selected',
linesSel: 'Lines {a} to {b} selected',
copyLink: 'Copy link to lines',
linkCopied: 'Link copied',
outline: 'Outline',
jump: 'Jump to',
position: 'Ln {l}, Col 1',
codeArea: 'Code of {name}',
spaces: 'Spaces 2',
sheets: 'Sheets',
rows: '{n} rows',
cols: '{n} columns',
filterRows: 'Filter rows',
shownRows: '{n} of {total} rows',
sortBy: 'Sort by {col}',
sum: 'Sum',
average: 'Average',
count: 'Count',
cell: 'Cell',
grid: 'Table {name}. Arrow keys move, shift and arrows select a range.',
selected: '{n} cells selected',
downloadCsv: 'Download CSV',
pages: 'Pages',
page: 'Page {n}',
pageOf: 'of {total}',
goToPage: 'Go to page',
contents: 'Contents',
zoom: 'Zoom',
print: 'Print',
thumbs: 'Page thumbnails',
noOutline: 'This document has no headings.',
play: 'Play',
pause: 'Pause',
seek: 'Playback position',
archive: '{n} files, {size} unpacked',
preview: 'Preview {name}',
gridHint: 'Space or Enter opens the preview, arrow keys move between files.',
quickLook: 'Quick look',
downloaded: '{name} is downloading',
shared: 'Share link for {name} is ready',
opened: '{name} opens',
image: 'Image',
pdf: 'PDF document',
code: 'Code',
sheet: 'Spreadsheet',
audio: 'Audio',
archiveKind: 'Archive',
text: 'Text',
},
de: {
of: '{n} von {total}',
previous: 'Vorherige Datei',
next: 'Nächste Datei',
zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern',
fit: 'An Fenster anpassen',
fitShort: 'Passend',
rotate: 'Drehen',
details: 'Details',
hideDetails: 'Details ausblenden',
download: 'Herunterladen',
share: 'Teilen',
open: 'Öffnen',
close: 'Vorschau schließen',
files: 'Dateien',
viewer: 'Bildbetrachter. Pfeiltasten wechseln die Datei, Plus und Minus zoomen, Null passt das Bild ein.',
dimensions: 'Abmessungen',
size: 'Größe',
modified: 'Geändert',
owner: 'Besitzer',
type: 'Typ',
pixels: '{w} × {h} px',
showing: '{name} wird angezeigt, {n} von {total}',
zoomAt: 'Zoom {v}',
search: 'In Datei suchen',
noHits: 'Keine Treffer',
hits: '{n} von {total}',
prevHit: 'Vorheriger Treffer',
nextHit: 'Nächster Treffer',
wrap: 'Zeilen umbrechen',
copy: 'Kopieren',
copied: 'Kopiert',
lines: '{n} Zeilen',
line: 'Zeile {n}',
lineSel: 'Zeile {n} ausgewählt',
linesSel: 'Zeilen {a} bis {b} ausgewählt',
copyLink: 'Link zu den Zeilen kopieren',
linkCopied: 'Link kopiert',
outline: 'Gliederung',
jump: 'Springen zu',
position: 'Z. {l}, Sp. 1',
codeArea: 'Code von {name}',
spaces: 'Leerzeichen 2',
sheets: 'Tabellenblätter',
rows: '{n} Zeilen',
cols: '{n} Spalten',
filterRows: 'Zeilen filtern',
shownRows: '{n} von {total} Zeilen',
sortBy: 'Nach {col} sortieren',
sum: 'Summe',
average: 'Mittelwert',
count: 'Anzahl',
cell: 'Zelle',
grid: 'Tabelle {name}. Pfeiltasten bewegen, Umschalt und Pfeile wählen einen Bereich.',
selected: '{n} Zellen ausgewählt',
downloadCsv: 'Als CSV laden',
pages: 'Seiten',
page: 'Seite {n}',
pageOf: 'von {total}',
goToPage: 'Zur Seite',
contents: 'Inhalt',
zoom: 'Zoom',
print: 'Drucken',
thumbs: 'Seitenvorschau',
noOutline: 'Dieses Dokument hat keine Überschriften.',
play: 'Abspielen',
pause: 'Pausieren',
seek: 'Wiedergabeposition',
archive: '{n} Dateien, entpackt {size}',
preview: 'Vorschau von {name}',
gridHint: 'Leertaste oder Eingabe öffnet die Vorschau, Pfeiltasten wechseln die Datei.',
quickLook: 'Schnellansicht',
downloaded: '{name} wird heruntergeladen',
shared: 'Freigabelink für {name} ist bereit',
opened: '{name} wird geöffnet',
image: 'Bild',
pdf: 'PDF-Dokument',
code: 'Code',
sheet: 'Tabelle',
audio: 'Audio',
archiveKind: 'Archiv',
text: 'Text',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Styles */
/* ------------------------------------------------------------------ */
const CSS = `
.kitfp .tk-keyword{color:color-mix(in oklch,var(--primary) 88%,var(--foreground));font-weight:560}
.kitfp .tk-tag{color:color-mix(in oklch,var(--primary) 78%,var(--foreground));font-weight:560}
.kitfp .tk-function{color:var(--foreground);font-weight:560}
.kitfp .tk-type{color:color-mix(in oklch,var(--primary) 58%,var(--foreground))}
.kitfp .tk-string{color:color-mix(in oklch,var(--primary) 42%,var(--muted-foreground))}
.kitfp .tk-number,.kitfp .tk-literal,.kitfp .tk-variable{color:color-mix(in oklch,var(--primary) 70%,var(--foreground))}
.kitfp .tk-attr{color:color-mix(in oklch,var(--primary) 34%,var(--muted-foreground))}
.kitfp .tk-property{color:color-mix(in oklch,var(--foreground) 80%,var(--primary))}
.kitfp .tk-comment{color:var(--subtle-foreground);font-style:italic}
.kitfp .tk-punct,.kitfp .tk-operator{color:var(--muted-foreground)}
.kitfp mark{background:color-mix(in oklab,var(--warning) 38%,transparent);color:inherit;border-radius:2px}
.kitfp mark[data-current]{background:color-mix(in oklab,var(--warning) 75%,transparent);box-shadow:0 0 0 2px color-mix(in oklab,var(--warning) 75%,transparent)}
.kitfp-paper mark{background:#ffe27a;color:#15171c}
.kitfp-paper mark[data-current]{background:#ffc93d;box-shadow:0 0 0 2px #ffc93d}
@keyframes kitfp-in{from{opacity:0;transform:scale(.97)}}
@keyframes kitfp-up{from{opacity:0;transform:translateY(10px)}}
@keyframes kitfp-flash{from{background-color:color-mix(in oklab,var(--primary) 26%,transparent)}}
@keyframes kitfp-bar{from{transform:scaleY(.15)}}
@media (prefers-reduced-motion:no-preference){
.kitfp-in{animation:kitfp-in .42s var(--ease-kit) both}
.kitfp-up{animation:kitfp-up .5s var(--ease-kit) var(--kitfp-d,0ms) both}
.kitfp-flash{animation:kitfp-flash 1.4s ease-out both}
.kitfp-bar{transform-origin:bottom;animation:kitfp-bar .7s var(--ease-kit) var(--kitfp-d,0ms) both}
}
@media print{.kitfp-noprint{display:none!important}.kitfp-scroll{max-height:none!important;overflow:visible!important}}
`;
function Style() {
return (
<style href="kitb5-file-preview" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k: string) => String(v[k] ?? ''));
const intlOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const num = (l: Locale, n: number, o?: Intl.NumberFormatOptions) => new Intl.NumberFormat(intlOf(l), o).format(n);
function bytes(l: Locale, n: number) {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = n;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
return `${num(l, v, { maximumFractionDigits: v < 10 && i > 0 ? 1 : 0 })} ${units[i]}`;
}
const parseIso = (iso: string) => new Date(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const dateTime = (l: Locale, iso: string) => new Intl.DateTimeFormat(intlOf(l), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }).format(parseIso(iso));
const dateOnly = (l: Locale, iso: string) => new Intl.DateTimeFormat(intlOf(l), { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }).format(parseIso(iso));
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const KIND_ICON: Record<PreviewKind, IconName> = { image: 'image', pdf: 'file', code: 'file-code', sheet: 'table', audio: 'music', archive: 'archive', text: 'file' };
const kindLabel = (t: Texts, k: PreviewKind) => ({ image: t.image, pdf: t.pdf, code: t.code, sheet: t.sheet, audio: t.audio, archive: t.archiveKind, text: t.text })[k];
const ext = (name: string) => (name.includes('.') ? name.split('.').pop()!.toUpperCase() : '');
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
function saveBlob(name: string, text: string, type = 'text/plain') {
try {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch {
/* the browser blocked it, nothing to do */
}
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
/** A square tool button with an icon and a name for screen readers */
function Tool({ icon, label, onClick, pressed, disabled, className, size = 16 }: { icon: IconName; label: string; onClick?: () => void; pressed?: boolean; disabled?: boolean; className?: string; size?: number }) {
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={pressed}
disabled={disabled}
onClick={onClick}
className={cn(
'inline-grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground transition-[color,background-color,transform] duration-200 hover:bg-accent hover:text-foreground active:scale-95 disabled:pointer-events-none disabled:opacity-40 sm:size-9',
pressed && 'bg-accent text-foreground',
ring,
className,
)}
>
<Icon name={icon} size={size} />
</button>
);
}
const fieldCls =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
/** Runs an action through onAction or simulates it, then reports it */
function useActions(onAction: FilePreviewProps['onAction'], t: Texts) {
const [msg, setMsg] = useState('');
const act = async (action: 'download' | 'share' | 'open', f: PreviewFile, fallback?: () => void) => {
if (onAction) await onAction(action, { id: f.id, name: f.name });
else {
if (action === 'download' && fallback) fallback();
await wait(prefersReducedMotion() ? 0 : 300);
}
setMsg(fill(action === 'download' ? t.downloaded : action === 'share' ? t.shared : t.opened, { name: f.name }) + ''.repeat(Math.random() * 3));
};
return [msg, act, setMsg] as const;
}
/** Text from a file for a real download, where the block has it */
function fileText(f: PreviewFile): string | null {
if (f.code) return f.code;
if (f.text) return f.text.join('\n\n');
if (f.sheets?.[0]) return toCsv(f.sheets[0]);
return null;
}
function toCsv(s: PreviewSheet) {
const q = (v: string | number | null) => {
const x = v === null ? '' : String(v);
return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x;
};
return [s.columns.map((c) => q(c.label)).join(','), ...s.rows.map((r) => r.map(q).join(','))].join('\n');
}
function Header({ title, subtitle, children }: { title?: string; subtitle?: string; children?: React.ReactNode }) {
if (!title && !subtitle && !children) return null;
return (
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
{title && <h2 className="mt-0.5 text-balance text-[clamp(1.375rem,1.15rem+0.9vw,1.75rem)] font-semibold tracking-[-0.03em] text-foreground">{title}</h2>}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Highlighting search hits in plain text */
/* ------------------------------------------------------------------ */
/** Splits text into plain parts and hits. counter carries the running hit index across calls. */
function marked(text: string, q: string, counter: { n: number }, current: number): React.ReactNode {
if (!q) return text;
const lower = text.toLowerCase();
const out: React.ReactNode[] = [];
let i = 0;
let at = lower.indexOf(q);
while (at >= 0) {
if (at > i) out.push(text.slice(i, at));
const n = counter.n++;
out.push(
<mark key={at} data-hit={n} data-current={n === current ? '' : undefined}>
{text.slice(at, at + q.length)}
</mark>,
);
i = at + q.length;
at = lower.indexOf(q, i);
}
if (i < text.length) out.push(text.slice(i));
return out;
}
const countHits = (text: string, q: string) => {
if (!q) return 0;
let n = 0;
let at = text.toLowerCase().indexOf(q);
while (at >= 0) {
n++;
at = text.toLowerCase().indexOf(q, at + q.length);
}
return n;
};
/** Scrolls an element into the middle of its scroll container, never the page */
function scrollInside(container: HTMLElement | null, el: Element | null, axis: 'y' | 'x' = 'y') {
if (!container || !el) return;
const c = container.getBoundingClientRect();
const r = el.getBoundingClientRect();
const behavior: ScrollBehavior = prefersReducedMotion() ? 'auto' : 'smooth';
if (axis === 'y') container.scrollTo({ top: container.scrollTop + r.top - c.top - c.height / 2 + r.height / 2, behavior });
else container.scrollTo({ left: container.scrollLeft + r.left - c.left - c.width / 2 + r.width / 2, behavior });
}
/** Which edge of a scroll row still hides content, for the matching fade */
function useEdgeFade<T extends HTMLElement>(ref: React.RefObject<T | null>, deps: unknown[] = []) {
const [fade, setFade] = useState('kit-fade-end');
useEffect(() => {
const el = ref.current;
if (!el) return;
const read = () => {
const start = el.scrollLeft > 4;
const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
setFade(start && end ? 'kit-fade-x' : end ? 'kit-fade-end' : start ? 'kit-fade-start' : '');
};
read();
el.addEventListener('scroll', read, { passive: true });
window.addEventListener('resize', read);
return () => {
el.removeEventListener('scroll', read);
window.removeEventListener('resize', read);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return fade;
}
/* ------------------------------------------------------------------ */
/* image-viewer */
/* ------------------------------------------------------------------ */
const ZOOMS = [1, 1.5, 2, 3, 4];
function ImageViewer({ t, files = [], start = 0, locale, onAction, title, subtitle }: Inner) {
const [idx, setIdx] = useState(Math.min(Math.max(0, start), Math.max(0, files.length - 1)));
const [zoom, setZoom] = useState(1);
const [rot, setRot] = useState(0);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [info, setInfo] = useState(true);
const [mobileInfo, setMobileInfo] = useState(false);
const [dragging, setDragging] = useState(false);
const [live, setLive] = useState('');
const [msg, act] = useActions(onAction, t);
const stage = useRef<HTMLDivElement>(null);
const strip = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number; px: number; py: number; moved: boolean } | null>(null);
const infoId = useId();
const f = files[idx];
const stripFade = useEdgeFade(strip, [files.length]);
const go = (n: number, focusStrip = false) => {
if (!files.length) return;
const next = (n + files.length) % files.length;
setIdx(next);
setZoom(1);
setRot(0);
setPan({ x: 0, y: 0 });
setLive(fill(t.showing, { name: files[next].name, n: next + 1, total: files.length }));
if (focusStrip) requestAnimationFrame(() => strip.current?.querySelectorAll<HTMLElement>('[role=option]')[next]?.focus());
};
useEffect(() => {
scrollInside(strip.current, strip.current?.querySelectorAll('[role=option]')[idx] ?? null, 'x');
}, [idx]);
const limit = (p: { x: number; y: number }, z = zoom) => {
const el = stage.current;
if (!el || z <= 1) return { x: 0, y: 0 };
const mx = (el.clientWidth * (z - 1)) / 2;
const my = (el.clientHeight * (z - 1)) / 2;
return { x: Math.max(-mx, Math.min(mx, p.x)), y: Math.max(-my, Math.min(my, p.y)) };
};
const setZ = (z: number) => {
const v = Math.max(1, Math.min(4, z));
setZoom(v);
setPan((p) => limit(p, v));
setLive(fill(t.zoomAt, { v: v === 1 ? t.fitShort : formatPct(v) }));
};
const step = (dir: 1 | -1) => {
const i = ZOOMS.findIndex((z) => z >= zoom - 0.01);
setZ(ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, (i < 0 ? 0 : i) + dir))]);
};
const formatPct = (z: number) => num(locale, z, { style: 'percent', maximumFractionDigits: 0 });
const onKey = (e: React.KeyboardEvent) => {
const k = e.key;
if (k === '+' || k === '=') setZ(zoom + 0.5);
else if (k === '-' || k === '_') setZ(zoom - 0.5);
else if (k === '0') setZ(1);
else if (k === 'r' || k === 'R') setRot((r) => r + 90);
else if (zoom > 1 && k.startsWith('Arrow')) {
const d = 48;
setPan((p) => limit({ x: p.x + (k === 'ArrowLeft' ? d : k === 'ArrowRight' ? -d : 0), y: p.y + (k === 'ArrowUp' ? d : k === 'ArrowDown' ? -d : 0) }));
} else if (k === 'ArrowLeft') go(idx - 1);
else if (k === 'ArrowRight') go(idx + 1);
else if (k === 'Home') go(0);
else if (k === 'End') go(files.length - 1);
else return;
e.preventDefault();
};
if (!f) return null;
const ratio = f.width && f.height ? (rot % 180 === 0 ? f.width / f.height : f.height / f.width) : 4 / 3;
const baseRatio = f.width && f.height ? f.width / f.height : 4 / 3;
const sideways = rot % 180 !== 0;
const details = (
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-[13px]">
{f.width && f.height ? (
<>
<dt className="text-muted-foreground">{t.dimensions}</dt>
<dd className="text-end tabular-nums text-foreground">{fill(t.pixels, { w: num(locale, f.width), h: num(locale, f.height) })}</dd>
</>
) : null}
<dt className="text-muted-foreground">{t.size}</dt>
<dd className="text-end tabular-nums text-foreground">{bytes(locale, f.size)}</dd>
<dt className="text-muted-foreground">{t.type}</dt>
<dd className="text-end text-foreground">{ext(f.name) || kindLabel(t, f.kind)}</dd>
{f.modified && (
<>
<dt className="text-muted-foreground">{t.modified}</dt>
<dd className="text-end tabular-nums text-foreground">{dateTime(locale, f.modified)}</dd>
</>
)}
{f.owner && (
<>
<dt className="text-muted-foreground">{t.owner}</dt>
<dd className="truncate text-end text-foreground">{f.owner}</dd>
</>
)}
</dl>
);
return (
<div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
<Header title={title} subtitle={subtitle} />
<div className={cn('grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]', info && 'lg:grid-cols-[minmax(0,1fr)_18.5rem]')}>
<div className="flex min-w-0 flex-col">
{/* Toolbar */}
<div className="kitfp-noprint flex items-center gap-1 border-b border-border px-2 py-1.5 sm:px-3">
<div className="me-auto min-w-0 ps-1.5">
<p className="truncate text-[13.5px] font-semibold text-foreground">{f.name}</p>
<p className="text-[12px] tabular-nums text-muted-foreground">{fill(t.of, { n: idx + 1, total: files.length })}</p>
</div>
<Tool icon="zoom-out" label={t.zoomOut} onClick={() => step(-1)} disabled={zoom <= 1} />
<button type="button" onClick={() => setZ(1)} aria-label={t.fit} title={t.fit} className={cn('hidden h-9 min-w-14 rounded-md px-2 text-[12.5px] font-medium tabular-nums text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-block', ring)}>
{zoom === 1 ? t.fitShort : formatPct(zoom)}
</button>
<Tool icon="zoom-in" label={t.zoomIn} onClick={() => step(1)} disabled={zoom >= 4} />
<Tool icon="rotate-cw" label={t.rotate} onClick={() => setRot((r) => r + 90)} className="max-[370px]:hidden" />
<span aria-hidden className="mx-1 hidden h-5 w-px bg-border sm:block" />
<Tool icon="download" label={t.download} onClick={() => void act('download', f)} />
<Tool icon="info" label={info ? t.hideDetails : t.details} pressed={info} onClick={() => setInfo((v) => !v)} className="hidden lg:inline-grid" />
</div>
{/* Stage, always dark like a light table */}
<div className="dark relative">
<div
ref={stage}
tabIndex={0}
role="group"
aria-roledescription={t.viewer}
aria-label={f.name}
onKeyDown={onKey}
onDoubleClick={() => setZ(zoom > 1 ? 1 : 2)}
onWheel={(e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setZ(zoom + (e.deltaY < 0 ? 0.25 : -0.25));
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
drag.current = { x: e.clientX, y: e.clientY, px: pan.x, py: pan.y, moved: false };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
if (Math.abs(dx) + Math.abs(dy) > 4) d.moved = true;
if (zoom > 1) {
setDragging(true);
setPan(limit({ x: d.px + dx, y: d.py + dy }));
}
}}
onPointerUp={(e) => {
const d = drag.current;
drag.current = null;
setDragging(false);
// A swipe pages on touch screens when the image is not zoomed
if (d && zoom === 1 && Math.abs(e.clientX - d.x) > 56 && Math.abs(e.clientY - d.y) < 60) go(idx + (e.clientX < d.x ? 1 : -1));
}}
onPointerCancel={() => {
drag.current = null;
setDragging(false);
}}
className={cn(
'relative h-[min(56svh,22rem)] min-h-[16rem] touch-pan-y select-none overflow-hidden bg-background [container-type:size] sm:h-[34rem] lg:h-[38rem]',
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
'outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_70%,transparent)]',
)}
style={{ backgroundImage: 'radial-gradient(color-mix(in oklab, var(--foreground) 9%, transparent) 1px, transparent 1px)', backgroundSize: '20px 20px' }}
>
<div className="absolute inset-5 grid place-items-center sm:inset-8 [container-type:size]">
<div
key={f.id}
className="kitfp-in relative"
style={{
width: `min(100cqw, calc(100cqh * ${round(ratio, 4)}))`,
aspectRatio: String(round(ratio, 4)),
}}
>
<div
className={cn('absolute left-1/2 top-1/2 overflow-hidden rounded-[3px] shadow-[0_24px_60px_-20px_rgb(0_0_0/0.7)] [&>*]:size-full', !dragging && 'transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none')}
style={{
// Turned sideways the picture spans the height of the box
width: sideways ? `${round(100 / ratio, 3)}%` : '100%',
aspectRatio: String(round(baseRatio, 4)),
transform: `translate(calc(-50% + ${round(pan.x)}px), calc(-50% + ${round(pan.y)}px)) scale(${zoom}) rotate(${rot}deg)`,
}}
>
{f.media ?? <div className="grid size-full place-items-center bg-surface-2 text-muted-foreground"><Icon name="image" size={32} /></div>}
</div>
</div>
</div>
{files.length > 1 && (
<>
<button type="button" aria-label={t.previous} onClick={() => go(idx - 1)} onPointerDown={(e) => e.stopPropagation()} className={cn('kitfp-noprint absolute start-2 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-border bg-[color-mix(in_oklab,var(--background)_72%,transparent)] text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-[transform,background-color] duration-200 hover:-translate-x-0.5 hover:bg-background sm:start-4', ring)}>
<Icon name="chevron-left" size={18} />
</button>
<button type="button" aria-label={t.next} onClick={() => go(idx + 1)} onPointerDown={(e) => e.stopPropagation()} className={cn('kitfp-noprint absolute end-2 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-border bg-[color-mix(in_oklab,var(--background)_72%,transparent)] text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-[transform,background-color] duration-200 hover:translate-x-0.5 hover:bg-background sm:end-4', ring)}>
<Icon name="chevron-right" size={18} />
</button>
</>
)}
<span aria-hidden className={cn('pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--background)_78%,transparent)] px-2.5 py-1 text-[12px] font-medium tabular-nums text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-opacity duration-300', zoom > 1 ? 'opacity-100' : 'opacity-0')}>
{formatPct(zoom)}
</span>
</div>
</div>
{/* Filmstrip */}
{files.length > 1 && (
<div className="kitfp-noprint border-t border-border bg-surface">
<div
ref={strip}
role="listbox"
aria-label={t.files}
aria-orientation="horizontal"
className={cn('kit-no-scrollbar flex gap-2 overflow-x-auto px-3 py-3 sm:px-4', stripFade)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
go(e.key === 'Home' ? 0 : files.length - 1, true);
} else if (d) {
e.preventDefault();
go(idx + d, true);
}
}}
>
{files.map((x, i) => (
<button
key={x.id}
type="button"
role="option"
aria-selected={i === idx}
aria-label={x.name}
tabIndex={i === idx ? 0 : -1}
onClick={() => go(i)}
className={cn(
'relative h-14 w-20 shrink-0 overflow-hidden rounded-md border bg-surface-2 transition-[opacity,transform,border-color] duration-300 ease-[var(--ease-kit)] [&>*]:size-full sm:h-16 sm:w-24',
i === idx ? 'border-transparent opacity-100 ring-2 ring-primary ring-offset-2 ring-offset-surface' : 'border-border opacity-70 hover:-translate-y-0.5 hover:opacity-100',
ring,
)}
>
<span aria-hidden className="pointer-events-none block size-full [&>*]:size-full">{x.media ?? <Icon name={KIND_ICON[x.kind]} size={18} />}</span>
</button>
))}
</div>
</div>
)}
{/* Phone and tablet: details as a disclosure under the strip */}
<div className="border-t border-border lg:hidden">
<button type="button" aria-expanded={mobileInfo} aria-controls={infoId} onClick={() => setMobileInfo((v) => !v)} className={cn('flex h-12 w-full items-center justify-between px-4 text-[13.5px] font-medium text-foreground', ring)}>
{t.details}
<Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', mobileInfo && 'rotate-180')} />
</button>
<div id={infoId} className={cn('grid transition-[grid-template-rows] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', mobileInfo ? 'grid-rows-[1fr]' : 'invisible grid-rows-[0fr]')}>
<div className="min-h-0 overflow-hidden">
<div className="px-4 pb-4">
{details}
<div className="mt-4 grid grid-cols-2 gap-2">
<Button size="sm" variant="outline" iconLeft="share" onClick={() => void act('share', f)}>
{t.share}
</Button>
<Button size="sm" iconLeft="download" onClick={() => void act('download', f)}>
{t.download}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
{info && (
<aside className="kitfp-noprint hidden min-w-0 flex-col border-s border-border bg-surface lg:flex">
<div key={f.id} className="kitfp-up flex flex-1 flex-col p-5">
<p className="text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.details}</p>
<h3 className="mt-2 break-words text-[15px] font-semibold leading-snug text-foreground">{f.name}</h3>
<div className="mt-5">{details}</div>
<div className="mt-auto grid gap-2 pt-6">
<Button size="sm" iconLeft="download" onClick={() => void act('download', f)}>
{t.download}
</Button>
<Button size="sm" variant="outline" iconLeft="share" onClick={() => void act('share', f)}>
{t.share}
</Button>
</div>
</div>
</aside>
)}
</div>
<Live text={live || msg} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* code */
/* ------------------------------------------------------------------ */
type Tok = { t: string; v: string };
function toLines(tokens: Tok[]): Tok[][] {
const lines: Tok[][] = [[]];
for (const tok of tokens) {
tok.v.split('\n').forEach((part, i) => {
if (i > 0) lines.push([]);
if (part) lines[lines.length - 1].push({ t: tok.t, v: part });
});
}
return lines;
}
/** Leading spaces become a fixed width per column, so code lines up in the one proportional face */
function indentOf(tokens: Tok[]) {
let cols = 0;
const rest: Tok[] = [];
let leading = true;
for (const tok of tokens) {
if (!leading) {
rest.push(tok);
continue;
}
const m = tok.v.match(/^[ \t]*/)![0];
cols += Array.from(m).reduce((a, c) => a + (c === '\t' ? 2 : 1), 0);
if (m.length < tok.v.length) {
leading = false;
rest.push({ t: tok.t, v: tok.v.slice(m.length) });
}
}
return { cols, rest };
}
/** Top level symbols only, helpers inside functions stay out of the outline */
const SYMBOL = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:(function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=)/;
function outlineOf(code: string, language?: CodeLanguage) {
const out: { line: number; name: string; kind: string }[] = [];
code.split('\n').forEach((l, i) => {
if (language === 'css') {
const m = l.match(/^([^\s{@][^{]*)\{/);
if (m) out.push({ line: i + 1, name: m[1].trim(), kind: 'rule' });
return;
}
const m = l.match(SYMBOL);
if (m) out.push({ line: i + 1, name: m[2] ?? m[3], kind: m[1] ?? 'const' });
});
return out;
}
function CodeLines({ lines, q, current, wrap, sel, onLine, compact }: { lines: Tok[][]; q: string; current: number; wrap: boolean; sel?: [number, number] | null; onLine?: (n: number, extend: boolean) => void; compact?: boolean }) {
const counter = { n: 0 };
return (
<div className={cn('min-w-full py-3 text-[13px] leading-[1.7]', !wrap && 'w-max')}>
{lines.map((toks, i) => {
const n = i + 1;
const { cols, rest } = indentOf(toks);
const on = !!sel && n >= sel[0] && n <= sel[1];
return (
<div key={i} data-line={n} className={cn('group/line flex', on ? 'bg-[color-mix(in_oklab,var(--primary)_11%,transparent)]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]')}>
<span
aria-hidden
onClick={onLine ? (e) => onLine(n, e.shiftKey) : undefined}
className={cn(
'sticky start-0 z-[1] w-12 shrink-0 select-none border-e pe-3 text-end tabular-nums',
compact ? 'w-10' : 'w-12',
onLine && 'cursor-pointer',
on ? 'border-[color-mix(in_oklab,var(--primary)_60%,transparent)] bg-[color-mix(in_oklab,var(--primary)_11%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'border-border bg-card text-subtle-foreground group-hover/line:text-muted-foreground',
)}
>
{n}
</span>
<span className={cn('min-w-0 ps-4 pe-6 text-foreground', wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre')} style={wrap ? { paddingInlineStart: `calc(1rem + ${round(cols * 0.6, 2)}em)`, textIndent: `-${round(cols * 0.6, 2)}em` } : undefined}>
{cols > 0 && <span aria-hidden className="inline-block" style={{ width: `${round(cols * 0.6, 2)}em` }} />}
{cols > 0 && <span className="sr-only">{' '.repeat(cols)}</span>}
{rest.length ? rest.map((tok, j) => (tok.t === 'plain' ? <span key={j}>{marked(tok.v, q, counter, current)}</span> : <span key={j} className={`tk-${tok.t}`}>{marked(tok.v, q, counter, current)}</span>)) : ''}
</span>
</div>
);
})}
</div>
);
}
function CodeView({ t, file, path = [], locale, onAction }: Inner) {
const f = file;
const code = f?.code ?? '';
const lines = useMemo(() => toLines(tokenize(code, f?.language ?? 'text') as Tok[]), [code, f?.language]);
const outline = useMemo(() => outlineOf(code, f?.language), [code, f?.language]);
const [query, setQuery] = useState('');
const [hit, setHit] = useState(0);
const [wrap, setWrap] = useState(false);
const [sel, setSel] = useState<[number, number] | null>(null);
const [anchor, setAnchor] = useState<number | null>(null);
const [copied, setCopied] = useState<'code' | 'link' | null>(null);
const [live, setLive] = useState('');
const [msg, act] = useActions(onAction, t);
const scroller = useRef<HTMLDivElement>(null);
const q = query.trim().toLowerCase();
const total = useMemo(() => (q ? lines.reduce((a, l) => a + l.reduce((b, tok) => b + countHits(tok.v, q), 0), 0) : 0), [lines, q]);
const current = total ? ((hit % total) + total) % total : -1;
useEffect(() => {
if (current < 0) return;
scrollInside(scroller.current, scroller.current?.querySelector(`[data-hit="${current}"]`) ?? null);
}, [current, q]);
if (!f) return null;
const pick = (n: number, extend: boolean) => {
const a = extend && anchor ? anchor : n;
const range: [number, number] = [Math.min(a, n), Math.max(a, n)];
if (!extend) setAnchor(n);
setSel(range);
setLive(range[0] === range[1] ? fill(t.lineSel, { n: range[0] }) : fill(t.linesSel, { a: range[0], b: range[1] }));
};
const jump = (n: number) => {
pick(n, false);
requestAnimationFrame(() => scrollInside(scroller.current, scroller.current?.querySelector(`[data-line="${n}"]`) ?? null));
};
const flash = (k: 'code' | 'link') => {
setCopied(k);
setTimeout(() => setCopied(null), 1600);
};
const nextHit = (d: number) => {
if (!total) return;
setHit((h) => h + d);
setLive(fill(t.hits, { n: (((current + d) % total) + total) % total + 1, total }));
};
const searchBox = (
<div className="flex min-w-0 flex-1 items-center gap-1 sm:max-w-80">
<label className="relative block min-w-0 flex-1">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHit(0);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
nextHit(e.shiftKey ? -1 : 1);
}
}}
placeholder={t.search}
className={cn(fieldCls, 'ps-9 pe-16')}
/>
{q && <span aria-live="polite" className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-[12px] tabular-nums text-muted-foreground">{total ? fill(t.hits, { n: current + 1, total }) : t.noHits}</span>}
</label>
<Tool icon="chevron-up" label={t.prevHit} onClick={() => nextHit(-1)} disabled={!total} />
<Tool icon="chevron-down" label={t.nextHit} onClick={() => nextHit(1)} disabled={!total} />
</div>
);
return (
<div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
{/* File head */}
<div className="flex flex-col gap-3 border-b border-border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="min-w-0">
<nav aria-label="Path" className="flex min-w-0 flex-wrap items-center gap-1 text-[12.5px] text-muted-foreground">
{path.map((p, i) => (
<span key={i} className="inline-flex items-center gap-1">
{p}
<Icon name="chevron-right" size={12} className="text-subtle-foreground" />
</span>
))}
</nav>
<p className="mt-0.5 flex min-w-0 items-center gap-2">
<Icon name="file-code" size={16} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
<span className="truncate text-[15px] font-semibold text-foreground">{f.name}</span>
</p>
<p className="mt-1 flex flex-wrap gap-x-3 text-[12.5px] tabular-nums text-subtle-foreground">
<span>{fill(t.lines, { n: num(locale, lines.length) })}</span>
<span>{bytes(locale, f.size)}</span>
{f.modified && <span>{dateTime(locale, f.modified)}</span>}
</p>
</div>
<div className="kitfp-noprint flex items-center gap-1.5">
<Tool icon="align-left" label={t.wrap} pressed={wrap} onClick={() => setWrap((v) => !v)} />
<Button
size="sm"
variant="outline"
iconLeft={copied === 'code' ? 'check' : 'copy'}
onClick={async () => {
if (await copyText(code)) flash('code');
setLive(t.copied);
}}
>
{copied === 'code' ? t.copied : t.copy}
</Button>
<Button size="sm" iconLeft="download" onClick={() => void act('download', f, () => saveBlob(f.name, code))}>
{t.download}
</Button>
</div>
</div>
{/* Search and jump */}
<div className="kitfp-noprint flex flex-wrap items-center gap-2 border-b border-border bg-surface px-3 py-2 sm:px-4">
{searchBox}
{outline.length > 0 && (
<label className="relative block w-full sm:hidden">
<span className="sr-only">{t.jump}</span>
<select value="" onChange={(e) => e.target.value && jump(Number(e.target.value))} className={cn(fieldCls, 'cursor-pointer appearance-none pe-8')}>
<option value="">{t.jump}</option>
{outline.map((o) => (
<option key={o.line} value={o.line}>
{o.name}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</label>
)}
</div>
<div className={cn('grid grid-cols-[minmax(0,1fr)]', outline.length > 0 && 'sm:grid-cols-[minmax(0,1fr)_13rem] lg:grid-cols-[minmax(0,1fr)_15rem]')}>
<div
ref={scroller}
tabIndex={0}
role="region"
aria-label={fill(t.codeArea, { name: f.name })}
className={cn('kitfp-scroll kit-scrollbar relative max-h-[34rem] overflow-auto', ring, 'focus-visible:outline-offset-[-2px]')}
>
<CodeLines lines={lines} q={q} current={current} wrap={wrap} sel={sel} onLine={pick} />
</div>
{outline.length > 0 && (
<nav aria-label={t.outline} className="kitfp-noprint hidden border-s border-border bg-surface sm:block">
<p className="px-4 pb-1 pt-3.5 text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.outline}</p>
<ul className="kit-scrollbar relative max-h-[30rem] overflow-y-auto px-2 pb-3">
{outline.map((o, i) => {
const on = !!sel && o.line >= sel[0] && o.line <= sel[1];
return (
<li key={o.line} className="kitfp-up" style={{ '--kitfp-d': `${i * 30}ms` } as React.CSSProperties}>
<button type="button" onClick={() => jump(o.line)} aria-current={on ? 'location' : undefined} className={cn('flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-start text-[13px] transition-colors', on ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', ring)}>
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded text-[10px] font-semibold uppercase', o.kind === 'function' ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'bg-muted text-muted-foreground')}>
{o.kind === 'function' ? 'ƒ' : o.kind.charAt(0)}
</span>
<span className="min-w-0 flex-1 truncate">{o.name}</span>
<span className="text-[11.5px] tabular-nums text-subtle-foreground">{o.line}</span>
</button>
</li>
);
})}
</ul>
</nav>
)}
</div>
{/* Status bar */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[12px] tabular-nums text-muted-foreground sm:px-5">
<span>{sel ? (sel[0] === sel[1] ? fill(t.line, { n: sel[0] }) : fill(t.linesSel, { a: sel[0], b: sel[1] })) : fill(t.position, { l: 1 })}</span>
{sel && (
<button
type="button"
onClick={async () => {
await copyText(`#L${sel[0]}${sel[1] !== sel[0] ? `-L${sel[1]}` : ''}`);
flash('link');
setLive(t.linkCopied);
}}
className={cn('kitfp-noprint inline-flex min-h-9 items-center gap-1 rounded px-1.5 font-medium text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] hover:underline max-sm:min-h-11', ring)}
>
<Icon name={copied === 'link' ? 'check' : 'link'} size={13} />
{copied === 'link' ? t.linkCopied : t.copyLink}
</button>
)}
<span className="ms-auto">{t.spaces}</span>
<span>UTF-8</span>
<span className="font-medium text-foreground">{(f.language ?? 'text').toUpperCase()}</span>
</div>
</div>
<Live text={live || msg} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* spreadsheet */
/* ------------------------------------------------------------------ */
const colName = (i: number) => {
let s = '';
let n = i + 1;
while (n > 0) {
const m = (n - 1) % 26;
s = String.fromCharCode(65 + m) + s;
n = Math.floor((n - 1) / 26);
}
return s;
};
function cellText(l: Locale, v: string | number | null, c: PreviewColumn) {
if (v === null || v === '') return '';
if (typeof v === 'number') {
if (c.type === 'currency') return num(l, v, { style: 'currency', currency: c.currency ?? 'EUR' });
if (c.type === 'percent') return num(l, v, { style: 'percent', maximumFractionDigits: 1 });
return num(l, v, { maximumFractionDigits: 2 });
}
if (c.type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(v)) return dateOnly(l, v);
return v;
}
function Spreadsheet({ t, file, locale, onAction }: Inner) {
const sheets = file?.sheets ?? [];
const [si, setSi] = useState(0);
const [cur, setCur] = useState({ r: 0, c: 0 });
const [anchor, setAnchor] = useState({ r: 0, c: 0 });
const [sort, setSort] = useState<{ c: number; dir: 'ascending' | 'descending' } | null>(null);
const [query, setQuery] = useState('');
const [msg, act] = useActions(onAction, t);
const [live, setLive] = useState('');
const grid = useRef<HTMLTableElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const tabs = useRef<HTMLDivElement>(null);
const sheet = sheets[si];
const rows = useMemo(() => {
if (!sheet) return [];
const q = query.trim().toLowerCase();
let list = sheet.rows.map((r, i) => ({ r, i }));
if (q) list = list.filter(({ r }) => r.some((v, c) => cellText(locale, v, sheet.columns[c] ?? { label: '' }).toLowerCase().includes(q)));
if (sort) {
const k = sort.c;
const sgn = sort.dir === 'ascending' ? 1 : -1;
list = [...list].sort((a, b) => {
const x = a.r[k];
const y = b.r[k];
if (x === null || x === undefined) return 1;
if (y === null || y === undefined) return -1;
return (typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), intlOf(locale))) * sgn;
});
}
return list;
}, [sheet, query, sort, locale]);
if (!file || !sheet) return null;
const cols = sheet.columns;
const R = rows.length;
const C = cols.length;
const rect = { r0: Math.min(anchor.r, cur.r), r1: Math.max(anchor.r, cur.r), c0: Math.min(anchor.c, cur.c), c1: Math.max(anchor.c, cur.c) };
const inSel = (r: number, c: number) => r >= rect.r0 && r <= rect.r1 && c >= rect.c0 && c <= rect.c1;
const cells = (rect.r1 - rect.r0 + 1) * (rect.c1 - rect.c0 + 1);
const values: number[] = [];
let fmtCol: PreviewColumn | null = null;
for (let r = rect.r0; r <= rect.r1 && r < R; r++)
for (let c = rect.c0; c <= rect.c1; c++) {
const v = rows[r]?.r[c];
if (typeof v === 'number') {
values.push(v);
fmtCol ??= cols[c];
}
}
const sum = values.reduce((a, b) => a + b, 0);
const fmt = (n: number) => cellText(locale, round(n, 4), fmtCol ?? { label: '', type: 'number' });
const move = (r: number, c: number, extend: boolean) => {
const nr = Math.max(0, Math.min(R - 1, r));
const nc = Math.max(0, Math.min(C - 1, c));
setCur({ r: nr, c: nc });
if (!extend) setAnchor({ r: nr, c: nc });
requestAnimationFrame(() => {
const el = grid.current?.querySelector<HTMLElement>(`[data-cell="${nr}-${nc}"]`);
el?.focus({ preventScroll: true });
const s = scroller.current;
if (el && s) {
const head = 41;
const side = 48;
const er = el.getBoundingClientRect();
const sr = s.getBoundingClientRect();
if (er.top < sr.top + head) s.scrollTop -= sr.top + head - er.top;
else if (er.bottom > sr.bottom) s.scrollTop += er.bottom - sr.bottom;
if (er.left < sr.left + side) s.scrollLeft -= sr.left + side - er.left;
else if (er.right > sr.right) s.scrollLeft += er.right - sr.right;
}
});
};
const onKey = (e: React.KeyboardEvent) => {
const ext = e.shiftKey;
const ctrl = e.ctrlKey || e.metaKey;
const map: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
if (map[e.key]) {
const [dr, dc] = map[e.key];
move(ctrl ? (dr ? (dr > 0 ? R - 1 : 0) : cur.r) : cur.r + dr, ctrl ? (dc ? (dc > 0 ? C - 1 : 0) : cur.c) : cur.c + dc, ext);
} else if (e.key === 'Home') move(ctrl ? 0 : cur.r, 0, ext);
else if (e.key === 'End') move(ctrl ? R - 1 : cur.r, C - 1, ext);
else if (e.key === 'PageDown') move(cur.r + 10, cur.c, ext);
else if (e.key === 'PageUp') move(cur.r - 10, cur.c, ext);
else if (ctrl && e.key.toLowerCase() === 'a') {
setAnchor({ r: 0, c: 0 });
setCur({ r: R - 1, c: C - 1 });
setLive(fill(t.selected, { n: R * C }));
} else if (ctrl && e.key.toLowerCase() === 'c') {
const text = [];
for (let r = rect.r0; r <= rect.r1; r++) text.push(rows[r].r.slice(rect.c0, rect.c1 + 1).map((v, k) => cellText(locale, v, cols[rect.c0 + k])).join('\t'));
void copyText(text.join('\n'));
setLive(t.copied);
return;
} else return;
e.preventDefault();
};
const curVal = rows[cur.r]?.r[cur.c];
const switchSheet = (i: number, focus = false) => {
setSi(i);
setCur({ r: 0, c: 0 });
setAnchor({ r: 0, c: 0 });
setSort(null);
if (focus) requestAnimationFrame(() => tabs.current?.querySelectorAll<HTMLElement>('[role=tab]')[i]?.focus());
};
return (
<div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div className="flex flex-col gap-3 border-b border-border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground">
<Icon name="table" size={18} />
</span>
<div className="min-w-0">
<p className="truncate text-[15px] font-semibold text-foreground">{file.name}</p>
<p className="flex flex-wrap gap-x-3 text-[12.5px] tabular-nums text-subtle-foreground">
<span>{fill(t.rows, { n: num(locale, sheet.rows.length) })}</span>
<span>{fill(t.cols, { n: C })}</span>
<span>{bytes(locale, file.size)}</span>
</p>
</div>
</div>
<div className="kitfp-noprint flex items-center gap-2">
<label className="relative block min-w-0 flex-1 sm:w-56 sm:flex-none">
<span className="sr-only">{t.filterRows}</span>
<Icon name="filter" size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setCur({ r: 0, c: 0 });
setAnchor({ r: 0, c: 0 });
}}
placeholder={t.filterRows}
className={cn(fieldCls, 'ps-9')}
/>
</label>
<Button size="sm" variant="outline" iconLeft="download" onClick={() => void act('download', file, () => saveBlob(file.name.replace(/\.\w+$/, '') + '.csv', toCsv(sheet), 'text/csv'))}>
<span className="max-sm:sr-only">{t.downloadCsv}</span>
</Button>
</div>
</div>
{/* Formula bar */}
<div className="flex items-stretch border-b border-border bg-surface text-[13px]">
<span className="flex w-16 shrink-0 items-center justify-center border-e border-border font-semibold tabular-nums text-foreground" aria-label={t.cell}>
{colName(cur.c)}
{R ? cur.r + 1 : ''}
</span>
<span aria-hidden className="flex items-center px-3 italic text-subtle-foreground">fx</span>
<span className="flex min-w-0 flex-1 items-center truncate py-2.5 pe-4 tabular-nums text-foreground">{curVal === null || curVal === undefined ? '' : String(curVal)}</span>
</div>
<div ref={scroller} className="kitfp-scroll kit-scrollbar relative max-h-[30rem] overflow-auto">
<table
ref={grid}
role="grid"
aria-label={fill(t.grid, { name: sheet.name })}
aria-rowcount={R + 1}
aria-colcount={C + 1}
aria-multiselectable
onKeyDown={onKey}
className="w-max min-w-full border-separate border-spacing-0 text-[13px]"
>
<thead>
<tr>
<th scope="col" className="sticky start-0 top-0 z-[3] h-11 w-12 sm:h-10 min-w-12 border-b border-e border-border bg-surface-2">
<span className="sr-only">#</span>
</th>
{cols.map((c, i) => {
const s = sort?.c === i ? sort.dir : undefined;
const isNum = c.type === 'number' || c.type === 'currency' || c.type === 'percent';
return (
<th key={i} scope="col" aria-sort={s ?? 'none'} className={cn('sticky top-0 z-[2] h-11 border-b sm:h-10 border-e border-border bg-surface-2 p-0 font-normal', i >= rect.c0 && i <= rect.c1 && 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))]')}>
<button
type="button"
aria-label={fill(t.sortBy, { col: c.label })}
onClick={() => setSort((p) => (p?.c === i ? (p.dir === 'ascending' ? { c: i, dir: 'descending' } : null) : { c: i, dir: 'ascending' }))}
className={cn('group/h flex h-11 w-full sm:h-10 min-w-28 items-center gap-1.5 px-3 text-[12.5px]', isNum ? 'justify-end' : 'justify-start', ring, 'focus-visible:outline-offset-[-2px]')}
>
<span aria-hidden className="text-[11px] font-medium text-subtle-foreground">{colName(i)}</span>
<span className="truncate font-semibold text-foreground">{c.label}</span>
<Icon name={s === 'descending' ? 'arrow-down' : 'arrow-up'} size={12} className={cn('shrink-0 transition-opacity', s ? 'text-foreground opacity-100' : 'opacity-0 group-hover/h:opacity-50')} />
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map(({ r, i: orig }, ri) => (
<tr key={orig} aria-rowindex={ri + 2}>
<th scope="row" className={cn('sticky start-0 z-[1] h-11 border-b sm:h-9 border-e border-border bg-surface-2 px-2 text-end text-[11.5px] font-medium tabular-nums', ri >= rect.r0 && ri <= rect.r1 ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))] text-foreground' : 'text-subtle-foreground')}>
{orig + 1}
</th>
{cols.map((c, ci) => {
const v = r[ci];
const on = inSel(ri, ci);
const focus = cur.r === ri && cur.c === ci;
const numeric = typeof v === 'number';
return (
<td
key={ci}
role="gridcell"
data-cell={`${ri}-${ci}`}
tabIndex={focus ? 0 : -1}
aria-selected={on}
onMouseDown={(e) => {
e.preventDefault();
move(ri, ci, e.shiftKey);
}}
onMouseEnter={(e) => {
if (e.buttons === 1) move(ri, ci, true);
}}
className={cn(
'h-11 max-w-64 sm:h-9 cursor-cell truncate border-b border-e border-border px-3 outline-none',
numeric ? 'text-end tabular-nums' : 'text-start',
numeric && v < 0 ? 'text-destructive' : 'text-foreground',
on && 'bg-[color-mix(in_oklab,var(--primary)_9%,transparent)]',
focus && 'shadow-[inset_0_0_0_2px_var(--primary)]',
)}
>
{cellText(locale, v ?? null, c)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
{!R && <p className="px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noHits}</p>}
</div>
{/* Sheet tabs and the summary of the selection */}
<div className="flex flex-col gap-1 border-t border-border bg-surface sm:flex-row sm:items-center sm:justify-between">
<div
ref={tabs}
role="tablist"
aria-label={t.sheets}
className="kit-no-scrollbar flex overflow-x-auto px-2"
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
switchSheet((si + d + sheets.length) % sheets.length, true);
}}
>
{sheets.map((s, i) => (
<button key={s.name} type="button" role="tab" aria-selected={i === si} tabIndex={i === si ? 0 : -1} onClick={() => switchSheet(i)} className={cn('relative h-11 shrink-0 whitespace-nowrap px-3.5 text-[13px] font-medium transition-colors sm:h-10', i === si ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ring, 'focus-visible:outline-offset-[-2px]')}>
{s.name}
<span aria-hidden className={cn('absolute inset-x-2 top-0 h-0.5 rounded-full bg-primary transition-transform duration-300 ease-[var(--ease-kit)]', i === si ? 'scale-x-100' : 'scale-x-0')} />
</button>
))}
</div>
<p className="flex flex-wrap items-center gap-x-4 px-4 pb-2.5 text-[12.5px] tabular-nums text-muted-foreground sm:pb-0">
{query && <span>{fill(t.shownRows, { n: num(locale, R), total: num(locale, sheet.rows.length) })}</span>}
{values.length > 1 ? (
<>
<span>
{t.sum} <span className="font-medium text-foreground">{fmt(sum)}</span>
</span>
<span>
{t.average} <span className="font-medium text-foreground">{fmt(sum / values.length)}</span>
</span>
<span>
{t.count} <span className="font-medium text-foreground">{num(locale, cells)}</span>
</span>
</>
) : (
<span className="hidden sm:inline">{fill(t.rows, { n: num(locale, R) })}</span>
)}
</p>
</div>
</div>
<Live text={live || msg} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* document */
/* ------------------------------------------------------------------ */
/** A page drawn as paper. Text scales with the page width through container units. */
function Page({ page, n, q, counter, current, thumb }: { page: PreviewPage; n: number; q: string; counter: { n: number }; current: number; thumb?: boolean }) {
const m = (s?: string) => (thumb || !s ? s : marked(s, q, counter, current));
const max = (v?: number[]) => Math.max(1, ...(v ?? [1]));
return (
<div className="kitfp-paper relative aspect-[210/297] w-full overflow-hidden rounded-[2px] text-start bg-[#fdfdfc] text-[#1d2027] shadow-[0_1px_2px_rgb(0_0_0/0.12),0_12px_32px_-12px_rgb(0_0_0/0.35)] [container-type:inline-size]">
<div className="absolute inset-0 flex flex-col gap-[2.6cqw] px-[9cqw] py-[10cqw] text-[2.15cqw] leading-[1.55]">
{page.blocks.map((b, i) => {
switch (b.type) {
case 'title':
return (
<h3 key={i} data-head className="text-[5cqw] font-semibold leading-[1.15] tracking-[-0.02em] text-[#111317]">
{m(b.text)}
</h3>
);
case 'meta':
return (
<p key={i} className="-mt-[1cqw] text-[1.9cqw] text-[#5d626d]">
{m(b.text)}
</p>
);
case 'heading':
return (
<h4 key={i} data-head className="mt-[1.6cqw] text-[2.9cqw] font-semibold text-[#111317]">
{m(b.text)}
</h4>
);
case 'quote':
return (
<blockquote key={i} className="border-s-[0.6cqw] border-[color-mix(in_oklab,var(--primary)_70%,#fff)] ps-[3cqw] italic text-[#3a3f49]">
{m(b.text)}
</blockquote>
);
case 'list':
return (
<ul key={i} className="grid gap-[1cqw] ps-[3.5cqw]">
{b.items?.map((it, k) => (
<li key={k} className="list-disc marker:text-[#8a8f99]">
{m(it)}
</li>
))}
</ul>
);
case 'table':
return (
<table key={i} className="w-full border-collapse text-[1.95cqw] tabular-nums">
<tbody>
{b.rows?.map((r, k) => (
<tr key={k} className={k === 0 ? 'border-b-[0.25cqw] border-[#1d2027] font-semibold' : 'border-b-[0.15cqw] border-[#e2e4e8]'}>
{r.map((c, j) => (
<td key={j} className={cn('py-[1cqw]', j > 0 && 'text-end')}>
{m(c)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
case 'chart':
return (
<div key={i} className="flex h-[22cqw] items-end gap-[2cqw] border-b-[0.2cqw] border-[#c9ccd2] pb-[0.4cqw]" aria-hidden={thumb}>
{b.values?.map((v, k) => (
<div key={k} className="flex h-full flex-1 flex-col items-center justify-end gap-[0.8cqw]">
<span className="text-[1.6cqw] tabular-nums text-[#5d626d]">{v}</span>
<span className="kitfp-bar w-full rounded-t-[0.6cqw]" style={{ height: `${round((v / max(b.values)) * 78, 1)}%`, background: k === (b.values?.length ?? 0) - 1 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 32%, #e6e8ec)', '--kitfp-d': `${k * 60}ms` } as React.CSSProperties} />
<span className="text-[1.6cqw] text-[#5d626d]">{b.items?.[k]}</span>
</div>
))}
</div>
);
default:
return (
<p key={i} className="text-pretty text-[#2b2f37]">
{m(b.text)}
</p>
);
}
})}
</div>
<span className="absolute bottom-[4cqw] end-[9cqw] text-[1.7cqw] tabular-nums text-[#8a8f99]">{n}</span>
</div>
);
}
const pageText = (p: PreviewPage) => p.blocks.flatMap((b) => [b.text ?? '', ...(b.items && b.type === 'list' ? b.items : []), ...(b.rows?.flat() ?? [])]);
function DocumentView({ t, file, locale, onAction }: Inner) {
const pages = file?.pages ?? [];
const [page, setPage] = useState(1);
const [zoom, setZoom] = useState(1);
const [rail, setRail] = useState<'pages' | 'outline'>('pages');
const [railOpen, setRailOpen] = useState(true);
const [contents, setContents] = useState(false);
const [query, setQuery] = useState('');
const [hit, setHit] = useState(0);
const [pageInput, setPageInput] = useState('1');
const [live, setLive] = useState('');
const [msg, act] = useActions(onAction, t);
const scroller = useRef<HTMLDivElement>(null);
const thumbs = useRef<HTMLDivElement>(null);
const contentsId = useId();
const q = query.trim().toLowerCase();
const total = useMemo(() => (q ? pages.reduce((a, p) => a + pageText(p).reduce((b, s) => b + countHits(s, q), 0), 0) : 0), [pages, q]);
const current = total ? ((hit % total) + total) % total : -1;
const outline = useMemo(() => pages.flatMap((p, i) => p.blocks.filter((b) => b.type === 'title' || b.type === 'heading').map((b) => ({ page: i + 1, text: b.text ?? '', level: b.type === 'title' ? 1 : 2 }))), [pages]);
useEffect(() => setPageInput(String(page)), [page]);
// The page in the middle of the viewport counts as current
useEffect(() => {
const root = scroller.current;
if (!root || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(
(entries) => {
const vis = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (vis) setPage(Number((vis.target as HTMLElement).dataset.page));
},
{ root, threshold: [0.3, 0.6] },
);
root.querySelectorAll('[data-page]').forEach((el) => io.observe(el));
return () => io.disconnect();
}, [pages.length, zoom]);
useEffect(() => {
scrollInside(thumbs.current, thumbs.current?.querySelector(`[data-thumb="${page}"]`) ?? null);
}, [page]);
useEffect(() => {
if (current < 0) return;
scrollInside(scroller.current, scroller.current?.querySelector(`[data-hit="${current}"]`) ?? null);
}, [current, q]);
if (!file || !pages.length) return null;
const goTo = (n: number) => {
const p = Math.max(1, Math.min(pages.length, n));
setPage(p);
const el = scroller.current?.querySelector<HTMLElement>(`[data-page="${p}"]`);
const root = scroller.current;
if (el && root) root.scrollTo({ top: root.scrollTop + el.getBoundingClientRect().top - root.getBoundingClientRect().top - 16, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
setLive(fill(t.page, { n: p }) + ' ' + fill(t.pageOf, { total: pages.length }));
};
const nextHit = (d: number) => {
if (!total) return;
setHit((h) => h + d);
setLive(fill(t.hits, { n: (((current + d) % total) + total) % total + 1, total }));
};
const zooms = [0.75, 1, 1.25, 1.5];
const counter = { n: 0 };
const pct = (z: number) => num(locale, z, { style: 'percent', maximumFractionDigits: 0 });
const outlineList = (onPick?: () => void) =>
outline.length ? (
<ul className="grid gap-0.5">
{outline.map((o, i) => (
<li key={i}>
<button
type="button"
onClick={() => {
goTo(o.page);
onPick?.();
}}
aria-current={o.page === page ? 'page' : undefined}
className={cn('flex min-h-10 w-full items-start gap-2 rounded-md px-2 py-2 text-start text-[13px] leading-snug transition-colors', o.level === 2 && 'ps-5', o.page === page ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', ring)}
>
<span className={cn('min-w-0 flex-1', o.level === 1 && 'font-medium')}>{o.text}</span>
<span className="text-[11.5px] tabular-nums text-subtle-foreground">{o.page}</span>
</button>
</li>
))}
</ul>
) : (
<p className="px-2 py-3 text-[13px] text-muted-foreground">{t.noOutline}</p>
);
return (
<div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
{/* Toolbar */}
<div className="kitfp-noprint flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-border px-2 py-2 sm:px-3">
<Tool icon="panel-left" label={t.thumbs} pressed={railOpen} onClick={() => setRailOpen((v) => !v)} className="hidden lg:inline-grid" />
<div className="me-auto flex min-w-0 items-center gap-2 ps-1.5">
<Icon name="file" size={16} className="shrink-0 text-destructive" />
<span className="truncate text-[13.5px] font-semibold text-foreground">{file.name}</span>
</div>
<div className="flex items-center gap-0.5">
<Tool icon="chevron-up" label={fill(t.page, { n: page - 1 })} onClick={() => goTo(page - 1)} disabled={page <= 1} />
<form
onSubmit={(e) => {
e.preventDefault();
goTo(Number(pageInput) || page);
}}
className="flex items-center gap-1.5 text-[13px] tabular-nums text-muted-foreground"
>
<label className="sr-only" htmlFor={`${contentsId}-p`}>
{t.goToPage}
</label>
<input id={`${contentsId}-p`} inputMode="numeric" value={pageInput} onChange={(e) => setPageInput(e.target.value.replace(/\D/g, ''))} onBlur={() => goTo(Number(pageInput) || page)} className={cn(fieldCls, 'w-12 px-1 text-center tabular-nums')} />
<span className="whitespace-nowrap">{fill(t.pageOf, { total: pages.length })}</span>
</form>
<Tool icon="chevron-down" label={fill(t.page, { n: page + 1 })} onClick={() => goTo(page + 1)} disabled={page >= pages.length} />
</div>
<span aria-hidden className="mx-1 hidden h-5 w-px bg-border sm:block" />
<div className="hidden items-center gap-0.5 sm:flex">
<Tool icon="zoom-out" label={t.zoomOut} onClick={() => setZoom(zooms[Math.max(0, zooms.indexOf(zoom) - 1)])} disabled={zoom <= zooms[0]} />
<span className="min-w-12 text-center text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{pct(zoom)}
</span>
<Tool icon="zoom-in" label={t.zoomIn} onClick={() => setZoom(zooms[Math.min(zooms.length - 1, zooms.indexOf(zoom) + 1)])} disabled={zoom >= zooms[zooms.length - 1]} />
</div>
<Tool icon="printer" label={t.print} onClick={() => (onAction ? void onAction('print', { id: file.id }) : window.print())} className="hidden sm:inline-grid" />
<Tool icon="download" label={t.download} onClick={() => void act('download', file)} />
</div>
{/* Search, with contents on phones */}
<div className="kitfp-noprint flex items-center gap-1 border-b border-border bg-surface px-2 py-2 sm:px-3">
<label className="relative block min-w-0 flex-1 sm:max-w-80">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHit(0);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
nextHit(e.shiftKey ? -1 : 1);
}
}}
placeholder={t.search}
className={cn(fieldCls, 'ps-9 pe-16')}
/>
{q && <span aria-live="polite" className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-[12px] tabular-nums text-muted-foreground">{total ? fill(t.hits, { n: current + 1, total }) : t.noHits}</span>}
</label>
<Tool icon="chevron-up" label={t.prevHit} onClick={() => nextHit(-1)} disabled={!total} />
<Tool icon="chevron-down" label={t.nextHit} onClick={() => nextHit(1)} disabled={!total} />
<button type="button" aria-expanded={contents} aria-controls={contentsId} onClick={() => setContents((v) => !v)} className={cn('ms-auto inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-foreground hover:bg-accent lg:hidden', ring)}>
<Icon name="list" size={15} />
<span className="max-[400px]:sr-only">{t.contents}</span>
</button>
</div>
<div id={contentsId} className={cn('grid border-b border-border transition-[grid-template-rows] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none lg:hidden', contents ? 'grid-rows-[1fr]' : 'invisible grid-rows-[0fr] border-b-0')}>
<div className="min-h-0 overflow-hidden">
<nav aria-label={t.contents} className="p-2">
{outlineList(() => setContents(false))}
</nav>
</div>
</div>
<div className={cn('grid grid-cols-[minmax(0,1fr)]', railOpen && 'lg:grid-cols-[15rem_minmax(0,1fr)]')}>
{railOpen && (
<aside className="kitfp-noprint hidden min-h-0 flex-col border-e border-border bg-surface lg:flex">
<div role="tablist" aria-label={t.thumbs} className="flex gap-1 border-b border-border p-2">
{(['pages', 'outline'] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={rail === k}
tabIndex={rail === k ? 0 : -1}
onClick={() => setRail(k)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const n = k === 'pages' ? 'outline' : 'pages';
setRail(n);
(e.currentTarget.parentElement?.querySelector(`[data-tab="${n}"]`) as HTMLElement | null)?.focus();
}
}}
data-tab={k}
className={cn('h-9 flex-1 rounded-md text-[13px] font-medium transition-colors', rail === k ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ring)}
>
{k === 'pages' ? t.pages : t.contents}
</button>
))}
</div>
<div ref={thumbs} role="tabpanel" className="kit-scrollbar relative max-h-[40rem] flex-1 overflow-y-auto p-3">
{rail === 'pages' ? (
<ul className="grid gap-4 px-6">
{pages.map((p, i) => (
<li key={i} data-thumb={i + 1}>
<button type="button" onClick={() => goTo(i + 1)} aria-label={fill(t.page, { n: i + 1 })} aria-current={page === i + 1 ? 'page' : undefined} className={cn('group/t block w-full rounded-[3px] transition-transform duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5', ring)}>
<span aria-hidden className={cn('block rounded-[3px] transition-shadow duration-300', page === i + 1 ? 'ring-2 ring-primary ring-offset-2 ring-offset-surface' : 'opacity-80 group-hover/t:opacity-100')} inert>
<Page page={p} n={i + 1} q="" counter={{ n: 0 }} current={-1} thumb />
</span>
<span className={cn('mt-1.5 block text-center text-[12px] tabular-nums', page === i + 1 ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{i + 1}</span>
</button>
</li>
))}
</ul>
) : (
<nav aria-label={t.contents}>{outlineList()}</nav>
)}
</div>
</aside>
)}
<div ref={scroller} tabIndex={0} role="region" aria-label={file.name} className={cn('kitfp-scroll kit-scrollbar relative max-h-[44rem] overflow-auto bg-surface-2 px-3 py-5 sm:px-8 sm:py-8', ring, 'focus-visible:outline-offset-[-2px]')}>
<div className="mx-auto grid gap-6 sm:gap-8" style={{ width: `min(100%, ${round(46 * zoom, 2)}rem)`, minWidth: zoom > 1 ? `${round(30 * zoom, 2)}rem` : undefined }}>
{pages.map((p, i) => (
<section key={i} data-page={i + 1} aria-label={fill(t.page, { n: i + 1 })} className="kitfp-up" style={{ '--kitfp-d': `${Math.min(i, 2) * 90}ms` } as React.CSSProperties}>
<Page page={p} n={i + 1} q={q} counter={counter} current={current} />
</section>
))}
</div>
</div>
</div>
</div>
<Live text={live || msg} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* quick-look */
/* ------------------------------------------------------------------ */
/** A small drawn picture of a file for the grid */
function Thumb({ f, locale }: { f: PreviewFile; locale: Locale }) {
if (f.kind === 'image' && f.media) return <span className="block size-full [&>*]:size-full">{f.media}</span>;
if (f.kind === 'pdf' && f.pages?.[0])
return (
<span className="flex size-full items-start justify-center overflow-hidden bg-surface-2 px-[18%] pt-[10%]" inert>
<span className="block w-full">
<Page page={f.pages[0]} n={1} q="" counter={{ n: 0 }} current={-1} thumb />
</span>
</span>
);
if (f.kind === 'code') {
const lines = (f.code ?? '').split('\n').slice(0, 9);
return (
<span className="kitfp block size-full overflow-hidden bg-card p-3 text-[7.5px] leading-[1.6]" aria-hidden>
{lines.map((l, i) => (
<span key={i} className="block whitespace-pre text-muted-foreground">
<span className="me-2 text-subtle-foreground">{i + 1}</span>
{l}
</span>
))}
</span>
);
}
if (f.kind === 'sheet') {
const s = f.sheets?.[0];
return (
<span className="grid size-full grid-cols-4 grid-rows-6 gap-px overflow-hidden bg-border p-px" aria-hidden>
{Array.from({ length: 24 }, (_, i) => (
<span key={i} className={cn('flex items-center truncate px-1 text-[7px]', i < 4 ? 'bg-surface-2 font-semibold text-foreground' : 'bg-card text-muted-foreground')}>
{i < 4 ? s?.columns[i]?.label : s ? cellText(locale, s.rows[Math.floor(i / 4) - 1]?.[i % 4] ?? null, s.columns[i % 4] ?? { label: '' }) : ''}
</span>
))}
</span>
);
}
if (f.kind === 'audio')
return (
<span className="flex size-full items-center justify-center gap-[3px] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] px-5" aria-hidden>
{wave(f.id, 28).map((h, i) => (
<span key={i} className="w-[3px] rounded-full bg-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]" style={{ height: `${round(h * 60, 1)}%`, opacity: 0.45 + h * 0.55 }} />
))}
</span>
);
if (f.kind === 'archive' && f.entries?.length)
return (
<span className="flex size-full flex-col justify-center gap-1.5 bg-surface-2 px-[12%]" aria-hidden>
{f.entries.slice(0, 4).map((e, i) => (
<span key={i} className="flex items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-[10px] text-muted-foreground shadow-[var(--shadow-sm)]" style={{ marginInlineStart: `${i * 6}%` }}>
<Icon name={/.(png|jpe?g|svg|webp)$/i.test(e.name) ? 'image' : 'file'} size={11} className="shrink-0" />
<span className="truncate">{e.name}</span>
</span>
))}
</span>
);
if (f.kind === 'text' && f.text?.length)
return (
<span className="flex size-full items-start justify-center overflow-hidden bg-surface-2 px-[14%] pt-[9%]" aria-hidden>
<span className="block h-full w-full rounded-t-[3px] bg-card px-3 pt-3 text-[8px] leading-[1.55] text-muted-foreground shadow-[var(--shadow-sm)]">
{f.text.join(' ').slice(0, 240)}
</span>
</span>
);
return (
<span className="grid size-full place-items-center bg-surface-2 text-muted-foreground" aria-hidden>
<Icon name={KIND_ICON[f.kind]} size={30} />
</span>
);
}
/** A stable waveform from the file id, the same on server and client */
function wave(seed: string, n: number) {
let h = 0;
for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return Array.from({ length: n }, (_, i) => round(0.25 + 0.75 * Math.abs(Math.sin(h * 0.0001 + i * 0.7) * Math.cos(i * 0.31 + (h % 7))), 3));
}
function AudioPlayer({ f, t, locale }: { f: PreviewFile; t: Texts; locale: Locale }) {
const dur = f.duration ?? 60;
const [pos, setPos] = useState(0);
const [playing, setPlaying] = useState(false);
const bars = useMemo(() => wave(f.id, 56), [f.id]);
useEffect(() => {
if (!playing) return;
let last = performance.now();
let raf = 0;
const tick = (now: number) => {
setPos((p) => {
const n = p + (now - last) / 1000;
if (n >= dur) {
setPlaying(false);
return dur;
}
return n;
});
last = now;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [playing, dur]);
const share = pos / dur;
return (
<div className="grid w-full max-w-xl gap-5 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-sm)] sm:p-6">
<div className="flex items-center gap-4">
<button
type="button"
aria-label={playing ? t.pause : t.play}
onClick={() => {
if (pos >= dur) setPos(0);
setPlaying((v) => !v);
}}
className={cn('grid size-12 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-md)] transition-transform duration-200 hover:scale-105 active:scale-95', ring)}
>
<Icon name={playing ? 'pause' : 'play'} size={18} />
</button>
<div className="min-w-0">
<p className="truncate text-[14.5px] font-semibold text-foreground">{f.name}</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{clock(pos)} / {clock(dur)} · {bytes(locale, f.size)}
</p>
</div>
</div>
<div
role="slider"
tabIndex={0}
aria-label={t.seek}
aria-valuemin={0}
aria-valuemax={Math.round(dur)}
aria-valuenow={Math.round(pos)}
aria-valuetext={`${clock(pos)} / ${clock(dur)}`}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 5 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -5 : 0;
if (e.key === 'Home') setPos(0);
else if (e.key === 'End') setPos(dur);
else if (d) setPos((p) => Math.max(0, Math.min(dur, p + d)));
else return;
e.preventDefault();
}}
onPointerDown={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setPos(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * dur);
}}
className={cn('flex h-16 cursor-pointer items-center gap-[2px] rounded-md', ring)}
>
{bars.map((h, i) => (
<span key={i} className={cn('flex-1 rounded-full transition-colors duration-150', i / bars.length < share ? 'bg-primary' : 'bg-track')} style={{ height: `${round(h * 100, 1)}%` }} />
))}
</div>
</div>
);
}
function LookBody({ f, t, locale }: { f: PreviewFile; t: Texts; locale: Locale }) {
switch (f.kind) {
case 'image':
return (
<div className="dark grid size-full place-items-center bg-background p-4 sm:p-8 [container-type:size]">
<div className="overflow-hidden rounded-[3px] shadow-[0_24px_60px_-20px_rgb(0_0_0/0.7)] [&>*]:size-full" style={{ width: `min(100cqw, calc(100cqh * ${round((f.width ?? 4) / (f.height ?? 3), 4)}))`, aspectRatio: String(round((f.width ?? 4) / (f.height ?? 3), 4)) }}>
{f.media}
</div>
</div>
);
case 'pdf':
return (
<div className="kit-scrollbar relative size-full overflow-y-auto bg-surface-2 px-4 py-6">
<div className="mx-auto grid w-[min(100%,34rem)] gap-6">
{(f.pages ?? []).map((p, i) => (
<Page key={i} page={p} n={i + 1} q="" counter={{ n: 0 }} current={-1} />
))}
</div>
</div>
);
case 'code': {
const lines = toLines(tokenize(f.code ?? '', f.language ?? 'text') as Tok[]);
return (
<div tabIndex={0} role="region" aria-label={fill(t.codeArea, { name: f.name })} className={cn('kitfp kit-scrollbar relative size-full overflow-auto bg-card', ring, 'focus-visible:outline-offset-[-2px]')}>
<CodeLines lines={lines} q="" current={-1} wrap={false} compact />
</div>
);
}
case 'sheet': {
const s = f.sheets?.[0];
if (!s) return null;
return (
<div tabIndex={0} role="region" aria-label={s.name} className={cn('kit-scrollbar relative size-full overflow-auto bg-card', ring, 'focus-visible:outline-offset-[-2px]')}>
<table className="w-max min-w-full border-separate border-spacing-0 text-[13px]">
<thead>
<tr>
{s.columns.map((c, i) => (
<th key={i} scope="col" className={cn('sticky top-0 h-10 border-b border-e border-border bg-surface-2 px-3 font-semibold text-foreground', c.type && c.type !== 'text' && c.type !== 'date' ? 'text-end' : 'text-start')}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{s.rows.map((r, ri) => (
<tr key={ri}>
{s.columns.map((c, ci) => (
<td key={ci} className={cn('h-9 border-b border-e border-border px-3 text-foreground', typeof r[ci] === 'number' && 'text-end tabular-nums')}>
{cellText(locale, r[ci] ?? null, c)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
case 'audio':
return (
<div className="grid size-full place-items-center bg-surface-2 p-4 sm:p-8">
<AudioPlayer f={f} t={t} locale={locale} />
</div>
);
case 'archive': {
const sum = (f.entries ?? []).reduce((a, e) => a + e.size, 0);
return (
<div className="kit-scrollbar relative size-full overflow-y-auto bg-card p-4 sm:p-6">
<p className="text-[13px] text-muted-foreground">{fill(t.archive, { n: f.entries?.length ?? 0, size: bytes(locale, sum) })}</p>
<ul className="mt-3 divide-y divide-border rounded-lg border border-border">
{(f.entries ?? []).map((e, i) => (
<li key={i} className="flex items-center gap-3 px-3 py-2.5 text-[13.5px]">
<Icon name={/\.(png|jpe?g|svg|webp)$/i.test(e.name) ? 'image' : /\.(pdf|docx?)$/i.test(e.name) ? 'file' : /\.(ts|js|css|html|json)$/i.test(e.name) ? 'file-code' : 'file'} size={15} className="shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{e.name}</span>
<span className="tabular-nums text-muted-foreground">{bytes(locale, e.size)}</span>
</li>
))}
</ul>
</div>
);
}
default:
return (
<div className="kit-scrollbar relative size-full overflow-y-auto bg-card px-5 py-6 sm:px-10">
<div className="mx-auto grid max-w-[34rem] gap-4 text-[15px] leading-relaxed text-foreground">
{(f.text ?? []).map((p, i) => (
<p key={i} className="text-pretty">
{p}
</p>
))}
</div>
</div>
);
}
}
function QuickLook({ t, files = [], locale, onAction, title, subtitle }: Inner) {
const [active, setActive] = useState(0);
const [open, setOpen] = useState(false);
const [msg, act] = useActions(onAction, t);
const [modal, present] = useModalMotion(open, setOpen);
const gridRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const hintId = useId();
const f = files[active];
const cols = () => {
const el = gridRef.current;
if (!el) return 1;
return getComputedStyle(el).gridTemplateColumns.split(' ').length || 1;
};
const focusItem = (i: number) => requestAnimationFrame(() => gridRef.current?.querySelectorAll<HTMLElement>('[role=option]')[i]?.focus());
const onGridKey = (e: React.KeyboardEvent) => {
const c = cols();
const map: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: c, ArrowUp: -c };
if (map[e.key] !== undefined) {
const n = Math.max(0, Math.min(files.length - 1, active + map[e.key]));
setActive(n);
focusItem(n);
} else if (e.key === 'Home' || e.key === 'End') {
const n = e.key === 'Home' ? 0 : files.length - 1;
setActive(n);
focusItem(n);
} else if (e.key === ' ' || e.key === 'Enter') {
rememberTrigger(e.target as Element);
setOpen(true);
} else return;
e.preventDefault();
};
const step = (d: number) => setActive((a) => (a + d + files.length) % files.length);
return (
<div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
<Header title={title} subtitle={subtitle} />
<p id={hintId} className="sr-only">
{t.gridHint}
</p>
<div
ref={gridRef}
role="listbox"
aria-label={title || t.files}
aria-describedby={hintId}
onKeyDown={onGridKey}
className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4"
>
{files.map((x, i) => (
<div
key={x.id}
role="option"
aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
onClick={(e) => {
setActive(i);
rememberTrigger(e.currentTarget);
setOpen(true);
}}
onFocus={() => setActive(i)}
className={cn(
'kitfp-up group/card relative flex cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-start shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]',
i === active ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'border-border',
ring,
)}
style={{ '--kitfp-d': `${i * 50}ms` } as React.CSSProperties}
>
<span className="relative block aspect-[4/3] overflow-hidden border-b border-border">
<span className="block size-full transition-transform duration-500 ease-[var(--ease-kit)] group-hover/card:scale-[1.04] motion-reduce:transition-none">
<Thumb f={x} locale={locale} />
</span>
<span aria-hidden className="absolute end-2 top-2 inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--background)_82%,transparent)] px-1.5 py-0.5 text-[10.5px] font-semibold text-foreground opacity-0 shadow-[var(--shadow-sm)] backdrop-blur transition-opacity duration-200 group-hover/card:opacity-100 group-focus-visible/card:opacity-100">
<Icon name="eye" size={11} />
{t.quickLook}
</span>
</span>
<span className="flex items-start gap-2.5 p-3">
<Icon name={KIND_ICON[x.kind]} size={15} className="mt-0.5 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span title={x.name} className="line-clamp-2 text-[13.5px] font-medium leading-snug text-foreground [overflow-wrap:anywhere]">{x.name}</span>
<span className="block text-[12px] tabular-nums text-subtle-foreground">
{ext(x.name) || kindLabel(t, x.kind)} · {bytes(locale, x.size)}
</span>
</span>
</span>
</div>
))}
</div>
<DialogMotionStyle />
<dialog
{...modal}
aria-labelledby={titleId}
onKeyDown={(e) => {
const tag = (e.target as HTMLElement).getAttribute('role');
if (tag === 'slider') return;
if (e.key === 'ArrowRight') {
e.preventDefault();
step(1);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
step(-1);
}
}}
className={cn('kitfx-dialog m-auto h-[min(48rem,calc(100dvh-1rem))] w-full max-w-none overflow-visible bg-transparent p-2 text-left outline-none data-[closing]:pointer-events-none sm:h-[min(46rem,calc(100dvh-4rem))] sm:w-[calc(100%-3rem)] sm:max-w-[64rem] sm:p-0', scrim)}
>
{present && f && (
<div className="flex size-full flex-col overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]">
<div className="kitfx-dialog-part flex items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
<span aria-hidden className="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
<Icon name={KIND_ICON[f.kind]} size={16} />
</span>
<div className="min-w-0 flex-1">
<h2 id={titleId} className="truncate text-[14.5px] font-semibold text-foreground">
{f.name}
</h2>
<p className="text-[12px] tabular-nums text-muted-foreground">
{kindLabel(t, f.kind)} · {bytes(locale, f.size)} · {fill(t.of, { n: active + 1, total: files.length })}
</p>
</div>
<Tool icon="chevron-left" label={t.previous} onClick={() => step(-1)} />
<Tool icon="chevron-right" label={t.next} onClick={() => step(1)} />
<Tool icon="x" label={t.close} onClick={() => setOpen(false)} />
</div>
<div key={f.id} className="kitfp kitfp-in relative min-h-0 flex-1">
<LookBody f={f} t={t} locale={locale} />
</div>
<div className="kitfx-dialog-part flex flex-wrap items-center justify-between gap-2 border-t border-border bg-surface px-3 py-2.5 sm:px-4">
<p className="hidden truncate text-[12.5px] text-muted-foreground sm:block">
{f.owner}
{f.owner && f.modified ? ' · ' : ''}
{f.modified && dateTime(locale, f.modified)}
</p>
<div className="flex w-full gap-2 sm:w-auto">
<Button size="sm" variant="outline" iconLeft="share" className="flex-1 sm:flex-none" onClick={() => void act('share', f)}>
{t.share}
</Button>
<Button
size="sm"
iconLeft="download"
className="flex-1 sm:flex-none"
onClick={() => {
const txt = fileText(f);
void act('download', f, txt ? () => saveBlob(f.kind === 'sheet' ? f.name.replace(/\.\w+$/, '') + '.csv' : f.name, txt) : undefined);
}}
>
{t.download}
</Button>
</div>
</div>
</div>
)}
</dialog>
<Live text={msg} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
type Inner = Omit<FilePreviewProps, 'variant' | 'labels' | 'className'> & { t: Texts; locale: Locale };
/**
* Previews for files in apps: an image viewer with zoom and filmstrip, code with search and outline,
* a spreadsheet grid with selection sums, a paged document with search and contents, and a quick look
* over mixed attachments.
* <FilePreview variant="code" file={{ id, name: 'routes.ts', kind: 'code', size, code, language: 'ts' }} />
*/
export function FilePreview({ variant = 'image-viewer', labels, className, ...props }: FilePreviewProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: Inner = { ...props, t, locale };
const body = (() => {
switch (variant) {
case 'code':
return <CodeView {...p} />;
case 'spreadsheet':
return <Spreadsheet {...p} />;
case 'document':
return <DocumentView {...p} />;
case 'quick-look':
return <QuickLook {...p} />;
default:
return <ImageViewer {...p} />;
}
})();
return (
<div className={cn('kitfp min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
}Frequent questions
How do I install File preview?
With aiw add @ai-website/file-preview. The command puts components/ui/file-preview.tsx into your project, the code is yours from then on. It brings along button, icon, code-block and dialog. It needs no further npm packages.
Which variants does File preview have?
5 variants, Image viewer, Code file, Spreadsheet, PDF document and Quick look. You pick one with the variant prop, for example variant="code".
Can I translate the texts of File preview?
Yes. File preview carries 82 texts of its own in English and German, such as “Previous file”, “Next file” and “Zoom in”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with File preview?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find File preview with aiw_components and install it with aiw_add @ai-website/file-preview. Name the variant in your prompt, for example variant="image-viewer", and the assistant writes your content straight into the props.