Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for box selection #332

Merged
merged 16 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
matthiaslehnertum marked this conversation as resolved.
Show resolved Hide resolved
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 => {
matthiaslehnertum marked this conversation as resolved.
Show resolved Hide resolved
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
Loading