fix(context-menu): fix context menu bugs

This commit is contained in:
liujuping 2024-01-09 16:37:42 +08:00 committed by 林熠
parent 8f0291fc3e
commit c381b85f0a
9 changed files with 176 additions and 64 deletions

View File

@ -42,7 +42,7 @@ CommonUI API 是一个专为低代码引擎设计的组件 UI 库,使用它开
|------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------| |------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------|
| name | 动作的唯一标识符<br/>Unique identifier for the action | string | | | name | 动作的唯一标识符<br/>Unique identifier for the action | string | |
| title | 显示的标题,可以是字符串或国际化数据<br/>Display title, can be a string or internationalized data | string \| IPublicTypeI18nData (optional) | | | title | 显示的标题,可以是字符串或国际化数据<br/>Display title, can be a string or internationalized data | string \| IPublicTypeI18nData (optional) | |
| type | 菜单项类型<br/>Menu item type | IPublicEnumContextMenuType (optional) | IPublicEnumPContextMenuType.MENU_ITEM | | type | 菜单项类型<br/>Menu item type | IPublicEnumContextMenuType (optional) | IPublicEnumContextMenuType.MENU_ITEM |
| action | 点击时执行的动作,可选<br/>Action to execute on click, optional | (nodes: IPublicModelNode[]) => void (optional) | | | action | 点击时执行的动作,可选<br/>Action to execute on click, optional | (nodes: IPublicModelNode[]) => void (optional) | |
| items | 子菜单项或生成子节点的函数,可选,仅支持两级<br/>Sub-menu items or function to generate child node, optional | Omit<IPublicTypeContextMenuAction, 'items'>[] \| ((nodes: IPublicModelNode[]) => Omit<IPublicTypeContextMenuAction, 'items'>[]) (optional) | | | items | 子菜单项或生成子节点的函数,可选,仅支持两级<br/>Sub-menu items or function to generate child node, optional | Omit<IPublicTypeContextMenuAction, 'items'>[] \| ((nodes: IPublicModelNode[]) => Omit<IPublicTypeContextMenuAction, 'items'>[]) (optional) | |
| condition | 显示条件函数<br/>Function to determine display condition | (nodes: IPublicModelNode[]) => boolean (optional) | | | condition | 显示条件函数<br/>Function to determine display condition | (nodes: IPublicModelNode[]) => boolean (optional) | |

View File

@ -1,6 +1,6 @@
import { IPublicTypeContextMenuAction, IPublicEnumContextMenuType, IPublicTypeContextMenuItem, IPublicApiMaterial } from '@alilc/lowcode-types'; import { IPublicTypeContextMenuAction, IPublicEnumContextMenuType, IPublicTypeContextMenuItem, IPublicApiMaterial } from '@alilc/lowcode-types';
import { IDesigner, INode } from './designer'; 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 { Menu } from '@alifd/next';
import { engineConfig } from '@alilc/lowcode-editor-core'; import { engineConfig } from '@alilc/lowcode-editor-core';
import './context-menu-actions.scss'; import './context-menu-actions.scss';
@ -17,19 +17,16 @@ export interface IContextMenuActions {
adjustMenuLayout: IPublicApiMaterial['adjustContextMenuLayout']; adjustMenuLayout: IPublicApiMaterial['adjustContextMenuLayout'];
} }
let destroyFn: Function | undefined; let adjustMenuLayoutFn: Function = (actions: IPublicTypeContextMenuAction[]) => actions;
export class ContextMenuActions implements IContextMenuActions { export class GlobalContextMenuActions {
actions: IPublicTypeContextMenuAction[] = []; enableContextMenu: boolean;
designer: IDesigner;
dispose: Function[]; dispose: Function[];
enableContextMenu: boolean; contextMenuActionsMap: Map<string, ContextMenuActions> = new Map();
constructor(designer: IDesigner) { constructor() {
this.designer = designer;
this.dispose = []; this.dispose = [];
engineConfig.onGot('enableContextMenu', (enable) => { engineConfig.onGot('enableContextMenu', (enable) => {
@ -44,6 +41,106 @@ export class ContextMenuActions implements IContextMenuActions {
}); });
} }
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[] = [];
designer: IDesigner;
dispose: Function[];
enableContextMenu: boolean;
id: string = uniqueId('contextMenu');;
constructor(designer: IDesigner) {
this.designer = designer;
this.dispose = [];
engineConfig.onGot('enableContextMenu', (enable) => {
if (this.enableContextMenu === enable) {
return;
}
this.enableContextMenu = enable;
this.dispose.forEach(d => d());
if (enable) {
this.initEvent();
}
});
globalContextMenuActions.registerContextMenuActions(this);
}
handleContextMenu = ( handleContextMenu = (
nodes: INode[], nodes: INode[],
event: MouseEvent, event: MouseEvent,
@ -57,7 +154,7 @@ export class ContextMenuActions implements IContextMenuActions {
const { bounds } = designer.project.simulator?.viewport || { bounds: { left: 0, top: 0 } }; const { bounds } = designer.project.simulator?.viewport || { bounds: { left: 0, top: 0 } };
const { left: simulatorLeft, top: simulatorTop } = bounds; const { left: simulatorLeft, top: simulatorTop } = bounds;
destroyFn?.(); let destroyFn: Function | undefined;
const destroy = () => { const destroy = () => {
destroyFn?.(); destroyFn?.();
@ -66,13 +163,14 @@ export class ContextMenuActions implements IContextMenuActions {
const menus: IPublicTypeContextMenuItem[] = parseContextMenuProperties(actions, { const menus: IPublicTypeContextMenuItem[] = parseContextMenuProperties(actions, {
nodes: nodes.map(d => designer.shellModelFactory.createNode(d)!), nodes: nodes.map(d => designer.shellModelFactory.createNode(d)!),
destroy, destroy,
event,
}); });
if (!menus.length) { if (!menus.length) {
return; return;
} }
const layoutMenu = designer.contextMenuActions.adjustMenuLayoutFn(menus); const layoutMenu = adjustMenuLayoutFn(menus);
const menuNode = parseContextMenuAsReactNode(layoutMenu, { const menuNode = parseContextMenuAsReactNode(layoutMenu, {
destroy, destroy,
@ -111,22 +209,9 @@ export class ContextMenuActions implements IContextMenuActions {
const nodes = designer.currentSelection.getNodes(); const nodes = designer.currentSelection.getNodes();
this.handleContextMenu(nodes, originalEvent); 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) { addMenuAction(action: IPublicTypeContextMenuAction) {
this.actions.push({ this.actions.push({
type: IPublicEnumContextMenuType.MENU_ITEM, type: IPublicEnumContextMenuType.MENU_ITEM,
@ -142,6 +227,6 @@ export class ContextMenuActions implements IContextMenuActions {
} }
adjustMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]) { adjustMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]) {
this.adjustMenuLayoutFn = fn; adjustMenuLayoutFn = fn;
} }
} }

View File

@ -70,7 +70,7 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => {
material.addContextMenuOption({ material.addContextMenuOption({
name: 'copyAndPaste', name: 'copyAndPaste',
title: intl('Copy'), title: intl('CopyAndPaste'),
condition: (nodes) => { condition: (nodes) => {
return nodes.length === 1; return nodes.length === 1;
}, },
@ -86,7 +86,7 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => {
material.addContextMenuOption({ material.addContextMenuOption({
name: 'copy', name: 'copy',
title: intl('Copy.1'), title: intl('Copy'),
condition(nodes) { condition(nodes) {
return nodes.length > 0; return nodes.length > 0;
}, },

View File

@ -1,8 +1,8 @@
{ {
"NotValidNodeData": "Not valid node data", "NotValidNodeData": "Not valid node data",
"SelectComponents": "Select components", "SelectComponents": "Select components",
"CopyAndPaste": "Copy and Paste",
"Copy": "Copy", "Copy": "Copy",
"Copy.1": "Copy",
"PasteToTheBottom": "Paste to the bottom", "PasteToTheBottom": "Paste to the bottom",
"PasteToTheInside": "Paste to the inside", "PasteToTheInside": "Paste to the inside",
"Delete": "Delete" "Delete": "Delete"

View File

@ -1,8 +1,8 @@
{ {
"NotValidNodeData": "不是有效的节点数据", "NotValidNodeData": "不是有效的节点数据",
"SelectComponents": "选择组件", "SelectComponents": "选择组件",
"Copy": "复制", "CopyAndPaste": "复制",
"Copy.1": "拷贝", "Copy": "拷贝",
"PasteToTheBottom": "粘贴至下方", "PasteToTheBottom": "粘贴至下方",
"PasteToTheInside": "粘贴至内部", "PasteToTheInside": "粘贴至内部",
"Delete": "删除" "Delete": "删除"

View File

@ -6,10 +6,12 @@ import React from 'react';
export function ContextMenu({ children, menus }: { export function ContextMenu({ children, menus }: {
menus: IPublicTypeContextMenuAction[]; menus: IPublicTypeContextMenuAction[];
children: React.ReactElement[]; children: React.ReactElement[] | React.ReactElement;
}): React.ReactElement<any, string | React.JSXElementConstructor<any>>[] { }): React.ReactElement<any, string | React.JSXElementConstructor<any>> {
if (!engineConfig.get('enableContextMenu')) { if (!engineConfig.get('enableContextMenu')) {
return children; return (
<>{ children }</>
);
} }
const handleContextMenu = (event: React.MouseEvent) => { const handleContextMenu = (event: React.MouseEvent) => {
@ -26,6 +28,10 @@ export function ContextMenu({ children, menus }: {
destroy, destroy,
})); }));
if (!children?.length) {
return;
}
const menuInstance = Menu.create({ const menuInstance = Menu.create({
target: event.target, target: event.target,
offset: [event.clientX - left, event.clientY - top], offset: [event.clientX - left, event.clientY - top],
@ -42,5 +48,7 @@ export function ContextMenu({ children, menus }: {
{ onContextMenu: handleContextMenu }, { onContextMenu: handleContextMenu },
)); ));
return childrenWithContextMenu; return (
<>{childrenWithContextMenu}</>
);
} }

View File

@ -49,6 +49,6 @@ export interface IPublicApiCommonUI {
get ContextMenu(): (props: { get ContextMenu(): (props: {
menus: IPublicTypeContextMenuAction[]; menus: IPublicTypeContextMenuAction[];
children: React.ReactElement[]; children: React.ReactElement[] | React.ReactElement;
}) => ReactElement[]; }) => ReactElement;
} }

View File

@ -26,7 +26,7 @@ export interface IPublicTypeContextMenuAction {
* *
* Menu item type * Menu item type
* @see IPublicEnumContextMenuType * @see IPublicEnumContextMenuType
* @default IPublicEnumPContextMenuType.MENU_ITEM * @default IPublicEnumContextMenuType.MENU_ITEM
*/ */
type?: IPublicEnumContextMenuType; type?: IPublicEnumContextMenuType;
@ -34,7 +34,7 @@ export interface IPublicTypeContextMenuAction {
* *
* Action to execute on click, optional * Action to execute on click, optional
*/ */
action?: (nodes: IPublicModelNode[]) => void; action?: (nodes: IPublicModelNode[], event?: MouseEvent) => void;
/** /**
* *

View File

@ -89,17 +89,24 @@ export function parseContextMenuAsReactNode(menus: IPublicTypeContextMenuItem[],
return children; return children;
} }
let destroyFn: Function | undefined;
export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction | Omit<IPublicTypeContextMenuAction, 'items'>)[], options: { export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction | Omit<IPublicTypeContextMenuAction, 'items'>)[], options: {
nodes?: IPublicModelNode[] | null; nodes?: IPublicModelNode[] | null;
destroy?: Function; destroy?: Function;
event?: MouseEvent;
}, level = 1): IPublicTypeContextMenuItem[] { }, level = 1): IPublicTypeContextMenuItem[] {
destroyFn?.();
destroyFn = options.destroy;
const { nodes, destroy } = options; const { nodes, destroy } = options;
if (level > MAX_LEVEL) { if (level > MAX_LEVEL) {
logger.warn('context menu level is too deep, please check your context menu config'); logger.warn('context menu level is too deep, please check your context menu config');
return []; return [];
} }
return menus.filter(menu => !menu.condition || (menu.condition && menu.condition(nodes || []))).map((menu) => { return menus
.filter(menu => !menu.condition || (menu.condition && menu.condition(nodes || [])))
.map((menu) => {
const { const {
name, name,
title, title,
@ -112,7 +119,7 @@ export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction
type, type,
action: () => { action: () => {
destroy?.(); destroy?.();
menu.action?.(nodes || []); menu.action?.(nodes || [], options.event);
}, },
disabled: menu.disabled && menu.disabled(nodes || []) || false, disabled: menu.disabled && menu.disabled(nodes || []) || false,
}; };
@ -126,5 +133,17 @@ export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction
} }
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;
}
}, []);
} }