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 2 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
273 changes: 273 additions & 0 deletions src/main/components/canvas/mouse-eventlistener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
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';

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: 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,
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: {
startX: undefined,
matthiaslehnertum marked this conversation as resolved.
Show resolved Hide resolved
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);
matthiaslehnertum marked this conversation as resolved.
Show resolved Hide resolved
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 ?? 0, this.state.selectionRectangle.endX ?? 0)}px`,
width: `${Math.abs(
(this.state.selectionRectangle.startX ?? 0) - (this.state.selectionRectangle.endX ?? 0),
)}px`,
top: `${Math.min(this.state.selectionRectangle.startY ?? 0, this.state.selectionRectangle.endY ?? 0)}px`,
height: `${Math.abs(
(this.state.selectionRectangle.startY ?? 0) - (this.state.selectionRectangle.endY ?? 0),
)}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.changeSelectionBox(true);

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);
matthiaslehnertum marked this conversation as resolved.
Show resolved Hide resolved

this.setState({
selectionStarted: false,
selectionRectangle: {
startX: undefined,
startY: undefined,
endX: undefined,
endY: 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 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 => {
matthiaslehnertum marked this conversation as resolved.
Show resolved Hide resolved
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) / this.props.zoomFactor -
canvasOrigin.x / this.props.zoomFactor;
const selectionRectangleTopRight =
Math.max(this.state.selectionRectangle.startX, this.state.selectionRectangle.endX) / this.props.zoomFactor -
canvasOrigin.x / this.props.zoomFactor;
const selectionRectangleBottomLeft =
Math.min(this.state.selectionRectangle.startY, this.state.selectionRectangle.endY) / this.props.zoomFactor -
canvasOrigin.y / this.props.zoomFactor;
const selectionRectangleBottomRight =
Math.max(this.state.selectionRectangle.startY, this.state.selectionRectangle.endY) / this.props.zoomFactor -
canvasOrigin.y / this.props.zoomFactor;

// 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);
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
11 changes: 11 additions & 0 deletions src/main/components/uml-element/movable/movable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type StateProps = {
movable: boolean;
moving: boolean;
zoomFactor: number;
selectionBoxActive: boolean;
};

type DispatchProps = {
Expand All @@ -35,6 +36,7 @@ const enhance = connect<StateProps, DispatchProps, UMLElementComponentProps, Mod
movable: state.selected.includes(props.id) && !state.resizing.includes(props.id) && !state.connecting.length,
moving: state.moving.includes(props.id),
zoomFactor: state.editor.zoomFactor,
selectionBoxActive: state.editor.selectionBoxActive,
}),
{
start: UMLElementRepository.startMoving,
Expand Down Expand Up @@ -78,6 +80,15 @@ export const movable = (
}
}

componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>, snapshot?: any) {
const node = findDOMNode(this) as HTMLElement;
if (this.props.selectionBoxActive) {
node.style.cursor = 'default';
} else {
node.style.cursor = 'move';
}
}

componentWillUnmount() {
const node = findDOMNode(this) as HTMLElement;
const child = node.firstChild as HTMLElement;
Expand Down
Loading
Loading