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..f7f16b6e --- /dev/null +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/AreaDragHint.css @@ -0,0 +1,42 @@ +.area-drag-hint__hint-container { + justify-content: center; + display: flex; + pointer-events: none; + position: absolute; + top: var(--pf-v5-global--spacer--sm); + left: 0; + right: 0; +} +.area-drag-hint__hint-background { + background-color: var(--pf-v5-global--BackgroundColor--100); + border: 1px solid var(--pf-v5-global--BorderColor--light-100); + border-radius: 8px; + padding: var(--pf-v5-global--spacer--xs) var(--pf-v5-global--spacer--sm); + pointer-events: none; +} + +.area-drag-hint { + align-items: center; + display: flex; +} +.area-drag-hint__icon { + color: var(--pf-v5-global--palette--blue-300); +} +.area-drag-hint__text { + margin-left: var(--pf-v5-global--spacer--sm); +} +.area-drag-hint-shortcut__cell { + padding-left: var(--pf-v5-global--spacer--sm); +} + +.area-drag-hint-shortcut__command:not(:last-child):after { + content: ' + '; +} + +.area-drag-hint-shortcut__kbd { + border: var(--pf-v5-global--BorderWidth--sm) solid var(--pf-v5-global--BorderColor--100); + border-radius: 3px; + color: var(--pf-v5-global--Color--200); + font-size: var(--pf-v5-global--FontSize--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/TopologyPackage.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx index d72a43ec..b498e7b7 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx @@ -12,7 +12,11 @@ import { Visualization, VisualizationProvider, VisualizationSurface, - observer + observer, + GraphAreaSelectedEventListener, + GRAPH_AREA_SELECTED_EVENT, + GraphAreaDraggingEvent, + GRAPH_AREA_DRAGGING_EVENT } from '@patternfly/react-topology'; import defaultLayoutFactory from '../../layouts/defaultLayoutFactory'; import defaultComponentFactory from '../../components/defaultComponentFactory'; @@ -23,6 +27,7 @@ import { DemoContext } from './DemoContext'; import demoComponentFactory from './demoComponentFactory'; import { graphPositionChangeListener, layoutEndListener } from './listeners'; import DemoControlBar from '../DemoControlBar'; +import AreaDragHint from './AreaDragHint'; interface TopologyViewComponentProps { useSidebar: boolean; @@ -32,6 +37,7 @@ interface TopologyViewComponentProps { const TopologyViewComponent: React.FunctionComponent = 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/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/css/topology-components.css b/packages/module/src/css/topology-components.css index 81d8cdf4..0ff5a355 100644 --- a/packages/module/src/css/topology-components.css +++ b/packages/module/src/css/topology-components.css @@ -161,6 +161,9 @@ --pf-topology-default-create-connector--m-hover--line--Stroke: var(--pf-v5-global--Color--100); --pf-topology-default-create-connector--m-hover--arrow--Fill: var(--pf-v5-global--Color--100); --pf-topology-default-create-connector--m-hover--arrow--Stroke: var(--pf-v5-global--Color--100); + + --pf-topology__area-select-rect--Fill: var(--pf-v5-global--palette--black-500); + --pf-topology__area-select-rect--Opacity: 0.4; } /* DARK THEME OVERRIDES */ @@ -245,6 +248,7 @@ --pf-topology__edge__tag__text--Fill: var(--pf-v5-global--palette--black-900); --pf-topology__edge__tag__text--Stroke: var(--pf-v5-global--palette--black-900); + --pf-topology__area-select-rect--Fill: var(--pf-v5-global--palette--black-300); } .pf-topology-visualization-surface { @@ -861,3 +865,7 @@ fill: var(--pf-topology__create-connector-color--Fill); } +.pf-topology-area-select-rect { + fill: var(--pf-topology__area-select-rect--Fill); + opacity: var(--pf-topology__area-select-rect--Opacity); +} diff --git a/packages/module/src/elements/BaseGraph.ts b/packages/module/src/elements/BaseGraph.ts index ed5d48f8..489366c3 100644 --- a/packages/module/src/elements/BaseGraph.ts +++ b/packages/module/src/elements/BaseGraph.ts @@ -369,6 +369,81 @@ export default class BaseGraph } }; + 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/types.ts b/packages/module/src/types.ts index a458180f..4c78a339 100644 --- a/packages/module/src/types.ts +++ b/packages/module/src/types.ts @@ -287,6 +287,8 @@ export interface Graph 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';