自定义渲染器
自定义渲染器允许你为特定的文件类型提供自定义的预览实现,并按内置渲染器同样的规范声明工具组、派发事件。
基本概念
一个自定义渲染器是一个对象,至少包含:
test:判断是否应该使用该渲染器render:返回 React 节点 / Vue 组件
可选地:
getToolbarGroups:按ToolbarGroup[]规范声明自定义工具组(命中时替代内置文件类型工具组)events:事件名白名单(仅用于 TS 与文档约定,运行时不拦截)
类型定义
ts
// React
interface CustomRenderer {
test: (file: PreviewFile) => boolean
render: (file: PreviewFile, ctx?: CustomRendererContext) => React.ReactNode
getToolbarGroups?: (file: PreviewFile, ctx: CustomRendererContext) => ToolbarGroup[]
events?: readonly string[]
}
interface CustomRendererContext {
emit: (name: string, payload?: unknown) => void
t: Translator
theme: 'dark' | 'light'
locale: Locale
}
interface CustomRendererEventPayload<T = unknown> {
name: string
payload?: T
file: PreviewFile
}Vue 端 render 返回 Component,其余字段同构。
基本示例
tsx
import { FilePreviewModal, CustomRenderer, PreviewFile } from '@eternalheart/react-file-preview'
const customRenderers: CustomRenderer[] = [
{
// 判断是否使用该渲染器
test: (file: PreviewFile) => {
return file.name.endsWith('.custom')
},
// 渲染预览内容
render: (file: PreviewFile) => {
return (
<div className="custom-preview">
<h2>自定义文件预览</h2>
<p>文件名: {file.name}</p>
</div>
)
}
}
]
function App() {
return (
<FilePreviewModal
isOpen={true}
onClose={() => {}}
files={files}
currentIndex={0}
customRenderers={customRenderers}
/>
)
}高级示例
自定义 JSON 查看器
tsx
import { useState, useEffect } from 'react'
function JsonViewer({ url }: { url: string }) {
const [content, setContent] = useState<string>('加载中...')
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(json => setContent(JSON.stringify(json, null, 2)))
.catch(err => setContent(`加载失败: ${err.message}`))
}, [url])
return (
<div className="w-full h-full p-8 overflow-auto">
<pre className="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm">
{content}
</pre>
</div>
)
}
const jsonRenderer: CustomRenderer = {
test: (file) => file.name.endsWith('.json'),
render: (file) => <JsonViewer url={file.url} />,
}自定义 CSV 表格查看器
tsx
import { useState, useEffect } from 'react'
function CsvViewer({ url }: { url: string }) {
const [rows, setRows] = useState<string[][]>([])
useEffect(() => {
fetch(url)
.then(res => res.text())
.then(text => {
const lines = text.split('\n')
const data = lines.map(line => line.split(','))
setRows(data)
})
}, [url])
return (
<div className="w-full h-full p-8 overflow-auto">
<table className="border-collapse border border-gray-300 bg-white">
<thead>
<tr className="bg-gray-100">
{rows[0]?.map((cell, i) => (
<th key={i} className="border border-gray-300 px-4 py-2 font-semibold">
{cell}
</th>
))}
</tr>
</thead>
<tbody>
{rows.slice(1).map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td key={j} className="border border-gray-300 px-4 py-2">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
const csvRenderer: CustomRenderer = {
test: (file) => file.name.endsWith('.csv'),
render: (file) => <CsvViewer url={file.url} />,
}渲染器优先级
自定义渲染器的优先级高于内置渲染器。渲染器按照数组顺序进行测试,第一个匹配的渲染器将被使用。
tsx
const customRenderers = [
// 这个会先被测试
{ test: (file) => file.name.endsWith('.txt'), render: CustomTextRenderer },
// 如果上面的不匹配,才会测试这个
{ test: (file) => file.name.endsWith('.log'), render: LogRenderer }
]访问文件内容
所有文件都通过 file.url 访问,这个 URL 可能是:
- HTTP/HTTPS URL(远程文件)
- Blob URL(本地 File 对象)
- Data URL(Base64 编码)
读取文本内容
tsx
function TextViewer({ url }: { url: string }) {
const [content, setContent] = useState<string>('加载中...')
useEffect(() => {
fetch(url)
.then(res => res.text())
.then(setContent)
.catch(err => setContent(`加载失败: ${err.message}`))
}, [url])
return <div>{content}</div>
}读取 JSON 内容
tsx
function JsonViewer({ url }: { url: string }) {
const [data, setData] = useState<any>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(err => console.error(err))
}, [url])
return <pre>{JSON.stringify(data, null, 2)}</pre>
}读取二进制内容
tsx
function BinaryViewer({ url }: { url: string }) {
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.arrayBuffer())
.then(setBuffer)
}, [url])
return <div>文件大小: {buffer?.byteLength} 字节</div>
}样式建议
建议为自定义渲染器添加适当的样式,以保持与内置渲染器的一致性:
css
.custom-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: auto;
}工具组与事件(v1.4+)
命中自定义渲染器后,除了渲染内容,还可以:
- 通过
getToolbarGroups声明自定义工具按钮,与内置 Image / Pdf 等工具栏同一视觉风格 - 通过
ctx.emit(name, payload)派发事件,宿主在FilePreviewContent/Modal/Embed上通过onCustomEvent(React)或@custom-event(Vue)统一接收
React 示例
tsx
import { FilePreviewModal } from '@eternalheart/react-file-preview'
import type {
CustomRenderer,
CustomRendererEventPayload,
} from '@eternalheart/react-file-preview'
import { Sparkles } from 'lucide-react'
const demoRenderers: CustomRenderer[] = [
{
test: (file) => file.name.endsWith('.demo'),
render: (file, ctx) => (
<div style={{ color: ctx?.theme === 'light' ? '#111' : '#fff', padding: 24 }}>
<p>file: {file.name}</p>
<p>locale: {ctx?.locale} · theme: {ctx?.theme}</p>
</div>
),
getToolbarGroups: (_file, ctx) => [
{
items: [
{
type: 'button',
icon: <Sparkles className="rfp-w-4 rfp-h-4" />,
tooltip: 'Say Hello',
action: () => ctx.emit('hello', { ok: true }),
},
],
},
],
events: ['hello'] as const,
},
]
function App() {
return (
<FilePreviewModal
files={files}
currentIndex={0}
isOpen={open}
onClose={() => setOpen(false)}
customRenderers={demoRenderers}
onCustomEvent={(e: CustomRendererEventPayload) => {
console.log(e.name, e.payload, e.file)
}}
/>
)
}Vue 示例
vue
<script setup lang="ts">
import { h, defineComponent } from 'vue'
import {
FilePreviewModal,
type CustomRenderer,
type CustomRendererContext,
type CustomRendererEventPayload,
} from '@eternalheart/vue-file-preview'
import { Sparkles } from 'lucide-vue-next'
const DemoRenderer = defineComponent({
props: {
file: { type: Object, required: true },
ctx: { type: Object as () => CustomRendererContext | undefined, default: undefined },
},
setup(props) {
return () => h('div', { style: { padding: '24px' } }, `file: ${props.file.name}`)
},
})
const demoRenderers: CustomRenderer[] = [
{
test: (file) => file.name.endsWith('.demo'),
render: () => DemoRenderer,
getToolbarGroups: (_file, ctx) => [
{
items: [
{
type: 'button',
icon: Sparkles,
tooltip: 'Say Hello',
action: () => ctx.emit('hello', { ok: true }),
},
],
},
],
events: ['hello'] as const,
},
]
function onCustomEvent(e: CustomRendererEventPayload) {
console.log(e.name, e.payload, e.file)
}
</script>
<template>
<FilePreviewModal
:files="files"
:current-index="0"
:is-open="open"
:custom-renderers="demoRenderers"
@close="open = false"
@custom-event="onCustomEvent"
/>
</template>工具组语义
- 自定义
getToolbarGroups返回结果会替代内置文件类型工具组;通用操作组(下载、关闭)保持不变 - 未声明
getToolbarGroups时工具栏仅保留通用操作组 headless === true时不渲染任何工具栏(包括自定义工具组)
事件语义
ctx.emit(name, payload)会通过顶层FilePreviewContent的onCustomEvent/custom-event出口向宿主转发- 载荷固定为
{ name, payload, file }(file为当前渲染的PreviewFile) - 宿主未绑定监听时静默忽略,不抛错
FilePreviewModal/FilePreviewEmbed会自动透传事件
向后兼容
- 旧版
render: (file) => <Comp />仍然可用;ctx是可选参数 - 不声明
getToolbarGroups/events/ 不绑定事件出口时,行为与旧版完全一致
内置渲染器架构
从 v2.0 开始,内置渲染器采用新架构,自定义渲染器也可参考这些模式以获得更好的性能和一致性。
事件驱动的工具栏更新
新架构使用事件驱动机制替代轮询,实现实时工具栏更新:
React 实现:
tsx
import { forwardRef, useImperativeHandle, useState, useEffect, useMemo, useCallback } from 'react';
import { ToolbarEventEmitter } from '@eternalheart/react-file-preview';
import type { RendererHandle, ToolbarGroup } from '@eternalheart/react-file-preview';
export const CustomRenderer = forwardRef<RendererHandle, Props>((props, ref) => {
const [zoom, setZoom] = useState(1);
const emitter = useMemo(() => new ToolbarEventEmitter(), []);
// 状态变化时通知工具栏
useEffect(() => {
emitter.notify();
}, [zoom, emitter]);
const getToolbarGroups = useCallback((): ToolbarGroup[] => [
{
items: [
{ type: 'text', content: `${Math.round(zoom * 100)}%` },
{ type: 'button', icon: <ZoomIn />, tooltip: 'Zoom In', action: () => setZoom(z => z * 1.1) }
]
}
], [zoom]);
useImperativeHandle(ref, () => ({
getToolbarGroups,
onToolbarChange: (listener) => emitter.subscribe(listener)
}), [getToolbarGroups, emitter]);
return <div style={{ transform: `scale(${zoom})` }}>内容</div>;
});Vue 实现:
vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { ToolbarGroup } from '@eternalheart/vue-file-preview';
const zoom = ref(1);
const getToolbarGroups = (): ToolbarGroup[] => [
{
items: [
{ type: 'text', content: `${Math.round(zoom.value * 100)}%` },
{ type: 'button', icon: ZoomIn, tooltip: '放大', action: () => zoom.value *= 1.1 }
]
}
];
defineExpose({ getToolbarGroups });
</script>
<template>
<div :style="{ transform: `scale(${zoom})` }">内容</div>
</template>优势:
- 实���更新:状态变化立即反映到工具栏
- 更好的性能:无轮询开销
- 类型安全:完整的 TypeScript 支持
Renderer 懒加载
所有内置渲染器通过 React.lazy / defineAsyncComponent 实现代码分割:
React 注册:
tsx
// src/renderers/lazy.tsx
import { lazy } from 'react';
import type { CustomRenderer as CustomRendererImpl } from './Custom';
export const CustomRenderer: Lazy<typeof CustomRendererImpl> = lazy(() =>
import('./Custom').then((m) => ({ default: m.CustomRenderer }))
);Vue 注册:
ts
// src/renderers/lazy.ts
import { defineAsyncComponent } from 'vue';
export const CustomRenderer = defineAsyncComponent({
loader: () => import('./Custom/index.vue'),
loadingComponent: RendererLoading,
delay: 0
});体积影响:
- React 主入口:gzip ≤ 80 KB
- Vue 主入口:gzip ≤ 60 KB
- 每个渲染器:独立异步 chunk
i18n 集成
自定义渲染器可以访问内置 i18n 系统:
React:
tsx
import { useTranslator } from '@eternalheart/react-file-preview';
export const CustomRenderer = forwardRef<RendererHandle, Props>((props, ref) => {
const t = useTranslator();
return (
<div>
<button>{t('common.download')}</button>
<span>{t('custom.loading')}</span>
</div>
);
});Vue:
vue
<script setup lang="ts">
import { useTranslator } from '@eternalheart/vue-file-preview';
const { t } = useTranslator();
</script>
<template>
<div>
<button>{{ t('common.download') }}</button>
<span>{{ t('custom.loading') }}</span>
</div>
</template>字典位置: file-preview-core/src/i18n/messages/zh-CN.ts 和 en-US.ts
主题适配
必须使用语义化 token 以支持 'auto' | 'dark' | 'light' 主题:
React 示例:
tsx
export const CustomRenderer = forwardRef<RendererHandle, Props>((props, ref) => {
return (
<div className="rfp-bg-surface-1 rfp-text-fg-primary rfp-border rfp-border-line">
<button className="rfp-bg-surface-2 hover:rfp-bg-surface-3">
{props.label}
</button>
</div>
);
});Vue 示例:
vue
<template>
<div class="vfp-bg-surface-1 vfp-text-fg-primary vfp-border vfp-border-line">
<button class="vfp-bg-surface-2 hover:vfp-bg-surface-3">
{{ label }}
</button>
</div>
</template>
<style scoped>
.custom-block {
color: var(--fp-fg-primary);
background: var(--fp-surface-2);
}
</style>禁止使用:
- ❌ 字面色类:
text-white/90、bg-gray-100 - ❌ 硬编码颜色:
color: '#ffffff'或类似的 style 内联字面色
主题切换库:
tsx
import { useResolvedTheme } from '@eternalheart/react-file-preview';
const resolvedTheme = useResolvedTheme(); // 'dark' | 'light'
<SyntaxHighlighter style={resolvedTheme === 'light' ? vs : vscDarkPlus} />