Skip to content

Commit

Permalink
Add support for box selection (#332)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthiaslehnertum authored May 6, 2024
1 parent fd8d4f0 commit 8c64112
Show file tree
Hide file tree
Showing 35 changed files with 595 additions and 182 deletions.
8 changes: 4 additions & 4 deletions src/main/components/canvas/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type StateProps = { moving: string[]; connecting: boolean; reconnecting: boolean

type DispatchProps = {
move: AsyncDispatch<typeof UMLElementRepository.move>;
changeZoomFactor: typeof EditorRepository.changeZoomFactor;
setZoomFactor: typeof EditorRepository.setZoomFactor;
};

const enhance = connect<StateProps, DispatchProps, OwnProps, ModelState>(
Expand All @@ -68,7 +68,7 @@ const enhance = connect<StateProps, DispatchProps, OwnProps, ModelState>(
}),
{
move: UMLElementRepository.move,
changeZoomFactor: EditorRepository.changeZoomFactor,
setZoomFactor: EditorRepository.setZoomFactor,
},
);

Expand Down Expand Up @@ -128,7 +128,7 @@ class EditorComponent extends Component<Props, State> {
<StyledEditor ref={this.editor} {...props} onTouchMove={this.customScrolling} scale={scale} />
<ZoomPane
value={scale}
onChange={(zoomFactor) => this.props.changeZoomFactor(zoomFactor)}
onChange={(zoomFactor) => this.props.setZoomFactor(zoomFactor)}
min={minScale}
max={maxScale}
step={0.2}
Expand All @@ -141,7 +141,7 @@ class EditorComponent extends Component<Props, State> {
<StyledEditor ref={this.editor} {...props} scale={scale} />
<ZoomPane
value={scale}
onChange={(zoomFactor) => this.props.changeZoomFactor(zoomFactor)}
onChange={(zoomFactor) => this.props.setZoomFactor(zoomFactor)}
min={minScale}
max={maxScale}
step={0.2}
Expand Down
260 changes: 260 additions & 0 deletions src/main/components/canvas/mouse-eventlistener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import React, { Component, ComponentType } from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { ApollonMode } from '../../services/editor/editor-types';
import { UMLElementRepository } from '../../services/uml-element/uml-element-repository';
import { AsyncDispatch } from '../../utils/actions/actions';
import { ModelState } from '../store/model-state';
import { CanvasContext } from './canvas-context';
import { withCanvas } from './with-canvas';
import { UMLElementState } from '../../services/uml-element/uml-element-types';
import { IUMLElement } from '../../services/uml-element/uml-element';
import { EditorRepository } from '../../services/editor/editor-repository';
import { areBoundsIntersecting, IBoundary } from '../../utils/geometry/boundary';
import { IPoint } from '../../utils/geometry/point';
import { defaults as getTheme } from '../../components/theme/styles';

type OwnProps = {};

type StateProps = {
readonly: boolean;
mode: ApollonMode;
elements: UMLElementState;
resizingInProgress: boolean;
connectingInProgress: boolean;
reconnectingInProgress: boolean;
hoveringInProgress: boolean;
zoomFactor: number;
};

type DispatchProps = {
select: AsyncDispatch<typeof UMLElementRepository.select>;
changeSelectionBox: typeof EditorRepository.setSelectionBoxActive;
};

type Props = OwnProps & StateProps & DispatchProps & CanvasContext;

type LocalState = {
selectionStarted: boolean;
selectionRectangle: Partial<IBoundary>;
};

const enhance = compose<ComponentType<OwnProps>>(
withCanvas,
connect<StateProps, DispatchProps, OwnProps, ModelState>(
(state) => ({
readonly: state.editor.readonly,
mode: state.editor.mode,
elements: state.elements,
resizingInProgress: state.resizing.length > 0,
connectingInProgress: state.connecting.length > 0,
reconnectingInProgress: Object.keys(state.reconnecting).length > 0,
hoveringInProgress: state.hovered.length > 0,
zoomFactor: state.editor.zoomFactor,
}),
{
select: UMLElementRepository.select,
changeSelectionBox: EditorRepository.setSelectionBoxActive,
},
),
);

class MouseEventListenerComponent extends Component<Props, LocalState> {
constructor(props: Props) {
super(props);
this.state = {
selectionStarted: false,
selectionRectangle: {
x: undefined,
y: undefined,
width: undefined,
height: undefined,
},
};
}

componentDidMount() {
const { layer } = this.props.canvas;
if (
!this.props.readonly &&
(this.props.mode === ApollonMode.Modelling || this.props.mode === ApollonMode.Exporting)
) {
layer.addEventListener('mousedown', this.mouseDown);
layer.addEventListener('mousemove', this.mouseMove);
layer.addEventListener('mouseup', this.mouseUp);
}
}

componentWillUnmount() {
const { layer } = this.props.canvas;
layer.removeEventListener('mousedown', this.mouseDown);
layer.removeEventListener('mousemove', this.mouseMove);
layer.removeEventListener('mouseup', this.mouseUp);
}

render() {
const { x = 0, y = 0, width = 0, height = 0 } = this.state.selectionRectangle;

const theme = getTheme();

return (
this.state.selectionStarted &&
width != 0 && (
<svg
opacity={0.5}
pointerEvents={'none'}
style={{
position: 'fixed',
left: `${Math.min(x, x + width)}px`,
width: `${Math.abs(width)}px`,
top: `${Math.min(y, y + height)}px`,
height: `${Math.abs(height)}px`,
backgroundColor: theme.color.primary,
}}
/>
)
);
}

/**
* Mouse down handler for starting the box selection
* @param event The triggering mouse down event
*/
private mouseDown = (event: MouseEvent): void => {
// if the cursor went out of the bounds of the canvas, then the selection box is still active
// we want to continue with the selection box from where we left off
if (this.state.selectionStarted) {
this.setState((prevState) => {
return {
...prevState,
selectionRectangle: {
...prevState.selectionRectangle,
endX: event.clientX,
endY: event.clientY,
},
};
});

return;
}

// The selection box will activate when clicking anywhere outside the bounds of an element however:
// * resizing an element can start when clicking slightly outside its bounds
// * the connection/reconnection port of an element is outside its bounding box
// in these cases the selection box needs to be disabled
if (
this.props.resizingInProgress ||
this.props.connectingInProgress ||
this.props.reconnectingInProgress ||
this.props.hoveringInProgress
) {
return;
}

this.props.changeSelectionBox(true);

this.setState({
selectionStarted: true,
selectionRectangle: {
x: event.clientX,
y: event.clientY,
width: undefined,
height: undefined,
},
});
};

/**
* Mouse up handler for finalising the box selection and determining which elements to select
*/
private mouseUp = (): void => {
// if no selection has been started, we can skip determining which
// elements are contained in the selection box.
if (!this.state.selectionStarted) {
return;
}

const selection = this.getElementIDsInSelectionBox();

this.setState({
selectionStarted: false,
selectionRectangle: {
x: undefined,
y: undefined,
width: undefined,
height: undefined,
},
});

this.props.changeSelectionBox(false);
};

/**
* Mouse move handler for dragging the selection rectangle
* @param event The triggering mouse move event
*/
private mouseMove = (event: MouseEvent): void => {
if (!this.state.selectionStarted) {
return;
}

const selection = this.getElementIDsInSelectionBox();

this.props.select(selection, true);

this.setState((prevState) => {
return {
selectionStarted: prevState.selectionStarted,
selectionRectangle: {
...prevState.selectionRectangle,
width: event.clientX - (prevState.selectionRectangle.x ?? 0),
height: event.clientY - (prevState.selectionRectangle.y ?? 0),
},
};
});
};

/**
* Check whether a given IUMLElement is contained in the currently active selection rectangle.
* Elements are only considered selected if they are fully contained within the selection rectangle.
*
* @param element The element for which containment in the selection box is determined
*/
private isElementInSelectionBox = (element: IUMLElement): boolean => {
const canvasOrigin = this.props.canvas.origin();

const { x, y, width, height } = this.state.selectionRectangle;

if (!x || !y || !width || !height) {
return false;
}

const normalizedSelectionBounds: IBoundary = {
x: (x - canvasOrigin.x) / this.props.zoomFactor,
y: (y - canvasOrigin.y) / this.props.zoomFactor,
height: height / this.props.zoomFactor,
width: width / this.props.zoomFactor,
};

return areBoundsIntersecting(element.bounds, normalizedSelectionBounds);
};

/**
* Retrieve the IDs of all elements fully contained within the selection box
*/
private getElementIDsInSelectionBox = (): string[] => {
return Object.entries(this.props.elements).reduce((selectedIDs, [id, element]) => {
if (element.owner !== null) {
return selectedIDs;
}

if (this.isElementInSelectionBox(element)) {
return [...selectedIDs, id];
}

return selectedIDs;
}, [] as string[]);
};
}

export const MouseEventListener = enhance(MouseEventListenerComponent);
15 changes: 10 additions & 5 deletions src/main/components/sidebar/sidebar-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import { compose } from 'redux';
import { EditorRepository } from '../../services/editor/editor-repository';
import { ApollonMode, ApollonView } from '../../services/editor/editor-types';
import { Switch } from '../controls/switch/switch';
import { CreatePane } from '../create-pane/create-pane';
import { I18nContext } from '../i18n/i18n-context';
import { localized } from '../i18n/localized';
Expand Down Expand Up @@ -48,10 +47,16 @@ class SidebarComponent extends Component<Props> {
return (
<Container id="modeling-editor-sidebar" data-cy="modeling-editor-sidebar">
{this.props.mode === ApollonMode.Exporting && (
<Switch value={this.props.view} onChange={this.props.changeView} color="primary">
<Switch.Item value={ApollonView.Modelling}>{this.props.translate('views.modelling')}</Switch.Item>
<Switch.Item value={ApollonView.Exporting}>{this.props.translate('views.exporting')}</Switch.Item>
</Switch>
<div className="dropdown" style={{ width: 128 }}>
<select
value={this.props.view}
onChange={(event) => this.props.changeView(event.target.value as ApollonView)}
color="primary"
>
<option value={ApollonView.Modelling}>{this.props.translate('views.modelling')}</option>
<option value={ApollonView.Exporting}>{this.props.translate('views.exporting')}</option>
</select>
</div>
)}
{this.props.view === ApollonView.Modelling ? (
<CreatePane />
Expand Down
7 changes: 5 additions & 2 deletions src/main/components/uml-element/canvas-relationship.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type StateProps = {
relationship: IUMLRelationship;
mode: ApollonMode;
readonly: boolean;
selectionBoxActive: boolean;
};

type DispatchProps = {
Expand Down Expand Up @@ -65,6 +66,7 @@ const enhance = compose<ComponentClass<OwnProps>>(
relationship: state.elements[props.id] as IUMLRelationship,
mode: state.editor.mode as ApollonMode,
readonly: state.editor.readonly || false,
selectionBoxActive: state.editor.selectionBoxActive,
}),
{
startwaypointslayout: UMLRelationshipRepository.startWaypointsLayout,
Expand Down Expand Up @@ -92,6 +94,7 @@ export class CanvasRelationshipComponent extends Component<Props, State> {
readonly,
startwaypointslayout,
endwaypointslayout,
selectionBoxActive,
...props
} = this.props;

Expand Down Expand Up @@ -147,8 +150,8 @@ export class CanvasRelationshipComponent extends Component<Props, State> {
{midPoints.map((point, index) => {
return (
<circle
visibility={interactive || interactable || readonly ? 'hidden' : undefined}
pointerEvents={interactive || interactable || readonly ? 'none' : 'all'}
visibility={selectionBoxActive || interactive || interactable || readonly ? 'hidden' : undefined}
pointerEvents={selectionBoxActive || interactive || interactable || readonly ? 'none' : 'all'}
style={{ cursor: 'grab' }}
key={props.id + '_' + point.mpX + '_' + point.mpY}
cx={point.mpX}
Expand Down
7 changes: 5 additions & 2 deletions src/main/components/uml-element/hoverable/hoverable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ type Props = OwnProps & StateProps & DispatchProps;
const enhance = connect<StateProps, DispatchProps, OwnProps, ModelState>(
(state, props) => {
return {
// cannot emmit hover events when any object is moving and the object is not a UMLContainer
cannotBeHovered: state.moving.length > 0 && !UMLContainer.isUMLContainer(state.elements[props.id]),
// cannot emmit hover events when the selection box is active
// or (any object is moving and the object is not a UMLContainer)
cannotBeHovered:
state.editor.selectionBoxActive ||
(state.moving.length > 0 && !UMLContainer.isUMLContainer(state.elements[props.id])),
};
},
{
Expand Down
Loading

0 comments on commit 8c64112

Please sign in to comment.