Skip to main content

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;
ArgWhenUse
imageAlwaysYour original data item from the images array
posDrag onlyCanvas-relative drop coordinates; undefined on click
element"Dropped on an element"E.g. replace the src of the image you dropped on
eventAlwaysRaw 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}
/>

💡 crossOrigin defaults 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

PropTypeDefaultDescription
imagesImageType[] | undefinedArray of image data
getPreview(image) => stringReturns the thumbnail URL (required)
onSelect(image, pos?, element?, event?) => voidUnified click / drop handler
isLoadingbooleanShow a spinner
rowsNumbernumber2Number of columns (named rowsNumber for legacy reasons)
getCredit(image) => ReactNodeundefinedOverlay on each image; hover-only on desktop, always visible on mobile
getImageClassName(image) => stringundefinedExtra className for the <img> tag
crossOriginstring'anonymous'<img crossOrigin> — keeps canvas exports untainted
shadowEnabledbooleanfalseDrop shadow on the image container
itemHeightnumber | string'auto'Fixed height or natural ratio
spacingnumber0Padding (px) around each image
erroranyundefinedTruthy = show the localized error message (translate('sidePanel.error'))
hideNoResultsbooleanfalseSuppress the "no results" label on empty data

⚠️ Yes — rowsNumber actually controls columns (the source computes width: 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:

CapabilityPolotnoYdesign equivalent
Paginated fetch + cacheuseInfiniteAPIuseSWRInfinite (SWR) or useInfiniteQuery (TanStack Query)
reset / hasMore / isReachingEndSWR: setSize(1) + inspect data length; TanStack: refetch / hasNextPage
Debounced search termtimeout: 500Roll 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_registerNextDomDrop for 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