From 8c641124febcca88a79aef0adcbacc241b2d71f7 Mon Sep 17 00:00:00 2001 From: Matthias Lehner <143808484+matthiaslehnertum@users.noreply.github.com> Date: Mon, 6 May 2024 12:53:45 +0200 Subject: [PATCH] Add support for box selection (#332) --- src/main/components/canvas/editor.tsx | 8 +- .../components/canvas/mouse-eventlistener.tsx | 260 ++++++++++++++++++ .../components/sidebar/sidebar-component.tsx | 15 +- .../uml-element/canvas-relationship.tsx | 7 +- .../uml-element/hoverable/hoverable.tsx | 7 +- .../uml-element/movable/movable.tsx | 11 + .../reconnectable/reconnectable.tsx | 8 +- .../uml-element/resizable/resizable.tsx | 12 +- src/main/scenes/application.tsx | 6 + src/main/services/editor/editor-reducer.ts | 11 +- src/main/services/editor/editor-repository.ts | 17 +- src/main/services/editor/editor-types.ts | 14 +- .../selectable/selectable-reducer.ts | 2 +- .../selectable/selectable-repository.ts | 19 +- .../selectable/selectable-types.ts | 1 + src/main/utils/geometry/boundary.ts | 40 ++- src/tests/unit/apollon-editor-test.tsx | 2 +- .../__snapshots__/update-pane-test.tsx.snap | 94 +++---- .../default-element-popup-test.tsx.snap | 6 +- .../default-relationship-popup-test.tsx.snap | 6 +- .../uml-classifier-update-test.tsx.snap | 34 +-- .../flowchart-decision-update-test.tsx.snap | 4 +- .../flowchart-flowline-update-test.tsx.snap | 10 +- ...owchart-function-call-update-test.tsx.snap | 4 +- ...lowchart-input-output-update-test.tsx.snap | 4 +- .../flowchart-process-update-test.tsx.snap | 4 +- .../flowchart-terminal-update-test.tsx.snap | 4 +- .../class-association-popup-test.tsx.snap | 34 +-- ...uml-communication-link-popup-test.tsx.snap | 46 ++-- .../deployment-node-popup-test.tsx.snap | 8 +- .../uml-petri-net-arc-update-test.tsx.snap | 6 +- .../uml-petri-net-place-update-test.tsx.snap | 16 +- ...eachability-graph-arc-update-test.tsx.snap | 6 +- ...ability-graph-marking-update-test.tsx.snap | 6 +- .../unit/utils/geometry/boundary-test.ts | 45 +++ 35 files changed, 595 insertions(+), 182 deletions(-) create mode 100644 src/main/components/canvas/mouse-eventlistener.tsx create mode 100644 src/tests/unit/utils/geometry/boundary-test.ts diff --git a/src/main/components/canvas/editor.tsx b/src/main/components/canvas/editor.tsx index 6b7f07645..41b82fd2c 100644 --- a/src/main/components/canvas/editor.tsx +++ b/src/main/components/canvas/editor.tsx @@ -56,7 +56,7 @@ type StateProps = { moving: string[]; connecting: boolean; reconnecting: boolean type DispatchProps = { move: AsyncDispatch; - changeZoomFactor: typeof EditorRepository.changeZoomFactor; + setZoomFactor: typeof EditorRepository.setZoomFactor; }; const enhance = connect( @@ -68,7 +68,7 @@ const enhance = connect( }), { move: UMLElementRepository.move, - changeZoomFactor: EditorRepository.changeZoomFactor, + setZoomFactor: EditorRepository.setZoomFactor, }, ); @@ -128,7 +128,7 @@ class EditorComponent extends Component { this.props.changeZoomFactor(zoomFactor)} + onChange={(zoomFactor) => this.props.setZoomFactor(zoomFactor)} min={minScale} max={maxScale} step={0.2} @@ -141,7 +141,7 @@ class EditorComponent extends Component { this.props.changeZoomFactor(zoomFactor)} + onChange={(zoomFactor) => this.props.setZoomFactor(zoomFactor)} min={minScale} max={maxScale} step={0.2} diff --git a/src/main/components/canvas/mouse-eventlistener.tsx b/src/main/components/canvas/mouse-eventlistener.tsx new file mode 100644 index 000000000..0fddc1f5d --- /dev/null +++ b/src/main/components/canvas/mouse-eventlistener.tsx @@ -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; + changeSelectionBox: typeof EditorRepository.setSelectionBoxActive; +}; + +type Props = OwnProps & StateProps & DispatchProps & CanvasContext; + +type LocalState = { + selectionStarted: boolean; + selectionRectangle: Partial; +}; + +const enhance = compose>( + withCanvas, + connect( + (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 { + 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 && ( + + ) + ); + } + + /** + * 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); diff --git a/src/main/components/sidebar/sidebar-component.tsx b/src/main/components/sidebar/sidebar-component.tsx index f6fbd9c72..c96601e4c 100644 --- a/src/main/components/sidebar/sidebar-component.tsx +++ b/src/main/components/sidebar/sidebar-component.tsx @@ -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'; @@ -48,10 +47,16 @@ class SidebarComponent extends Component { return ( {this.props.mode === ApollonMode.Exporting && ( - - {this.props.translate('views.modelling')} - {this.props.translate('views.exporting')} - +
+ +
)} {this.props.view === ApollonView.Modelling ? ( diff --git a/src/main/components/uml-element/canvas-relationship.tsx b/src/main/components/uml-element/canvas-relationship.tsx index 925a3d795..68929a180 100644 --- a/src/main/components/uml-element/canvas-relationship.tsx +++ b/src/main/components/uml-element/canvas-relationship.tsx @@ -29,6 +29,7 @@ type StateProps = { relationship: IUMLRelationship; mode: ApollonMode; readonly: boolean; + selectionBoxActive: boolean; }; type DispatchProps = { @@ -65,6 +66,7 @@ const enhance = compose>( 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, @@ -92,6 +94,7 @@ export class CanvasRelationshipComponent extends Component { readonly, startwaypointslayout, endwaypointslayout, + selectionBoxActive, ...props } = this.props; @@ -147,8 +150,8 @@ export class CanvasRelationshipComponent extends Component { {midPoints.map((point, index) => { return ( ( (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])), }; }, { diff --git a/src/main/components/uml-element/movable/movable.tsx b/src/main/components/uml-element/movable/movable.tsx index 1fbac8295..93631ac0b 100644 --- a/src/main/components/uml-element/movable/movable.tsx +++ b/src/main/components/uml-element/movable/movable.tsx @@ -14,6 +14,7 @@ type StateProps = { movable: boolean; moving: boolean; zoomFactor: number; + selectionBoxActive: boolean; }; type DispatchProps = { @@ -35,6 +36,7 @@ const enhance = connect, prevState: Readonly, 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; diff --git a/src/main/components/uml-element/reconnectable/reconnectable.tsx b/src/main/components/uml-element/reconnectable/reconnectable.tsx index d22148f32..a19ecf47a 100644 --- a/src/main/components/uml-element/reconnectable/reconnectable.tsx +++ b/src/main/components/uml-element/reconnectable/reconnectable.tsx @@ -15,6 +15,7 @@ type StateProps = { path: IPath; reconnecting: boolean; disabled: boolean; + selectionBoxActive: boolean; }; type DispatchProps = { @@ -36,6 +37,7 @@ const enhance = connect ); diff --git a/src/main/components/uml-element/resizable/resizable.tsx b/src/main/components/uml-element/resizable/resizable.tsx index c1dda7cc7..5d78ae68a 100644 --- a/src/main/components/uml-element/resizable/resizable.tsx +++ b/src/main/components/uml-element/resizable/resizable.tsx @@ -10,6 +10,7 @@ import { UMLElementComponentProps } from '../uml-element-component-props'; type StateProps = { zoomFactor: number; + selectionBoxActive: boolean; }; type DispatchProps = { @@ -30,6 +31,7 @@ type State = typeof initialState; const enhance = connect( (state) => ({ zoomFactor: state.editor.zoomFactor, + selectionBoxActive: state.editor.selectionBoxActive, }), { start: UMLElementRepository.startResizing, @@ -51,7 +53,6 @@ const HandleBottomRight = styled.rect.attrs({ ...Handle, })` cursor: nwse-resize; - pointer-events: all; `; const HandleTopLeft = styled.rect.attrs({ @@ -60,7 +61,6 @@ const HandleTopLeft = styled.rect.attrs({ ...Handle, })` cursor: nwse-resize; - pointer-events: all; `; const HandleTopRight = styled.rect.attrs({ @@ -69,7 +69,6 @@ const HandleTopRight = styled.rect.attrs({ ...Handle, })` cursor: nesw-resize; - pointer-events: all; `; const HandleBottomLeft = styled.rect.attrs({ @@ -78,7 +77,6 @@ const HandleBottomLeft = styled.rect.attrs({ ...Handle, })` cursor: nesw-resize; - pointer-events: all; `; export const resizable = @@ -95,7 +93,7 @@ export const resizable = } render() { - const { start, resize, end, ...props } = this.props; + const { start, resize, end, selectionBoxActive, ...props } = this.props; return ( {props.children} @@ -103,21 +101,25 @@ export const resizable = onPointerDown={(e) => { this.onPointerDown(e, ResizeFrom.BOTTOMRIGHT); }} + pointerEvents={selectionBoxActive ? 'none' : 'all'} /> { this.onPointerDown(e, ResizeFrom.TOPLEFT); }} + pointerEvents={selectionBoxActive ? 'none' : 'all'} /> { this.onPointerDown(e, ResizeFrom.TOPRIGHT); }} + pointerEvents={selectionBoxActive ? 'none' : 'all'} /> { this.onPointerDown(e, ResizeFrom.BOTTOMLEFT); }} + pointerEvents={selectionBoxActive ? 'none' : 'all'} /> ); diff --git a/src/main/scenes/application.tsx b/src/main/scenes/application.tsx index e2dcd0612..16c2d8d16 100644 --- a/src/main/scenes/application.tsx +++ b/src/main/scenes/application.tsx @@ -18,6 +18,7 @@ import { Layout } from './application-styles'; import { RootContext, RootProvider } from '../components/root/root-context'; import { UMLModel } from '../typings'; import { Patcher } from '../services/patcher'; +import { MouseEventListener } from '../components/canvas/mouse-eventlistener'; type Props = { patcher: Patcher; @@ -85,6 +86,11 @@ export class Application extends React.Component { + {canvasContext && ( + <> + + + )} )} diff --git a/src/main/services/editor/editor-reducer.ts b/src/main/services/editor/editor-reducer.ts index 24051b88e..1bfde1526 100644 --- a/src/main/services/editor/editor-reducer.ts +++ b/src/main/services/editor/editor-reducer.ts @@ -10,6 +10,7 @@ const initialState: EditorState = { mode: ApollonMode.Exporting, view: ApollonView.Modelling, zoomFactor: 1.0, + selectionBoxActive: false, features: { hoverable: true, selectable: true, @@ -32,7 +33,7 @@ export const EditorReducer: Reducer = (state = initialStat view: payload.view, }; } - case EditorActionTypes.CHANGE_ZOOM_FACTOR: { + case EditorActionTypes.SET_ZOOM_FACTOR: { const { payload } = action; return { @@ -40,6 +41,14 @@ export const EditorReducer: Reducer = (state = initialStat zoomFactor: payload.zoomFactor, }; } + case EditorActionTypes.SET_SELECTION_BOX: { + const { payload } = action; + + return { + ...state, + selectionBoxActive: payload.selectionBoxActive, + }; + } } return state; }; diff --git a/src/main/services/editor/editor-repository.ts b/src/main/services/editor/editor-repository.ts index 274f9826b..e03463177 100644 --- a/src/main/services/editor/editor-repository.ts +++ b/src/main/services/editor/editor-repository.ts @@ -1,4 +1,10 @@ -import { ApollonView, ChangeViewAction, ChangeZoomFactorAction, EditorActionTypes } from './editor-types'; +import { + ApollonView, + SetSelectionBoxAction, + ChangeViewAction, + SetZoomFactorAction, + EditorActionTypes, +} from './editor-types'; export class EditorRepository { static changeView = (view: ApollonView): ChangeViewAction => ({ @@ -6,9 +12,14 @@ export class EditorRepository { payload: { view }, undoable: false, }); - static changeZoomFactor = (zoomFactor: number): ChangeZoomFactorAction => ({ - type: EditorActionTypes.CHANGE_ZOOM_FACTOR, + static setZoomFactor = (zoomFactor: number): SetZoomFactorAction => ({ + type: EditorActionTypes.SET_ZOOM_FACTOR, payload: { zoomFactor }, undoable: false, }); + static setSelectionBoxActive = (selectionBoxActive: boolean): SetSelectionBoxAction => ({ + type: EditorActionTypes.SET_SELECTION_BOX, + payload: { selectionBoxActive }, + undoable: false, + }); } diff --git a/src/main/services/editor/editor-types.ts b/src/main/services/editor/editor-types.ts index 236187556..b23c11c0b 100644 --- a/src/main/services/editor/editor-types.ts +++ b/src/main/services/editor/editor-types.ts @@ -20,7 +20,8 @@ export const enum ApollonView { export const enum EditorActionTypes { CHANGE_VIEW = '@@element/CHANGE_VIEW', - CHANGE_ZOOM_FACTOR = '@@element/CHANGE_ZOOM_FACTOR', + SET_ZOOM_FACTOR = '@@element/SET_ZOOM_FACTOR', + SET_SELECTION_BOX = '@@element/SET_SELECTION_BOX_ACTIVE', } export type EditorState = { @@ -32,9 +33,10 @@ export type EditorState = { readonly features: UMLElementFeatures; readonly colorEnabled: boolean; readonly zoomFactor: number; + readonly selectionBoxActive: boolean; }; -export type EditorActions = ChangeViewAction | ChangeZoomFactorAction; +export type EditorActions = ChangeViewAction | SetZoomFactorAction | SetSelectionBoxAction; export type ChangeViewAction = Action & { payload: { @@ -42,8 +44,14 @@ export type ChangeViewAction = Action & { }; }; -export type ChangeZoomFactorAction = Action & { +export type SetZoomFactorAction = Action & { payload: { zoomFactor: number; }; }; + +export type SetSelectionBoxAction = Action & { + payload: { + selectionBoxActive: boolean; + }; +}; diff --git a/src/main/services/uml-element/selectable/selectable-reducer.ts b/src/main/services/uml-element/selectable/selectable-reducer.ts index 4a09b0f32..e83e3d347 100644 --- a/src/main/services/uml-element/selectable/selectable-reducer.ts +++ b/src/main/services/uml-element/selectable/selectable-reducer.ts @@ -8,7 +8,7 @@ export const SelectableReducer: Reducer = (state = [], case SelectableActionTypes.SELECT: { const { payload } = action; - return [...new Set([...payload.ids, ...state])]; + return [...new Set([...payload.ids, ...(payload.overwrite ? [] : state)])]; } case UMLElementActionTypes.DELETE: case SelectableActionTypes.DESELECT: { diff --git a/src/main/services/uml-element/selectable/selectable-repository.ts b/src/main/services/uml-element/selectable/selectable-repository.ts index 9f0ab4ef9..2ab05f617 100644 --- a/src/main/services/uml-element/selectable/selectable-repository.ts +++ b/src/main/services/uml-element/selectable/selectable-repository.ts @@ -1,22 +1,31 @@ import { AsyncAction } from '../../../utils/actions/actions'; import { DeselectAction, SelectableActionTypes, SelectAction } from './selectable-types'; +import { SetSelectionBoxAction, EditorActionTypes } from '../../editor/editor-types'; export const Selectable = { select: - (id?: string | string[]): AsyncAction => + (id?: string | string[], overwrite?: boolean): AsyncAction => (dispatch, getState) => { const ids = id ? (Array.isArray(id) ? id : [id]) : Object.keys(getState().elements); if (!ids.length) { - return; + dispatch({ + type: EditorActionTypes.SET_SELECTION_BOX, + payload: { + selectionBoxActive: false, + }, + undoable: false, + }); } return dispatch({ type: SelectableActionTypes.SELECT, - payload: { ids }, + payload: { + ids: ids, + overwrite: overwrite, + }, undoable: false, }); }, - deselect: (id?: string | string[]): AsyncAction => (dispatch, getState) => { @@ -27,7 +36,7 @@ export const Selectable = { return dispatch({ type: SelectableActionTypes.DESELECT, - payload: { ids }, + payload: { ids: ids }, undoable: false, }); }, diff --git a/src/main/services/uml-element/selectable/selectable-types.ts b/src/main/services/uml-element/selectable/selectable-types.ts index b0515ba2d..e848f2e67 100644 --- a/src/main/services/uml-element/selectable/selectable-types.ts +++ b/src/main/services/uml-element/selectable/selectable-types.ts @@ -12,6 +12,7 @@ export type SelectableActions = SelectAction | DeselectAction; export type SelectAction = Action & { payload: { ids: string[]; + overwrite?: boolean; }; }; diff --git a/src/main/utils/geometry/boundary.ts b/src/main/utils/geometry/boundary.ts index 39752aae0..4c62883f0 100644 --- a/src/main/utils/geometry/boundary.ts +++ b/src/main/utils/geometry/boundary.ts @@ -1,4 +1,4 @@ -import { Point } from './point'; +import { IPoint, Point } from './point'; export interface IBoundary { x: number; @@ -7,6 +7,10 @@ export interface IBoundary { height: number; } +/** + * Compute a bounding box for a set of points + * @param points The points that should be contained within the bounding box + */ export function computeBoundingBox(points: Point[]): IBoundary { if (points.length === 0) { return { x: 0, y: 0, width: 0, height: 0 }; @@ -36,6 +40,10 @@ export function computeBoundingBox(points: Point[]): IBoundary { }; } +/** + * Calculate the bounding box for a set of elements + * @param elements The elements for which a common bounding box should be calculated + */ export function computeBoundingBoxForElements(elements: { bounds: IBoundary }[]): IBoundary { if (!elements.length) { return { x: 0, y: 0, width: 0, height: 0 }; @@ -55,3 +63,33 @@ export function computeDimension(scale: number, value: number, isCircle?: boolea return Math.round((value * scale) / 10) * 10; } } + +/** + * Check whether a given element is intersected by a boundary. This method is used for checking if an element is + * intersected. + * + * @param bounds The bounds for which intersection by the intersecting boundaries is determined + * @param intersectingBounds The potentially intersecting bounds + */ +export const areBoundsIntersecting = (bounds: IBoundary, intersectingBounds: IBoundary): boolean => { + const cornerPoints: IPoint[] = [ + { x: bounds.x, y: bounds.y }, + { x: bounds.x + bounds.width, y: bounds.y }, + { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + { x: bounds.x, y: bounds.y + bounds.height }, + ]; + + const intersectingBoundsStartX = Math.min(intersectingBounds.x, intersectingBounds.x + intersectingBounds.width); + const intersectingBoundsEndX = Math.max(intersectingBounds.x, intersectingBounds.x + intersectingBounds.width); + const intersectingBoundsStartY = Math.min(intersectingBounds.y, intersectingBounds.y + intersectingBounds.height); + const intersectingBoundsEndY = Math.max(intersectingBounds.y, intersectingBounds.y + intersectingBounds.height); + + // Determine if the given bounds are at least partially contained within the intersecting bounds + return cornerPoints.some( + (point) => + intersectingBoundsStartX <= point.x && + point.x <= intersectingBoundsEndX && + intersectingBoundsStartY <= point.y && + point.y <= intersectingBoundsEndY, + ); +}; diff --git a/src/tests/unit/apollon-editor-test.tsx b/src/tests/unit/apollon-editor-test.tsx index 257471f22..636805a5b 100644 --- a/src/tests/unit/apollon-editor-test.tsx +++ b/src/tests/unit/apollon-editor-test.tsx @@ -117,7 +117,7 @@ describe('test apollon editor ', () => { elements: arrayToInclusionMap(Object.keys(testClassDiagram.elements)), relationships: arrayToInclusionMap(Object.keys(testClassDiagram.relationships)), }; - const selectionCallback = fn((s: Selection) => {}); + const selectionCallback = fn((selection: Selection) => {}); // subscribe to selection and call select const selectionSubscription = editor.subscribeToSelectionChange(selectionCallback); diff --git a/src/tests/unit/components/update-pane/__snapshots__/update-pane-test.tsx.snap b/src/tests/unit/components/update-pane/__snapshots__/update-pane-test.tsx.snap index 4e0f21663..18ea54f4e 100644 --- a/src/tests/unit/components/update-pane/__snapshots__/update-pane-test.tsx.snap +++ b/src/tests/unit/components/update-pane/__snapshots__/update-pane-test.tsx.snap @@ -147,53 +147,6 @@ exports[`test update pane render with element 1`] = ` color: var(--apollon-background,#ffffff); } -.c14 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - min-height: 1.9rem; -} - -.c16 { - -webkit-flex: 1 1 auto; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c16:not(:first-child) { - margin-left: -1px; -} - -.c16:first-child { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -.c16:last-child { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.c16:not(:first-child):not(:last-child) { - border-radius: 0; -} - .c8 { background-clip: padding-box; background-color: var(--apollon-background,#ffffff); @@ -289,6 +242,53 @@ exports[`test update pane render with element 1`] = ` overflow: visible; } +.c14 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + min-height: 1.9rem; +} + +.c16 { + -webkit-flex: 1 1 auto; + -ms-flex: 1 1 auto; + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; +} + +.c16:not(:first-child) { + margin-left: -1px; +} + +.c16:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.c16:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.c16:not(:first-child):not(:last-child) { + border-radius: 0; +} + .c18 { display: -webkit-box; display: -webkit-flex; diff --git a/src/tests/unit/packages/common/default-element-popup/__snapshots__/default-element-popup-test.tsx.snap b/src/tests/unit/packages/common/default-element-popup/__snapshots__/default-element-popup-test.tsx.snap index 73dc7803b..72d4136ee 100644 --- a/src/tests/unit/packages/common/default-element-popup/__snapshots__/default-element-popup-test.tsx.snap +++ b/src/tests/unit/packages/common/default-element-popup/__snapshots__/default-element-popup-test.tsx.snap @@ -7,14 +7,14 @@ exports[`test default element popup render 1`] = `


Attributes

@@ -72,12 +72,12 @@ exports[`test class association popup render 1`] = ` class="sc-JrDLc cQSxzb" >

Methods

@@ -113,12 +113,12 @@ exports[`test class association popup render 1`] = ` class="sc-JrDLc cQSxzb" > diff --git a/src/tests/unit/packages/flowchart/flowchart-decision/__snapshots__/flowchart-decision-update-test.tsx.snap b/src/tests/unit/packages/flowchart/flowchart-decision/__snapshots__/flowchart-decision-update-test.tsx.snap index 1427d951c..ef5dfd57e 100644 --- a/src/tests/unit/packages/flowchart/flowchart-decision/__snapshots__/flowchart-decision-update-test.tsx.snap +++ b/src/tests/unit/packages/flowchart/flowchart-decision/__snapshots__/flowchart-decision-update-test.tsx.snap @@ -9,13 +9,13 @@ exports[`test flowchart decision update render 1`] = ` class="sc-eZkCL cXjuPz" >
diff --git a/src/tests/unit/packages/flowchart/flowchart-function-call/__snapshots__/flowchart-function-call-update-test.tsx.snap b/src/tests/unit/packages/flowchart/flowchart-function-call/__snapshots__/flowchart-function-call-update-test.tsx.snap index b25e421c9..303747c6d 100644 --- a/src/tests/unit/packages/flowchart/flowchart-function-call/__snapshots__/flowchart-function-call-update-test.tsx.snap +++ b/src/tests/unit/packages/flowchart/flowchart-function-call/__snapshots__/flowchart-function-call-update-test.tsx.snap @@ -9,13 +9,13 @@ exports[`test flowchart function call update render 1`] = ` class="sc-eZkCL cXjuPz" >
@@ -56,31 +56,31 @@ exports[`test class association popup render 1`] = ` class="sc-hCPjZK" >

Multiplicity Role

Multiplicity Role diff --git a/src/tests/unit/packages/uml-communication-diagram/uml-communication-link/__snapshots__/uml-communication-link-popup-test.tsx.snap b/src/tests/unit/packages/uml-communication-diagram/uml-communication-link/__snapshots__/uml-communication-link-popup-test.tsx.snap index 533b7db44..92d1fb7e3 100644 --- a/src/tests/unit/packages/uml-communication-diagram/uml-communication-link/__snapshots__/uml-communication-link-popup-test.tsx.snap +++ b/src/tests/unit/packages/uml-communication-diagram/uml-communication-link/__snapshots__/uml-communication-link-popup-test.tsx.snap @@ -9,12 +9,12 @@ exports[`test communication link popup render 1`] = ` class="sc-gFAWRd gHOPDJ" >

Communication Link


Messages ( @@ -49,12 +49,12 @@ exports[`test communication link popup render 1`] = ` class="sc-gFAWRd gHOPDJ" > diff --git a/src/tests/unit/packages/uml-deployment-diagram/uml-deployment-node/uml-deployment-node-popup/__snapshots__/deployment-node-popup-test.tsx.snap b/src/tests/unit/packages/uml-deployment-diagram/uml-deployment-node/uml-deployment-node-popup/__snapshots__/deployment-node-popup-test.tsx.snap index dac0d15ca..91620e897 100644 --- a/src/tests/unit/packages/uml-deployment-diagram/uml-deployment-node/uml-deployment-node-popup/__snapshots__/deployment-node-popup-test.tsx.snap +++ b/src/tests/unit/packages/uml-deployment-diagram/uml-deployment-node/uml-deployment-node-popup/__snapshots__/deployment-node-popup-test.tsx.snap @@ -9,12 +9,12 @@ exports[`test deployment node popup render 1`] = ` class="sc-iHbSHJ brJDWu" >


diff --git a/src/tests/unit/packages/uml-petri-diagram/uml-petri-net-arc/__snapshots__/uml-petri-net-arc-update-test.tsx.snap b/src/tests/unit/packages/uml-petri-diagram/uml-petri-net-arc/__snapshots__/uml-petri-net-arc-update-test.tsx.snap index 9c665653e..5478354b2 100644 --- a/src/tests/unit/packages/uml-petri-diagram/uml-petri-net-arc/__snapshots__/uml-petri-net-arc-update-test.tsx.snap +++ b/src/tests/unit/packages/uml-petri-diagram/uml-petri-net-arc/__snapshots__/uml-petri-net-arc-update-test.tsx.snap @@ -9,12 +9,12 @@ exports[`test petri net arc update render 1`] = ` class="sc-dSCufp fAuGzn" >

@@ -40,13 +40,13 @@ exports[`test petri net arc update render 1`] = ` class="sc-ddjGPC hMjkq" > Tokens Capacity @@ -69,7 +69,7 @@ exports[`test petri net arc update render 1`] = ` style="position: relative;" >
diff --git a/src/tests/unit/utils/geometry/boundary-test.ts b/src/tests/unit/utils/geometry/boundary-test.ts new file mode 100644 index 000000000..d4b8003ad --- /dev/null +++ b/src/tests/unit/utils/geometry/boundary-test.ts @@ -0,0 +1,45 @@ +import { areBoundsIntersecting, IBoundary } from '../../../../main/utils/geometry/boundary'; + +describe('test areBoundsIntersecting utility', () => { + it('bounds entirely contained in intersecting bounds', () => { + const bounds: IBoundary = { x: 10, y: 10, height: 90, width: 90 }; + const intersectingBounds: IBoundary = { x: 0, y: 0, height: 100, width: 100 }; + + expect(areBoundsIntersecting(bounds, intersectingBounds)).toBeTruthy(); + }); + + it('bounds not contained in intersecting bounds', () => { + const bounds: IBoundary = { x: 0, y: 0, height: 100, width: 100 }; + const intersectingBounds: IBoundary = { x: 110, y: 0, height: 100, width: 100 }; + + expect(areBoundsIntersecting(bounds, intersectingBounds)).toBeFalsy(); + }); + + it('top left corner contained in intersecting bounds', () => { + const bounds: IBoundary = { x: 0, y: 0, height: 100, width: 100 }; + const intersectingBounds: IBoundary = { x: -90, y: -90, height: 100, width: 100 }; + + expect(areBoundsIntersecting(bounds, intersectingBounds)).toBeTruthy(); + }); + + it('top right corner contained in intersecting bounds', () => { + const bounds: IBoundary = { x: 90, y: -90, height: 100, width: 100 }; + const intersectingBounds: IBoundary = { x: 0, y: 0, height: 100, width: 100 }; + + expect(areBoundsIntersecting(bounds, intersectingBounds)).toBeTruthy(); + }); + + it('bottom left corner contained in intersecting bounds', () => { + const bounds: IBoundary = { x: 0, y: 0, height: 100, width: 100 }; + const intersectingBounds: IBoundary = { x: -90, y: 90, height: 100, width: 100 }; + + expect(areBoundsIntersecting(bounds, intersectingBounds)).toBeTruthy(); + }); + + it('bottom right corner contained in intersecting bounds', () => { + const bounds: IBoundary = { x: 0, y: 0, height: 100, width: 100 }; + const intersectingBounds: IBoundary = { x: 90, y: 90, height: 100, width: 100 }; + + expect(areBoundsIntersecting(bounds, intersectingBounds)).toBeTruthy(); + }); +});