跳到主要内容

ImageViewer

  • 组件说明:全屏图片查看器(lightbox),在半透明遮罩上居中展示图片,底部浮动工具栏承载导航、缩放、删除、导出与关闭操作。
  • 交互特征:内置多图导航(上一张 / 下一张,到边界禁用,支持 ← / → 键)、范围内放大 / 缩小(支持 + / = / - 键)、可选删除、导出(缺省回退浏览器下载),以及通过收起按钮 / Esc / 点击遮罩关闭。
  • 实现约定:无 ui/ primitive,直接在 design 层基于 Radix Dialog primitive 实现,复用其 portal / focus trap / Esc / 滚动锁定 / a11y,视觉完全由 design 层接管。
  • Figma 规范

基础用法​

传入 images 数组并将触发元素作为 children。上一张 / 下一张在列表内导航,到边界时自动禁用。

结果
Loading...
实时编辑器
const images = [
  { src: 'https://picsum.photos/seed/plaud-a/900/600', alt: 'Sample A' },
  { src: 'https://picsum.photos/seed/plaud-b/900/600', alt: 'Sample B' },
  { src: 'https://picsum.photos/seed/plaud-c/900/600', alt: 'Sample C' },
]
render(
  <div style={{ padding: '40px 0' }}>
    <ImageViewer images={images}>
      <Button>打开查看器</Button>
    </ImageViewer>
  </div>,
)

单图​

单图时传入只含一个元素的数组。上一张 / 下一张按钮(及其分隔线)直接隐藏,缩放、导出、关闭仍可用。

结果
Loading...
实时编辑器
const images = ['https://picsum.photos/seed/plaud-single/1200/800']
render(
  <div style={{ padding: '40px 0' }}>
    <ImageViewer images={images}>
      <Button variant="secondary">打开单张图片</Button>
    </ImageViewer>
  </div>,
)

删除与导出​

传入 onDelete 后显示删除按钮。onExport 会覆盖默认下载(缺省时先把图片 fetch 成 blob 再保存,跨域 CDN 地址也能真正下载而非直接跳转新页面)。若只想改「下载哪个地址」、保留内置下载,用 resolveExportSrc 而不是接管。

结果
Loading...
实时编辑器
const images = [
  { src: 'https://picsum.photos/seed/plaud-d1/900/600', alt: 'Photo 1' },
  { src: 'https://picsum.photos/seed/plaud-d2/900/600', alt: 'Photo 2' },
]
render(
  <div style={{ padding: '40px 0' }}>
    <ImageViewer
      images={images}
      onDelete={(index) => alert('删除索引: ' + index)}
      onExport={(image, index) => alert('导出 ' + index + ': ' + image.src)}
    >
      <Button>打开(含删除与导出)</Button>
    </ImageViewer>
  </div>,
)

导出下载机制说明​

导出有三档模式,按调用方接管程度由浅到深:

  1. 缺省(两个 prop 都不传) —— 组件执行内置下载。
  2. resolveExportSrc(只改地址) —— 仍走内置下载,但下载的是你先解析出的地址。
  3. onExport(完全接管) —— 组件完全不执行下载。

未传 onExport 时,内置下载行为如下:

  • 默认走 blob 下载:先 fetch(image.src) 拉取图片并转成 blob,再用同源 objectURL + <a download> 触发下载。这样跨域 CDN 图片也能真正下载,而不会变成「打开新页面」。
  • 为什么不用裸 <a download>:download 属性只对同源 / blob: / data: URL 生效。直接用跨域地址时浏览器会忽略 download,转为普通导航(表现为跳转 / 新开页面)。
  • 依赖 CORS:blob 方案要求图片所在 CDN 返回允许跨域读取的响应头(Access-Control-Allow-Origin)。若 CDN 未配置 CORS,fetch 会失败,此时回退到原始地址下载(跨域场景仍可能跳转),保证不静默失败。
  • 文件名:从 src 的路径末段推断;无有效文件名时交由浏览器决定。
  • 只改地址(resolveExportSrc(image, index)):在下载前调用,解析本次实际使用的地址,适用于地址有时效的场景(如预签名 URL 过期换签)。返回新地址即用它下载;返回空值或抛错则回退 image.src。上面所有行为(blob、cache: 'reload'、跨域回退、文件名推断)依旧适用。解析期间导出按钮没有 loading 态,与现有 fetch 的等待窗口一致。
  • 完全自定义(onExport(image, index)):接管整个导出动作(如调用业务侧带鉴权的下载接口),组件既不执行默认下载,也不调用 resolveExportSrc。
// 保留内置下载,只把过期地址换成新鲜地址
<ImageViewer
images={[src]}
resolveExportSrc={(image) => (isExpired(image.src) ? resign(key) : undefined)}
/>

受控模式​

外部控制 open 与 index,由自身状态驱动查看器。

结果
Loading...
实时编辑器
const images = [
  { src: 'https://picsum.photos/seed/plaud-c1/900/600', alt: 'Image 1' },
  { src: 'https://picsum.photos/seed/plaud-c2/900/600', alt: 'Image 2' },
  { src: 'https://picsum.photos/seed/plaud-c3/900/600', alt: 'Image 3' },
]
const Demo = () => {
  const [open, setOpen] = useState(false)
  const [index, setIndex] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '40px 0' }}>
      <Button onClick={() => setOpen(true)}>打开受控</Button>
      <span style={{ fontSize: 14, color: '#999' }}>index: {index}</span>
      <ImageViewer
        images={images}
        open={open}
        onOpenChange={setOpen}
        index={index}
        onIndexChange={setIndex}
      />
    </div>
  )
}
render(<Demo />)

命令式 API​

ImageViewer.open() 用于命令式打开查看器——适合相册缩略图、表格行操作、快捷键等没有 JSX 触发器的场景。需要在应用根部挂载 DesignProvider 或 OverlayHost。返回带 close / update / afterClosed 的控制器。

import { ImageViewer } from '@plaud/design'

const controller = ImageViewer.open({
images: ['/a.png', '/b.png', '/c.png'],
defaultIndex: 0,
onDelete: (index) => {
// 在调用方自己的列表里删除后,再同步给查看器
controller.update({ images: nextImages })
},
onExport: (image) => download(image.src),
})

// 任意位置关闭
controller.close()
await controller.afterClosed

ImageViewer.open() 接受与声明式组件相同的入参,但不含 children / open / defaultOpen(显隐由 overlay host 接管)。

Token​

区域Token
遮罩Overlays/Default#00000066
工具栏背景Foregrounds/Tooltip#3d3d3d
工具栏图标Foregrounds/White#ffffff
禁用图标Labels/Disabled#808080
几何值Token
工具栏圆角5pxRadius_5
工具栏内边距 / 按钮间距 / 按钮内边距4pxSpacing_4
工具栏距底部24pxSpacing_24
图标容器20×20px—
工具栏按钮 hover(fallback)white/10—
分隔线(fallback)white/20,高 28px—
图片最大尺寸(fallback)max-h-[80vh] / max-w-[min(900px,90vw)]—

Props​

属性类型默认值说明
images(string | { src: string; alt?: string })[]—图片列表;单图传只含一个元素的数组
childrenReactNode—触发元素(非受控模式)
openboolean—受控显隐
defaultOpenbooleanfalse非受控初始显隐
onOpenChange(open: boolean) => void—显隐变化回调
indexnumber—受控当前索引
defaultIndexnumber0非受控初始索引
onIndexChange(index: number) => void—索引变化回调
onDelete(index: number) => void—删除回调;传入后才显示删除按钮
onExport(image: { src; alt? }, index: number) => void—导出回调;缺省时回退到 blob fetch + 下载
resolveExportSrc(image: { src; alt? }, index: number) => string | null | undefined | Promise<...>—解析缺省下载使用的地址(如预签名过期换签);返回空值或抛错则回退 image.src。传了 onExport 时不会被调用
minZoomnumber0.5最小缩放倍率
maxZoomnumber3最大缩放倍率
zoomStepnumber0.25单次缩放步进
classNamestring—内容层 className

使用约束​

  • 查看器是基于 Radix Dialog primitive 的全屏遮罩弹层;非受控模式下 children 作为触发器。
  • 命令式 ImageViewer.open() 需要在应用根部挂载 DesignProvider 或 OverlayHost。
  • 删除时组件不会自行修改 images,请在调用方的 onDelete 回调中更新列表。
  • 切换当前图片或重新打开查看器时,缩放会复位为 1。