Utils API
Helper components and hooks for building custom side panels.
When writing a custom Section you rarely have to build from scratch — Ydesign ships a small set of reusable utilities. This page covers the core ones: the <ImagesGrid /> component plus recipes for remote pagination and drag-and-drop.
📌 The API here takes cues from Polotno's Utils API — you'll see a similar section structure — but every prop, default, and implementation detail is strictly aligned with Ydesign's real code (
packages/react-editor/src/side-panel/images-grid.tsx).
<ImagesGrid /> component
When building a custom asset panel, the most common need is "a grid of thumbnails — click to add to canvas, or drag onto it". Ydesign packages this as <ImagesGrid />, reusable in any Section.
Basic usage
Start with a minimal panel: pass the image collection, a preview getter, and an onSelect callback. The handler receives the original item plus optional drop coordinates and the element that was targeted on the canvas.
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
The current Ydesign build of <ImagesGrid /> does not yet ship a built-in loadMore callback (Polotno does; it's on our roadmap). Until it lands, there are two practical approaches:
Option A: a "Load more" button at the bottom
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}>
Load more
</Button>
)}
</>;
Option B: use SWR / TanStack Query for "near-bottom auto load"
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) ?? [];
// Wrap in a scrollable container; call setSize(size + 1) near the bottom
<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>;
Once native loadMore lands, you can swap the SWR plumbing with minimal changes.
Drag and drop behavior
Every <img> rendered by <ImagesGrid /> has draggable={true} — users can drag thumbnails straight onto the canvas. All drag info is funneled through your onSelect callback; no extra listeners are needed.
onSelect: (
image: ImageType,
pos?: { x: number; y: number }, // drop position (undefined on click)
element?: FabricObject, // canvas element underneath the drop
event?: any, // raw drag event
) => void;
| Arg | When | Use |
|---|---|---|
image | Always | Your original data item from the images array |
pos | Drag only | Canvas-relative drop coordinates; undefined on click |
element | "Dropped on an element" | E.g. replace the src of the image you dropped on |
event | Always | Raw event for extra checks |
A "smart drop" pattern — replace the image if dropped on one, otherwise create a new one:
<ImagesGrid
images={images}
getPreview={i => i.url}
onSelect={(img, pos, target) => {
// 1) Dropped on an existing image → replace its src
if (target?.type === 'image') {
store.set({ src: img.url }, target);
return;
}
// 2) Otherwise create a new one at the drop position
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}
/>
💡
crossOrigindefaults to'anonymous', so exported canvases don't get tainted. Make sure your own image service serves with CORS.
Styling options
<ImagesGrid /> exposes a small set of visual props:
<ImagesGrid
images={images}
getPreview={i => i.url}
onSelect={/* ... */}
isLoading={false}
rowsNumber={3} // number of columns (default 2)
itemHeight={120} // fixed height per image (omit for natural ratio)
spacing={6} // inner padding around each image (px)
shadowEnabled // enable drop shadow on the image container
getCredit={i => <span>by {i.author}</span>} // overlay on top of each image
getImageClassName={i => (i.isVip ? 'vip-badge' : '')} // extra className on <img>
/>
Common recipes:
- Compact grid:
rowsNumber={3}+spacing={2}+itemHeight={80} - Gallery card:
rowsNumber={2}+shadowEnabled+getCredit - List style:
rowsNumber={1}+itemHeight={60}
Prop reference
| Prop | Type | Default | Description |
|---|---|---|---|
images | ImageType[] | undefined | — | Array of image data |
getPreview | (image) => string | — | Returns the thumbnail URL (required) |
onSelect | (image, pos?, element?, event?) => void | — | Unified click / drop handler |
isLoading | boolean | — | Show a spinner |
rowsNumber | number | 2 | Number of columns (named rowsNumber for legacy reasons) |
getCredit | (image) => ReactNode | undefined | Overlay on each image; hover-only on desktop, always visible on mobile |
getImageClassName | (image) => string | undefined | Extra className for the <img> tag |
crossOrigin | string | 'anonymous' | <img crossOrigin> — keeps canvas exports untainted |
shadowEnabled | boolean | false | Drop shadow on the image container |
itemHeight | number | string | 'auto' | Fixed height or natural ratio |
spacing | number | 0 | Padding (px) around each image |
error | any | undefined | Truthy = show the localized error message (translate('sidePanel.error')) |
hideNoResults | boolean | false | Suppress the "no results" label on empty data |
⚠️ Yes —
rowsNumberactually controls columns (the source computeswidth: 100 / rows + '%'). Legacy naming; a friendlier alias with backward compatibility is on the roadmap.
How to use the useInfiniteAPI hook?
Polotno ships a useInfiniteAPI hook for paginated fetches, which is a wrapper around SWR. Ydesign does not ship an equivalent, but the ecosystem has excellent drop-in replacements:
| Capability | Polotno | Ydesign equivalent |
|---|---|---|
| Paginated fetch + cache | useInfiniteAPI | useSWRInfinite (SWR) or useInfiniteQuery (TanStack Query) |
reset / hasMore / isReachingEnd | ✅ | SWR: setSize(1) + inspect data length; TanStack: refetch / hasNextPage |
| Debounced search term | timeout: 500 | Roll your own with useDeferredValue / lodash debounce |
A full SWR + <ImagesGrid /> example:
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="Search…" 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>
);
};
How to drop elements from side panel into workspace?
Most of the time, <ImagesGrid /> handles drag and drop for you — just implement onSelect (see Drag and drop behavior above).
If your panel layout doesn't fit a grid (e.g. a single column of cards, a tree view, or a rich media layout), skip <ImagesGrid /> and hand-wire a draggable <img>:
<img
draggable
src={url}
onDragEnd={async e => {
// Did the user release over the Workspace container?
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;
// Convert screen coords to canvas coords
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 ships
unstable_registerNextDomDropfor this. Ydesign doesn't have an equivalent helper yet — the snippet above is the recommended pattern. Once we ship a dedicated helper, this code can be simplified.
Next
- 👉 Custom Section
- 👉 Upload panel — a canonical
<ImagesGrid />consumer - 👉 API Reference · Utility Functions —
getImageSize/getCrophelpers