diff --git a/.gitignore b/.gitignore index 05159f047..6a19ae3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ typings/ # codealike codealike.json .node + +.must.config.js \ No newline at end of file diff --git a/docs/docs/api/commonUI.md b/docs/docs/api/commonUI.md index 9d1f70652..ef7af7f41 100644 --- a/docs/docs/api/commonUI.md +++ b/docs/docs/api/commonUI.md @@ -29,6 +29,26 @@ CommonUI API 是一个专为低代码引擎设计的组件 UI 库,使用它开 | className | className | string (optional) | | | onClick | 点击事件 | () => void (optional) | | +### ContextMenu + +| 参数 | 说明 | 类型 | 默认值 | +|--------|----------------------------------------------------|------------------------------------|--------| +| menus | 定义上下文菜单的动作数组 | IPublicTypeContextMenuAction[] | | +| children | 组件的子元素 | React.ReactElement[] | | + +**IPublicTypeContextMenuAction Interface** + +| 参数 | 说明 | 类型 | 默认值 | +|------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------| +| 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 | +| 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) | | +| disabled | 禁用条件函数,可选
Function to determine disabled condition, optional | (nodes: IPublicModelNode[]) => boolean (optional) | | + + ### Balloon 详细文档: [Balloon Documentation](https://fusion.design/pc/component/balloon) diff --git a/docs/docs/api/configOptions.md b/docs/docs/api/configOptions.md index 5f6ade710..67d2fae2c 100644 --- a/docs/docs/api/configOptions.md +++ b/docs/docs/api/configOptions.md @@ -185,6 +185,12 @@ config.set('enableCondition', false) `@type {boolean}` `@default {false}` +#### enableContextMenu - 开启右键菜单 + +`@type {boolean}` `@default {false}` + +是否开启右键菜单 + #### disableDetecting `@type {boolean}` `@default {false}` diff --git a/docs/docs/guide/expand/editor/theme.md b/docs/docs/guide/expand/editor/theme.md index ef0e04d28..1c442c513 100644 --- a/docs/docs/guide/expand/editor/theme.md +++ b/docs/docs/guide/expand/editor/theme.md @@ -128,6 +128,7 @@ sidebar_position: 9 - `--pane-title-height`: 面板标题高度 - `--pane-title-font-size`: 面板标题字体大小 - `--pane-title-padding`: 面板标题边距 +- `--context-menu-item-height`: 右键菜单项高度 diff --git a/packages/designer/src/context-menu-actions.scss b/packages/designer/src/context-menu-actions.scss new file mode 100644 index 000000000..863c92944 --- /dev/null +++ b/packages/designer/src/context-menu-actions.scss @@ -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); + } + } +} \ No newline at end of file diff --git a/packages/designer/src/context-menu-actions.ts b/packages/designer/src/context-menu-actions.ts new file mode 100644 index 000000000..053e4f031 --- /dev/null +++ b/packages/designer/src/context-menu-actions.ts @@ -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; + } +} \ No newline at end of file diff --git a/packages/designer/src/designer/designer.ts b/packages/designer/src/designer/designer.ts index d7e17e84c..1dd4bc04e 100644 --- a/packages/designer/src/designer/designer.ts +++ b/packages/designer/src/designer/designer.ts @@ -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 | 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); diff --git a/packages/designer/src/index.ts b/packages/designer/src/index.ts index 489d81482..11e6453b8 100644 --- a/packages/designer/src/index.ts +++ b/packages/designer/src/index.ts @@ -6,3 +6,4 @@ export * from './project'; export * from './builtin-simulator'; export * from './plugin'; export * from './types'; +export * from './context-menu-actions'; diff --git a/packages/editor-core/src/config.ts b/packages/editor-core/src/config.ts index a5da74742..c4ff407b9 100644 --- a/packages/editor-core/src/config.ts +++ b/packages/editor-core/src/config.ts @@ -159,6 +159,11 @@ const VALID_ENGINE_OPTIONS = { type: 'function', description: '应用级设计模式下,窗口为空时展示的占位组件', }, + enableContextMenu: { + type: 'boolean', + description: '是否开启右键菜单', + default: false, + }, hideComponentAction: { type: 'boolean', description: '是否隐藏设计器辅助层', diff --git a/packages/engine/src/engine-core.ts b/packages/engine/src/engine-core.ts index 9f29046fb..29b4a7f03 100644 --- a/packages/engine/src/engine-core.ts +++ b/packages/engine/src/engine-core.ts @@ -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); }; } diff --git a/packages/engine/src/inner-plugins/default-context-menu.ts b/packages/engine/src/inner-plugins/default-context-menu.ts new file mode 100644 index 000000000..86feb17d9 --- /dev/null +++ b/packages/engine/src/inner-plugins/default-context-menu.ts @@ -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 { + 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___'; diff --git a/packages/engine/src/locale/en-US.json b/packages/engine/src/locale/en-US.json new file mode 100644 index 000000000..1cdb06f28 --- /dev/null +++ b/packages/engine/src/locale/en-US.json @@ -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" +} diff --git a/packages/engine/src/locale/index.ts b/packages/engine/src/locale/index.ts new file mode 100644 index 000000000..510fcf056 --- /dev/null +++ b/packages/engine/src/locale/index.ts @@ -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 }; diff --git a/packages/engine/src/locale/zh-CN.json b/packages/engine/src/locale/zh-CN.json new file mode 100644 index 000000000..ba0f87f8e --- /dev/null +++ b/packages/engine/src/locale/zh-CN.json @@ -0,0 +1,9 @@ +{ + "NotValidNodeData": "不是有效的节点数据", + "SelectComponents": "选择组件", + "Copy": "复制", + "Copy.1": "拷贝", + "PasteToTheBottom": "粘贴至下方", + "PasteToTheInside": "粘贴至内部", + "Delete": "删除" +} diff --git a/packages/shell/src/api/commonUI.ts b/packages/shell/src/api/commonUI.ts index 718b40970..9f40afa11 100644 --- a/packages/shell/src/api/commonUI.ts +++ b/packages/shell/src/api/commonUI.ts @@ -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; + } } diff --git a/packages/shell/src/api/material.ts b/packages/shell/src/api/material.ts index 39b21848e..f0c37d8a4 100644 --- a/packages/shell/src/api/material.ts +++ b/packages/shell/src/api/material.ts @@ -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); + } } diff --git a/packages/shell/src/components/context-menu.tsx b/packages/shell/src/components/context-menu.tsx new file mode 100644 index 000000000..0085e1c77 --- /dev/null +++ b/packages/shell/src/components/context-menu.tsx @@ -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>[] { + 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; +} \ 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 3d7bb57bf..dcc6fab0c 100644 --- a/packages/types/src/shell/api/commonUI.ts +++ b/packages/types/src/shell/api/commonUI.ts @@ -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[]; } \ No newline at end of file diff --git a/packages/types/src/shell/api/material.ts b/packages/types/src/shell/api/material.ts index d64455edd..6354c7fa0 100644 --- a/packages/types/src/shell/api/material.ts +++ b/packages/types/src/shell/api/material.ts @@ -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; } diff --git a/packages/types/src/shell/enum/context-menu.ts b/packages/types/src/shell/enum/context-menu.ts new file mode 100644 index 000000000..fd209b197 --- /dev/null +++ b/packages/types/src/shell/enum/context-menu.ts @@ -0,0 +1,7 @@ +export enum IPublicEnumContextMenuType { + SEPARATOR = 'separator', + // 'menuItem' + MENU_ITEM = 'menuItem', + // 'nodeTree' + NODE_TREE = 'nodeTree', +} \ No newline at end of file diff --git a/packages/types/src/shell/enum/index.ts b/packages/types/src/shell/enum/index.ts index f3d558011..13282d0f2 100644 --- a/packages/types/src/shell/enum/index.ts +++ b/packages/types/src/shell/enum/index.ts @@ -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'; \ No newline at end of file +export * from './plugin-register-level'; +export * from './context-menu'; \ 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 new file mode 100644 index 000000000..595893d32 --- /dev/null +++ b/packages/types/src/shell/type/context-menu.ts @@ -0,0 +1,57 @@ +import { IPublicEnumContextMenuType } from '../enum'; +import { IPublicModelNode } from '../model'; +import { IPublicTypeI18nData } from './i8n-data'; + +export interface IPublicTypeContextMenuItem extends Omit { + disabled?: boolean; + + items?: Omit[]; +} + +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[] | ((nodes: IPublicModelNode[]) => Omit[]); + + /** + * 显示条件函数 + * Function to determine display condition + */ + condition?: (nodes: IPublicModelNode[]) => boolean; + + /** + * 禁用条件函数,可选 + * Function to determine disabled condition, optional + */ + disabled?: (nodes: IPublicModelNode[]) => boolean; +} + diff --git a/packages/types/src/shell/type/engine-options.ts b/packages/types/src/shell/type/engine-options.ts index 72078c810..8221c4089 100644 --- a/packages/types/src/shell/type/engine-options.ts +++ b/packages/types/src/shell/type/engine-options.ts @@ -178,6 +178,12 @@ export interface IPublicTypeEngineOptions { */ enableAutoOpenFirstWindow?: boolean; + /** + * @default false + * 开启右键菜单能力 + */ + enableContextMenu?: boolean; + /** * @default false * 隐藏设计器辅助层 diff --git a/packages/types/src/shell/type/index.ts b/packages/types/src/shell/type/index.ts index b2fd3313f..b1c7779d0 100644 --- a/packages/types/src/shell/type/index.ts +++ b/packages/types/src/shell/type/index.ts @@ -91,4 +91,5 @@ export * from './hotkey-callback-config'; export * from './hotkey-callbacks'; export * from './scrollable'; export * from './simulator-renderer'; -export * from './config-transducer'; \ No newline at end of file +export * from './config-transducer'; +export * from './context-menu'; \ No newline at end of file diff --git a/packages/utils/src/context-menu.scss b/packages/utils/src/context-menu.scss new file mode 100644 index 000000000..744eede87 --- /dev/null +++ b/packages/utils/src/context-menu.scss @@ -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); +} \ No newline at end of file diff --git a/packages/utils/src/context-menu.tsx b/packages/utils/src/context-menu.tsx new file mode 100644 index 000000000..3c7bb5d13 --- /dev/null +++ b/packages/utils/src/context-menu.tsx @@ -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 ( +
+
+ {props.children} +
+
+ ); + } + + const commonUI = props.options.designer?.editor?.get('commonUI'); + + const Title = commonUI?.Title; + + return ( + + {props.options.nodes?.[0].id === node.id ? () : null} + + <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; + }); +} \ No newline at end of file diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3e37f46ba..b99ab0207 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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';