feat(engine): add context menu
This commit is contained in:
parent
d47c2d2f91
commit
1b00c61a32
|
@ -108,3 +108,5 @@ typings/
|
|||
# codealike
|
||||
codealike.json
|
||||
.node
|
||||
|
||||
.must.config.js
|
|
@ -29,6 +29,26 @@ CommonUI API 是一个专为低代码引擎设计的组件 UI 库,使用它开
|
|||
| className | className | string (optional) | |
|
||||
| onClick | 点击事件 | () => void (optional) | |
|
||||
|
||||
### ContextMenu
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
|--------|----------------------------------------------------|------------------------------------|--------|
|
||||
| menus | 定义上下文菜单的动作数组 | IPublicTypeContextMenuAction[] | |
|
||||
| children | 组件的子元素 | React.ReactElement[] | |
|
||||
|
||||
**IPublicTypeContextMenuAction Interface**
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
|------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------|
|
||||
| name | 动作的唯一标识符<br>Unique identifier for the action | string | |
|
||||
| title | 显示的标题,可以是字符串或国际化数据<br>Display title, can be a string or internationalized data | string \| IPublicTypeI18nData (optional) | |
|
||||
| type | 菜单项类型<br>Menu item type | IPublicEnumContextMenuType (optional) | IPublicEnumPContextMenuType.MENU_ITEM |
|
||||
| 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) | |
|
||||
| condition | 显示条件函数<br>Function to determine display condition | (nodes: IPublicModelNode[]) => boolean (optional) | |
|
||||
| disabled | 禁用条件函数,可选<br>Function to determine disabled condition, optional | (nodes: IPublicModelNode[]) => boolean (optional) | |
|
||||
|
||||
|
||||
### Balloon
|
||||
详细文档: [Balloon Documentation](https://fusion.design/pc/component/balloon)
|
||||
|
||||
|
|
|
@ -185,6 +185,12 @@ config.set('enableCondition', false)
|
|||
|
||||
`@type {boolean}` `@default {false}`
|
||||
|
||||
#### enableContextMenu - 开启右键菜单
|
||||
|
||||
`@type {boolean}` `@default {false}`
|
||||
|
||||
是否开启右键菜单
|
||||
|
||||
#### disableDetecting
|
||||
|
||||
`@type {boolean}` `@default {false}`
|
||||
|
|
|
@ -128,6 +128,7 @@ sidebar_position: 9
|
|||
- `--pane-title-height`: 面板标题高度
|
||||
- `--pane-title-font-size`: 面板标题字体大小
|
||||
- `--pane-title-padding`: 面板标题边距
|
||||
- `--context-menu-item-height`: 右键菜单项高度
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
.engine-context-menu {
|
||||
&.next-menu.next-ver .next-menu-item {
|
||||
padding-right: 30px;
|
||||
|
||||
.next-menu-item-inner {
|
||||
height: var(--context-menu-item-height, 30px);
|
||||
line-height: var(--context-menu-item-height, 30px);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
import { IPublicTypeContextMenuAction, IPublicEnumContextMenuType, IPublicTypeContextMenuItem, IPublicApiMaterial } from '@alilc/lowcode-types';
|
||||
import { IDesigner, INode } from './designer';
|
||||
import { parseContextMenuAsReactNode, parseContextMenuProperties } from '@alilc/lowcode-utils';
|
||||
import { Menu } from '@alifd/next';
|
||||
import { engineConfig } from '@alilc/lowcode-editor-core';
|
||||
import './context-menu-actions.scss';
|
||||
|
||||
export interface IContextMenuActions {
|
||||
actions: IPublicTypeContextMenuAction[];
|
||||
|
||||
adjustMenuLayoutFn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[];
|
||||
|
||||
addMenuAction: IPublicApiMaterial['addContextMenuOption'];
|
||||
|
||||
removeMenuAction: IPublicApiMaterial['removeContextMenuOption'];
|
||||
|
||||
adjustMenuLayout: IPublicApiMaterial['adjustContextMenuLayout'];
|
||||
}
|
||||
|
||||
export class ContextMenuActions implements IContextMenuActions {
|
||||
actions: IPublicTypeContextMenuAction[] = [];
|
||||
|
||||
designer: IDesigner;
|
||||
|
||||
dispose: Function[];
|
||||
|
||||
enableContextMenu: boolean;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleContextMenu = (
|
||||
nodes: INode[],
|
||||
event: MouseEvent,
|
||||
) => {
|
||||
const designer = this.designer;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const actions = designer.contextMenuActions.actions;
|
||||
|
||||
const { bounds } = designer.project.simulator?.viewport || { bounds: { left: 0, top: 0 } };
|
||||
const { left: simulatorLeft, top: simulatorTop } = bounds;
|
||||
|
||||
let destroyFn: Function | undefined;
|
||||
|
||||
const destroy = () => {
|
||||
destroyFn?.();
|
||||
};
|
||||
|
||||
const menus: IPublicTypeContextMenuItem[] = parseContextMenuProperties(actions, {
|
||||
nodes: nodes.map(d => designer.shellModelFactory.createNode(d)!),
|
||||
destroy,
|
||||
});
|
||||
|
||||
if (!menus.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutMenu = designer.contextMenuActions.adjustMenuLayoutFn(menus);
|
||||
|
||||
const menuNode = parseContextMenuAsReactNode(layoutMenu, {
|
||||
destroy,
|
||||
nodes: nodes.map(d => designer.shellModelFactory.createNode(d)!),
|
||||
designer,
|
||||
});
|
||||
|
||||
const target = event.target;
|
||||
|
||||
const { top, left } = target?.getBoundingClientRect();
|
||||
|
||||
const menuInstance = Menu.create({
|
||||
target: event.target,
|
||||
offset: [event.clientX - left + simulatorLeft, event.clientY - top + simulatorTop],
|
||||
children: menuNode,
|
||||
className: 'engine-context-menu',
|
||||
});
|
||||
|
||||
destroyFn = (menuInstance as any).destroy;
|
||||
};
|
||||
|
||||
initEvent() {
|
||||
const designer = this.designer;
|
||||
this.dispose.push(
|
||||
designer.editor.eventBus.on('designer.builtinSimulator.contextmenu', ({
|
||||
node,
|
||||
originalEvent,
|
||||
}: {
|
||||
node: INode;
|
||||
originalEvent: MouseEvent;
|
||||
}) => {
|
||||
// 如果右键的节点不在 当前选中的节点中,选中该节点
|
||||
if (!designer.currentSelection.has(node.id)) {
|
||||
designer.currentSelection.select(node.id);
|
||||
}
|
||||
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,
|
||||
...action,
|
||||
});
|
||||
}
|
||||
|
||||
removeMenuAction(name: string) {
|
||||
const i = this.actions.findIndex((action) => action.name === name);
|
||||
if (i > -1) {
|
||||
this.actions.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
adjustMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]) {
|
||||
this.adjustMenuLayoutFn = fn;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from '@alilc/lowcode-types';
|
||||
import { mergeAssets, IPublicTypeAssetsJson, isNodeSchema, isDragNodeObject, isDragNodeDataObject, isLocationChildrenDetail, Logger } from '@alilc/lowcode-utils';
|
||||
import { IProject, Project } from '../project';
|
||||
import { Node, DocumentModel, insertChildren, INode } from '../document';
|
||||
import { Node, DocumentModel, insertChildren, INode, ISelection } from '../document';
|
||||
import { ComponentMeta, IComponentMeta } from '../component-meta';
|
||||
import { INodeSelector, Component } from '../simulator';
|
||||
import { Scroller } from './scroller';
|
||||
|
@ -32,6 +32,7 @@ import { OffsetObserver, createOffsetObserver } from './offset-observer';
|
|||
import { ISettingTopEntry, SettingTopEntry } from './setting';
|
||||
import { BemToolsManager } from '../builtin-simulator/bem-tools/manager';
|
||||
import { ComponentActions } from '../component-actions';
|
||||
import { ContextMenuActions, IContextMenuActions } from '../context-menu-actions';
|
||||
|
||||
const logger = new Logger({ level: 'warn', bizName: 'designer' });
|
||||
|
||||
|
@ -72,12 +73,16 @@ export interface IDesigner {
|
|||
|
||||
get componentActions(): ComponentActions;
|
||||
|
||||
get contextMenuActions(): ContextMenuActions;
|
||||
|
||||
get editor(): IPublicModelEditor;
|
||||
|
||||
get detecting(): Detecting;
|
||||
|
||||
get simulatorComponent(): ComponentType<any> | undefined;
|
||||
|
||||
get currentSelection(): ISelection;
|
||||
|
||||
createScroller(scrollable: IPublicTypeScrollable): IPublicModelScroller;
|
||||
|
||||
refreshComponentMetasMap(): void;
|
||||
|
@ -122,6 +127,8 @@ export class Designer implements IDesigner {
|
|||
|
||||
readonly componentActions = new ComponentActions();
|
||||
|
||||
readonly contextMenuActions: IContextMenuActions;
|
||||
|
||||
readonly activeTracker = new ActiveTracker();
|
||||
|
||||
readonly detecting = new Detecting();
|
||||
|
@ -198,6 +205,8 @@ export class Designer implements IDesigner {
|
|||
this.postEvent('dragstart', e);
|
||||
});
|
||||
|
||||
this.contextMenuActions = new ContextMenuActions(this);
|
||||
|
||||
this.dragon.onDrag((e) => {
|
||||
if (this.props?.onDrag) {
|
||||
this.props.onDrag(e);
|
||||
|
|
|
@ -6,3 +6,4 @@ export * from './project';
|
|||
export * from './builtin-simulator';
|
||||
export * from './plugin';
|
||||
export * from './types';
|
||||
export * from './context-menu-actions';
|
||||
|
|
|
@ -159,6 +159,11 @@ const VALID_ENGINE_OPTIONS = {
|
|||
type: 'function',
|
||||
description: '应用级设计模式下,窗口为空时展示的占位组件',
|
||||
},
|
||||
enableContextMenu: {
|
||||
type: 'boolean',
|
||||
description: '是否开启右键菜单',
|
||||
default: false,
|
||||
},
|
||||
hideComponentAction: {
|
||||
type: 'boolean',
|
||||
description: '是否隐藏设计器辅助层',
|
||||
|
|
|
@ -62,6 +62,7 @@ import { setterRegistry } from './inner-plugins/setter-registry';
|
|||
import { defaultPanelRegistry } from './inner-plugins/default-panel-registry';
|
||||
import { shellModelFactory } from './modules/shell-model-factory';
|
||||
import { builtinHotkey } from './inner-plugins/builtin-hotkey';
|
||||
import { defaultContextMenu } from './inner-plugins/default-context-menu';
|
||||
import { OutlinePlugin } from '@alilc/lowcode-plugin-outline-pane';
|
||||
|
||||
export * from './modules/skeleton-types';
|
||||
|
@ -78,6 +79,7 @@ async function registryInnerPlugin(designer: IDesigner, editor: IEditor, plugins
|
|||
await plugins.register(defaultPanelRegistryPlugin);
|
||||
await plugins.register(builtinHotkey);
|
||||
await plugins.register(registerDefaults, {}, { autoInit: true });
|
||||
await plugins.register(defaultContextMenu);
|
||||
|
||||
return () => {
|
||||
plugins.delete(OutlinePlugin.pluginName);
|
||||
|
@ -86,6 +88,7 @@ async function registryInnerPlugin(designer: IDesigner, editor: IEditor, plugins
|
|||
plugins.delete(defaultPanelRegistryPlugin.pluginName);
|
||||
plugins.delete(builtinHotkey.pluginName);
|
||||
plugins.delete(registerDefaults.pluginName);
|
||||
plugins.delete(defaultContextMenu.pluginName);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
import {
|
||||
IPublicEnumContextMenuType,
|
||||
IPublicEnumTransformStage,
|
||||
IPublicModelNode,
|
||||
IPublicModelPluginContext,
|
||||
IPublicTypeNodeSchema,
|
||||
} from '@alilc/lowcode-types';
|
||||
import { isProjectSchema } from '@alilc/lowcode-utils';
|
||||
import { Notification } from '@alifd/next';
|
||||
import { intl } from '../locale';
|
||||
|
||||
function getNodesSchema(nodes: IPublicModelNode[]) {
|
||||
const componentsTree = nodes.map((node) => node?.exportSchema(IPublicEnumTransformStage.Clone));
|
||||
const data = { type: 'nodeSchema', componentsMap: {}, componentsTree };
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getClipboardText(): Promise<IPublicTypeNodeSchema[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 使用 Clipboard API 读取剪贴板内容
|
||||
navigator.clipboard.readText().then(
|
||||
(text) => {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (isProjectSchema(data)) {
|
||||
resolve(data.componentsTree);
|
||||
} else {
|
||||
Notification.open({
|
||||
content: intl('NotValidNodeData'),
|
||||
type: 'error',
|
||||
});
|
||||
reject(
|
||||
new Error(intl('NotValidNodeData')),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Notification.open({
|
||||
content: intl('NotValidNodeData'),
|
||||
type: 'error',
|
||||
});
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const defaultContextMenu = (ctx: IPublicModelPluginContext) => {
|
||||
const { material, canvas } = ctx;
|
||||
const { clipboard } = canvas;
|
||||
|
||||
return {
|
||||
init() {
|
||||
material.addContextMenuOption({
|
||||
name: 'selectComponent',
|
||||
title: intl('SelectComponents'),
|
||||
condition: (nodes) => {
|
||||
return nodes.length === 1;
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'nodeTree',
|
||||
type: IPublicEnumContextMenuType.NODE_TREE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
material.addContextMenuOption({
|
||||
name: 'copyAndPaste',
|
||||
title: intl('Copy'),
|
||||
condition: (nodes) => {
|
||||
return nodes.length === 1;
|
||||
},
|
||||
action(nodes) {
|
||||
const node = nodes[0];
|
||||
const { document: doc, parent, index } = node;
|
||||
if (parent) {
|
||||
const newNode = doc?.insertNode(parent, node, (index ?? 0) + 1, true);
|
||||
newNode?.select();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
material.addContextMenuOption({
|
||||
name: 'copy',
|
||||
title: intl('Copy.1'),
|
||||
action(nodes) {
|
||||
if (!nodes || nodes.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getNodesSchema(nodes);
|
||||
clipboard.setData(data);
|
||||
},
|
||||
});
|
||||
|
||||
material.addContextMenuOption({
|
||||
name: 'zhantieToBottom',
|
||||
title: intl('PasteToTheBottom'),
|
||||
condition: (nodes) => {
|
||||
return nodes.length === 1;
|
||||
},
|
||||
async action(nodes) {
|
||||
if (!nodes || nodes.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodes[0];
|
||||
const { document: doc, parent, index } = node;
|
||||
|
||||
try {
|
||||
const nodeSchema = await getClipboardText();
|
||||
if (parent) {
|
||||
nodeSchema.forEach((schema, schemaIndex) => {
|
||||
doc?.insertNode(parent, schema, (index ?? 0) + 1 + schemaIndex, true);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
material.addContextMenuOption({
|
||||
name: 'zhantieToInner',
|
||||
title: intl('PasteToTheInside'),
|
||||
condition: (nodes) => {
|
||||
return nodes.length === 1;
|
||||
},
|
||||
disabled: (nodes) => {
|
||||
// 获取粘贴数据
|
||||
const node = nodes[0];
|
||||
return !node.isContainerNode;
|
||||
},
|
||||
async action(nodes) {
|
||||
const node = nodes[0];
|
||||
const { document: doc, parent } = node;
|
||||
|
||||
try {
|
||||
const nodeSchema = await getClipboardText();
|
||||
if (parent) {
|
||||
const index = node.children?.size || 0;
|
||||
|
||||
if (parent) {
|
||||
nodeSchema.forEach((schema, schemaIndex) => {
|
||||
doc?.insertNode(node, schema, (index ?? 0) + 1 + schemaIndex, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
material.addContextMenuOption({
|
||||
name: 'delete',
|
||||
title: intl('Delete'),
|
||||
action(nodes) {
|
||||
nodes.forEach((node) => {
|
||||
node.remove();
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
defaultContextMenu.pluginName = '___default_context_menu___';
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"NotValidNodeData": "Not valid node data",
|
||||
"SelectComponents": "Select components",
|
||||
"Copy": "Copy",
|
||||
"Copy.1": "Copy",
|
||||
"PasteToTheBottom": "Paste to the bottom",
|
||||
"PasteToTheInside": "Paste to the inside",
|
||||
"Delete": "Delete"
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { createIntl } from '@alilc/lowcode-editor-core';
|
||||
import enUS from './en-US.json';
|
||||
import zhCN from './zh-CN.json';
|
||||
|
||||
const { intl } = createIntl?.({
|
||||
'en-US': enUS,
|
||||
'zh-CN': zhCN,
|
||||
}) || {
|
||||
intl: (id) => {
|
||||
return zhCN[id];
|
||||
},
|
||||
};
|
||||
|
||||
export { intl, enUS, zhCN };
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"NotValidNodeData": "不是有效的节点数据",
|
||||
"SelectComponents": "选择组件",
|
||||
"Copy": "复制",
|
||||
"Copy.1": "拷贝",
|
||||
"PasteToTheBottom": "粘贴至下方",
|
||||
"PasteToTheInside": "粘贴至内部",
|
||||
"Delete": "删除"
|
||||
}
|
|
@ -4,6 +4,7 @@ import {
|
|||
Title as InnerTitle,
|
||||
} from '@alilc/lowcode-editor-core';
|
||||
import { Balloon, Breadcrumb, Button, Card, Checkbox, DatePicker, Dialog, Dropdown, Form, Icon, Input, Loading, Message, Overlay, Pagination, Radio, Search, Select, SplitButton, Step, Switch, Tab, Table, Tree, TreeSelect, Upload, Divider } from '@alifd/next';
|
||||
import { ContextMenu } from '../components/context-menu';
|
||||
|
||||
export class CommonUI implements IPublicApiCommonUI {
|
||||
Balloon = Balloon;
|
||||
|
@ -40,4 +41,7 @@ export class CommonUI implements IPublicApiCommonUI {
|
|||
get Title() {
|
||||
return InnerTitle;
|
||||
}
|
||||
get ContextMenu() {
|
||||
return ContextMenu;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import {
|
|||
IPublicTypeNpmInfo,
|
||||
IPublicModelEditor,
|
||||
IPublicTypeDisposable,
|
||||
IPublicTypeContextMenuAction,
|
||||
IPublicTypeContextMenuItem,
|
||||
} from '@alilc/lowcode-types';
|
||||
import { Workspace as InnerWorkspace } from '@alilc/lowcode-workspace';
|
||||
import { editorSymbol, designerSymbol } from '../symbols';
|
||||
|
@ -190,4 +192,16 @@ export class Material implements IPublicApiMaterial {
|
|||
dispose.forEach(d => d && d());
|
||||
};
|
||||
}
|
||||
|
||||
addContextMenuOption(option: IPublicTypeContextMenuAction) {
|
||||
this[designerSymbol].contextMenuActions.addMenuAction(option);
|
||||
}
|
||||
|
||||
removeContextMenuOption(name: string) {
|
||||
this[designerSymbol].contextMenuActions.removeMenuAction(name);
|
||||
}
|
||||
|
||||
adjustContextMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]) {
|
||||
this[designerSymbol].contextMenuActions.adjustMenuLayout(fn);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import { Menu } from '@alifd/next';
|
||||
import { parseContextMenuAsReactNode, parseContextMenuProperties } from '@alilc/lowcode-utils';
|
||||
import { engineConfig } from '@alilc/lowcode-editor-core';
|
||||
import { IPublicTypeContextMenuAction } from '@alilc/lowcode-types';
|
||||
import React from 'react';
|
||||
|
||||
export function ContextMenu({ children, menus }: {
|
||||
menus: IPublicTypeContextMenuAction[];
|
||||
children: React.ReactElement[];
|
||||
}): React.ReactElement<any, string | React.JSXElementConstructor<any>>[] {
|
||||
if (!engineConfig.get('enableContextMenu')) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const target = event.target;
|
||||
const { top, left } = target?.getBoundingClientRect();
|
||||
let destroyFn: Function | undefined;
|
||||
const destroy = () => {
|
||||
destroyFn?.();
|
||||
};
|
||||
const children: React.ReactNode[] = parseContextMenuAsReactNode(parseContextMenuProperties(menus, {
|
||||
destroy,
|
||||
}));
|
||||
|
||||
const menuInstance = Menu.create({
|
||||
target: event.target,
|
||||
offset: [event.clientX - left, event.clientY - top],
|
||||
children,
|
||||
});
|
||||
|
||||
destroyFn = (menuInstance as any).destroy;
|
||||
};
|
||||
|
||||
// 克隆 children 并添加 onContextMenu 事件处理器
|
||||
const childrenWithContextMenu = React.Children.map(children, (child) =>
|
||||
React.cloneElement(
|
||||
child,
|
||||
{ onContextMenu: handleContextMenu },
|
||||
));
|
||||
|
||||
return childrenWithContextMenu;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { IPublicTypeTitleContent } from '../type';
|
||||
import { ReactElement } from 'react';
|
||||
import { IPublicTypeContextMenuAction, IPublicTypeTitleContent } from '../type';
|
||||
import { Balloon, Breadcrumb, Button, Card, Checkbox, DatePicker, Dialog, Dropdown, Form, Icon, Input, Loading, Message, Overlay, Pagination, Radio, Search, Select, SplitButton, Step, Switch, Tab, Table, Tree, TreeSelect, Upload, Divider } from '@alifd/next';
|
||||
|
||||
export interface IPublicApiCommonUI {
|
||||
|
@ -45,4 +46,9 @@ export interface IPublicApiCommonUI {
|
|||
match?: boolean;
|
||||
keywords?: string | null;
|
||||
}>;
|
||||
|
||||
get ContextMenu(): (props: {
|
||||
menus: IPublicTypeContextMenuAction[];
|
||||
children: React.ReactElement[];
|
||||
}) => ReactElement[];
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { IPublicTypeAssetsJson, IPublicTypeMetadataTransducer, IPublicTypeComponentAction, IPublicTypeNpmInfo, IPublicTypeDisposable } from '../type';
|
||||
import { IPublicTypeAssetsJson, IPublicTypeMetadataTransducer, IPublicTypeComponentAction, IPublicTypeNpmInfo, IPublicTypeDisposable, IPublicTypeContextMenuAction, IPublicTypeContextMenuItem } from '../type';
|
||||
import { IPublicModelComponentMeta } from '../model';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
|
@ -128,4 +128,22 @@ export interface IPublicApiMaterial {
|
|||
* @since v1.1.7
|
||||
*/
|
||||
refreshComponentMetasMap(): void;
|
||||
|
||||
/**
|
||||
* 添加右键菜单项
|
||||
* @param action
|
||||
*/
|
||||
addContextMenuOption(action: IPublicTypeContextMenuAction): void;
|
||||
|
||||
/**
|
||||
* 删除特定右键菜单项
|
||||
* @param name
|
||||
*/
|
||||
removeContextMenuOption(name: string): void;
|
||||
|
||||
/**
|
||||
* 调整右键菜单项布局
|
||||
* @param actions
|
||||
*/
|
||||
adjustContextMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]): void;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export enum IPublicEnumContextMenuType {
|
||||
SEPARATOR = 'separator',
|
||||
// 'menuItem'
|
||||
MENU_ITEM = 'menuItem',
|
||||
// 'nodeTree'
|
||||
NODE_TREE = 'nodeTree',
|
||||
}
|
|
@ -3,4 +3,5 @@ export * from './transition-type';
|
|||
export * from './transform-stage';
|
||||
export * from './drag-object-type';
|
||||
export * from './prop-value-changed-type';
|
||||
export * from './plugin-register-level';
|
||||
export * from './plugin-register-level';
|
||||
export * from './context-menu';
|
|
@ -0,0 +1,57 @@
|
|||
import { IPublicEnumContextMenuType } from '../enum';
|
||||
import { IPublicModelNode } from '../model';
|
||||
import { IPublicTypeI18nData } from './i8n-data';
|
||||
|
||||
export interface IPublicTypeContextMenuItem extends Omit<IPublicTypeContextMenuAction, 'condition' | 'disabled' | 'items'> {
|
||||
disabled?: boolean;
|
||||
|
||||
items?: Omit<IPublicTypeContextMenuItem, 'items'>[];
|
||||
}
|
||||
|
||||
export interface IPublicTypeContextMenuAction {
|
||||
|
||||
/**
|
||||
* 动作的唯一标识符
|
||||
* Unique identifier for the action
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 显示的标题,可以是字符串或国际化数据
|
||||
* Display title, can be a string or internationalized data
|
||||
*/
|
||||
title?: string | IPublicTypeI18nData;
|
||||
|
||||
/**
|
||||
* 菜单项类型
|
||||
* Menu item type
|
||||
* @see IPublicEnumContextMenuType
|
||||
* @default IPublicEnumPContextMenuType.MENU_ITEM
|
||||
*/
|
||||
type?: IPublicEnumContextMenuType;
|
||||
|
||||
/**
|
||||
* 点击时执行的动作,可选
|
||||
* Action to execute on click, optional
|
||||
*/
|
||||
action?: (nodes: IPublicModelNode[]) => void;
|
||||
|
||||
/**
|
||||
* 子菜单项或生成子节点的函数,可选,仅支持两级
|
||||
* Sub-menu items or function to generate child node, optional
|
||||
*/
|
||||
items?: Omit<IPublicTypeContextMenuAction, 'items'>[] | ((nodes: IPublicModelNode[]) => Omit<IPublicTypeContextMenuAction, 'items'>[]);
|
||||
|
||||
/**
|
||||
* 显示条件函数
|
||||
* Function to determine display condition
|
||||
*/
|
||||
condition?: (nodes: IPublicModelNode[]) => boolean;
|
||||
|
||||
/**
|
||||
* 禁用条件函数,可选
|
||||
* Function to determine disabled condition, optional
|
||||
*/
|
||||
disabled?: (nodes: IPublicModelNode[]) => boolean;
|
||||
}
|
||||
|
|
@ -178,6 +178,12 @@ export interface IPublicTypeEngineOptions {
|
|||
*/
|
||||
enableAutoOpenFirstWindow?: boolean;
|
||||
|
||||
/**
|
||||
* @default false
|
||||
* 开启右键菜单能力
|
||||
*/
|
||||
enableContextMenu?: boolean;
|
||||
|
||||
/**
|
||||
* @default false
|
||||
* 隐藏设计器辅助层
|
||||
|
|
|
@ -91,4 +91,5 @@ export * from './hotkey-callback-config';
|
|||
export * from './hotkey-callbacks';
|
||||
export * from './scrollable';
|
||||
export * from './simulator-renderer';
|
||||
export * from './config-transducer';
|
||||
export * from './config-transducer';
|
||||
export * from './context-menu';
|
|
@ -0,0 +1,33 @@
|
|||
.context-menu-tree-wrap {
|
||||
position: relative;
|
||||
padding: 4px 10px 4px 24px;
|
||||
}
|
||||
|
||||
.context-menu-tree-children {
|
||||
margin-left: 8px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.context-menu-tree-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-menu-tree-bg-inner {
|
||||
position: absolute;
|
||||
height: 24px;
|
||||
top: -24px;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-block-background-light);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-tree-selected-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--color-icon-active);
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
import { Menu, Icon } from '@alifd/next';
|
||||
import { IDesigner } from '@alilc/lowcode-designer';
|
||||
import { IPublicEnumContextMenuType, IPublicModelNode, IPublicTypeContextMenuAction, IPublicTypeContextMenuItem } from '@alilc/lowcode-types';
|
||||
import { Logger } from '@alilc/lowcode-utils';
|
||||
import React from 'react';
|
||||
import './context-menu.scss';
|
||||
|
||||
const logger = new Logger({ level: 'warn', bizName: 'designer' });
|
||||
const { Item, Divider, PopupItem } = Menu;
|
||||
|
||||
const MAX_LEVEL = 2;
|
||||
|
||||
const Tree = (props: {
|
||||
node?: IPublicModelNode;
|
||||
children?: React.ReactNode;
|
||||
options: {
|
||||
nodes?: IPublicModelNode[] | null;
|
||||
destroy?: Function;
|
||||
designer?: IDesigner;
|
||||
};
|
||||
}) => {
|
||||
const { node } = props;
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!node.parent) {
|
||||
return (
|
||||
<div className="context-menu-tree-wrap">
|
||||
<div className="context-menu-tree-children">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const commonUI = props.options.designer?.editor?.get('commonUI');
|
||||
|
||||
const Title = commonUI?.Title;
|
||||
|
||||
return (
|
||||
<Tree {...props} node={node.parent} >
|
||||
{props.options.nodes?.[0].id === node.id ? (<Icon className="context-menu-tree-selected-icon" size="small" type="success" />) : null}
|
||||
<Title title={node.title} />
|
||||
<div
|
||||
className="context-menu-tree-children"
|
||||
>
|
||||
<div
|
||||
className="context-menu-tree-bg"
|
||||
onClick={() => {
|
||||
props.options.destroy?.();
|
||||
node.select();
|
||||
}}
|
||||
>
|
||||
<div className="context-menu-tree-bg-inner" />
|
||||
</div>
|
||||
{ props.children }
|
||||
</div>
|
||||
</Tree>
|
||||
);
|
||||
};
|
||||
|
||||
export function parseContextMenuAsReactNode(menus: IPublicTypeContextMenuItem[], options: {
|
||||
nodes?: IPublicModelNode[] | null;
|
||||
destroy?: Function;
|
||||
designer?: IDesigner;
|
||||
} = {}): React.ReactNode[] {
|
||||
const children: React.ReactNode[] = [];
|
||||
menus.forEach((menu, index) => {
|
||||
if (menu.type === IPublicEnumContextMenuType.SEPARATOR) {
|
||||
children.push(<Divider key={menu.name || index} />);
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.type === IPublicEnumContextMenuType.MENU_ITEM) {
|
||||
if (menu.items && menu.items.length) {
|
||||
children.push((
|
||||
<PopupItem key={menu.name} label={menu.title}>
|
||||
<Menu className="next-context engine-context-menu">
|
||||
{ parseContextMenuAsReactNode(menu.items, options) }
|
||||
</Menu>
|
||||
</PopupItem>
|
||||
));
|
||||
} else {
|
||||
children.push((<Item disabled={menu.disabled} onClick={menu.action} key={menu.name}>{menu.title}</Item>));
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.type === IPublicEnumContextMenuType.NODE_TREE) {
|
||||
children.push((
|
||||
<Tree node={options.nodes?.[0]} options={options} />
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction | Omit<IPublicTypeContextMenuAction, 'items'>)[], options: {
|
||||
nodes?: IPublicModelNode[] | null;
|
||||
destroy?: Function;
|
||||
}, level = 1): IPublicTypeContextMenuItem[] {
|
||||
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 result;
|
||||
});
|
||||
}
|
|
@ -31,3 +31,4 @@ export * as css from './css-helper';
|
|||
export { transactionManager } from './transaction-manager';
|
||||
export * from './check-types';
|
||||
export * from './workspace';
|
||||
export * from './context-menu';
|
||||
|
|
Loading…
Reference in New Issue