diff --git a/docs/docs/api/material.md b/docs/docs/api/material.md index 51ed4e305..c83935b97 100644 --- a/docs/docs/api/material.md +++ b/docs/docs/api/material.md @@ -237,6 +237,43 @@ material.modifyBuiltinComponentAction('remove', (action) => { }); ``` +### 右键菜单项 +#### addContextMenuOption + +添加右键菜单项 + +```typescript +/** + * 添加右键菜单项 + * @param action + */ +addContextMenuOption(action: IPublicTypeContextMenuAction): void; +``` + +#### removeContextMenuOption + +删除特定右键菜单项 + +```typescript +/** + * 删除特定右键菜单项 + * @param name + */ +removeContextMenuOption(name: string): void; +``` + +#### adjustContextMenuLayout + +调整右键菜单项布局,每次调用都会覆盖之前注册的调整函数,只有最后注册的函数会被应用。 + +```typescript +/** + * 调整右键菜单项布局 + * @param actions + */ +adjustContextMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]): void; +``` + ### 物料元数据 #### getComponentMeta 获取指定名称的物料元数据 diff --git a/packages/designer/src/context-menu-actions.ts b/packages/designer/src/context-menu-actions.ts index 8c210d89e..5b44055b4 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, uniqueId } from '@alilc/lowcode-utils'; +import { createContextMenu, parseContextMenuAsReactNode, parseContextMenuProperties, uniqueId } from '@alilc/lowcode-utils'; import { Menu } from '@alifd/next'; import { engineConfig } from '@alilc/lowcode-editor-core'; import './context-menu-actions.scss'; @@ -178,18 +178,10 @@ export class ContextMenuActions implements IContextMenuActions { 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 = createContextMenu(menuNode, { + event, + offset: [simulatorLeft, simulatorTop], }); - - destroyFn = (menuInstance as any).destroy; }; initEvent() { diff --git a/packages/engine/src/inner-plugins/default-context-menu.ts b/packages/engine/src/inner-plugins/default-context-menu.ts index fc1da96b4..9d1336b34 100644 --- a/packages/engine/src/inner-plugins/default-context-menu.ts +++ b/packages/engine/src/inner-plugins/default-context-menu.ts @@ -1,13 +1,16 @@ import { IPublicEnumContextMenuType, + IPublicEnumDragObjectType, IPublicEnumTransformStage, IPublicModelNode, IPublicModelPluginContext, + IPublicTypeDragNodeDataObject, + IPublicTypeI18nData, IPublicTypeNodeSchema, } from '@alilc/lowcode-types'; -import { isProjectSchema } from '@alilc/lowcode-utils'; +import { isI18nData, isProjectSchema } from '@alilc/lowcode-utils'; import { Notification } from '@alifd/next'; -import { intl } from '../locale'; +import { intl, getLocale } from '../locale'; function getNodesSchema(nodes: IPublicModelNode[]) { const componentsTree = nodes.map((node) => node?.exportSchema(IPublicEnumTransformStage.Clone)); @@ -15,6 +18,15 @@ function getNodesSchema(nodes: IPublicModelNode[]) { return data; } +function getIntlStr(data: string | IPublicTypeI18nData) { + if (!isI18nData(data)) { + return data; + } + + const locale = getLocale(); + return data[locale] || data['zh-CN'] || data['zh_CN'] || data['en-US'] || data['en_US'] || ''; +} + async function getClipboardText(): Promise { return new Promise((resolve, reject) => { // 使用 Clipboard API 读取剪贴板内容 @@ -71,12 +83,18 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { material.addContextMenuOption({ name: 'copyAndPaste', title: intl('CopyAndPaste'), + disabled: (nodes) => { + return nodes?.filter((node) => !node?.canPerformAction('copy')).length > 0; + }, condition: (nodes) => { return nodes.length === 1; }, action(nodes) { const node = nodes[0]; const { document: doc, parent, index } = node; + const data = getNodesSchema(nodes); + clipboard.setData(data); + if (parent) { const newNode = doc?.insertNode(parent, node, (index ?? 0) + 1, true); newNode?.select(); @@ -87,6 +105,9 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { material.addContextMenuOption({ name: 'copy', title: intl('Copy'), + disabled: (nodes) => { + return nodes?.filter((node) => !node?.canPerformAction('copy')).length > 0; + }, condition(nodes) { return nodes.length > 0; }, @@ -101,7 +122,7 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { }); material.addContextMenuOption({ - name: 'zhantieToBottom', + name: 'pasteToBottom', title: intl('PasteToTheBottom'), condition: (nodes) => { return nodes.length === 1; @@ -116,10 +137,30 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { try { const nodeSchema = await getClipboardText(); + if (nodeSchema.length === 0) { + return; + } if (parent) { - nodeSchema.forEach((schema, schemaIndex) => { - doc?.insertNode(parent, schema, (index ?? 0) + 1 + schemaIndex, true); + let canAddNodes = nodeSchema.filter((nodeSchema: IPublicTypeNodeSchema) => { + const dragNodeObject: IPublicTypeDragNodeDataObject = { + type: IPublicEnumDragObjectType.NodeData, + data: nodeSchema, + }; + return doc?.checkNesting(parent, dragNodeObject); + }); + if (canAddNodes.length === 0) { + Notification.open({ + content: `${nodeSchema.map(d => getIntlStr(d.title || d.componentName)).join(',')}等组件无法放置到${getIntlStr(parent.title || parent.componentName as any)}内`, + type: 'error', + }); + return; + } + const nodes: IPublicModelNode[] = []; + canAddNodes.forEach((schema, schemaIndex) => { + const node = doc?.insertNode(parent, schema, (index ?? 0) + 1 + schemaIndex, true); + node && nodes.push(node); }); + doc?.selection.selectAll(nodes.map((node) => node?.id)); } } catch (error) { console.error(error); @@ -128,7 +169,7 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { }); material.addContextMenuOption({ - name: 'zhantieToInner', + name: 'pasteToInner', title: intl('PasteToTheInside'), condition: (nodes) => { return nodes.length === 1; @@ -140,19 +181,35 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { }, async action(nodes) { const node = nodes[0]; - const { document: doc, parent } = node; + const { document: doc } = 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); - }); - } + const index = node.children?.size || 0; + if (nodeSchema.length === 0) { + return; } + let canAddNodes = nodeSchema.filter((nodeSchema: IPublicTypeNodeSchema) => { + const dragNodeObject: IPublicTypeDragNodeDataObject = { + type: IPublicEnumDragObjectType.NodeData, + data: nodeSchema, + }; + return doc?.checkNesting(node, dragNodeObject); + }); + if (canAddNodes.length === 0) { + Notification.open({ + content: `${nodeSchema.map(d => getIntlStr(d.title || d.componentName)).join(',')}等组件无法放置到${getIntlStr(node.title || node.componentName as any)}内`, + type: 'error', + }); + return; + } + + const nodes: IPublicModelNode[] = []; + nodeSchema.forEach((schema, schemaIndex) => { + const newNode = doc?.insertNode(node, schema, (index ?? 0) + 1 + schemaIndex, true); + newNode && nodes.push(newNode); + }); + doc?.selection.selectAll(nodes.map((node) => node?.id)); } catch (error) { console.error(error); } @@ -162,6 +219,9 @@ export const defaultContextMenu = (ctx: IPublicModelPluginContext) => { material.addContextMenuOption({ name: 'delete', title: intl('Delete'), + disabled(nodes) { + return nodes?.filter((node) => !node?.canPerformAction('remove')).length > 0; + }, condition(nodes) { return nodes.length > 0; }, diff --git a/packages/engine/src/locale/index.ts b/packages/engine/src/locale/index.ts index 510fcf056..ca89840b0 100644 --- a/packages/engine/src/locale/index.ts +++ b/packages/engine/src/locale/index.ts @@ -2,7 +2,7 @@ import { createIntl } from '@alilc/lowcode-editor-core'; import enUS from './en-US.json'; import zhCN from './zh-CN.json'; -const { intl } = createIntl?.({ +const { intl, getLocale } = createIntl?.({ 'en-US': enUS, 'zh-CN': zhCN, }) || { @@ -11,4 +11,4 @@ const { intl } = createIntl?.({ }, }; -export { intl, enUS, zhCN }; +export { intl, enUS, zhCN, getLocale }; diff --git a/packages/shell/src/components/context-menu.tsx b/packages/shell/src/components/context-menu.tsx index f12f6ca93..acefbebd2 100644 --- a/packages/shell/src/components/context-menu.tsx +++ b/packages/shell/src/components/context-menu.tsx @@ -1,5 +1,4 @@ -import { Menu } from '@alifd/next'; -import { parseContextMenuAsReactNode, parseContextMenuProperties } from '@alilc/lowcode-utils'; +import { createContextMenu, parseContextMenuAsReactNode, parseContextMenuProperties } from '@alilc/lowcode-utils'; import { engineConfig } from '@alilc/lowcode-editor-core'; import { IPublicTypeContextMenuAction } from '@alilc/lowcode-types'; import React from 'react'; @@ -18,8 +17,6 @@ export function ContextMenu({ children, menus }: { event.preventDefault(); event.stopPropagation(); - const target = event.target; - const { top, left } = target?.getBoundingClientRect(); let destroyFn: Function | undefined; const destroy = () => { destroyFn?.(); @@ -32,13 +29,9 @@ export function ContextMenu({ children, menus }: { return; } - const menuInstance = Menu.create({ - target: event.target, - offset: [event.clientX - left, event.clientY - top], - children, + destroyFn = createContextMenu(children, { + event, }); - - destroyFn = (menuInstance as any).destroy; }; // 克隆 children 并添加 onContextMenu 事件处理器 diff --git a/packages/utils/src/context-menu.tsx b/packages/utils/src/context-menu.tsx index f28619df6..1816f5b52 100644 --- a/packages/utils/src/context-menu.tsx +++ b/packages/utils/src/context-menu.tsx @@ -53,6 +53,8 @@ const Tree = (props: { ); }; +let destroyFn: Function | undefined; + export function parseContextMenuAsReactNode(menus: IPublicTypeContextMenuItem[], options: { nodes?: IPublicModelNode[] | null; destroy?: Function; @@ -89,14 +91,12 @@ 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) { @@ -146,4 +146,55 @@ export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction return menus; } }, []); +} + +let cachedMenuItemHeight: string | undefined; + +function getMenuItemHeight() { + if (cachedMenuItemHeight) { + return cachedMenuItemHeight; + } + const root = document.documentElement; + const styles = getComputedStyle(root); + // Access the value of the CSS variable + const menuItemHeight = styles.getPropertyValue('--context-menu-item-height').trim(); + cachedMenuItemHeight = menuItemHeight; + + return menuItemHeight; +} + +export function createContextMenu(children: React.ReactNode[], { + event, + offset = [0, 0], +}: { + event: MouseEvent | React.MouseEvent; + offset?: [number, number]; +}) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const dividerCount = React.Children.count(children.filter(child => React.isValidElement(child) && child.type === Divider)); + const popupItemCount = React.Children.count(children.filter(child => React.isValidElement(child) && (child.type === PopupItem || child.type === Item))); + const menuHeight = popupItemCount * parseInt(getMenuItemHeight(), 10) + dividerCount * 8 + 16; + const menuWidthLimit = 200; + const target = event.target; + const { top, left } = (target as any)?.getBoundingClientRect(); + let x = event.clientX - left + offset[0]; + let y = event.clientY - top + offset[1]; + if (x + menuWidthLimit + left > viewportWidth) { + x = x - menuWidthLimit; + } + if (y + menuHeight + top > viewportHeight) { + y = y - menuHeight; + } + + const menuInstance = Menu.create({ + target, + offset: [x, y, 0, 0], + children, + className: 'engine-context-menu', + }); + + destroyFn = (menuInstance as any).destroy; + + return destroyFn; } \ No newline at end of file