diff --git a/docs/docs/api/commonUI.md b/docs/docs/api/commonUI.md index 82b57c389..f3afe1fc2 100644 --- a/docs/docs/api/commonUI.md +++ b/docs/docs/api/commonUI.md @@ -42,7 +42,7 @@ CommonUI API 是一个专为低代码引擎设计的组件 UI 库,使用它开 |------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------| | name | 动作的唯一标识符
Unique identifier for the action | string | | | title | 显示的标题,可以是字符串或国际化数据
Display title, can be a string or internationalized data | string \| IPublicTypeI18nData (optional) | | -| type | 菜单项类型
Menu item type | IPublicEnumContextMenuType (optional) | IPublicEnumPContextMenuType.MENU_ITEM | +| type | 菜单项类型
Menu item type | IPublicEnumContextMenuType (optional) | IPublicEnumContextMenuType.MENU_ITEM | | action | 点击时执行的动作,可选
Action to execute on click, optional | (nodes: IPublicModelNode[]) => void (optional) | | | items | 子菜单项或生成子节点的函数,可选,仅支持两级
Sub-menu items or function to generate child node, optional | Omit[] \| ((nodes: IPublicModelNode[]) => Omit[]) (optional) | | | condition | 显示条件函数
Function to determine display condition | (nodes: IPublicModelNode[]) => boolean (optional) | | diff --git a/packages/designer/src/context-menu-actions.ts b/packages/designer/src/context-menu-actions.ts index 55a83ef2f..8c210d89e 100644 --- a/packages/designer/src/context-menu-actions.ts +++ b/packages/designer/src/context-menu-actions.ts @@ -1,6 +1,6 @@ import { IPublicTypeContextMenuAction, IPublicEnumContextMenuType, IPublicTypeContextMenuItem, IPublicApiMaterial } from '@alilc/lowcode-types'; import { IDesigner, INode } from './designer'; -import { parseContextMenuAsReactNode, parseContextMenuProperties } from '@alilc/lowcode-utils'; +import { parseContextMenuAsReactNode, parseContextMenuProperties, uniqueId } from '@alilc/lowcode-utils'; import { Menu } from '@alifd/next'; import { engineConfig } from '@alilc/lowcode-editor-core'; import './context-menu-actions.scss'; @@ -17,7 +17,100 @@ export interface IContextMenuActions { adjustMenuLayout: IPublicApiMaterial['adjustContextMenuLayout']; } -let destroyFn: Function | undefined; +let adjustMenuLayoutFn: Function = (actions: IPublicTypeContextMenuAction[]) => actions; + +export class GlobalContextMenuActions { + enableContextMenu: boolean; + + dispose: Function[]; + + contextMenuActionsMap: Map = new Map(); + + constructor() { + this.dispose = []; + + engineConfig.onGot('enableContextMenu', (enable) => { + if (this.enableContextMenu === enable) { + return; + } + this.enableContextMenu = enable; + this.dispose.forEach(d => d()); + if (enable) { + this.initEvent(); + } + }); + } + + handleContextMenu = ( + event: MouseEvent, + ) => { + event.stopPropagation(); + event.preventDefault(); + + const actions: IPublicTypeContextMenuAction[] = []; + this.contextMenuActionsMap.forEach((contextMenu) => { + actions.push(...contextMenu.actions); + }); + + let destroyFn: Function | undefined; + + const destroy = () => { + destroyFn?.(); + }; + + const menus: IPublicTypeContextMenuItem[] = parseContextMenuProperties(actions, { + nodes: [], + destroy, + event, + }); + + if (!menus.length) { + return; + } + + const layoutMenu = adjustMenuLayoutFn(menus); + + const menuNode = parseContextMenuAsReactNode(layoutMenu, { + destroy, + nodes: [], + }); + + const target = event.target; + + const { top, left } = target?.getBoundingClientRect(); + + const menuInstance = Menu.create({ + target: event.target, + offset: [event.clientX - left, event.clientY - top], + children: menuNode, + className: 'engine-context-menu', + }); + + destroyFn = (menuInstance as any).destroy; + }; + + initEvent() { + this.dispose.push( + (() => { + const handleContextMenu = (e: MouseEvent) => { + this.handleContextMenu(e); + }; + + document.addEventListener('contextmenu', handleContextMenu); + + return () => { + document.removeEventListener('contextmenu', handleContextMenu); + }; + })(), + ); + } + + registerContextMenuActions(contextMenu: ContextMenuActions) { + this.contextMenuActionsMap.set(contextMenu.id, contextMenu); + } +} + +const globalContextMenuActions = new GlobalContextMenuActions(); export class ContextMenuActions implements IContextMenuActions { actions: IPublicTypeContextMenuAction[] = []; @@ -28,6 +121,8 @@ export class ContextMenuActions implements IContextMenuActions { enableContextMenu: boolean; + id: string = uniqueId('contextMenu');; + constructor(designer: IDesigner) { this.designer = designer; this.dispose = []; @@ -42,6 +137,8 @@ export class ContextMenuActions implements IContextMenuActions { this.initEvent(); } }); + + globalContextMenuActions.registerContextMenuActions(this); } handleContextMenu = ( @@ -57,7 +154,7 @@ export class ContextMenuActions implements IContextMenuActions { const { bounds } = designer.project.simulator?.viewport || { bounds: { left: 0, top: 0 } }; const { left: simulatorLeft, top: simulatorTop } = bounds; - destroyFn?.(); + let destroyFn: Function | undefined; const destroy = () => { destroyFn?.(); @@ -66,13 +163,14 @@ export class ContextMenuActions implements IContextMenuActions { const menus: IPublicTypeContextMenuItem[] = parseContextMenuProperties(actions, { nodes: nodes.map(d => designer.shellModelFactory.createNode(d)!), destroy, + event, }); if (!menus.length) { return; } - const layoutMenu = designer.contextMenuActions.adjustMenuLayoutFn(menus); + const layoutMenu = adjustMenuLayoutFn(menus); const menuNode = parseContextMenuAsReactNode(layoutMenu, { destroy, @@ -111,22 +209,9 @@ export class ContextMenuActions implements IContextMenuActions { const nodes = designer.currentSelection.getNodes(); this.handleContextMenu(nodes, originalEvent); }), - (() => { - const handleContextMenu = (e: MouseEvent) => { - this.handleContextMenu([], e); - }; - - document.addEventListener('contextmenu', handleContextMenu); - - return () => { - document.removeEventListener('contextmenu', handleContextMenu); - }; - })(), ); } - adjustMenuLayoutFn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[] = (actions) => actions; - addMenuAction(action: IPublicTypeContextMenuAction) { this.actions.push({ type: IPublicEnumContextMenuType.MENU_ITEM, @@ -142,6 +227,6 @@ export class ContextMenuActions implements IContextMenuActions { } adjustMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]) { - this.adjustMenuLayoutFn = fn; + adjustMenuLayoutFn = fn; } } \ No newline at end of file diff --git a/packages/engine/src/inner-plugins/default-context-menu.ts b/packages/engine/src/inner-plugins/default-context-menu.ts index 50a86fcec..fc1da96b4 100644 --- a/packages/engine/src/inner-plugins/default-context-menu.ts +++ b/packages/engine/src/inner-plugins/default-context-menu.ts @@ -70,7 +70,7 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { material.addContextMenuOption({ name: 'copyAndPaste', - title: intl('Copy'), + title: intl('CopyAndPaste'), condition: (nodes) => { return nodes.length === 1; }, @@ -86,7 +86,7 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { material.addContextMenuOption({ name: 'copy', - title: intl('Copy.1'), + title: intl('Copy'), condition(nodes) { return nodes.length > 0; }, diff --git a/packages/engine/src/locale/en-US.json b/packages/engine/src/locale/en-US.json index 1cdb06f28..e93160707 100644 --- a/packages/engine/src/locale/en-US.json +++ b/packages/engine/src/locale/en-US.json @@ -1,8 +1,8 @@ { "NotValidNodeData": "Not valid node data", "SelectComponents": "Select components", + "CopyAndPaste": "Copy and Paste", "Copy": "Copy", - "Copy.1": "Copy", "PasteToTheBottom": "Paste to the bottom", "PasteToTheInside": "Paste to the inside", "Delete": "Delete" diff --git a/packages/engine/src/locale/zh-CN.json b/packages/engine/src/locale/zh-CN.json index ba0f87f8e..9b68b7149 100644 --- a/packages/engine/src/locale/zh-CN.json +++ b/packages/engine/src/locale/zh-CN.json @@ -1,8 +1,8 @@ { "NotValidNodeData": "不是有效的节点数据", "SelectComponents": "选择组件", - "Copy": "复制", - "Copy.1": "拷贝", + "CopyAndPaste": "复制", + "Copy": "拷贝", "PasteToTheBottom": "粘贴至下方", "PasteToTheInside": "粘贴至内部", "Delete": "删除" diff --git a/packages/shell/src/components/context-menu.tsx b/packages/shell/src/components/context-menu.tsx index 0085e1c77..f12f6ca93 100644 --- a/packages/shell/src/components/context-menu.tsx +++ b/packages/shell/src/components/context-menu.tsx @@ -6,10 +6,12 @@ import React from 'react'; export function ContextMenu({ children, menus }: { menus: IPublicTypeContextMenuAction[]; - children: React.ReactElement[]; -}): React.ReactElement>[] { + children: React.ReactElement[] | React.ReactElement; +}): React.ReactElement> { if (!engineConfig.get('enableContextMenu')) { - return children; + return ( + <>{ children } + ); } const handleContextMenu = (event: React.MouseEvent) => { @@ -26,6 +28,10 @@ export function ContextMenu({ children, menus }: { destroy, })); + if (!children?.length) { + return; + } + const menuInstance = Menu.create({ target: event.target, offset: [event.clientX - left, event.clientY - top], @@ -42,5 +48,7 @@ export function ContextMenu({ children, menus }: { { onContextMenu: handleContextMenu }, )); - return childrenWithContextMenu; + return ( + <>{childrenWithContextMenu} + ); } \ No newline at end of file diff --git a/packages/types/src/shell/api/commonUI.ts b/packages/types/src/shell/api/commonUI.ts index dcc6fab0c..71e2bbe83 100644 --- a/packages/types/src/shell/api/commonUI.ts +++ b/packages/types/src/shell/api/commonUI.ts @@ -49,6 +49,6 @@ export interface IPublicApiCommonUI { get ContextMenu(): (props: { menus: IPublicTypeContextMenuAction[]; - children: React.ReactElement[]; - }) => ReactElement[]; + children: React.ReactElement[] | React.ReactElement; + }) => ReactElement; } \ No newline at end of file diff --git a/packages/types/src/shell/type/context-menu.ts b/packages/types/src/shell/type/context-menu.ts index 595893d32..1eeb93d69 100644 --- a/packages/types/src/shell/type/context-menu.ts +++ b/packages/types/src/shell/type/context-menu.ts @@ -26,7 +26,7 @@ export interface IPublicTypeContextMenuAction { * 菜单项类型 * Menu item type * @see IPublicEnumContextMenuType - * @default IPublicEnumPContextMenuType.MENU_ITEM + * @default IPublicEnumContextMenuType.MENU_ITEM */ type?: IPublicEnumContextMenuType; @@ -34,7 +34,7 @@ export interface IPublicTypeContextMenuAction { * 点击时执行的动作,可选 * Action to execute on click, optional */ - action?: (nodes: IPublicModelNode[]) => void; + action?: (nodes: IPublicModelNode[], event?: MouseEvent) => void; /** * 子菜单项或生成子节点的函数,可选,仅支持两级 diff --git a/packages/utils/src/context-menu.tsx b/packages/utils/src/context-menu.tsx index f65bc312f..f28619df6 100644 --- a/packages/utils/src/context-menu.tsx +++ b/packages/utils/src/context-menu.tsx @@ -89,42 +89,61 @@ export function parseContextMenuAsReactNode(menus: IPublicTypeContextMenuItem[], return children; } +let destroyFn: Function | undefined; export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction | Omit)[], options: { nodes?: IPublicModelNode[] | null; destroy?: Function; + event?: MouseEvent; }, level = 1): IPublicTypeContextMenuItem[] { + destroyFn?.(); + destroyFn = options.destroy; + const { nodes, destroy } = options; if (level > MAX_LEVEL) { logger.warn('context menu level is too deep, please check your context menu config'); return []; } - return menus.filter(menu => !menu.condition || (menu.condition && menu.condition(nodes || []))).map((menu) => { - const { - name, - title, - type = IPublicEnumContextMenuType.MENU_ITEM, - } = menu; - - const result: IPublicTypeContextMenuItem = { - name, - title, - type, - action: () => { - destroy?.(); - menu.action?.(nodes || []); - }, - disabled: menu.disabled && menu.disabled(nodes || []) || false, - }; - - if ('items' in menu && menu.items) { - result.items = parseContextMenuProperties( - typeof menu.items === 'function' ? menu.items(nodes || []) : menu.items, - options, - level + 1, - ); - } + return menus + .filter(menu => !menu.condition || (menu.condition && menu.condition(nodes || []))) + .map((menu) => { + const { + name, + title, + type = IPublicEnumContextMenuType.MENU_ITEM, + } = menu; + + const result: IPublicTypeContextMenuItem = { + name, + title, + type, + action: () => { + destroy?.(); + menu.action?.(nodes || [], options.event); + }, + disabled: menu.disabled && menu.disabled(nodes || []) || false, + }; + + if ('items' in menu && menu.items) { + result.items = parseContextMenuProperties( + typeof menu.items === 'function' ? menu.items(nodes || []) : menu.items, + options, + level + 1, + ); + } - return result; - }); + return result; + }) + .reduce((menus: IPublicTypeContextMenuItem[], currentMenu: IPublicTypeContextMenuItem) => { + if (!currentMenu.name) { + return menus.concat([currentMenu]); + } + + const index = menus.find(item => item.name === currentMenu.name); + if (!index) { + return menus.concat([currentMenu]); + } else { + return menus; + } + }, []); } \ No newline at end of file