Skip to main content

ContextMenu (Right-click Menu)

Planned

A native <ContextMenu /> is coming soon. @ydesign/react-editor does not ship a built-in context menu today.

This page covers both ① the expected API of the upcoming component, and ② a practical DIY workaround you can use right now.


What it is

<ContextMenu /> is an add-on component that lives next to <Workspace /> and pops up on right-click inside the canvas.

Its contents change based on the current selection type:

  • Image selected: Crop / Replace image / Stroke / AI eraser / Duplicate / Delete / Bring to front…
  • Text selected: Edit / Paste as plain text / Duplicate / Delete / Bring to front…
  • No selection (empty area right-click): Paste / Select all / Fit to screen…

It complements Toolbar: the toolbar is always visible, the context menu shows up on demand, and both dispatch actions to the same store.editor.xxxHandler methods.


Why a dedicated component?

You might think: "Can't I just listen for contextmenu myself and show a menu?"

You can (see Workarounds) — but a built-in component handles several annoying details for you:

  • Correct positioning: which object was clicked, where the menu should appear, which side to flip on when near the edge
  • Selection sync: right-clicking an unselected object should auto-select it first
  • Type awareness: menu contents differ per element type — hand-rolling this turns into an if-else jungle
  • Single customization surface: host apps can push their own items (e.g. "Save to favorites / Send to collab") through a single API

Basic usage (expected)

import { ContextMenu } from '@ydesign/react-editor/canvas/context-menu';
import Workspace from '@ydesign/react-editor/canvas/workspace';

const App = ({ store }) => (
<div style={{ position: 'relative', flex: 1 }}>
<Workspace store={store} />
<ContextMenu store={store} />
</div>
);

Mount it and you get sensible defaults.


interface MenuItem {
/** Label text — can also be an i18n key or a function */
label: string | (({ store, element, elements }) => string);

/** Click handler */
action: (ctx: {
store: StoreType;
element?: FabricObject; // the right-clicked element (may be undefined)
elements: FabricObject[]; // currently selected elements
event: MouseEvent; // the raw contextmenu event
}) => void;

/** Icon — any React node; lucide-react icons are recommended */
icon?: React.ReactNode;

/** Hidden */
hidden?: boolean | ((ctx) => boolean);

/** Disabled (still rendered, not clickable) */
disabled?: boolean | ((ctx) => boolean);

/** Keyboard shortcut hint — display only; bind via HotkeyHandler */
shortcut?: string;

/** Group id — adjacent items in the same group get separators between groups */
group?: string;

/** Submenu */
children?: MenuItem[];
}

Built-in action shortcuts

For common actions, you can pass a string:

{ label: 'Duplicate', action: 'duplicate' }
{ label: 'Delete', action: 'remove' }
{ label: 'To front', action: 'bringToFront' }
{ label: 'To back', action: 'sendToBack' }
{ label: 'Forward', action: 'bringForward' }
{ label: 'Backward', action: 'sendBackwards' }
{ label: 'Lock', action: 'lock' }
{ label: 'Group', action: 'group' }
{ label: 'Ungroup', action: 'ungroup' }

These map directly to the matching store.editor.xxxHandler.xxx() calls.


Customizing items

A) Replace the whole menu via items

import { ContextMenu } from '@ydesign/react-editor/canvas/context-menu';
import { Copy, Trash, Sparkles } from 'lucide-react';

const items = [
{
label: 'Duplicate',
icon: <Copy size={14} />,
shortcut: '⌘C',
action: 'duplicate',
},
{
label: 'Delete',
icon: <Trash size={14} />,
shortcut: 'Del',
action: 'remove',
disabled: ({ elements }) => elements.length === 0,
},
{
label: 'AI enhance',
icon: <Sparkles size={14} />,
// Only on images
hidden: ({ element }) => element?.type !== 'image',
action: async ({ store, element }) => {
const newSrc = await callYourAIService(element.src);
store.set({ src: newSrc }, element);
},
},
];

<ContextMenu store={store} items={items} />;

B) Register by element type (symmetric to Toolbar)

import { registerContextMenu } from '@ydesign/react-editor/canvas/context-menu';

// When an image is selected, use this menu
registerContextMenu('image', [
{ label: 'Crop', action: ({ store, element }) => store.editor?.imageCropHandler.cropImg.onEnterCrop(element) },
{ label: 'AI eraser', action: () => store.editor?.inpaintHandler.activate('brush') },
{ label: 'Replace', action: () => openImagePicker() },
{ label: '---', group: 'divider' },
{ label: 'Duplicate', action: 'duplicate' },
{ label: 'Delete', action: 'remove' },
]);

// Override the default text menu
registerContextMenu('textbox', [ /* ... */ ]);

// Menu for right-clicking empty canvas
registerContextMenu('default', [
{ label: 'Paste', action: 'paste' },
{ label: 'Select all', action: 'selectAll' },
{ label: 'Fit to screen', action: () => store.editor?.zoomHandler.zoomToFit() },
]);

Registerable types:

  • Any Fabric element type (textbox / image / path / rect / circle / …)
  • many — multi-selection
  • default — right-click on empty canvas

C) transformer to patch the default menu

If you don't want to rewrite the whole menu — just add or tweak one item:

<ContextMenu
store={store}
transformer={({ items, store, element, elements }) => {
// Add "Download original" only for images
if (element?.type === 'image') {
return [
...items,
{
label: 'Download original',
action: () => downloadImage(element.src),
},
];
}
return items;
}}
/>

transformer runs every time the menu opens. It receives the items the system would normally show for the current selection. Return a new array.


Disable the menu entirely

// Falls back to the native browser menu
<ContextMenu store={store} disabled />

To also suppress the browser menu, add onContextMenu={e => e.preventDefault()} to the container hosting <Workspace />.


Full example

import { Workspace } from '@ydesign/react-editor/canvas/workspace';
import { ContextMenu } from '@ydesign/react-editor/canvas/context-menu';
import { Copy, Trash, Sparkles, Download } from 'lucide-react';

const items = [
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘C', action: 'duplicate' },
{ label: 'Cut', shortcut: '⌘X', action: 'cut' },
{ label: 'Paste', shortcut: '⌘V', action: 'paste' },
{ group: 'layer' },
{ label: 'Forward', action: 'bringForward' },
{ label: 'Backward', action: 'sendBackwards' },
{ label: 'To front', action: 'bringToFront' },
{ label: 'To back', action: 'sendToBack' },
{ group: 'ai' },
{
label: 'AI enhance image',
icon: <Sparkles size={14} />,
hidden: ({ element }) => element?.type !== 'image',
action: async ({ store, element }) => {
const url = await callAIService(element.src);
store.set({ src: url }, element);
},
},
{
label: 'Download original',
icon: <Download size={14} />,
hidden: ({ element }) => element?.type !== 'image',
action: ({ element }) => downloadImage(element.src),
},
{ group: 'danger' },
{
label: 'Delete',
icon: <Trash size={14} />,
shortcut: 'Del',
action: 'remove',
},
];

const App = ({ store }) => (
<div style={{ position: 'relative', flex: 1 }}>
<Workspace store={store} />
<ContextMenu store={store} items={items} />
</div>
);

Workarounds (use this before the native one ships)

Until the native <ContextMenu /> ships, Antd's Dropdown gets you a workable menu with minimal effort. A self-contained example:

import { useState, useRef } from 'react';
import { Dropdown } from 'antd';
import { observer } from 'mobx-react-lite';
import type { MenuProps } from 'antd';

export const DIYContextMenu = observer(({ store, children }) => {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);

const element = store.selectedElements[0];

const items: MenuProps['items'] = (() => {
if (!element) {
return [
{ key: 'paste', label: 'Paste', onClick: () => store.editor?.objectsHandler.paste?.() },
{ key: 'selectAll', label: 'Select all' },
];
}
const common: MenuProps['items'] = [
{ key: 'duplicate', label: 'Duplicate', onClick: () => store.clone() },
{ key: 'remove', label: 'Delete', danger: true, onClick: () => store.editor?.objectsHandler.remove() },
{ type: 'divider' },
{ key: 'top', label: 'To front', onClick: () => store.moveElementsTop([element.id]) },
{ key: 'bottom', label: 'To back', onClick: () => store.moveElementsBottom([element.id]) },
];
if (element.type === 'image') {
return [
{ key: 'crop', label: 'Crop', onClick: () => store.editor?.imageCropHandler.cropImg.onEnterCrop(element) },
{ type: 'divider' },
...common,
];
}
return common;
})();

const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const rect = containerRef.current!.getBoundingClientRect();
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setOpen(true);
};

return (
<div ref={containerRef} onContextMenu={onContextMenu} style={{ position: 'relative', flex: 1 }}>
{children}
<Dropdown
menu={{ items, onClick: () => setOpen(false) }}
open={open}
onOpenChange={setOpen}
trigger={[]}
>
<div
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: 1,
height: 1,
pointerEvents: 'none',
}}
/>
</Dropdown>
</div>
);
});

// Usage
<DIYContextMenu store={store}>
<Workspace store={store} />
</DIYContextMenu>;

Key details:

  • The outer div catches onContextMenu and calls preventDefault() to kill the native menu
  • position is the mouse coordinate relative to the container
  • Antd Dropdown's anchor is a 1×1 invisible div positioned at the mouse — the menu appears right where the click happened
  • items switches based on store.selectedElements[0].type

Roadmap

  • M1 — Base component (popup + auto-position + edge avoidance)
  • M2 — Built-in type menus: default / textbox / image / figure / many
  • M3items / transformer customization paths
  • M4registerContextMenu('type', items) type-level registration
  • M5 — Pull shortcut labels from HotkeyHandler automatically
  • M6 — Submenus, groups, icons, i18n

Want to help design or beta-test? Join us in GitHub Discussions.


Differences from Polotno's ContextMenu

FeaturePolotnoYdesign (planned)
Override via <Workspace components={{ ContextMenu }} />❌ (mounted as a sibling of Workspace, not a components child)
Item fields action / label / hidden / disabled✅ aligned
Item field iconName (Blueprint icon names)icon: ReactNode instead (lucide-react recommended)
transformer to patch the default menu✅ aligned
Pass null to disable✅ (disabled prop)
Per-element-type registrationNot a primary path✅ extra registerContextMenu('type', items)

Next