diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx index 2c6a18c3..79dcc359 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoTaskNode.tsx @@ -72,7 +72,7 @@ const DemoTaskNode: React.FunctionComponent = ({ }; return ( - + = ({ const data = element.getData(); const detailsLevel = element.getGraph().getDetailsLevel(); const [hover, hoverRef] = useHover(); + const focused = hover || contextMenuOpen; const passedData = React.useMemo(() => { const newData = { ...data }; @@ -154,18 +155,19 @@ const StyleNode: React.FunctionComponent = ({ const LabelIcon = passedData.labelIcon; return ( - + = ({ onHideCreateConnector={onHideCreateConnector} labelIcon={LabelIcon && } attachments={ - (hover || detailsLevel === ScaleDetailsLevel.high) && + (focused || detailsLevel === ScaleDetailsLevel.high) && renderDecorators(nodeElement, passedData, rest.getShapeDecoratorCenter) } > - {(hover || detailsLevel !== ScaleDetailsLevel.low) && renderIcon(passedData, nodeElement)} + {(focused || detailsLevel !== ScaleDetailsLevel.low) && renderIcon(passedData, nodeElement)} diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.css b/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.css new file mode 100644 index 00000000..d3ad0935 --- /dev/null +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.css @@ -0,0 +1,43 @@ +.area-drag-hint__hint-container { + justify-content: center; + display: flex; + pointer-events: none; + position: absolute; + top: var(--pf-t--global--spacer--sm); + left: 0; + right: 0; + z-index: 99; +} +.area-drag-hint__hint-background { + background-color: var(--pf-t--global--background--color--primary--default); + border: 1px solid var(--pf-t--global--border--color--nonstatus--gray--default); + border-radius: 8px; + padding: var(--pf-t--global--spacer--xs) var(--pf-t--global--spacer--sm); + pointer-events: none; +} + +.area-drag-hint { + align-items: center; + display: flex; +} +.area-drag-hint__icon { + color: var(--pf-t--global--icon--color--status--info--default); +} +.area-drag-hint__text { + margin-left: var(--pf-t--global--spacer--sm); +} +.area-drag-hint-shortcut__cell { + padding-left: var(--pf-t--global--spacer--sm); +} + +.area-drag-hint-shortcut__command:not(:last-child):after { + content: ' + '; +} + +.area-drag-hint-shortcut__kbd { + border: var(--pf-t--global--border--width--regular) solid var(--pf-t--global--border--color--nonstatus--gray--default); + border-radius: 3px; + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--sm); + padding: 1px 3px; +} \ No newline at end of file diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.tsx new file mode 100644 index 00000000..66e48f2a --- /dev/null +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { InfoCircleIcon, MouseIcon } from '@patternfly/react-icons'; + +import './AreaDragHint.css'; + +const AreaDragHint: React.FC = () => { + return ( +
+
+
+ + + + + + + + + + + + + +
+ + Shift + + + + Drag + + + Select nodes in area
+ + Ctrl + + + + Drag + + + Zoom to selected area
+
+
+
+
+ ); +}; + +export default AreaDragHint; diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoNode.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoNode.tsx index cf64fab5..2f12e70a 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoNode.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoNode.tsx @@ -147,12 +147,13 @@ const getShapeComponent = (shape: NodeShape): React.FunctionComponent = observer( - ({ element, onContextMenu, dragging, onShowCreateConnector, onHideCreateConnector, ...rest }) => { + ({ element, onContextMenu, dragging, contextMenuOpen, onShowCreateConnector, onHideCreateConnector, ...rest }) => { const options = React.useContext(DemoContext).nodeOptions; const nodeElement = element as Node; const data = element.getData() as GeneratedNodeData; const detailsLevel = element.getGraph().getDetailsLevel(); const [hover, hoverRef] = useHover(); + const focused = hover || contextMenuOpen; React.useEffect(() => { if (detailsLevel === ScaleDetailsLevel.low) { @@ -164,19 +165,22 @@ const DemoNode: React.FunctionComponent = observer( const LabelIcon = data.index % 2 === 1 ? (SignOutAltIcon as any) : undefined; return ( - + } @@ -186,12 +190,12 @@ const DemoNode: React.FunctionComponent = observer( getCustomShape={options.showShapes ? () => getShapeComponent(data.shape) : undefined} badge={options.badges ? data.objectType : undefined} attachments={ - (hover || detailsLevel === ScaleDetailsLevel.high) && + (focused || detailsLevel === ScaleDetailsLevel.high) && options.showDecorators && renderDecorators(options, nodeElement, rest.getShapeDecoratorCenter) } > - {(hover || detailsLevel !== ScaleDetailsLevel.low) && renderIcon(data, nodeElement)} + {(focused || detailsLevel !== ScaleDetailsLevel.low) && renderIcon(data, nodeElement)} diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsContextBar.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsContextBar.tsx index 272a7f18..8cec1624 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsContextBar.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsContextBar.tsx @@ -125,6 +125,17 @@ const OptionsContextBar: React.FC = observer(() => { > Context Menus + + options.setNodeOptions({ ...options.nodeOptions, hideKebabMenu: !options.nodeOptions.hideKebabMenu }) + } + > + Hide kebab for context menu + = observer( ({ useSidebar, sideBarResizable = false }) => { const [selectedIds, setSelectedIds] = React.useState([]); + const [showAreaDragHint, setShowAreaDragHint] = React.useState(false); const controller = useVisualizationController(); const options = React.useContext(DemoContext); @@ -59,6 +65,31 @@ const TopologyViewComponent: React.FunctionComponent setSelectedIds(ids); }); + useEventListener( + GRAPH_AREA_SELECTED_EVENT, + ({ graph, modifier, startPoint, endPoint }) => { + if (modifier === 'ctrlKey') { + graph.zoomToSelection(startPoint, endPoint); + return; + } + if (modifier === 'shiftKey') { + const selections = graph.nodesInSelection(startPoint, endPoint); + setSelectedIds( + selections.reduce((acc, node) => { + if (!node.isGroup()) { + acc.push(node.getId()); + } + return acc; + }, []) + ); + } + } + ); + + useEventListener(GRAPH_AREA_DRAGGING_EVENT, ({ isDragging }) => { + setShowAreaDragHint(isDragging); + }); + React.useEffect(() => { let resizeTimeout: NodeJS.Timeout; @@ -111,6 +142,7 @@ const TopologyViewComponent: React.FunctionComponent sideBarOpen={useSidebar && !!selectedIds?.length} sideBarResizable={sideBarResizable} > + {showAreaDragHint ? : null} ); diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/demoComponentFactory.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/demoComponentFactory.tsx index 2be4992c..1c17022f 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/demoComponentFactory.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/demoComponentFactory.tsx @@ -10,8 +10,9 @@ import { ModelKind, DragObjectWithType, Node, - withPanZoom, GraphComponent, + withPanZoom, + withAreaSelection, withCreateConnector, Graph, isNode, @@ -60,7 +61,9 @@ const demoComponentFactory: ComponentFactory = ( type: string ): React.ComponentType<{ element: GraphElement }> | undefined => { if (kind === ModelKind.graph) { - return withDndDrop(graphDropTargetSpec([NODE_DRAG_TYPE]))(withPanZoom()(GraphComponent)); + return withDndDrop(graphDropTargetSpec([NODE_DRAG_TYPE]))( + withPanZoom()(withAreaSelection(['ctrlKey', 'shiftKey'])(GraphComponent)) + ); } switch (type) { case 'node': diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts b/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts index 47a2c485..e8d7695d 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts @@ -88,6 +88,7 @@ export interface GeneratorNodeOptions { badges?: boolean; icons?: boolean; contextMenus?: boolean; + hideKebabMenu?: boolean; hulledOutline?: boolean; } diff --git a/packages/module/src/behavior/index.ts b/packages/module/src/behavior/index.ts index 728a0d13..413812b0 100644 --- a/packages/module/src/behavior/index.ts +++ b/packages/module/src/behavior/index.ts @@ -6,6 +6,7 @@ export * from './useDndDrop'; export * from './useDndManager'; export * from './useDragNode'; export * from './usePanZoom'; +export * from './useAreaSelection'; export * from './useReconnect'; export * from './useSelection'; export * from './usePolygonAnchor'; diff --git a/packages/module/src/behavior/useAreaSelection.tsx b/packages/module/src/behavior/useAreaSelection.tsx new file mode 100644 index 00000000..a59dc961 --- /dev/null +++ b/packages/module/src/behavior/useAreaSelection.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import * as d3 from 'd3'; +import { observer } from 'mobx-react'; +import { action } from 'mobx'; +import ElementContext from '../utils/ElementContext'; +import useCallbackRef from '../utils/useCallbackRef'; +import { Graph, GRAPH_AREA_DRAGGING_EVENT, GRAPH_AREA_SELECTED_EVENT, isGraph, ModifierKey } from '../types'; +import Point from '../geom/Point'; + +export type AreaSelectionRef = (node: SVGGElement | null) => void; + +// Used to send events prevented by d3.zoom to the document allowing modals, dropdowns, etc, to close +const propagateAreaSelectionMouseEvent = (e: Event): void => { + document.dispatchEvent(new MouseEvent(e.type, e)); +}; + +export const useAreaSelection = (modifiers: ModifierKey[] = ['ctrlKey']): WithAreaSelectionProps => { + const element = React.useContext(ElementContext); + const [draggingState, setDraggingState] = React.useState>({}); + + if (!isGraph(element)) { + throw new Error('useAreaSelection must be used within the scope of a Graph'); + } + const elementRef = React.useRef(element); + elementRef.current = element; + + const areaSelectionRef = useCallbackRef((node: SVGGElement | null) => { + if (node) { + // TODO fix any type + const $svg = d3.select(node.ownerSVGElement) as any; + if (node && node.ownerSVGElement) { + node.ownerSVGElement.addEventListener('mousedown', propagateAreaSelectionMouseEvent); + node.ownerSVGElement.addEventListener('click', propagateAreaSelectionMouseEvent); + } + const drag = d3 + .drag() + .on( + 'start', + action((event: d3.D3DragEvent) => { + const { offsetX, offsetY } = + event.sourceEvent instanceof MouseEvent ? event.sourceEvent : { offsetX: 0, offsetY: 0 }; + const { width: maxX, height: maxY } = elementRef.current.getDimensions(); + + const startPoint = new Point(Math.min(Math.max(offsetX, 0), maxX), Math.min(Math.max(offsetY, 0), maxY)); + const modifier = modifiers.find((m) => event.sourceEvent[m]); + + setDraggingState({ + modifier, + isAreaSelectDragging: true, + areaSelectDragStart: startPoint, + areaSelectDragEnd: startPoint + }); + elementRef.current + .getController() + .fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: true }); + }) + ) + .on( + 'drag', + action((event: d3.D3DragEvent) => { + const { offsetX, offsetY } = + event.sourceEvent instanceof MouseEvent ? event.sourceEvent : { offsetX: 0, offsetY: 0 }; + const { width: maxX, height: maxY } = elementRef.current.getDimensions(); + setDraggingState((prev) => ({ + ...prev, + areaSelectDragEnd: new Point(Math.min(Math.max(offsetX, 0), maxX), Math.min(Math.max(offsetY, 0), maxY)) + })); + }) + ) + .on( + 'end', + action(() => { + setDraggingState((prev) => { + elementRef.current.getController().fireEvent(GRAPH_AREA_SELECTED_EVENT, { + graph: elementRef.current, + modifier: prev.modifier, + startPoint: prev.areaSelectDragStart, + endPoint: prev.areaSelectDragEnd + }); + return { isAreaSelectDragging: false }; + }); + elementRef.current + .getController() + .fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: false }); + }) + ) + .filter((event: React.MouseEvent) => modifiers.find((m) => event[m]) && !event.button); + drag($svg); + } + + return () => { + if (node) { + // remove all drag listeners + d3.select(node.ownerSVGElement).on('.drag', null); + if (node.ownerSVGElement) { + node.ownerSVGElement.removeEventListener('mousedown', propagateAreaSelectionMouseEvent); + node.ownerSVGElement.removeEventListener('click', propagateAreaSelectionMouseEvent); + } + } + }; + }); + return { areaSelectionRef, ...draggingState }; +}; +export interface WithAreaSelectionProps { + areaSelectionRef?: AreaSelectionRef; + modifier?: ModifierKey; + isAreaSelectDragging?: boolean; + areaSelectDragStart?: Point; + areaSelectDragEnd?: Point; +} + +export const withAreaSelection = + (modifier: ModifierKey[] = ['ctrlKey']) => +

(WrappedComponent: React.ComponentType

) => { + const Component: React.FunctionComponent> = (props) => { + const areaSelectionProps = useAreaSelection(modifier); + return ; + }; + Component.displayName = `withAreaSelection(${WrappedComponent.displayName || WrappedComponent.name})`; + return observer(Component); + }; diff --git a/packages/module/src/behavior/usePanZoom.tsx b/packages/module/src/behavior/usePanZoom.tsx index a26aa936..60a02844 100644 --- a/packages/module/src/behavior/usePanZoom.tsx +++ b/packages/module/src/behavior/usePanZoom.tsx @@ -5,7 +5,7 @@ import { action, autorun, IReactionDisposer } from 'mobx'; import ElementContext from '../utils/ElementContext'; import useCallbackRef from '../utils/useCallbackRef'; import Point from '../geom/Point'; -import { Graph, isGraph, ModelKind } from '../types'; +import { Graph, GRAPH_AREA_DRAGGING_EVENT, isGraph, ModelKind } from '../types'; import { ATTR_DATA_KIND } from '../const'; export type PanZoomRef = (node: SVGGElement | null) => void; @@ -38,12 +38,25 @@ export const usePanZoom = (): PanZoomRef => { .on( 'zoom', action((event: d3.D3ZoomEvent) => { + if (event.sourceEvent?.type === 'mousemove') { + elementRef.current + .getController() + .fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: true }); + } elementRef.current.setPosition(new Point(event.transform.x, event.transform.y)); elementRef.current.setScale(event.transform.k); }) ) + .on( + 'end', + action(() => { + elementRef.current + .getController() + .fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: false }); + }) + ) .filter((event: React.MouseEvent) => { - if (event.ctrlKey || event.button) { + if (event.ctrlKey || event.shiftKey || event.altKey || event.button) { return false; } // only allow zoom from double clicking the graph directly diff --git a/packages/module/src/components/GraphComponent.tsx b/packages/module/src/components/GraphComponent.tsx index d958d539..cb94848a 100644 --- a/packages/module/src/components/GraphComponent.tsx +++ b/packages/module/src/components/GraphComponent.tsx @@ -1,16 +1,20 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import { Graph, isGraph } from '../types'; +import styles from '../css/topology-components'; import { WithPanZoomProps } from '../behavior/usePanZoom'; +import { WithAreaSelectionProps } from '../behavior/useAreaSelection'; import { WithDndDropProps } from '../behavior/useDndDrop'; import { WithSelectionProps } from '../behavior/useSelection'; import { WithContextMenuProps } from '../behavior/withContextMenu'; +import useCombineRefs from '../utils/useCombineRefs'; import LayersProvider from './layers/LayersProvider'; import ElementWrapper from './ElementWrapper'; import { GraphElementProps } from './factories'; type GraphComponentProps = GraphElementProps & WithPanZoomProps & + WithAreaSelectionProps & WithDndDropProps & WithSelectionProps & WithContextMenuProps; @@ -39,10 +43,15 @@ const Inner: React.FunctionComponent<{ element: Graph }> = React.memo( const GraphComponent: React.FunctionComponent = ({ element, panZoomRef, + areaSelectionRef, dndDropRef, onSelect, - onContextMenu + onContextMenu, + isAreaSelectDragging, + areaSelectDragStart, + areaSelectDragEnd }) => { + const zoomRefs = useCombineRefs(panZoomRef, areaSelectionRef); if (!isGraph(element)) { return null; } @@ -60,9 +69,18 @@ const GraphComponent: React.FunctionComponent = ({ onClick={onSelect} onContextMenu={onContextMenu} /> - + + {isAreaSelectDragging && areaSelectDragStart && areaSelectDragEnd ? ( + + ) : null} ); }; diff --git a/packages/module/src/components/nodes/DefaultNode.tsx b/packages/module/src/components/nodes/DefaultNode.tsx index 4f41b269..61ab8b44 100644 --- a/packages/module/src/components/nodes/DefaultNode.tsx +++ b/packages/module/src/components/nodes/DefaultNode.tsx @@ -118,6 +118,10 @@ interface DefaultNodeProps { onContextMenu?: (e: React.MouseEvent) => void; /** Flag indicating that the context menu for the node is currently open */ contextMenuOpen?: boolean; + /** Flag indicating the label should move to the top layer when the node is hovered, set to `false` if you are already using TOP_LAYER on hover */ + raiseLabelOnHover?: boolean; // TODO: Update default to be false, assume demo code will be followed + /** Hide context menu kebab for the node */ + hideContextMenuKebab?: boolean; } const SCALE_UP_TIME = 200; @@ -166,7 +170,9 @@ const DefaultNodeInner: React.FunctionComponent = observe onHideCreateConnector, onShowCreateConnector, onContextMenu, - contextMenuOpen + contextMenuOpen, + raiseLabelOnHover = true, + hideContextMenuKebab }) => { const [hovered, hoverRef] = useHover(); const status = nodeStatus || element.getNodeStatus(); @@ -317,23 +323,73 @@ const DefaultNodeInner: React.FunctionComponent = observe return { translateX, translateY }; }, [element, nodeScale, scaleNode]); - let labelX; - let labelY; - const labelPaddingX = 8; - const labelPaddingY = 4; - if (nodeLabelPosition === LabelPosition.right) { - labelX = (width + labelPaddingX) * labelPositionScale; - labelY = height / 2; - } else if (nodeLabelPosition === LabelPosition.left) { - labelX = 0; - labelY = height / 2 - labelPaddingY; - } else if (nodeLabelPosition === LabelPosition.top) { - labelX = width / 2; - labelY = labelPaddingY + labelPaddingY / 2; - } else { - labelX = (width / 2) * labelPositionScale; - labelY = height + labelPaddingY + labelPaddingY / 2; - } + const renderLabel = () => { + if (!showLabel || !(label || element.getLabel())) { + return null; + } + + let labelX; + let labelY; + const labelPaddingX = 8; + const labelPaddingY = 4; + if (nodeLabelPosition === LabelPosition.right) { + labelX = (width + labelPaddingX) * labelPositionScale; + labelY = height / 2; + } else if (nodeLabelPosition === LabelPosition.left) { + labelX = 0; + labelY = height / 2 - labelPaddingY; + } else if (nodeLabelPosition === LabelPosition.top) { + labelX = width / 2; + labelY = labelPaddingY + labelPaddingY / 2; + } else { + labelX = (width / 2) * labelPositionScale; + labelY = height + labelPaddingY + labelPaddingY / 2; + } + + const nodeLabel = ( + + + + {label || element.getLabel()} + + + + ); + if (isHover && raiseLabelOnHover) { + return {nodeLabel}; + } + return nodeLabel; + }; + return ( = observe filter={filter} /> )} - {showLabel && (label || element.getLabel()) && ( - - - - - {label || element.getLabel()} - - - - - )} + {renderLabel()} {children} {statusDecorator} diff --git a/packages/module/src/components/nodes/labels/NodeLabel.tsx b/packages/module/src/components/nodes/labels/NodeLabel.tsx index 0f5d0ca7..1eb6c71b 100644 --- a/packages/module/src/components/nodes/labels/NodeLabel.tsx +++ b/packages/module/src/components/nodes/labels/NodeLabel.tsx @@ -43,6 +43,7 @@ export type NodeLabelProps = { badgeBorderColor?: string; badgeClassName?: string; badgeLocation?: BadgeLocation; + hideContextMenuKebab?: boolean; } & Partial; /** @@ -77,6 +78,7 @@ const NodeLabel: React.FunctionComponent = ({ dropTarget, onContextMenu, contextMenuOpen, + hideContextMenuKebab, actionIcon, actionIconClassName, onActionIconClick, @@ -86,7 +88,7 @@ const NodeLabel: React.FunctionComponent = ({ const [labelHover, labelHoverRef] = useHover(); const refs = useCombineRefs(dragRef, typeof truncateLength === 'number' ? labelHoverRef : undefined); - const [textSize, textRef] = useSize([children, truncateLength, className, labelHover]); + const [textSize, textRef] = useSize([children, truncateLength, className, labelHover, contextMenuOpen]); const [secondaryTextSize, secondaryTextRef] = useSize([secondaryLabel, truncateLength, className, labelHover]); const [badgeSize, badgeRef] = useSize([badge]); const [actionSize, actionRef] = useSize([actionIcon, paddingX]); @@ -124,7 +126,7 @@ const NodeLabel: React.FunctionComponent = ({ const height = Math.max(textSize.height, badgeSize?.height ?? 0) + paddingY * 2; const iconSpace = labelIconClass || labelIcon ? (height + paddingY * 0.5) / 2 : 0; const actionSpace = actionIcon && actionSize ? actionSize.width : 0; - const contextSpace = onContextMenu && contextSize ? contextSize.width : 0; + const contextSpace = !hideContextMenuKebab && onContextMenu && contextSize ? contextSize.width : 0; const primaryWidth = iconSpace + badgeSpace + paddingX + textSize.width + actionSpace + contextSpace + paddingX; const secondaryWidth = secondaryLabel && secondaryTextSize ? secondaryTextSize.width + 2 * paddingX : 0; const width = Math.max(primaryWidth, secondaryWidth); @@ -184,6 +186,7 @@ const NodeLabel: React.FunctionComponent = ({ labelIcon, actionIcon, actionSize, + hideContextMenuKebab, onContextMenu, contextSize, secondaryLabel, @@ -266,7 +269,9 @@ const NodeLabel: React.FunctionComponent = ({ /> )} - {truncateLength > 0 && !labelHover ? truncateMiddle(children, { length: truncateLength }) : children} + {truncateLength > 0 && !labelHover && !contextMenuOpen + ? truncateMiddle(children, { length: truncateLength }) + : children} {textSize && actionIcon && ( <> @@ -291,7 +296,7 @@ const NodeLabel: React.FunctionComponent = ({ /> )} - {textSize && onContextMenu && ( + {textSize && onContextMenu && !hideContextMenuKebab && ( <> } }; + zoomToSelection = (startPoint: Point, endPoint: Point) => { + const currentScale = this.getScale(); + const graphPosition = this.getPosition(); + + const x = (Math.min(startPoint.x, endPoint.x) - graphPosition.x) / currentScale; + const y = (Math.min(startPoint.y, endPoint.y) - graphPosition.y) / currentScale; + const width = Math.abs(endPoint.x - startPoint.x) / currentScale; + const height = Math.abs(endPoint.y - startPoint.y) / currentScale; + + if (width < 10 || height < 10) { + return; + } + + const { width: fullWidth, height: fullHeight } = this.getDimensions(); + + // compute the scale + const xScale = fullWidth / width; + const yScale = fullHeight / height; + const scale = Math.min(xScale, yScale); + + // translate to center + const midX = x + width / 2; + const midY = y + height / 2; + const tx = fullWidth / 2 - midX * scale; + const ty = fullHeight / 2 - midY * scale; + + this.setScale(scale); + this.setPosition(new Point(tx, ty)); + }; + + isInBounds = (node: Node, bounds: Rect): boolean => { + const { x, y, width, height } = node.getBounds(); + return ( + x + width >= bounds.x && x <= bounds.x + bounds.width && y + height >= bounds.y && y <= bounds.y + bounds.height + ); + }; + + childrenInBounds = (node: Node, bounds: Rect): Node[] => { + if (!node.isGroup() || node.isCollapsed()) { + return []; + } + const nodes: Node[] = []; + node.getChildren().forEach((child) => { + if (isNode(child)) { + if (this.isInBounds(child, bounds)) { + nodes.push(child); + nodes.push(...this.childrenInBounds(child, bounds)); + } + } + }); + return nodes; + }; + + nodesInSelection = (startPoint: Point, endPoint: Point): Node[] => { + const currentScale = this.getScale(); + const graphPosition = this.getPosition(); + const x = (Math.min(startPoint.x, endPoint.x) - graphPosition.x) / currentScale; + const y = (Math.min(startPoint.y, endPoint.y) - graphPosition.y) / currentScale; + const width = Math.abs(endPoint.x - startPoint.x) / currentScale; + const height = Math.abs(endPoint.y - startPoint.y) / currentScale; + + const bounds = new Rect(x, y, width, height); + + const selections: Node[] = []; + + this.getNodes().forEach((child) => { + if (this.isInBounds(child, bounds)) { + selections.push(child); + selections.push(...this.childrenInBounds(child, bounds)); + } + }); + + return selections; + }; + isNodeInView(element: Node, { padding = 0 }): boolean { const graph = element.getGraph(); const { x: viewX, y: viewY, width: viewWidth, height: viewHeight } = graph.getBounds(); diff --git a/packages/module/src/pipelines/components/nodes/TaskNode.tsx b/packages/module/src/pipelines/components/nodes/TaskNode.tsx index 865e7f1d..04736595 100644 --- a/packages/module/src/pipelines/components/nodes/TaskNode.tsx +++ b/packages/module/src/pipelines/components/nodes/TaskNode.tsx @@ -96,6 +96,8 @@ export interface TaskNodeProps { onContextMenu?: (e: React.MouseEvent) => void; /** Flag indicating that the context menu for the node is currently open */ contextMenuOpen?: boolean; + /** Hide context menu kebab for the node */ + hideContextMenuKebab?: boolean; /** Number of shadowed pills to show */ shadowCount?: number; /** Offset for each shadow */ diff --git a/packages/module/src/pipelines/components/nodes/TaskPill.tsx b/packages/module/src/pipelines/components/nodes/TaskPill.tsx index ce18ff79..f8524890 100644 --- a/packages/module/src/pipelines/components/nodes/TaskPill.tsx +++ b/packages/module/src/pipelines/components/nodes/TaskPill.tsx @@ -69,6 +69,7 @@ const TaskPill: React.FC = observer( onSelect, onContextMenu, contextMenuOpen, + hideContextMenuKebab, actionIcon, actionIconClassName, onActionIconClick, @@ -147,7 +148,7 @@ const TaskPill: React.FC = observer( const actionSpace = actionIcon && actionSize ? actionSize.width + paddingX : 0; const contextStartX = actionStartX + actionSpace; - const contextSpace = onContextMenu && contextSize ? contextSize.width + paddingX / 2 : 0; + const contextSpace = !hideContextMenuKebab && onContextMenu && contextSize ? contextSize.width + paddingX / 2 : 0; const pillWidth = contextStartX + contextSpace + paddingX / 2; @@ -185,6 +186,7 @@ const TaskPill: React.FC = observer( badge, actionIcon, actionSize, + hideContextMenuKebab, onContextMenu, contextSize, verticalLayout, @@ -416,7 +418,7 @@ const TaskPill: React.FC = observer( /> )} - {textSize && onContextMenu && ( + {textSize && onContextMenu && !hideContextMenuKebab && ( <> extends Graph fit(padding?: number, node?: Node): void; centerInView(nodeElement: Node): void; panIntoView(element: Node, options?: { offset?: number; minimumVisible?: number }): void; + zoomToSelection(startPoint: Point, endPoint: Point): void; + nodesInSelection(startPoint: Point, endPoint: Point): Node[]; isNodeInView(element: Node, options?: { padding: number }): boolean; expandAll(): void; collapseAll(): void; @@ -356,6 +358,13 @@ export type NodeCollapseChangeEventListener = EventListener<[{ node: Node }]>; export type GraphLayoutEndEventListener = EventListener<[{ graph: Graph }]>; +export type ModifierKey = 'ctrlKey' | 'shiftKey' | 'altKey'; + +export type GraphAreaDraggingEvent = EventListener<[{ graph: Graph; isDragging: boolean }]>; +export type GraphAreaSelectedEventListener = EventListener< + [{ graph: Graph; modifier: ModifierKey; startPoint: Point; endPoint: Point }] +>; + export const ADD_CHILD_EVENT = 'element-add-child'; export const ELEMENT_VISIBILITY_CHANGE_EVENT = 'element-visibility-change'; export const REMOVE_CHILD_EVENT = 'element-remove-child'; @@ -363,3 +372,5 @@ export const NODE_COLLAPSE_CHANGE_EVENT = 'node-collapse-change'; export const NODE_POSITIONED_EVENT = 'node-positioned'; export const GRAPH_LAYOUT_END_EVENT = 'graph-layout-end'; export const GRAPH_POSITION_CHANGE_EVENT = 'graph-position-change'; +export const GRAPH_AREA_DRAGGING_EVENT = 'graph-area-dragging'; +export const GRAPH_AREA_SELECTED_EVENT = 'graph-area-selected';