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>, )
导出下载机制说明
导出有三档模式,按调用方接管程度由浅到深:
- 缺省(两个 prop 都不传) —— 组件执行内置下载。
resolveExportSrc(只改地址) —— 仍走内置下载,但下载的是你先解析出的地址。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 |
|---|---|---|
| 工具栏圆角 | 5px | Radius_5 |
| 工具栏内边距 / 按钮间距 / 按钮内边距 | 4px | Spacing_4 |
| 工具栏距底部 | 24px | Spacing_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 })[] | — | 图片列表;单图传只含一个元素的数组 |
children | ReactNode | — | 触发元素(非受控模式) |
open | boolean | — | 受控显隐 |
defaultOpen | boolean | false | 非受控初始显隐 |
onOpenChange | (open: boolean) => void | — | 显隐变化回调 |
index | number | — | 受控当前索引 |
defaultIndex | number | 0 | 非受控初始索引 |
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 时不会被调用 |
minZoom | number | 0.5 | 最小缩放倍率 |
maxZoom | number | 3 | 最大缩放倍率 |
zoomStep | number | 0.25 | 单次缩放步进 |
className | string | — | 内容层 className |
使用约束
- 查看器是基于 Radix Dialog primitive 的全屏遮罩弹层;非受控模式下
children作为触发器。 - 命令式
ImageViewer.open()需要在应用根部挂载DesignProvider或OverlayHost。 - 删除时组件不会自行修改
images,请在调用方的onDelete回调中更新列表。 - 切换当前图片或重新打开查看器时,缩放会复位为
1。