) => {
+ 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';