跳到主要内容

工具 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 · 完整属性参考

PropTypeDefault说明
imagesImageType[] | undefined图片数据数组
getPreview(image) => string返回缩略图 URL(必填)
onSelect(image, pos?, element?, event?) => void点击 / 拖放的统一回调
isLoadingboolean是否正在加载,展示 <Spin />
rowsNumbernumber2列数(是的,命名叫 rowsNumber,但语义是列数)
getCredit(image) => ReactNodeundefined在图片底部浮现一层署名/操作区;桌面端 hover 显示,移动端常驻
getImageClassName(image) => stringundefined<img> 标签加额外 class
crossOriginstring'anonymous'控制 <img crossOrigin>,保证能用于画布导出
shadowEnabledbooleanfalse图片容器是否有投影
itemHeightnumber | string'auto'图片高度;不设为按原图比例,设为数值时固定高度
spacingnumber0每张图周围的 padding(px)
erroranyundefined任意 truthy 值都会显示错误文案(会走 translate('sidePanel.error')
hideNoResultsbooleanfalse空数组时不显示"暂无结果"占位

⚠️ 你可能注意到 rowsNumber 这个名字语义其实是列数——源码中通过 width: 100 / rows + '%' 平分宽度。这是一个命名遗留问题,未来会做改名 + 兼容旧 prop 的迁移。


如何替代 useInfiniteAPI hook?

Polotno 提供了一个 useInfiniteAPI hook 做分页请求,底层是 SWR 的封装。Ydesign 没有内置这个 hook,但你可以用社区常用方案完整替代:

能力PolotnoYdesign 的替代
分页请求 + 缓存useInfiniteAPIuseSWRInfiniteSWR)或 useInfiniteQueryTanStack Query
reset / hasMore / isReachingEndSWR 的 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 后,这段代码可以简化。


下一步