跳到主要内容

ContextMenu

规划中

原生 <ContextMenu /> 组件即将推出。目前 @ydesign/react-editor 还没有内置的右键菜单。

这个页面同时介绍:① 即将发布的 <ContextMenu /> 预期 API,以及 ② 当前可用的 DIY 替代方案


它是什么?

<ContextMenu /> 是一个挂在 <Workspace /> 旁边的附加组件,在画布上右键点击时弹出。

它会根据当前选中的元素类型动态决定显示哪些菜单项:

  • 选中图片:裁剪 / 替换图片 / 描边 / 消除笔 / 复制 / 删除 / 置顶…
  • 选中文字:编辑 / 粘贴为纯文本 / 复制 / 删除 / 置顶…
  • 没有选中(空白处右键):粘贴 / 全选 / 适配屏幕…

Toolbar 是互补的关系:Toolbar 常驻,ContextMenu 按需弹出,两者共用同一套 store.editor.xxxHandler 下发的动作。


为什么单独做这个组件?

一般人第一反应会想:"我自己监听 contextmenu 弹个菜单不就行了?"

确实可以(见 替代方案),但做成独立组件后可以解决这些琐碎问题:

  • 坐标正确:点在画布里的哪个对象上、菜单应该弹在哪个位置、超出边缘时向哪个方向翻转
  • 选区同步:右键点击一个未选中的对象时,应该自动把它选中
  • 类型感知:不同元素类型的菜单项完全不同,硬编码会写成 if-else 地狱
  • 定制入口统一:业务方可以通过一个 API 往菜单里塞自家的"收藏这张图 / 发送到协同"等按钮

基本用法(预期)

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>
);

挂上就有默认菜单。


菜单项数据结构

interface MenuItem {
/** 菜单项文案;也可以是 i18n key */
label: string | (({ store, element, elements }) => string);

/** 点击时触发 */
action: (ctx: {
store: StoreType;
element?: FabricObject; // 右键点中的元素(可能为空)
elements: FabricObject[]; // 当前选中的元素列表
event: MouseEvent; // 原始 contextmenu 事件
}) => void;

/** 菜单项图标(任意 React 节点,建议 lucide-react 图标)*/
icon?: React.ReactNode;

/** 是否隐藏 */
hidden?: boolean | ((ctx) => boolean);

/** 是否禁用(仍显示但不可点击) */
disabled?: boolean | ((ctx) => boolean);

/** 快捷键提示文案,仅展示不绑定;绑定请用 HotkeyHandler */
shortcut?: string;

/** 分组标识;相邻项同组之间自动加分隔线 */
group?: string;

/** 子菜单 */
children?: MenuItem[];
}

内置的 action 快捷方式

对于常见动作,可以直接用字符串:

{ label: '复制',   action: 'duplicate' }
{ label: '删除', action: 'remove' }
{ label: '置顶', action: 'bringToFront' }
{ label: '置底', action: 'sendToBack' }
{ label: '上移一层', action: 'bringForward' }
{ label: '下移一层', action: 'sendBackwards' }
{ label: '锁定', action: 'lock' }
{ label: '组合', action: 'group' }
{ label: '拆分', action: 'ungroup' }

底层等价于对应的 store.editor.xxxHandler.xxx() 调用。


自定义菜单项

方式 A:整体替换(通过 items prop)

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

const items = [
{
label: '复制',
icon: <Copy size={14} />,
shortcut: '⌘C',
action: 'duplicate',
},
{
label: '删除',
icon: <Trash size={14} />,
shortcut: 'Del',
action: 'remove',
disabled: ({ elements }) => elements.length === 0,
},
{
label: 'AI 美化',
icon: <Sparkles size={14} />,
// 只在选中图片时显示
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:按元素类型注册(和 Toolbar 保持同风格)

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

// 选中图片时,使用这套菜单
registerContextMenu('image', [
{ label: '裁剪', action: ({ store, element }) => store.editor?.imageCropHandler.cropImg.onEnterCrop(element) },
{ label: 'AI 消除笔', action: () => store.editor?.inpaintHandler.activate('brush') },
{ label: '替换图片', action: () => openImagePicker() },
{ label: '---', group: 'divider' },
{ label: '复制', action: 'duplicate' },
{ label: '删除', action: 'remove' },
]);

// 选中文字时,覆盖默认文字菜单
registerContextMenu('textbox', [
/* ... */
]);

// 画布空白区的菜单
registerContextMenu('default', [
{ label: '粘贴', action: 'paste' },
{ label: '全选', action: 'selectAll' },
{ label: '适配屏幕', action: () => store.editor?.zoomHandler.zoomToFit() },
]);

支持注册的 type:

  • 所有 Fabric 元素类型(textbox / image / path / rect / circle / ...)
  • many —— 多选场景
  • default —— 空白区域右键

方式 C:transformer 在默认菜单基础上增改

如果你不想重写整份菜单,只想"在默认基础上加一项 / 改一项":

<ContextMenu
store={store}
transformer={({ items, store, element, elements }) => {
// 只在图片上追加一项
if (element?.type === 'image') {
return [
...items,
{
label: '下载原图',
action: () => downloadImage(element.src),
},
];
}
return items;
}}
/>

transformer 每次菜单打开时都会被调用,接到的 items 是系统计算好的"当前选中场景应该显示的菜单项",返回新数组即可。


禁用整个菜单

传一个 null 组件或设置 disabled

// 彻底关掉(浏览器原生菜单会出来)
<ContextMenu store={store} disabled />

想让浏览器原生菜单也不要出来,在 <Workspace /> 所在容器上加 onContextMenu={e => e.preventDefault()}


完整示例

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: '复制', icon: <Copy size={14} />, shortcut: '⌘C', action: 'duplicate' },
{ label: '剪切', shortcut: '⌘X', action: 'cut' },
{ label: '粘贴', shortcut: '⌘V', action: 'paste' },
{ group: 'layer' },
{ label: '上移一层', action: 'bringForward' },
{ label: '下移一层', action: 'sendBackwards' },
{ label: '置顶', action: 'bringToFront' },
{ label: '置底', action: 'sendToBack' },
{ group: 'ai' },
{
label: 'AI 美化图片',
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: '下载原图',
icon: <Download size={14} />,
hidden: ({ element }) => element?.type !== 'image',
action: ({ element }) => downloadImage(element.src),
},
{ group: 'danger' },
{
label: '删除',
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>
);

替代方案(开箱前先用这个)

原生 <ContextMenu /> 发布前,可以用 Antd 的 Dropdown 手动拼一个。下面是一个能直接跑的最小实现:

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: '粘贴', onClick: () => store.editor?.objectsHandler.paste?.() },
{ key: 'selectAll', label: '全选' },
];
}
const common: MenuProps['items'] = [
{ key: 'duplicate', label: '复制', onClick: () => store.clone() },
{ key: 'remove', label: '删除', danger: true, onClick: () => store.editor?.objectsHandler.remove() },
{ type: 'divider' },
{ key: 'top', label: '置顶', onClick: () => store.moveElementsTop([element.id]) },
{ key: 'bottom', label: '置底', onClick: () => store.moveElementsBottom([element.id]) },
];
if (element.type === 'image') {
return [
{ key: 'crop', label: '裁剪', 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>
);
});

// 使用
<DIYContextMenu store={store}>
<Workspace store={store} />
</DIYContextMenu>;

几个关键点:

  • 外层 div 承担 onContextMenu 监听,用 preventDefault() 阻止浏览器原生菜单
  • position 是鼠标相对容器的坐标
  • Antd Dropdown 的"定位锚点"是一个 1×1 的不可见 div,通过绝对定位让菜单出现在右键位置
  • items 根据 store.selectedElements[0].type 动态切换

路线图

  • M1 — 基础组件(右键弹出 + 自动定位 + 边缘避让)
  • M2 — 内置类型菜单:default / textbox / image / figure / many
  • M3items / transformer 两种定制入口
  • M4registerContextMenu('type', items) 按类型注册
  • M5 — 快捷键提示文案自动从 HotkeyHandler 读取
  • M6 — 子菜单、分组、图标、i18n 支持

想加入 Beta 或有功能建议,请在 GitHub Discussions 留言。


与 Polotno ContextMenu 的区别

能力PolotnoYdesign(规划)
通过 <Workspace components={{ ContextMenu }} /> 覆盖❌(附加组件挂在 Workspace 旁,不是 components 子项)
菜单项字段 action / label / hidden / disabled✅ 对齐
菜单项字段 iconName(Blueprint 图标名)改为 icon: ReactNode(建议 lucide-react)
transformer 在默认菜单基础上增改✅ 对齐
null 禁用✅(disabled prop)
按元素类型注册不强调✅ 额外提供 registerContextMenu('type', items)

下一步