From bb067a08b18791a70ad79c75bdc47d6a2a044052 Mon Sep 17 00:00:00 2001 From: smallstonesk <575913857@qq.com> Date: Sat, 2 Jan 2021 20:07:49 +0800 Subject: [PATCH] feat: add context menu for graph and node when right clicking --- packages/core/src/common/shortcuts.ts | 13 ++ packages/core/src/components/xIcon/index.less | 1 + packages/core/src/components/xIcon/index.tsx | 2 +- packages/core/src/hooks/useClickAway.ts | 65 ++++++++++ .../src/mods/flowChart/contextMenu/index.tsx | 118 ++++++++++++++++++ .../contextMenu/menuConfig/blank.tsx | 24 ++++ .../flowChart/contextMenu/menuConfig/index.ts | 7 ++ .../flowChart/contextMenu/menuConfig/node.tsx | 66 ++++++++++ .../src/mods/flowChart/createFlowChart.ts | 15 +++ .../core/src/mods/flowChart/index.module.less | 7 ++ packages/core/src/mods/flowChart/index.tsx | 58 +++++++-- .../src/mods/toolBar/widgets/bringToBack.tsx | 7 +- .../src/mods/toolBar/widgets/bringToTop.tsx | 7 +- 13 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/hooks/useClickAway.ts create mode 100644 packages/core/src/mods/flowChart/contextMenu/index.tsx create mode 100644 packages/core/src/mods/flowChart/contextMenu/menuConfig/blank.tsx create mode 100644 packages/core/src/mods/flowChart/contextMenu/menuConfig/index.ts create mode 100644 packages/core/src/mods/flowChart/contextMenu/menuConfig/node.tsx diff --git a/packages/core/src/common/shortcuts.ts b/packages/core/src/common/shortcuts.ts index 8e03c9b..88adbfe 100644 --- a/packages/core/src/common/shortcuts.ts +++ b/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; diff --git a/packages/core/src/components/xIcon/index.less b/packages/core/src/components/xIcon/index.less index 68118fc..9ed32cb 100644 --- a/packages/core/src/components/xIcon/index.less +++ b/packages/core/src/components/xIcon/index.less @@ -1,3 +1,4 @@ +#icon-select-all, #icon-align-top, #icon-align-left, #icon-align-vertical-center, diff --git a/packages/core/src/components/xIcon/index.tsx b/packages/core/src/components/xIcon/index.tsx index f06bb3d..785c378 100644 --- a/packages/core/src/components/xIcon/index.tsx +++ b/packages/core/src/components/xIcon/index.tsx @@ -8,7 +8,7 @@ interface IconFontProps { } const XIcon: React.SFC = createFromIconfontCN({ - scriptUrl: '//at.alicdn.com/t/font_2024452_zgg5gvivois.js', + scriptUrl: '//at.alicdn.com/t/font_2024452_0i8o2pf1wyac.js', }); export default XIcon; diff --git a/packages/core/src/hooks/useClickAway.ts b/packages/core/src/hooks/useClickAway.ts new file mode 100644 index 0000000..d1a3366 --- /dev/null +++ b/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 | null) + | T + | null + | MutableRefObject; + +const defaultEvent = 'click'; + +const getTargetElement = ( + target: BasicTarget, + 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; diff --git a/packages/core/src/mods/flowChart/contextMenu/index.tsx b/packages/core/src/mods/flowChart/contextMenu/index.tsx new file mode 100644 index 0000000..7b486bb --- /dev/null +++ b/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 = 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 : ( +
+ + {Helper.makeMenuContent(flowChart, menuConfig)} + +
+ ); +}; + +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 = ( + + {loop(children)} + + ); + } else { + content = ( + + {title} + + ); + } + return [ + content, + showDividerBehind && + ]; + }); + }; + return loop(menuConfig); + } +}; + +export default FlowChartContextMenu; diff --git a/packages/core/src/mods/flowChart/contextMenu/menuConfig/blank.tsx b/packages/core/src/mods/flowChart/contextMenu/menuConfig/blank.tsx new file mode 100644 index 0000000..3a3eea8 --- /dev/null +++ b/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: , + handler: shortcuts.selectAll.handler + }, + { + key: 'paste', + title: '粘贴', + icon: , + disabled: (flowChart: Graph) => flowChart.isClipboardEmpty(), + handler: shortcuts.paste.handler + } +]; + +export default blankMenuConfig; diff --git a/packages/core/src/mods/flowChart/contextMenu/menuConfig/index.ts b/packages/core/src/mods/flowChart/contextMenu/menuConfig/index.ts new file mode 100644 index 0000000..e32bd3d --- /dev/null +++ b/packages/core/src/mods/flowChart/contextMenu/menuConfig/index.ts @@ -0,0 +1,7 @@ +import nodeMenuConfig from './node'; +import blankMenuConfig from './blank'; + +export { + nodeMenuConfig, + blankMenuConfig +}; diff --git a/packages/core/src/mods/flowChart/contextMenu/menuConfig/node.tsx b/packages/core/src/mods/flowChart/contextMenu/menuConfig/node.tsx new file mode 100644 index 0000000..dfaf0a3 --- /dev/null +++ b/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: , + handler: shortcuts.copy.handler + }, + { + key: 'delete', + title: '删除', + icon: , + handler: shortcuts.delete.handler + }, + { + key: 'rename', + title: '编辑文本', + icon: , + showDividerBehind: true, + handler() { + // TODO + } + }, + { + key: 'bringToTop', + title: '置于顶层', + icon: , + handler:shortcuts.bringToTop.handler + }, + { + key: 'bringToBack', + title: '置于底层', + icon: , + showDividerBehind: true, + handler:shortcuts.bringToBack.handler + }, + { + key: 'editCode', + title: '编辑代码', + icon: , + handler() { + // TODO + } + }, + { + key: 'executeCode', + title: '执行代码', + icon: , + handler() { + // TODO + } + } +]; + +export default nodeMenuConfig; diff --git a/packages/core/src/mods/flowChart/createFlowChart.ts b/packages/core/src/mods/flowChart/createFlowChart.ts index 0680058..b611ad9 100644 --- a/packages/core/src/mods/flowChart/createFlowChart.ts +++ b/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 diff --git a/packages/core/src/mods/flowChart/index.module.less b/packages/core/src/mods/flowChart/index.module.less index fff309d..8447223 100644 --- a/packages/core/src/mods/flowChart/index.module.less +++ b/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; + } } \ No newline at end of file diff --git a/packages/core/src/mods/flowChart/index.tsx b/packages/core/src/mods/flowChart/index.tsx index e52751c..f68ceff 100644 --- a/packages/core/src/mods/flowChart/index.tsx +++ b/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 = (props) => { const { onReady } = props; const graphRef = useRef(null); const miniMapRef = useRef(null); + const [flowChart, setFlowChart] = useState(); + const [contextMenuInfo, setContextMenuInfo] = useState(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 = (props) => {
+ {flowChart && }
); }; diff --git a/packages/core/src/mods/toolBar/widgets/bringToBack.tsx b/packages/core/src/mods/toolBar/widgets/bringToBack.tsx index c32c6a6..c6e63ab 100644 --- a/packages/core/src/mods/toolBar/widgets/bringToBack.tsx +++ b/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 = makeBtnWidget({ tooltip: '置于底层', + handler: shortcuts.bringToBack.handler, getIcon() { return ; }, - handler(flowChart: Graph) { - getSelectedNodes(flowChart).forEach((node) => node.toBack()); - }, disabled(flowChart: Graph) { return !hasNodeSelected(flowChart); }, diff --git a/packages/core/src/mods/toolBar/widgets/bringToTop.tsx b/packages/core/src/mods/toolBar/widgets/bringToTop.tsx index 3fceb5b..93ecf8c 100644 --- a/packages/core/src/mods/toolBar/widgets/bringToTop.tsx +++ b/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 = makeBtnWidget({ tooltip: '置于顶层', + handler: shortcuts.bringToTop.handler, getIcon() { return ; }, - handler(flowChart: Graph) { - getSelectedNodes(flowChart).forEach((node) => node.toFront()); - }, disabled(flowChart: Graph) { return !hasNodeSelected(flowChart); },