Skip to content

自定义渲染器

自定义渲染器允许你为特定的文件类型提供自定义的预览实现,并按内置渲染器同样的规范声明工具组、派发事件。

基本概念

一个自定义渲染器是一个对象,至少包含:

  • 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) 会通过顶层 FilePreviewContentonCustomEvent / 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.tsen-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/90bg-gray-100
  • ❌ 硬编码颜色:color: '#ffffff' 或类似的 style 内联字面色

主题切换库:

tsx
import { useResolvedTheme } from '@eternalheart/react-file-preview';

const resolvedTheme = useResolvedTheme();  // 'dark' | 'light'
<SyntaxHighlighter style={resolvedTheme === 'light' ? vs : vscDarkPlus} />

下一步

Released under the MIT License.