Skip to content

Commit 28040fc

Browse files
authored
feat: add context menu (#161)
* feat: add more actions menu on SelectionTools * feat: add view source context action * feat: normalize hotkey by platform * feat: add context menu to components tree * feat: add context menu to viewport
1 parent 3f3981b commit 28040fc

File tree

18 files changed

+559
-60
lines changed

18 files changed

+559
-60
lines changed

packages/core/src/models/designer.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ export class Designer {
8383
*/
8484
_addComponentPopoverPosition = { clientX: 0, clientY: 0 };
8585

86+
/**
87+
* 是否显示右键菜单
88+
*/
89+
_showContextMenu = false;
90+
91+
/**
92+
* 右键菜单在 iframe 上的位置
93+
*/
94+
_contextMenuPosition = { clientX: 0, clientY: 0 };
95+
8696
/**
8797
* 是否显示右侧面板
8898
*/
@@ -136,6 +146,14 @@ export class Designer {
136146
return this._addComponentPopoverPosition;
137147
}
138148

149+
get showContextMenu() {
150+
return this._showContextMenu;
151+
}
152+
153+
get contextMenuPosition() {
154+
return this._contextMenuPosition;
155+
}
156+
139157
get menuData() {
140158
return this._menuData ?? ([] as MenuDataType);
141159
}
@@ -178,6 +196,8 @@ export class Designer {
178196
_showRightPanel: observable,
179197
_showAddComponentPopover: observable,
180198
_addComponentPopoverPosition: observable,
199+
_showContextMenu: observable,
200+
_contextMenuPosition: observable,
181201
_menuData: observable,
182202
_isPreview: observable,
183203
simulator: computed,
@@ -189,6 +209,8 @@ export class Designer {
189209
showSmartWizard: computed,
190210
showAddComponentPopover: computed,
191211
addComponentPopoverPosition: computed,
212+
showContextMenu: computed,
213+
contextMenuPosition: computed,
192214
menuData: computed,
193215
setSimulator: action,
194216
setViewport: action,
@@ -199,6 +221,7 @@ export class Designer {
199221
toggleSmartWizard: action,
200222
toggleIsPreview: action,
201223
toggleAddComponentPopover: action,
224+
toggleContextMenu: action,
202225
});
203226
}
204227

@@ -264,4 +287,15 @@ export class Designer {
264287
this.workspace.selectSource.clear();
265288
}
266289
}
290+
291+
toggleContextMenu(
292+
value: boolean,
293+
position: {
294+
clientX: number;
295+
clientY: number;
296+
} = this.contextMenuPosition,
297+
) {
298+
this._showContextMenu = value;
299+
this._contextMenuPosition = position;
300+
}
267301
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React from 'react';
2+
import { getWidget } from '../widgets';
3+
import { Menu, MenuProps } from 'antd';
4+
import { observer, useWorkspace } from '@music163/tango-context';
5+
import { ISelectedItemData, isString } from '@music163/tango-helpers';
6+
import { Box, css } from 'coral-system';
7+
import { IconFont } from '@music163/tango-ui';
8+
9+
const contextMenuStyle = css`
10+
.ant-dropdown-menu {
11+
width: 240px;
12+
}
13+
14+
.ant-dropdown-menu-title-content {
15+
padding-left: 20px;
16+
}
17+
18+
.ant-dropdown-menu-item-icon + .ant-dropdown-menu-title-content {
19+
padding-left: 0;
20+
}
21+
22+
.ant-dropdown-menu-submenu-popup {
23+
padding: 0;
24+
margin-top: -4px;
25+
}
26+
`;
27+
28+
const ParentNodesMenuItem = ({
29+
record,
30+
onClick,
31+
key,
32+
}: {
33+
record: ISelectedItemData;
34+
onClick: () => any;
35+
key?: string;
36+
}) => {
37+
const workspace = useWorkspace();
38+
const componentPrototype = workspace.componentPrototypes.get(record.name);
39+
const icon = componentPrototype?.icon || 'icon-placeholder';
40+
41+
const iconRender = icon.startsWith('icon-') ? (
42+
<IconFont className="material-icon" type={icon} />
43+
) : (
44+
<img className="material-icon" src={icon} alt={componentPrototype.name} />
45+
);
46+
47+
return (
48+
<Menu.Item key={key || record.id} icon={iconRender} onClick={onClick}>
49+
{record.name}
50+
{!!record.codeId && ` (${record.codeId})`}
51+
</Menu.Item>
52+
);
53+
};
54+
55+
const ParentNodesMenu = observer(() => {
56+
const workspace = useWorkspace();
57+
const selectSource = workspace.selectSource;
58+
const parents = selectSource.first?.parents;
59+
60+
if (!parents?.length) {
61+
return null;
62+
}
63+
64+
return (
65+
<Menu.SubMenu key="parentNodes" title="选取父节点">
66+
{parents.map((item, index) => (
67+
<ParentNodesMenuItem
68+
key={item.id}
69+
record={item}
70+
onClick={() => {
71+
selectSource.select({
72+
...item,
73+
parents: parents.slice(index + 1),
74+
});
75+
}}
76+
/>
77+
))}
78+
</Menu.SubMenu>
79+
);
80+
});
81+
82+
export interface ContextMenuProps extends MenuProps {
83+
/**
84+
* 动作列表,默认列出全部
85+
*/
86+
actions?: Array<string | React.ReactElement>;
87+
/**
88+
* 是否显示父节点选项
89+
*/
90+
showParents?: boolean;
91+
className?: string;
92+
style?: React.CSSProperties;
93+
menuStyle?: React.CSSProperties;
94+
}
95+
96+
export const ContextMenu = observer(
97+
({
98+
showParents,
99+
actions: actionsProp,
100+
className,
101+
style,
102+
menuStyle,
103+
...rest
104+
}: ContextMenuProps) => {
105+
const actions = actionsProp || Object.keys(getWidget('contextMenu'));
106+
const menus = actions.map((item) => {
107+
if (isString(item)) {
108+
const widget = getWidget(['contextMenu', item].join('.'));
109+
return widget ? React.createElement(widget) : null;
110+
}
111+
return item;
112+
});
113+
if (showParents) {
114+
menus.unshift(<ParentNodesMenu />);
115+
}
116+
117+
return (
118+
<Box display="inline-block" css={contextMenuStyle} className={className} style={style}>
119+
<Menu activeKey={null} {...rest} style={menuStyle}>
120+
{menus}
121+
</Menu>
122+
</Box>
123+
);
124+
},
125+
);

packages/designer/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './context-menu';
12
export * from './drag-box';
23
export * from './input-kv';
34
export * from './variable-tree';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
import { useWorkspace, observer } from '@music163/tango-context';
3+
import { ContextAction } from '@music163/tango-ui';
4+
import { CopyOutlined } from '@ant-design/icons';
5+
6+
export const CopyNodeContextAction = observer(() => {
7+
const workspace = useWorkspace();
8+
return (
9+
<ContextAction
10+
icon={<CopyOutlined />}
11+
hotkey="Command+C"
12+
onClick={() => {
13+
workspace.copySelectedNode();
14+
}}
15+
>
16+
复制节点
17+
</ContextAction>
18+
);
19+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
import { useWorkspace, observer } from '@music163/tango-context';
3+
import { ContextAction } from '@music163/tango-ui';
4+
import { DeleteOutlined } from '@ant-design/icons';
5+
6+
export const DeleteNodeContextAction = observer(() => {
7+
const workspace = useWorkspace();
8+
return (
9+
<ContextAction
10+
icon={<DeleteOutlined />}
11+
hotkey="Backspace"
12+
onClick={() => {
13+
workspace.removeSelectedNode();
14+
}}
15+
>
16+
删除节点
17+
</ContextAction>
18+
);
19+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './copy-node';
2+
export * from './delete-node';
3+
export * from './paste-node';
4+
export * from './view-source';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
import { useWorkspace, observer } from '@music163/tango-context';
3+
import { ContextAction } from '@music163/tango-ui';
4+
import { SnippetsOutlined } from '@ant-design/icons';
5+
6+
export const PasteNodeContextAction = observer(() => {
7+
const workspace = useWorkspace();
8+
return (
9+
<ContextAction
10+
icon={<SnippetsOutlined />}
11+
hotkey="Command+V"
12+
onClick={() => {
13+
workspace.pasteSelectedNode();
14+
}}
15+
>
16+
粘贴节点
17+
</ContextAction>
18+
);
19+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
import { useDesigner, observer } from '@music163/tango-context';
3+
import { ContextAction } from '@music163/tango-ui';
4+
import { CodeOutlined } from '@ant-design/icons';
5+
6+
export const ViewSourceContextAction = observer(() => {
7+
const designer = useDesigner();
8+
return (
9+
<ContextAction
10+
icon={<CodeOutlined />}
11+
onClick={() => {
12+
designer.setActiveView('code');
13+
}}
14+
>
15+
查看源码
16+
</ContextAction>
17+
);
18+
});

packages/designer/src/dnd/use-dnd.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ export function useDnd({
7777
if (designer.isPreview) {
7878
return;
7979
}
80+
if (designer.showContextMenu) {
81+
designer.toggleContextMenu(false);
82+
}
8083

8184
const point = sandboxQuery.getRelativePoint({ x: e.clientX, y: e.clientY });
8285
selectSource.setStart({
@@ -128,6 +131,9 @@ export function useDnd({
128131
};
129132

130133
const onClick = (e: React.MouseEvent) => {
134+
if (designer.showContextMenu) {
135+
designer.toggleContextMenu(false);
136+
}
131137
const data = sandboxQuery.getDraggableParentsData(e.target as HTMLElement, true);
132138
if (data && data.id) {
133139
selectSource.select(data);
@@ -388,6 +394,56 @@ export function useDnd({
388394
}
389395
};
390396

397+
const onContextMenu = (event: React.MouseEvent) => {
398+
if (designer.showContextMenu) {
399+
designer.toggleContextMenu(false);
400+
}
401+
// 按下其他按键时,视为用户有特殊操作,此时不展示右键菜单
402+
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
403+
return;
404+
}
405+
const { clientX, clientY } = event;
406+
let target;
407+
if (workspace.selectSource.isSelected) {
408+
for (const item of workspace.selectSource.selected) {
409+
if (
410+
// 如果选中的节点是页面根节点(无 parents),则忽略
411+
item.parents?.length &&
412+
item.bounding &&
413+
clientX >= item.bounding.left &&
414+
clientX <= item.bounding.left + item.bounding.width &&
415+
clientY >= item.bounding.top &&
416+
clientY <= item.bounding.top + item.bounding.height
417+
) {
418+
// 右键坐标已经在当前选中组件的选区内,直接展示右键菜单
419+
target = item;
420+
break;
421+
}
422+
}
423+
}
424+
// 否则,根据右键的元素选中最接近的组件
425+
if (!target) {
426+
target = sandboxQuery.getDraggableParentsData(event.target as HTMLElement, true);
427+
}
428+
if (target && target.id) {
429+
if (!target.parents?.length) {
430+
// 页面根节点不展示右键菜单操作
431+
return;
432+
}
433+
// 右键时高亮选中当前元素
434+
// 以防之前选区有多个元素,即便已经是选中的元素也再选中一遍
435+
event.preventDefault();
436+
selectSource.select(target);
437+
// 在下一周期再展示右键菜单,以让先前的菜单先被销毁
438+
requestAnimationFrame(() => {
439+
designer.toggleContextMenu(true, {
440+
clientX,
441+
clientY,
442+
});
443+
});
444+
}
445+
};
446+
391447
const onTango = (e: CustomEvent) => {
392448
const detail = e.detail || {};
393449

@@ -437,6 +493,7 @@ export function useDnd({
437493
onDragEnd,
438494
onScroll,
439495
onKeyDown,
496+
onContextMenu,
440497
onTango,
441498
...selectHandler,
442499
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './copy-node';
22
export * from './delete-node';
3+
export * from './more-actions';
34
export * from './select-parent-node';
45
export * from './view-source';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
import { observer } from '@music163/tango-context';
3+
import { SelectAction } from '@music163/tango-ui';
4+
import { MoreOutlined } from '@ant-design/icons';
5+
import { Dropdown } from 'antd';
6+
import { ContextMenu } from '../components';
7+
8+
export const MoreActionsAction = observer(() => {
9+
return (
10+
<Dropdown placement="bottomRight" overlay={<ContextMenu showParents />}>
11+
<SelectAction tooltip="更多">
12+
<MoreOutlined />
13+
</SelectAction>
14+
</Dropdown>
15+
);
16+
});

0 commit comments

Comments
 (0)