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 mouse event listener with selection box feature #290

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
9 changes: 6 additions & 3 deletions src/main/apollon-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,8 @@ export class ApollonEditor {
if (!this.store) return;
const { elements, selected, assessments } = this.store.getState();
const selection: Apollon.Selection = {
elements: selected.filter((id) => elements[id].type in UMLElementType),
relationships: selected.filter((id) => elements[id].type in UMLRelationshipType),
elements: selected.ids.filter((id) => elements[id].type in UMLElementType),
relationships: selected.ids.filter((id) => elements[id].type in UMLRelationshipType),
};

// check if previous selection differs from current selection, if yes -> notify subscribers
Expand Down Expand Up @@ -434,7 +434,10 @@ export class ApollonEditor {
const state = {
...this.currentModelState,
hovered: [],
selected: [],
selected: {
ids: [],
selectionBoxActive: false,
},
moving: [],
resizing: [],
connecting: [],
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';

type OwnProps = {};

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

type DispatchProps = {
select: AsyncDispatch<typeof UMLElementRepository.select>;
startSelectionBox: AsyncDispatch<typeof UMLElementRepository.startSelectionBox>;
};

type Props = OwnProps & StateProps & DispatchProps & CanvasContext;

type LocalState = {
selectionStarted: boolean;
selectionRectangle: SelectionRectangle;
};

type SelectionRectangle = {
startX: number | undefined;
startY: number | undefined;
endX: number | undefined;
endY: number | undefined;
};

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,
}),
{
select: UMLElementRepository.select,
startSelectionBox: UMLElementRepository.startSelectionBox,
},
),
);

class MouseEventListenerComponent extends Component<Props, LocalState> {
constructor(props: Props) {
super(props);
this.state = {
selectionStarted: false,
selectionRectangle: {
startX: undefined,
startY: undefined,
endX: undefined,
endY: undefined,
},
};
}

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

render() {
return (
this.state.selectionStarted &&
this.state.selectionRectangle.endX && (
<svg
opacity={0.5}
pointerEvents={'none'}
style={{
position: 'fixed',
left: `${Math.min(this.state.selectionRectangle.startX!, this.state.selectionRectangle.endX!)}px`,
width: `${Math.abs(this.state.selectionRectangle.startX! - this.state.selectionRectangle.endX!)}px`,
top: `${Math.min(this.state.selectionRectangle.startY!, this.state.selectionRectangle.endY!)}px`,
height: `${Math.abs(this.state.selectionRectangle.startY! - this.state.selectionRectangle.endY!)}px`,
backgroundColor: '#1E90FF',
borderStyle: 'solid',
borderWidth: '1px',
borderColor: 'blue',
}}
/>
)
);
}

/**
* 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.startSelectionBox();

this.setState({
selectionStarted: true,
selectionRectangle: {
startX: event.clientX,
startY: event.clientY,
endX: undefined,
endY: 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.props.select(selection);

this.setState({
selectionStarted: false,
selectionRectangle: {
startX: undefined,
startY: undefined,
endX: undefined,
endY: undefined,
},
});
};

/**
* 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 elementsInSelectionBox = this.getElementIDsInSelectionBox();

this.setState((prevState) => {
return {
selectionStarted: prevState.selectionStarted,
elementsInSelectionBox,
selectionRectangle: {
...prevState.selectionRectangle,
endX: event.clientX,
endY: event.clientY,
},
};
});
};

/**
* 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();

if (
!this.state.selectionRectangle.startX ||
!this.state.selectionRectangle.endX ||
!this.state.selectionRectangle.startY ||
!this.state.selectionRectangle.endY
) {
return false;
}

const selectionRectangleTopLeft =
Math.min(this.state.selectionRectangle.startX, this.state.selectionRectangle.endX) - canvasOrigin.x;
const selectionRectangleTopRight =
Math.max(this.state.selectionRectangle.startX, this.state.selectionRectangle.endX) - canvasOrigin.x;
const selectionRectangleBottomLeft =
Math.min(this.state.selectionRectangle.startY, this.state.selectionRectangle.endY) - canvasOrigin.y;
const selectionRectangleBottomRight =
Math.max(this.state.selectionRectangle.startY, this.state.selectionRectangle.endY) - canvasOrigin.y;

// determine if the given element is fully contained within the selection rectangle
return (
selectionRectangleTopLeft <= element.bounds.x &&
element.bounds.x + element.bounds.width <= selectionRectangleTopRight &&
selectionRectangleBottomLeft <= element.bounds.y &&
element.bounds.y + element.bounds.height <= selectionRectangleBottomRight
);
};

/**
* 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);
6 changes: 5 additions & 1 deletion src/main/components/uml-element/canvas-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type StateProps = {
interactable: boolean;
element: IUMLElement;
scale: number;
selectionBoxActive: boolean;
};

type DispatchProps = {};
Expand All @@ -35,12 +36,13 @@ const enhance = compose<ComponentClass<OwnProps>>(
connect<StateProps, DispatchProps, OwnProps, ModelState>(
(state, props) => ({
hovered: state.hovered[0] === props.id,
selected: state.selected.includes(props.id),
selected: state.selected.ids.includes(props.id),
moving: state.moving.includes(props.id),
interactive: state.interactive.includes(props.id),
interactable: state.editor.view === ApollonView.Exporting || state.editor.view === ApollonView.Highlight,
element: state.elements[props.id],
scale: state.editor.scale || 1.0,
selectionBoxActive: state.selected.selectionBoxActive,
}),
{},
),
Expand All @@ -58,6 +60,7 @@ class CanvasElementComponent extends Component<Props> {
child: ChildComponent,
children,
theme,
selectionBoxActive,
...props
} = this.props;

Expand All @@ -79,6 +82,7 @@ class CanvasElementComponent extends Component<Props> {
: theme.color.background;

const selectedByList = element.selectedBy || [];

return (
<svg
{...props}
Expand Down
9 changes: 6 additions & 3 deletions src/main/components/uml-element/canvas-relationship.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type StateProps = {
mode: ApollonMode;
scale: number;
readonly: boolean;
selectionBoxActive: boolean;
};

type DispatchProps = {
Expand Down Expand Up @@ -55,7 +56,7 @@ const enhance = compose<ComponentClass<OwnProps>>(
connect<StateProps, DispatchProps, OwnProps, ModelState>(
(state, props) => ({
hovered: state.hovered[0] === props.id,
selected: state.selected.includes(props.id),
selected: state.selected.ids.includes(props.id),
interactive: state.interactive.includes(props.id),
interactable: state.editor.view === ApollonView.Exporting || state.editor.view === ApollonView.Highlight,
reconnecting: !!state.reconnecting[props.id],
Expand All @@ -64,6 +65,7 @@ const enhance = compose<ComponentClass<OwnProps>>(
mode: state.editor.mode as ApollonMode,
scale: state.editor.scale || 1.0,
readonly: state.editor.readonly || false,
selectionBoxActive: state.selected.selectionBoxActive,
}),
{
startwaypointslayout: UMLRelationshipRepository.startWaypointsLayout,
Expand Down Expand Up @@ -91,6 +93,7 @@ export class CanvasRelationshipComponent extends Component<Props, State> {
readonly,
startwaypointslayout,
endwaypointslayout,
selectionBoxActive,
...props
} = this.props;

Expand Down Expand Up @@ -135,8 +138,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
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const enhance = connect<StateProps, DispatchProps, UMLElementComponentProps, Mod
(state, props) => {
return {
hovered: state.hovered[0] === props.id,
selected: state.selected.includes(props.id),
selected: state.selected.ids.includes(props.id),
connecting: !!state.connecting.length,
reconnecting: !!Object.keys(state.reconnecting).length,
element: state.elements[props.id],
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.selected.selectionBoxActive ||
(state.moving.length > 0 && !UMLContainer.isUMLContainer(state.elements[props.id])),
};
},
{
Expand Down
Loading