{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "file-viewer", "title": "File Viewer", "description": "Bounded Text, Image, PDF, Audio, Video, and unsupported-file viewers with an optional filesystem adapter", "dependencies": ["daisyui", "lucide-react"], "files": [ { "path": "registry/default/ui/addressable-frame.tsx", "content": "import type { ComponentPropsWithRef, ReactNode } from 'react';\nimport { cn } from '@/lib/utils';\n\nexport type AddressableFrameProps = ComponentPropsWithRef<'section'>;\n\nexport function AddressableFrame({ className, ...props }: AddressableFrameProps) {\n\treturn (\n\t\t\n\t);\n}\n\nexport type AddressableFrameBarProps = ComponentPropsWithRef<'div'>;\n\nexport function AddressableFrameBar({ className, ...props }: AddressableFrameBarProps) {\n\treturn (\n\t\t\n\t);\n}\n\nexport type AddressableFrameNavigationProps = ComponentPropsWithRef<'nav'>;\n\nexport function AddressableFrameNavigation({ className, ...props }: AddressableFrameNavigationProps) {\n\treturn (\n\t\t\n\t);\n}\n\nexport type AddressableFrameAddressProps = ComponentPropsWithRef<'div'> & {\n\ticon?: ReactNode;\n};\n\nexport function AddressableFrameAddress({ icon, children, className, ...props }: AddressableFrameAddressProps) {\n\treturn (\n\t\t\n\t\t\t{icon ? (\n\t\t\t\t\n\t\t\t\t\t{icon}\n\t\t\t\t\n\t\t\t) : null}\n\t\t\t
\n\t\t\t\t{children}\n\t\t\t
\n\t\t\n\t);\n}\n\nexport type AddressableFrameAddressInputProps = ComponentPropsWithRef<'input'>;\n\nexport function AddressableFrameAddressInput({ className, ...props }: AddressableFrameAddressInputProps) {\n\treturn (\n\t\t\n\t);\n}\n\nexport type AddressableFrameActionsProps = ComponentPropsWithRef<'div'>;\n\nexport function AddressableFrameActions({ className, ...props }: AddressableFrameActionsProps) {\n\treturn (\n\t\t\n\t);\n}\n\nexport type AddressableFrameTitleElement = 'h2' | 'h3' | 'h4' | 'h5' | 'h6';\n\nexport type AddressableFrameHeaderProps = Omit, 'title'> & {\n\ttitle?: ReactNode;\n\ttitleAs?: AddressableFrameTitleElement;\n\ttitleId?: string;\n\tdescription?: ReactNode;\n\tactions?: ReactNode;\n};\n\nexport function AddressableFrameHeader({\n\ttitle,\n\ttitleAs: Title = 'h2',\n\ttitleId,\n\tdescription,\n\tactions,\n\tchildren,\n\tclassName,\n\t...props\n}: AddressableFrameHeaderProps) {\n\treturn (\n\t\t\n\t\t\t{title || description ? (\n\t\t\t\t
\n\t\t\t\t\t{title ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\n\t\t\t\t\t) : null}\n\t\t\t\t\t{description ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{description}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : null}\n\t\t\t\t\n\t\t\t) : null}\n\t\t\t{children}\n\t\t\t{actions ?
{actions}
: null}\n\t\t\n\t);\n}\n\nexport type AddressableFrameContentProps = ComponentPropsWithRef<'div'>;\n\nexport function AddressableFrameContent({ className, ...props }: AddressableFrameContentProps) {\n\treturn (\n\t\t\n\t);\n}\n\nexport type AddressableFrameFooterProps = ComponentPropsWithRef<'footer'>;\n\nexport function AddressableFrameFooter({ className, ...props }: AddressableFrameFooterProps) {\n\treturn (\n\t\t\n\t);\n}\n", "type": "registry:ui", "target": "@ui/addressable-frame.tsx" }, { "path": "registry/default/ui/file-viewer/file-viewer-types.ts", "content": "export type FileViewerKind = 'text' | 'image' | 'pdf' | 'audio' | 'video' | 'unsupported';\n\nexport type FileViewerBytes = ArrayBuffer | Uint8Array;\n\nexport type FileViewerFileDescriptor = {\n\tname: string;\n\tpath?: string;\n\tmimeType?: string;\n\tsize?: number;\n\tlastModified?: Date | number;\n\tmeta?: Readonly>;\n};\n\nexport type FileViewerFile = FileViewerFileDescriptor;\n\nexport type FileViewerMessages = {\n\taudioLabel: (file: FileViewerFileDescriptor) => string;\n\tcancel: string;\n\tdimensions: (width: number, height: number) => string;\n\tdownload: string;\n\tedit: string;\n\teditingActionsLabel: (file: FileViewerFileDescriptor) => string;\n\teditFileLabel: (file: FileViewerFileDescriptor) => string;\n\teditorLabel: (file: FileViewerFileDescriptor) => string;\n\tfileName: string;\n\tfileSize: string;\n\tfileTooLarge: (maxBytes: number) => string;\n\timageAlt: (file: FileViewerFileDescriptor) => string;\n\tlastModified: string;\n\tloadFailed: (error: unknown) => string;\n\tloading: string;\n\tmimeType: string;\n\tmissingSource: string;\n\tmediaLoadFailed: (file: FileViewerFileDescriptor) => string;\n\topenInNewWindow: string;\n\tpdfFallback: string;\n\tpdfLabel: (file: FileViewerFileDescriptor) => string;\n\treadOnly: string;\n\tsave: string;\n\tsaveFailed: (error: unknown) => string;\n\tsaving: string;\n\tunsupportedDescription: string;\n\tunsupportedTitle: string;\n\tvideoLabel: (file: FileViewerFileDescriptor) => string;\n};\n\nexport const defaultFileViewerMessages: FileViewerMessages = {\n\taudioLabel: (file) => `${file.name} 音频播放器`,\n\tcancel: '取消',\n\tdimensions: (width, height) => `${width} × ${height} 像素`,\n\tdownload: '下载文件',\n\tedit: '编辑',\n\teditingActionsLabel: (file) => `${file.name} 编辑操作`,\n\teditFileLabel: (file) => `编辑 ${file.name}`,\n\teditorLabel: (file) => `编辑 ${file.name}`,\n\tfileName: '文件名',\n\tfileSize: '大小',\n\tfileTooLarge: (maxBytes) => `文件超过预览上限 ${formatFileViewerBytes(maxBytes)}`,\n\timageAlt: (file) => file.name,\n\tlastModified: '修改时间',\n\tloadFailed: (error) => `无法读取文件:${getFileViewerErrorMessage(error)}`,\n\tloading: '正在读取文件…',\n\tmimeType: 'MIME 类型',\n\tmissingSource: '暂无可预览内容',\n\tmediaLoadFailed: (file) => `${file.name} 加载失败`,\n\topenInNewWindow: '在新窗口打开',\n\tpdfFallback: '当前浏览器无法内嵌预览 PDF。',\n\tpdfLabel: (file) => `${file.name} PDF 预览`,\n\treadOnly: '只读',\n\tsave: '保存',\n\tsaveFailed: (error) => `保存失败:${getFileViewerErrorMessage(error)}`,\n\tsaving: '保存中',\n\tunsupportedDescription: '当前文件类型不支持内嵌预览。',\n\tunsupportedTitle: '无法预览此文件',\n\tvideoLabel: (file) => `${file.name} 视频播放器`,\n};\n\nexport function mergeFileViewerMessages(overrides?: Partial): FileViewerMessages {\n\treturn { ...defaultFileViewerMessages, ...overrides };\n}\n\nexport function getFileViewerErrorMessage(error: unknown): string {\n\treturn error instanceof Error ? error.message : String(error);\n}\n\nexport function formatFileViewerBytes(bytes: number | undefined): string {\n\tif (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return '—';\n\tif (bytes < 1024) return `${Math.floor(bytes)} B`;\n\tconst units = ['KB', 'MB', 'GB', 'TB'] as const;\n\tlet value = bytes / 1024;\n\tlet unit: (typeof units)[number] = units[0];\n\tfor (let index = 1; index < units.length && value >= 1024; index += 1) {\n\t\tvalue /= 1024;\n\t\tunit = units[index];\n\t}\n\treturn `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;\n}\n\nexport function formatFileViewerDate(value: Date | number | undefined): string {\n\tif (value === undefined) return '—';\n\tconst date = value instanceof Date ? value : new Date(value);\n\treturn Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();\n}\n", "type": "registry:lib", "target": "@components/file-viewer/file-viewer-types.ts" }, { "path": "registry/default/ui/file-viewer/file-viewer-kind.ts", "content": "import type { FileViewerFileDescriptor, FileViewerKind } from './file-viewer-types';\n\nconst textExtensions = new Set([\n\t'c',\n\t'cc',\n\t'conf',\n\t'cpp',\n\t'css',\n\t'csv',\n\t'go',\n\t'h',\n\t'hpp',\n\t'htm',\n\t'html',\n\t'ini',\n\t'java',\n\t'js',\n\t'json',\n\t'jsonl',\n\t'jsx',\n\t'log',\n\t'md',\n\t'mdx',\n\t'properties',\n\t'py',\n\t'rs',\n\t'sh',\n\t'sql',\n\t'svg',\n\t'toml',\n\t'ts',\n\t'tsv',\n\t'tsx',\n\t'txt',\n\t'vue',\n\t'xml',\n\t'yaml',\n\t'yml',\n]);\nconst imageExtensions = new Set([\n\t'avif',\n\t'bmp',\n\t'gif',\n\t'heic',\n\t'ico',\n\t'jpeg',\n\t'jpg',\n\t'png',\n\t'svg',\n\t'tif',\n\t'tiff',\n\t'webp',\n]);\nconst audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);\nconst videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogv', 'webm']);\n\nconst extensionMimeTypes: Readonly> = {\n\taac: 'audio/aac',\n\tavif: 'image/avif',\n\tbmp: 'image/bmp',\n\tcss: 'text/css',\n\tcsv: 'text/csv',\n\tflac: 'audio/flac',\n\tgif: 'image/gif',\n\thtml: 'text/html',\n\tico: 'image/x-icon',\n\tjpeg: 'image/jpeg',\n\tjpg: 'image/jpeg',\n\tjs: 'text/javascript',\n\tjson: 'application/json',\n\tjsonl: 'application/x-ndjson',\n\tm4a: 'audio/mp4',\n\tm4v: 'video/mp4',\n\tmd: 'text/markdown',\n\tmkv: 'video/x-matroska',\n\tmov: 'video/quicktime',\n\tmp3: 'audio/mpeg',\n\tmp4: 'video/mp4',\n\tmpeg: 'video/mpeg',\n\toga: 'audio/ogg',\n\togg: 'audio/ogg',\n\togv: 'video/ogg',\n\topus: 'audio/ogg',\n\tpdf: 'application/pdf',\n\tpng: 'image/png',\n\tsvg: 'image/svg+xml',\n\ttsv: 'text/tab-separated-values',\n\ttxt: 'text/plain',\n\twav: 'audio/wav',\n\tweba: 'audio/webm',\n\twebm: 'video/webm',\n\twebp: 'image/webp',\n\txml: 'application/xml',\n\tyaml: 'application/yaml',\n\tyml: 'application/yaml',\n};\n\nexport type FileViewerKindInput = Pick;\n\nexport function getFileViewerExtension(value: string): string {\n\tconst clean = value.split(/[?#]/, 1)[0].replaceAll('\\\\', '/');\n\tconst name = clean.slice(clean.lastIndexOf('/') + 1);\n\tconst dot = name.lastIndexOf('.');\n\treturn dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : '';\n}\n\nexport function getFileSystemViewerExtension(value: string): string {\n\tconst clean = value.replaceAll('\\\\', '/').replace(/\\/+$/, '');\n\tconst name = clean.slice(clean.lastIndexOf('/') + 1);\n\tconst dot = name.lastIndexOf('.');\n\treturn dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : '';\n}\n\nexport function normalizeFileViewerMimeType(mimeType: string | undefined): string {\n\treturn mimeType?.split(';', 1)[0].trim().toLowerCase() ?? '';\n}\n\nexport function resolveFileViewerKind(input: FileViewerKindInput | string, mimeType?: string): FileViewerKind {\n\tconst file = typeof input === 'string' ? { name: input, mimeType } : input;\n\tconst normalizedMime = normalizeFileViewerMimeType(file.mimeType);\n\tconst mimeKind = resolveMimeKind(normalizedMime);\n\tif (mimeKind) return mimeKind;\n\tconst extension = getFileViewerExtension(file.name || file.path || '');\n\treturn resolveFileViewerExtensionKind(extension);\n}\n\nexport function resolveFileSystemViewerKind(input: FileViewerKindInput): FileViewerKind {\n\tconst normalizedMime = normalizeFileViewerMimeType(input.mimeType);\n\tconst mimeKind = resolveMimeKind(normalizedMime);\n\tif (mimeKind) return mimeKind;\n\treturn resolveFileViewerExtensionKind(getFileSystemViewerExtension(input.name || input.path || ''));\n}\n\nfunction resolveFileViewerExtensionKind(extension: string): FileViewerKind {\n\tif (textExtensions.has(extension)) return 'text';\n\tif (imageExtensions.has(extension)) return 'image';\n\tif (extension === 'pdf') return 'pdf';\n\tif (audioExtensions.has(extension)) return 'audio';\n\tif (videoExtensions.has(extension)) return 'video';\n\treturn 'unsupported';\n}\n\nexport function getFileViewerMimeType(file: FileViewerKindInput, kind = resolveFileViewerKind(file)): string {\n\tconst normalized = normalizeFileViewerMimeType(file.mimeType);\n\tif (normalized) return normalized;\n\tconst extension = getFileViewerExtension(file.name || file.path || '');\n\treturn extensionMimeTypes[extension] ?? defaultFileViewerMimeType(kind);\n}\n\nexport function defaultFileViewerMimeType(kind: FileViewerKind): string {\n\treturn {\n\t\taudio: 'audio/mpeg',\n\t\timage: 'image/*',\n\t\tpdf: 'application/pdf',\n\t\ttext: 'text/plain',\n\t\tunsupported: 'application/octet-stream',\n\t\tvideo: 'video/mp4',\n\t}[kind];\n}\n\nfunction resolveMimeKind(mimeType: string): FileViewerKind | undefined {\n\tif (!mimeType || mimeType === 'application/octet-stream' || mimeType === 'binary/octet-stream') return undefined;\n\tif (mimeType.startsWith('text/')) return 'text';\n\tif (mimeType.startsWith('image/')) return 'image';\n\tif (mimeType === 'application/pdf') return 'pdf';\n\tif (mimeType.startsWith('audio/')) return 'audio';\n\tif (mimeType.startsWith('video/')) return 'video';\n\tif (\n\t\tmimeType === 'application/json' ||\n\t\tmimeType === 'application/xml' ||\n\t\tmimeType === 'application/yaml' ||\n\t\tmimeType.endsWith('+json') ||\n\t\tmimeType.endsWith('+xml')\n\t)\n\t\treturn 'text';\n\treturn undefined;\n}\n", "type": "registry:lib", "target": "@components/file-viewer/file-viewer-kind.ts" }, { "path": "registry/default/ui/file-viewer/use-file-viewer-object-url.ts", "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { FileViewerBytes } from './file-viewer-types';\n\ntype ObjectUrlState = {\n\tbytes: FileViewerBytes;\n\tmimeType: string;\n\turl: string;\n};\n\nexport function useFileViewerObjectUrl(bytes: FileViewerBytes | undefined, mimeType: string): string | undefined {\n\tconst [state, setState] = useState();\n\n\tuseEffect(() => {\n\t\tif (!bytes || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) {\n\t\t\tsetState(undefined);\n\t\t\treturn;\n\t\t}\n\t\tconst url = URL.createObjectURL(new Blob([copyFileViewerBytes(bytes)], { type: mimeType }));\n\t\tsetState({ bytes, mimeType, url });\n\t\treturn () => URL.revokeObjectURL(url);\n\t}, [bytes, mimeType]);\n\n\treturn state && state.bytes === bytes && state.mimeType === mimeType ? state.url : undefined;\n}\n\nexport function copyFileViewerBytes(bytes: FileViewerBytes): ArrayBuffer {\n\tif (bytes instanceof Uint8Array) return bytes.slice().buffer;\n\treturn bytes.slice(0);\n}\n", "type": "registry:lib", "target": "@components/file-viewer/use-file-viewer-object-url.ts" }, { "path": "registry/default/ui/file-viewer/text-file-viewer.tsx", "content": "'use client';\n\nimport { AlertCircle, LoaderCircle, Pencil, Save, X } from 'lucide-react';\nimport type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from 'react';\nimport { useEffect, useRef, useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport type { FileViewerFileDescriptor, FileViewerMessages } from './file-viewer-types';\nimport { mergeFileViewerMessages } from './file-viewer-types';\n\nexport type TextFileViewerProps = Omit, 'children' | 'onChange'> & {\n\tfile: FileViewerFileDescriptor;\n\ttext: string;\n\tdraft?: string;\n\tediting?: boolean;\n\tdefaultEditing?: boolean;\n\terror?: ReactNode;\n\tmessages?: Partial;\n\tonDraftChange?: (draft: string) => void;\n\tonEditingChange?: (editing: boolean) => void;\n\tonSave?: (draft: string) => Promise | void;\n\tpending?: boolean;\n\treadOnly?: boolean;\n\tshowHeader?: boolean;\n\ttextareaProps?: Omit, 'children' | 'defaultValue' | 'onChange' | 'value'>;\n};\n\nexport function TextFileViewer({\n\tclassName,\n\tdefaultEditing = false,\n\tdraft: controlledDraft,\n\tediting: controlledEditing,\n\terror,\n\tfile,\n\tmessages: messageOverrides,\n\tonDraftChange,\n\tonEditingChange,\n\tonSave,\n\tpending = false,\n\treadOnly = false,\n\tshowHeader = true,\n\ttext,\n\ttextareaProps,\n\t...props\n}: TextFileViewerProps) {\n\tconst messages = mergeFileViewerMessages(messageOverrides);\n\tconst [internalDraft, setInternalDraft] = useState(text);\n\tconst [internalEditing, setInternalEditing] = useState(defaultEditing);\n\tconst [internalPending, setInternalPending] = useState(false);\n\tconst [internalError, setInternalError] = useState();\n\tconst previousText = useRef(text);\n\tconst draft = controlledDraft ?? internalDraft;\n\tconst editing = !readOnly && (controlledEditing ?? internalEditing);\n\tconst saving = pending || internalPending;\n\tconst dirty = isTextFileViewerDirty(text, draft);\n\tconst canEdit = !readOnly && Boolean(onSave || onDraftChange || controlledDraft !== undefined);\n\n\tconst changeDraft = (nextDraft: string) => {\n\t\tif (controlledDraft === undefined) setInternalDraft(nextDraft);\n\t\tonDraftChange?.(nextDraft);\n\t\tsetInternalError(undefined);\n\t};\n\tconst changeEditing = (nextEditing: boolean) => {\n\t\tif (controlledEditing === undefined) setInternalEditing(nextEditing);\n\t\tonEditingChange?.(nextEditing);\n\t};\n\n\tuseEffect(() => {\n\t\tif (previousText.current === text) return;\n\t\tconst wasDirty = draft !== previousText.current;\n\t\tpreviousText.current = text;\n\t\tif (!editing || !wasDirty) changeDraft(text);\n\t}, [draft, editing, text]);\n\n\tconst cancel = () => {\n\t\tif (saving) return;\n\t\tchangeDraft(text);\n\t\tchangeEditing(false);\n\t};\n\tconst save = async () => {\n\t\tif (!onSave || !dirty || saving) return;\n\t\tsetInternalPending(true);\n\t\tsetInternalError(undefined);\n\t\ttry {\n\t\t\tawait onSave(draft);\n\t\t\tchangeEditing(false);\n\t\t} catch (saveError) {\n\t\t\tsetInternalError(messages.saveFailed(saveError));\n\t\t} finally {\n\t\t\tsetInternalPending(false);\n\t\t}\n\t};\n\tconst handleSaveShortcut = (event: KeyboardEvent) => {\n\t\ttextareaProps?.onKeyDown?.(event);\n\t\tif (event.defaultPrevented || !onSave || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's')\n\t\t\treturn;\n\t\tevent.preventDefault();\n\t\tvoid save();\n\t};\n\tconst effectiveError = internalError ?? error;\n\n\treturn (\n\t\t\n\t\t\t{showHeader ? (\n\t\t\t\t
\n\t\t\t\t\t
{file.name}
\n\t\t\t\t\t{readOnly ? {messages.readOnly} : null}\n\t\t\t\t\t{editing ? (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{onSave ? (\n\t\t\t\t\t\t\t\t void save()}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{saving ? (\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t) : canEdit ? (\n\t\t\t\t\t\t changeEditing(true)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t