Browse Source

feat: add context menu for graph and node when right clicking

master
smallstonesk 4 years ago
parent
commit
bb067a08b1
  1. 13
      packages/core/src/common/shortcuts.ts
  2. 1
      packages/core/src/components/xIcon/index.less
  3. 2
      packages/core/src/components/xIcon/index.tsx
  4. 65
      packages/core/src/hooks/useClickAway.ts
  5. 118
      packages/core/src/mods/flowChart/contextMenu/index.tsx
  6. 24
      packages/core/src/mods/flowChart/contextMenu/menuConfig/blank.tsx
  7. 7
      packages/core/src/mods/flowChart/contextMenu/menuConfig/index.ts
  8. 66
      packages/core/src/mods/flowChart/contextMenu/menuConfig/node.tsx
  9. 15
      packages/core/src/mods/flowChart/createFlowChart.ts
  10. 7
      packages/core/src/mods/flowChart/index.module.less
  11. 58
      packages/core/src/mods/flowChart/index.tsx
  12. 7
      packages/core/src/mods/toolBar/widgets/bringToBack.tsx
  13. 7
      packages/core/src/mods/toolBar/widgets/bringToTop.tsx

13
packages/core/src/common/shortcuts.ts

@ -2,6 +2,7 @@ import { safeGet } from '../utils';
import { localSave } from '../api';
import { Cell, Edge, Graph, Node } from '@antv/x6';
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from './const';
import { getSelectedNodes } from '../utils/flowChartUtils';
interface Shortcut {
keys: string | string[];
@ -144,6 +145,18 @@ const shortcuts: { [key: string]: Shortcut } = {
return false;
},
},
bringToTop: {
keys: 'meta + ]',
handler(flowChart: Graph) {
getSelectedNodes(flowChart).forEach((node) => node.toFront());
}
},
bringToBack: {
keys: 'meta + [',
handler(flowChart: Graph) {
getSelectedNodes(flowChart).forEach((node) => node.toBack());
}
}
};
export default shortcuts;

1
packages/core/src/components/xIcon/index.less

@ -1,3 +1,4 @@
#icon-select-all,
#icon-align-top,
#icon-align-left,
#icon-align-vertical-center,

2
packages/core/src/components/xIcon/index.tsx

@ -8,7 +8,7 @@ interface IconFontProps {
}
const XIcon: React.SFC<IconFontProps> = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/font_2024452_zgg5gvivois.js',
scriptUrl: '//at.alicdn.com/t/font_2024452_0i8o2pf1wyac.js',
});
export default XIcon;

65
packages/core/src/hooks/useClickAway.ts

@ -0,0 +1,65 @@
import {
useRef,
useEffect,
MutableRefObject
} from 'react';
type EventType = MouseEvent | TouchEvent;
type TargetElement = HTMLElement | Element | Document | Window;
type BasicTarget<T = HTMLElement> =
| (() => T | null)
| T
| null
| MutableRefObject<T | null | undefined>;
const defaultEvent = 'click';
const getTargetElement = (
target: BasicTarget<TargetElement>,
defaultElement?: TargetElement,
): TargetElement | undefined | null => {
if (!target) {
return defaultElement;
}
let targetElement: TargetElement | undefined | null;
if (typeof target === 'function') {
targetElement = target();
} else if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}
return targetElement;
};
const useClickAway = (
onClickAway: (event: EventType) => void,
target: BasicTarget | BasicTarget[],
eventName: string = defaultEvent,
): void => {
const onClickAwayRef = useRef(onClickAway);
onClickAwayRef.current = onClickAway;
useEffect(() => {
const handler = (event: any) => {
const targets = Array.isArray(target) ? target : [target];
if (
targets.some((targetItem) => {
const targetElement = getTargetElement(targetItem) as HTMLElement;
return !targetElement || targetElement?.contains(event.target);
})
) {
return;
}
onClickAwayRef.current(event);
};
document.addEventListener(eventName, handler);
return () => {
document.removeEventListener(eventName, handler);
};
}, [target, eventName]);
};
export default useClickAway;

118
packages/core/src/mods/flowChart/contextMenu/index.tsx

@ -0,0 +1,118 @@
import React, {
useRef,
useCallback
} from 'react';
import styles from '../index.module.less';
import { Menu } from 'antd';
import { Graph } from '@antv/x6';
import useClickAway from '../../../hooks/useClickAway';
import { nodeMenuConfig, blankMenuConfig } from './menuConfig';
interface IProps {
x: number;
y: number;
scene: string;
visible: boolean;
flowChart: Graph;
}
interface IMenuConfig {
key: string;
title: string;
icon?: React.ReactElement;
children?: IMenuConfig[];
showDividerBehind?: boolean;
disabled?: boolean | ((flowChart: Graph) => boolean);
handler: (flowChart: Graph) => void;
}
const menuConfigMap: { [scene: string]: IMenuConfig[] } = {
node: nodeMenuConfig,
blank: blankMenuConfig
};
const FlowChartContextMenu: React.FC<IProps> = props => {
const menuRef = useRef(null);
const { x, y, scene, visible, flowChart } = props;
const menuConfig = menuConfigMap[scene];
useClickAway(() => onClickAway(), menuRef);
const onClickAway = useCallback(() => flowChart.trigger('graph:hideContextMenu'), [flowChart]);
const onClickMenu = useCallback(({ key }) => {
const handlerMap = Helper.makeMenuHandlerMap(menuConfig);
const handler = handlerMap[key];
if (handler) {
onClickAway();
handler(flowChart);
}
}, [flowChart, menuConfig]);
return !visible ? null : (
<div
ref={menuRef}
className={styles.contextMenu}
style={{ left: x, top: y }}
>
<Menu
mode={'vertical'}
selectable={false}
onClick={onClickMenu}
>
{Helper.makeMenuContent(flowChart, menuConfig)}
</Menu>
</div>
);
};
const Helper = {
makeMenuHandlerMap(config: IMenuConfig[]) {
const queue = config.slice(0);
const handlerMap: { [key: string]: (flowChart: Graph) => void } = {};
while (queue.length > 0) {
const { key, handler, children } = queue.pop() as IMenuConfig;
if (children && children.length > 0) {
queue.push(...children);
} else {
handlerMap[key] = handler;
}
}
return handlerMap;
},
makeMenuContent(flowChart: Graph, menuConfig: IMenuConfig[]) {
const loop = (config: IMenuConfig[]) => {
return config.map(item => {
let content = null;
let { key, title, icon, children, disabled = false, showDividerBehind } = item;
if (key === 'paste') {
debugger
}
if (typeof disabled === 'function') {
disabled = disabled(flowChart);
}
if (children && children.length > 0) {
content = (
<Menu.SubMenu key={key} icon={icon} title={title} disabled={disabled}>
{loop(children)}
</Menu.SubMenu>
);
} else {
content = (
<Menu.Item key={key} icon={icon} disabled={disabled}>
{title}
</Menu.Item>
);
}
return [
content,
showDividerBehind && <Menu.Divider />
];
});
};
return loop(menuConfig);
}
};
export default FlowChartContextMenu;

24
packages/core/src/mods/flowChart/contextMenu/menuConfig/blank.tsx

@ -0,0 +1,24 @@
import React from 'react';
import { Graph } from '@antv/x6';
import XIcon from '../../../../components/xIcon';
import shortcuts from '../../../../common/shortcuts';
import { SnippetsOutlined } from '@ant-design/icons';
const blankMenuConfig = [
{
key: 'selectAll',
title: '全选',
icon: <XIcon type={'icon-select-all'} />,
handler: shortcuts.selectAll.handler
},
{
key: 'paste',
title: '粘贴',
icon: <SnippetsOutlined />,
disabled: (flowChart: Graph) => flowChart.isClipboardEmpty(),
handler: shortcuts.paste.handler
}
];
export default blankMenuConfig;

7
packages/core/src/mods/flowChart/contextMenu/menuConfig/index.ts

@ -0,0 +1,7 @@
import nodeMenuConfig from './node';
import blankMenuConfig from './blank';
export {
nodeMenuConfig,
blankMenuConfig
};

66
packages/core/src/mods/flowChart/contextMenu/menuConfig/node.tsx

@ -0,0 +1,66 @@
import React from 'react';
import {
CopyOutlined,
EditOutlined,
CodeOutlined,
FormOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import XIcon from '../../../../components/xIcon';
import shortcuts from '../../../../common/shortcuts';
const nodeMenuConfig = [
{
key: 'copy',
title: '复制',
icon: <CopyOutlined />,
handler: shortcuts.copy.handler
},
{
key: 'delete',
title: '删除',
icon: <DeleteOutlined />,
handler: shortcuts.delete.handler
},
{
key: 'rename',
title: '编辑文本',
icon: <EditOutlined />,
showDividerBehind: true,
handler() {
// TODO
}
},
{
key: 'bringToTop',
title: '置于顶层',
icon: <XIcon type={'icon-bringtotop'} />,
handler:shortcuts.bringToTop.handler
},
{
key: 'bringToBack',
title: '置于底层',
icon: <XIcon type={'icon-bringtobottom'} />,
showDividerBehind: true,
handler:shortcuts.bringToBack.handler
},
{
key: 'editCode',
title: '编辑代码',
icon: <FormOutlined />,
handler() {
// TODO
}
},
{
key: 'executeCode',
title: '执行代码',
icon: <CodeOutlined />,
handler() {
// TODO
}
}
];
export default nodeMenuConfig;

15
packages/core/src/mods/flowChart/createFlowChart.ts

@ -55,6 +55,20 @@ const registerEvents = (flowChart: Graph): void => {
args.edge.attr('line/strokeWidth', '2px');
}
});
flowChart.on('blank:contextmenu', (args) => {
const { e: { clientX, clientY } } = args;
flowChart.cleanSelection();
flowChart.trigger('graph:showContextMenu', { x: clientX, y: clientY, scene: 'blank' });
});
flowChart.on('node:contextmenu', (args) => {
const { e: { clientX, clientY }, node } = args;
// NOTE: if the clicked node is not in the selected nodes, then clear selection
if(!flowChart.getSelectedCells().includes(node)) {
flowChart.cleanSelection();
flowChart.select(node);
}
flowChart.trigger('graph:showContextMenu', { x: clientX, y: clientY, scene: 'node' });
});
};
const registerShortcuts = (flowChart: Graph): void => {
@ -108,6 +122,7 @@ const createFlowChart = (container: HTMLDivElement, miniMapContainer: HTMLDivEle
multiple: true,
rubberband: true,
movable: true,
strict: true,
showNodeSelectionBox: true
},
// https://x6.antv.vision/zh/docs/tutorial/basic/snapline

7
packages/core/src/mods/flowChart/index.module.less

@ -22,4 +22,11 @@
}
}
}
.contextMenu {
z-index: 1003;
position: fixed;
min-width: 200px;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
}
}

58
packages/core/src/mods/flowChart/index.tsx

@ -1,36 +1,79 @@
import React, { useRef, useEffect } from 'react';
import React, {
useRef,
useState,
useEffect
} from 'react';
import styles from './index.module.less';
import { message } from 'antd';
import { Graph } from '@antv/x6';
import { queryGraph } from '../../api';
import { parseQuery } from '../../utils';
import createFlowChart from './createFlowChart';
import FlowChartContextMenu from './contextMenu';
interface IProps {
onReady: (graph: Graph) => void;
}
interface IMenuInfo {
x: number;
y: number;
scene: string;
visible: boolean;
}
const defaultMenuInfo = {
x: 0,
y: 0,
scene: 'blank',
visible: false
};
const FlowChart: React.FC<IProps> = (props) => {
const { onReady } = props;
const graphRef = useRef<HTMLDivElement>(null);
const miniMapRef = useRef<HTMLDivElement>(null);
const [flowChart, setFlowChart] = useState<Graph>();
const [contextMenuInfo, setContextMenuInfo] = useState<IMenuInfo>(defaultMenuInfo);
useEffect(() => {
if (graphRef.current && miniMapRef.current) {
const graph = createFlowChart(graphRef.current, miniMapRef.current);
onReady(graph);
fetchData(graph);
const flowChart = createFlowChart(graphRef.current, miniMapRef.current);
onReady(flowChart);
fetchData(flowChart);
setFlowChart(flowChart);
}
}, []);
const fetchData = (graph: Graph) => {
// NOTE: listen toggling context menu event
useEffect(() => {
const showHandler = (info: IMenuInfo) => {
flowChart?.lockScroller();
setContextMenuInfo({ ...info, visible: true });
};
const hideHandler = () => {
flowChart?.unlockScroller();
setContextMenuInfo({ ...contextMenuInfo, visible: false });
};
if (flowChart) {
flowChart.on('graph:showContextMenu', showHandler);
flowChart.on('graph:hideContextMenu', hideHandler);
}
return () => {
if (flowChart) {
flowChart.off('graph:showContextMenu', showHandler);
flowChart.off('graph:hideContextMenu', hideHandler);
}
}
}, [flowChart]);
const fetchData = (flowChart: Graph) => {
const { projectId } = parseQuery();
queryGraph(projectId as string)
.then((res) => {
const { data: dsl } = res;
graph.fromJSON(dsl);
flowChart.fromJSON(dsl);
})
.catch((error) => {
console.log('query graph data failed, the error is:', error);
@ -41,6 +84,7 @@ const FlowChart: React.FC<IProps> = (props) => {
<div className={styles.container}>
<div className={styles.flowChart} ref={graphRef} />
<div className={styles.miniMap} ref={miniMapRef} />
{flowChart && <FlowChartContextMenu {...contextMenuInfo} flowChart={flowChart} />}
</div>
);
};

7
packages/core/src/mods/toolBar/widgets/bringToBack.tsx

@ -2,8 +2,9 @@ import React from 'react';
import { Graph } from '@antv/x6';
import XIcon from '../../../components/xIcon';
import shortcuts from '../../../common/shortcuts';
import makeBtnWidget from './common/makeBtnWidget';
import { getSelectedNodes, hasNodeSelected } from '../../../utils/flowChartUtils';
import { hasNodeSelected } from '../../../utils/flowChartUtils';
interface IProps {
flowChart: Graph;
@ -11,12 +12,10 @@ interface IProps {
const BringToBack: React.FC<IProps> = makeBtnWidget({
tooltip: '置于底层',
handler: shortcuts.bringToBack.handler,
getIcon() {
return <XIcon type={'icon-bringtobottom'} />;
},
handler(flowChart: Graph) {
getSelectedNodes(flowChart).forEach((node) => node.toBack());
},
disabled(flowChart: Graph) {
return !hasNodeSelected(flowChart);
},

7
packages/core/src/mods/toolBar/widgets/bringToTop.tsx

@ -3,8 +3,9 @@ import React from 'react';
import { Graph } from '@antv/x6';
import XIcon from '../../../components/xIcon';
import shortcuts from '../../../common/shortcuts';
import makeBtnWidget from './common/makeBtnWidget';
import { getSelectedNodes, hasNodeSelected } from '../../../utils/flowChartUtils';
import { hasNodeSelected } from '../../../utils/flowChartUtils';
interface IProps {
flowChart: Graph;
@ -12,12 +13,10 @@ interface IProps {
const BringToTop: React.FC<IProps> = makeBtnWidget({
tooltip: '置于顶层',
handler: shortcuts.bringToTop.handler,
getIcon() {
return <XIcon type={'icon-bringtotop'} />;
},
handler(flowChart: Graph) {
getSelectedNodes(flowChart).forEach((node) => node.toFront());
},
disabled(flowChart: Graph) {
return !hasNodeSelected(flowChart);
},

Loading…
Cancel
Save