工具 API
用于构建自定义侧边面板的辅助组件与 Hook。
写自定义 Section 时你并不需要什么都从零造,Ydesign 在内部沉淀了一组通用工具。本页列出最核心的几个:<ImagesGrid /> 组件,以及对应的远程分页 / 拖放辅助方案。
📌 本页 API 的设计参考了 Polotno 的 Utils API —— 你会看到相似的章节结构;但所有 prop、默认值、实现细节都严格以 Ydesign 的真实代码为准(
packages/react-editor/src/side-panel/images-grid.tsx)。
<ImagesGrid /> 组件
做自定义素材面板时,最常见的就是"一个预览图网格 —— 点一下加到画布,或者拖拽进画布"。Ydesign 把这个能力抽成单独组件 <ImagesGrid />,可以在任何 Section 里复用。
Basic usage · 基本用法
先从一个最小的面板开始:给它一个图片集合、一个预览图 URL 提取函数、一个 onSelect 回调。回调里可以拿到原始数据项、可选的拖放坐标,以及被拖到的画布元素。
import { ImagesGrid } from '@ydesign/react-editor/side-panel/images-grid';
const images = [
{ id: '1', url: 'https://picsum.photos/seed/1/600/800' },
{ id: '2', url: 'https://picsum.photos/seed/2/600/800' },
];
export const TemplatesPanel = ({ store }) => (
<ImagesGrid
images={images}
getPreview={item => item.url}
onSelect={(image, pos) => {
const width = 200;
const height = 200;
const x = (pos?.x ?? store.width / 2) - width / 2;
const y = (pos?.y ?? store.height / 2) - height / 2;
store.addElement({
type: 'image',
src: image.url,
width,
height,
left: x,
top: y,
});
}}
isLoading={false}
/>
);
Infinite loading · 无限加载
当前版本的 <ImagesGrid /> 还没有内置 loadMore 回调(Polotno 有,路线图上我们也会跟上)。在内置完善之前,推荐两种方案:
方案 A:底部放一个 "加载更多" 按钮
import { useState } from 'react';
import { Button } from 'antd';
import { ImagesGrid } from '@ydesign/react-editor/side-panel/images-grid';
const [page, setPage] = useState(1);
const [images, setImages] = useState<any[]>([]);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
async function loadNext() {
setLoading(true);
const res = await fetch(`/api/photos?page=${page}`).then(r => r.json());
setImages(prev => [...prev, ...res.list]);
setHasMore(res.hasMore);
setPage(p => p + 1);
setLoading(false);
}
<>
<ImagesGrid images={images} isLoading={loading} getPreview={i => i.url} onSelect={/* ... */} />
{hasMore && (
<Button block onClick={loadNext} loading={loading}>
加载更多
</Button>
)}
</>;
方案 B:用 SWR / TanStack Query 做"接近底部自动加载"
import useSWRInfinite from 'swr/infinite';
const { data, isLoading, size, setSize } = useSWRInfinite(
index => `/api/photos?page=${index + 1}`,
url => fetch(url).then(r => r.json())
);
const images = data?.flatMap(page => page.list) ?? [];
// 用一个外层 div 监听 scroll;滚到底部调 setSize(size + 1)
<div
onScroll={e => {
const t = e.currentTarget;
if (t.scrollHeight - t.scrollTop - t.clientHeight < 200 && !isLoading) {
setSize(size + 1);
}
}}
style={{ overflow: 'auto', height: '100%' }}
>
<ImagesGrid images={images} isLoading={isLoading} getPreview={i => i.url} onSelect={/* ... */} />
</div>;
等 loadMore 原生支持发布后,你可以把这段 SWR 代码直接替换掉,API 变化很小。
Drag and drop behavior · 拖拽行为
<ImagesGrid /> 渲染出的每张 <img> 都带了 draggable={true} 属性 —— 用户可以把图片从侧边拖进画布。所有拖拽信息都统一通过 onSelect 传给你,不需要额外监听:
onSelect: (
image: ImageType,
pos?: { x: number; y: number }, // 鼠标落点坐标(拖拽才有值)
element?: FabricObject, // 拖到的画布元素
event?: any, // 原始拖拽事件
) => void;
| 参数 | 何时拿到 | 用途 |
|---|---|---|
image | 总是 | 你传进 images 里的那个原始数据项 |
pos | 仅拖拽时 | 鼠标落在画布上的相对坐标;点击时为 undefined |
element | 仅"拖到某个元素上" | 比如把新图拖到一张老图上,用于做"替换 src" |
event | 总是 | 需要时可拿到原始事件做额外判断 |
一个"智能拖放"范式 —— 拖到图片元素上就替换、否则新建:
<ImagesGrid
images={images}
getPreview={i => i.url}
onSelect={(img, pos, target) => {
// 1) 拖到了已有图片元素上:替换它的 src
if (target?.type === 'image') {
store.set({ src: img.url }, target);
return;
}
// 2) 否则在鼠标位置新建一张图
store.addElement({
type: 'image',
src: img.url,
left: pos?.x ?? store.width / 2,
top: pos?.y ?? store.height / 2,
width: 300,
height: 300,
});
}}
isLoading={false}
/>
💡 图片的
crossOrigin默认是'anonymous',保证导出画布时不会 taint。自家图床请务必开 CORS。
Styling options · 样式选项
<ImagesGrid /> 提供一组可调的外观 prop:
<ImagesGrid
images={images}
getPreview={i => i.url}
onSelect={/* ... */}
isLoading={false}
rowsNumber={3} // 列数(默认 2)
itemHeight={120} // 每张图固定高度(不传则按原图比例)
spacing={6} // 图之间的间距(px)
shadowEnabled // 开启图片容器阴影
getCredit={i => <span>by {i.author}</span>} // 在每张图底部叠一层署名/操作区
getImageClassName={i => (i.isVip ? 'vip-badge' : '')} // 给 <img> 额外加 class
/>
几个常见微调的效果:
- 紧凑网格:
rowsNumber={3}+spacing={2}+itemHeight={80} - 画廊卡片:
rowsNumber={2}+shadowEnabled+getCredit - 列表风:
rowsNumber={1}+itemHeight={60}
Prop reference · 完整属性参考
| Prop | Type | Default | 说明 |
|---|---|---|---|
images | ImageType[] | undefined | — | 图片数据数组 |
getPreview | (image) => string | — | 返回缩略图 URL(必填) |
onSelect | (image, pos?, element?, event?) => void | — | 点击 / 拖放的统一回调 |
isLoading | boolean | — | 是否正在加载,展示 <Spin /> |
rowsNumber | number | 2 | 列数(是的,命名叫 rowsNumber,但语义是列数) |
getCredit | (image) => ReactNode | undefined | 在图片底部浮现一层署名/操作区;桌面端 hover 显示,移动端常驻 |
getImageClassName | (image) => string | undefined | 给 <img> 标签加额外 class |
crossOrigin | string | 'anonymous' | 控制 <img crossOrigin>,保证能用于画布导出 |
shadowEnabled | boolean | false | 图片容器是否有投影 |
itemHeight | number | string | 'auto' | 图片高度;不设为按原图比例,设为数值时固定高度 |
spacing | number | 0 | 每张图周围的 padding(px) |
error | any | undefined | 任意 truthy 值都会显示错误文案(会走 translate('sidePanel.error')) |
hideNoResults | boolean | false | 空数组时不显示"暂无结果"占位 |
⚠️ 你可能注意到
rowsNumber这个名字语义其实是列数——源码中通过width: 100 / rows + '%'平分宽度。这是一个命名遗留问题,未来会做改名 + 兼容旧 prop 的迁移。
如何替代 useInfiniteAPI hook?
Polotno 提供了一个 useInfiniteAPI hook 做分页请求,底层是 SWR 的封装。Ydesign 没有内置这个 hook,但你可以用社区常用方案完整替代:
| 能力 | Polotno | Ydesign 的替代 |
|---|---|---|
| 分页请求 + 缓存 | useInfiniteAPI | useSWRInfinite(SWR)或 useInfiniteQuery(TanStack Query) |
reset / hasMore / isReachingEnd | ✅ | SWR 的 setSize(1) / data 长度判断;TanStack 的 refetch / hasNextPage |
| debounce 搜索词 | timeout: 500 | 自己用 useDeferredValue / lodash 的 debounce |
一个 SWR + <ImagesGrid /> 的完整例子:
import useSWRInfinite from 'swr/infinite';
import { ImagesGrid } from '@ydesign/react-editor/side-panel/images-grid';
export const PhotosPanel = ({ store }) => {
const [query, setQuery] = React.useState('');
const { data, isLoading, size, setSize } = useSWRInfinite(
index => `/api/photos?page=${index + 1}&q=${encodeURIComponent(query)}`,
url => fetch(url).then(r => r.json()),
{ revalidateOnFocus: false }
);
const images = data?.flatMap(page => page.list) ?? [];
const hasMore = data ? data[data.length - 1]?.hasMore : true;
return (
<div
onScroll={e => {
const t = e.currentTarget;
if (hasMore && !isLoading && t.scrollHeight - t.scrollTop - t.clientHeight < 200) {
setSize(size + 1);
}
}}
style={{ overflow: 'auto', height: '100%' }}
>
<Input placeholder="搜索…" onChange={e => setQuery(e.target.value)} style={{ marginBottom: 12 }} />
<ImagesGrid
images={images}
isLoading={isLoading}
getPreview={i => i.url}
onSelect={img => store.addElement({ type: 'image', src: img.url, left: 50, top: 50 })}
/>
</div>
);
};
如何从侧边面板拖拽元素到画布?
大多数情况下,<ImagesGrid /> 已经帮你处理好了拖放 —— 你只需要实现 onSelect(见 上面的 Drag and drop behavior)。
但如果你的面板 UI 不适合用网格(比如一列卡片、树形结构、或者你想用富媒体元素),可以不用 <ImagesGrid />,自己写一段 draggable <img> 即可:
<img
draggable
src={url}
onDragEnd={async e => {
// 判断鼠标释放位置是否在 Workspace 容器里
const canvasEl = document.getElementById('canvas_container');
if (!canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) return;
// 拿到 Workspace 坐标系下的位置
const canvas = store.editor!.customCanvas.canvas;
const vpt = canvas.viewportTransform!;
const zoom = vpt[0];
const x = (e.clientX - rect.left - vpt[4]) / zoom;
const y = (e.clientY - rect.top - vpt[5]) / zoom;
store.addElement({
type: 'image',
src: url,
left: x,
top: y,
width: 200,
height: 200,
});
}}
/>
📌 Polotno 为此提供了
unstable_registerNextDomDrop辅助函数。Ydesign 目前没有等价 API —— 上面手写的版本是推荐做法。等后续 Ydesign 也提供专用 helper 后,这段代码可以简化。
下一步
- 👉 自定义 Section
- 👉 Upload Panel ——
<ImagesGrid />的典型使用场景 - 👉 API 参考 · Utility Functions ——
getImageSize/getCrop等辅助方法