Skip to content

Commit

Permalink
feat(graph): Add selection based zoom
Browse files Browse the repository at this point in the history
  • Loading branch information
jeff-phillips-18 committed Nov 12, 2024
1 parent 2a1b615 commit 4ec7379
Show file tree
Hide file tree
Showing 11 changed files with 381 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import { InfoCircleIcon, MouseIcon } from '@patternfly/react-icons';

import './AreaDragHint.css';

const AreaDragHint: React.FC = () => {
return (
<div className="area-drag-hint__hint-container">
<div className="area-drag-hint__hint-background">
<div className="area-drag-hint">
<InfoCircleIcon className="area-drag-hint__icon" />
<span className="area-drag-hint__text">
<table>
<tbody>
<tr>
<td className="area-drag-hint-shortcut__cell">
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">Shift</kbd>
</span>
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">
<MouseIcon /> Drag
</kbd>
</span>
</td>
<td className="area-drag-hint-shortcut__cell">Select nodes in area</td>
</tr>
<tr>
<td className="area-drag-hint-shortcut__cell">
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">Ctrl</kbd>
</span>
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">
<MouseIcon /> Drag
</kbd>
</span>
</td>
<td className="area-drag-hint-shortcut__cell">Zoom to selected area</td>
</tr>
</tbody>
</table>
</span>
</div>
</div>
</div>
);
};

export default AreaDragHint;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -32,6 +37,7 @@ interface TopologyViewComponentProps {
const TopologyViewComponent: React.FunctionComponent<TopologyViewComponentProps> = observer(
({ useSidebar, sideBarResizable = false }) => {
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [showAreaDragHint, setShowAreaDragHint] = React.useState<boolean>(false);
const controller = useVisualizationController();
const options = React.useContext(DemoContext);

Expand Down Expand Up @@ -59,6 +65,31 @@ const TopologyViewComponent: React.FunctionComponent<TopologyViewComponentProps>
setSelectedIds(ids);
});

useEventListener<GraphAreaSelectedEventListener>(
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<GraphAreaDraggingEvent>(GRAPH_AREA_DRAGGING_EVENT, ({ isDragging }) => {
setShowAreaDragHint(isDragging);
});

React.useEffect(() => {
let resizeTimeout: NodeJS.Timeout;

Expand Down Expand Up @@ -111,6 +142,7 @@ const TopologyViewComponent: React.FunctionComponent<TopologyViewComponentProps>
sideBarOpen={useSidebar && !!selectedIds?.length}
sideBarResizable={sideBarResizable}
>
{showAreaDragHint ? <AreaDragHint /> : null}
<VisualizationSurface state={{ selectedIds }} />
</TopologyView>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
ModelKind,
DragObjectWithType,
Node,
withPanZoom,
GraphComponent,
withPanZoom,
withAreaSelection,
withCreateConnector,
Graph,
isNode,
Expand Down Expand Up @@ -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':
Expand Down
1 change: 1 addition & 0 deletions packages/module/src/behavior/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
121 changes: 121 additions & 0 deletions packages/module/src/behavior/useAreaSelection.tsx
Original file line number Diff line number Diff line change
@@ -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<Omit<WithAreaSelectionProps, 'areaSelectionRef'>>({});

if (!isGraph(element)) {
throw new Error('useAreaSelection must be used within the scope of a Graph');
}
const elementRef = React.useRef<Graph>(element);
elementRef.current = element;

const areaSelectionRef = useCallbackRef<AreaSelectionRef>((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<Element, any, any>) => {
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<Element, any, any>) => {
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']) =>
<P extends WithAreaSelectionProps>(WrappedComponent: React.ComponentType<P>) => {
const Component: React.FunctionComponent<Omit<P, keyof WithAreaSelectionProps>> = (props) => {
const areaSelectionProps = useAreaSelection(modifier);
return <WrappedComponent {...(props as any)} {...areaSelectionProps} />;
};
Component.displayName = `withAreaSelection(${WrappedComponent.displayName || WrappedComponent.name})`;
return observer(Component);
};
17 changes: 15 additions & 2 deletions packages/module/src/behavior/usePanZoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,12 +38,25 @@ export const usePanZoom = (): PanZoomRef => {
.on(
'zoom',
action((event: d3.D3ZoomEvent<any, any>) => {
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
Expand Down
Loading

0 comments on commit 4ec7379

Please sign in to comment.