Repository: ProcessMaker/modeler
Pull request #1114 (2020-02-12 21:37:54)

Add align and distribute options to toolbar
This adds the alignment and distribution functions to the toolbar.

<img width="260" alt="image" src="https://user-images.githubusercontent.com/28595383/74945556-dc81ba80-53ef-11ea-855e-1c5d5140f452.png">

From left to right:

1. align left
2. center horizontally
3. align right
4. align bottom
5. center vertically
6. align top
7. distribute horizontally
8. distribute vertically

Icons and layout to be confirmed.


Patch:
diff --git a/src/assets/distribute-horizontally-icon.svg b/src/assets/distribute-horizontally-icon.svg
new file mode 100644
index 00000000..d0e99cee
--- /dev/null
+++ b/src/assets/distribute-horizontally-icon.svg
@@ -0,0 +1,3 @@
+<svg width="29" height="30" viewBox="0 0 29 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 3.5H6V4V10H4V26H9V10H7V4.5H14.25V10H12.25V21H17.25V10H15.25V4.5H22V10H20V26H25V10H23V4V3.5H22.5H14.75H6.5Z" fill="white"/>
+</svg>
diff --git a/src/assets/distribute-vertically-icon.svg b/src/assets/distribute-vertically-icon.svg
new file mode 100644
index 00000000..2f555e1a
--- /dev/null
+++ b/src/assets/distribute-vertically-icon.svg
@@ -0,0 +1,3 @@
+<svg width="29" height="30" viewBox="0 0 29 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 7.49997V9.49997H25.5V4.49997H9.5V6.49997H3.5H3V6.99997V14.75V23V23.5H3.5H9.5V25.5H25.5V20.5H9.5V22.5H4V15.25H9.5V17.25H20.5V12.25H9.5V14.25H4V7.49997H9.5Z" fill="white"/>
+</svg>
diff --git a/src/components/modeler/Modeler.vue b/src/components/modeler/Modeler.vue
index 1bcb9efc..01c23629 100644
--- a/src/components/modeler/Modeler.vue
+++ b/src/components/modeler/Modeler.vue
@@ -10,6 +10,7 @@
       @toggle-panels-compressed="panelsCompressed = $event"
       @toggle-mini-map-open="miniMapOpen = $event"
       @saveBpmn="$emit('saveBpmn')"
+      @save-state="pushToUndoStack"
     />
     <b-row class="modeler h-100">
       <b-tooltip
diff --git a/src/components/modeler/setUpSelectionBox.js b/src/components/modeler/setUpSelectionBox.js
index 828edd25..02bb77ce 100644
--- a/src/components/modeler/setUpSelectionBox.js
+++ b/src/components/modeler/setUpSelectionBox.js
@@ -1,5 +1,6 @@
 import { g, shapes } from 'jointjs';
 import store from '@/store';
+import { canMoveProgrammatically } from '@/components/nodes/utilities/shapeMovement';
 
 const shapesToNotTranslate = [
   'processmaker.modeler.bpmn.pool',
@@ -99,7 +100,7 @@ export default function setUpSelectionBox(setCursor, resetCursor, paperManager,
   function moveAllOtherHighlightedShapes(element, newPosition, { movedWithArrowKeys }) {
     if (
       movedWithArrowKeys ||
-      shapesToNotTranslate.includes(element.get('type')) ||
+      !canMoveProgrammatically(element) ||
       !store.getters.highlightedShapes.includes(element) ||
       element.component.node.pool
     ) {
diff --git a/src/components/nodes/utilities/distribute.js b/src/components/nodes/utilities/distribute.js
index c3937420..b459f60a 100644
--- a/src/components/nodes/utilities/distribute.js
+++ b/src/components/nodes/utilities/distribute.js
@@ -1,5 +1,6 @@
-import { moveShapeLeftTo, moveShapeMiddleXTo, moveShapeMiddleYTo } from '@/components/nodes/utilities/shapeMovement';
+import { moveShapeLeftTo, moveShapeMiddleYTo, moveShapeTopTo } from '@/components/nodes/utilities/shapeMovement';
 import { getBoundingBox } from '@/components/nodes/utilities/shapeGroup';
+import { shapeCenterX, shapeCenterY } from '@/components/nodes/utilities/shapeMetrics';
 
 export function distributeVerticalCentersEvenly(shapes) {
   const bounds = getBoundingBox(shapes);
@@ -8,7 +9,7 @@ export function distributeVerticalCentersEvenly(shapes) {
     return;
   }
 
-  const topToBottom = shapes.sort((a, b) => a.position().y - b.position().y);
+  const topToBottom = shapes.sort((a, b) => shapeCenterY(a) - shapeCenterY(b));
   const topShape = topToBottom[0];
   const bottomShape = topToBottom[itemCount - 1];
 
@@ -27,42 +28,35 @@ export function distributeVerticalCentersEvenly(shapes) {
   });
 }
 
-export function distributeHorizontalCentersEvenly(shapes) {
+export function distributeVerticalSpacingEvenly(shapes) {
   const bounds = getBoundingBox(shapes);
   const itemCount = shapes.length;
-  if (itemCount < 3) {
+  const minimumArrangeable = 3; // less than 3 is evenly spaced by definition
+  if (itemCount < minimumArrangeable) {
     return;
   }
-
-  const leftToRight = shapes.sort((a, b) => a.position().x - b.position().x);
-  const leftmostShape = leftToRight[0];
-  const rightmostShape = leftToRight[itemCount - 1];
-
-  const availableWidth = bounds.width
-    - (leftmostShape.size().width / 2)
-    - (rightmostShape.size().width / 2);
-
-  const distanceBetweenCenters = availableWidth / (itemCount - 1);
-  const offset = bounds.left + (leftmostShape.size().width / 2);
-
-  leftToRight.forEach((shape, idx) => {
+  const totalShapeHeight = shapes.reduce((sum, shape) => shape.size().height + sum, 0);
+  const topToBottom = shapes.sort((a, b) => shapeCenterY(a) - shapeCenterY(b));
+  let offset = bounds.top + shapes[0].size().height;
+  const spaceBetween = (bounds.height - totalShapeHeight) / (topToBottom.length - 1);
+  topToBottom.forEach((shape, idx) => {
     if (idx === 0) {
       return;
     }
-
-    moveShapeMiddleXTo(shape, offset + idx * distanceBetweenCenters);
+    moveShapeTopTo(shape, offset + spaceBetween);
+    offset += spaceBetween + shape.size().height;
   });
 }
 
 export function distributeHorizontalSpacingEvenly(shapes) {
   const bounds = getBoundingBox(shapes);
   const itemCount = shapes.length;
-  const minimumArrangeable = 3; // less than 3 is evenly spaced by definition
+  const minimumArrangeable = 3;
   if (itemCount < minimumArrangeable) {
     return;
   }
   const totalShapeWidth = shapes.reduce((sum, shape) => shape.size().width + sum, 0);
-  const leftToRight = shapes.sort((a, b) => a.position().x - b.position().x);
+  const leftToRight = shapes.sort((a, b) => shapeCenterX(a) - shapeCenterX(b));
   let offset = bounds.left + shapes[0].size().width;
   const spaceBetween = (bounds.width - totalShapeWidth) / (leftToRight.length - 1);
   leftToRight.forEach((shape, idx) => {
diff --git a/src/components/nodes/utilities/shapeGroup.js b/src/components/nodes/utilities/shapeGroup.js
index 8bffab8a..0a3fd332 100644
--- a/src/components/nodes/utilities/shapeGroup.js
+++ b/src/components/nodes/utilities/shapeGroup.js
@@ -1,12 +1,40 @@
+import {
+  hasPositionAndSizeAttribs,
+  shapeBottom,
+  shapeCenterX,
+  shapeCenterY,
+  shapeLeft,
+  shapeRight,
+  shapeTop,
+} from '@/components/nodes/utilities/shapeMetrics';
+import {
+  alignBottom,
+  alignLeft,
+  alignRight,
+  alignTop,
+  centerHorizontally,
+  centerVertically,
+} from '@/components/nodes/utilities/align';
+import {
+  distributeHorizontalSpacingEvenly,
+  distributeVerticalSpacingEvenly,
+} from '@/components/nodes/utilities/distribute';
+import { canAlign } from '@/components/nodes/utilities/shapeMovement';
+
 /**
  * Utility functions that operate on a collection of shapes
  */
 
 export function getBoundingBox(shapes) {
-  const left = (shape) => shape.position().x;
-  const right = (shape) => shape.position().x + shape.size().width;
-  const top = (shape) => shape.position().y;
-  const bottom = (shape) => shape.position().y + shape.size().height;
+
+  if (!shapes || !shapes.length || shapes.length === 0) {
+    return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, vMiddle: 0, hMiddle: 0 };
+  }
+
+  const left = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().x : 0;
+  const right = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().x + shape.size().width : 0;
+  const top = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().y : 0;
+  const bottom = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().y + shape.size().height : 0;
 
   const minX = Math.min(...shapes.map(left));
   const maxX = Math.max(...shapes.map(right));
@@ -26,3 +54,40 @@ export function getBoundingBox(shapes) {
     hMiddle: minX + (width / 2),
   };
 }
+
+/**
+ * Gets the available options for a group of shapes
+ */
+export function getShapesOptions(shapes) {
+  const alignableShapes = shapes.filter(shape => canAlign(shape));
+  const bounds = getBoundingBox(shapes);
+  const enoughShapes = alignableShapes.length > 1;
+  return {
+    can: {
+      align: {
+        left: enoughShapes && alignableShapes.some(shape => shapeLeft(shape) !== bounds.left),
+        horizontalCenter: enoughShapes && alignableShapes.some(shape => shapeCenterX(shape) !== bounds.hMiddle),
+        right: enoughShapes && alignableShapes.some(shape => shapeRight(shape) !== bounds.right),
+        bottom: enoughShapes && alignableShapes.some(shape => shapeBottom(shape) !== bounds.bottom),
+        verticalCenter: enoughShapes && alignableShapes.some(shape => shapeCenterY(shape) !== bounds.vMiddle),
+        top: enoughShapes && alignableShapes.some(shape => shapeTop(shape) !== bounds.top),
+      },
+      distribute: {
+        horizontally: enoughShapes && alignableShapes.length > 2,
+        vertically: enoughShapes && alignableShapes.length > 2,
+      },
+    },
+    align: {
+      left: () => alignLeft(alignableShapes),
+      horizontalCenter: () => centerHorizontally(alignableShapes),
+      right: () => alignRight(alignableShapes),
+      bottom: () => alignBottom(alignableShapes),
+      verticalCenter: () => centerVertically(alignableShapes),
+      top: () => alignTop(alignableShapes),
+    },
+    distribute: {
+      horizontally: () => distributeHorizontalSpacingEvenly(alignableShapes),
+      vertically: () => distributeVerticalSpacingEvenly(alignableShapes),
+    },
+  };
+}
diff --git a/src/components/nodes/utilities/shapeMetrics.js b/src/components/nodes/utilities/shapeMetrics.js
index b84447bc..8e7247f3 100644
--- a/src/components/nodes/utilities/shapeMetrics.js
+++ b/src/components/nodes/utilities/shapeMetrics.js
@@ -4,3 +4,12 @@ export const shapeCenterX = (shape) => shape.position().x + (shape.size().width
 export const shapeCenterY = (shape) => shape.position().y + (shape.size().height / 2);
 export const shapeRight = (shape) => shape.position().x + shape.size().width;
 export const shapeTop = (shape) => shape.position().y;
+export const shapeArea = (shape) => hasPositionAndSizeAttribs(shape)
+  ? shape.size().width * shape.size().height()
+  : 0;
+
+export const hasPositionAndSizeAttribs = (shape) => {
+  return Boolean(shape
+    && shape.position
+    && shape.size);
+};
diff --git a/src/components/nodes/utilities/shapeMovement.js b/src/components/nodes/utilities/shapeMovement.js
index 5c439432..a73acaf2 100644
--- a/src/components/nodes/utilities/shapeMovement.js
+++ b/src/components/nodes/utilities/shapeMovement.js
@@ -1,4 +1,5 @@
 import {
+  hasPositionAndSizeAttribs,
   shapeBottom,
   shapeCenterX,
   shapeCenterY,
@@ -6,14 +7,24 @@ import {
   shapeRight,
   shapeTop,
 } from '@/components/nodes/utilities/shapeMetrics';
-
-export function isProgrammaticallyMovable(shape) {
-  const programaticallyImmovable = [
-    'processmaker.modeler.bpmn.pool',
-    'PoolLane',
-    'processmaker.components.nodes.boundaryEvent.Shape',
-  ];
-  return shape || !programaticallyImmovable.includes(shape.get('type'));
+import { shapeTypes } from '../../../../tests/e2e/support/constants';
+
+const PROGRAMMATICALLY_IMMOVABLE_SHAPES = [
+  shapeTypes.boundaryEvent,
+  shapeTypes.poolLane,
+];
+
+/**
+ * Shapes we can safely call translate() on.
+ */
+export const canMoveProgrammatically = (shape) => !PROGRAMMATICALLY_IMMOVABLE_SHAPES.includes(shape.get('type'));
+
+/**
+ * Things like flow lines can still be moved programmatically (e.g. with
+ * arrow keys) - but should not be programmatically aligned.
+ */
+export function canAlign(shape) {
+  return hasPositionAndSizeAttribs(shape) && canMoveProgrammatically(shape);
 }
 
 export function moveShapeBottomTo(shape, Y) {
diff --git a/src/components/toolbar/ToolBar.vue b/src/components/toolbar/ToolBar.vue
index 89e87c1b..93a0b6b3 100644
--- a/src/components/toolbar/ToolBar.vue
+++ b/src/components/toolbar/ToolBar.vue
@@ -5,6 +5,8 @@
     >
       <breadcrumb :breadcrumb-data="breadcrumbData" />
       <div class="mr-3">
+        <align-buttons @save-state="$emit('save-state')" />
+
         <div class="btn-group btn-group-sm mr-2" role="group" aria-label="Undo/redo controls">
           <b-button
             class="btn btn-sm btn-secondary btn-undo"
@@ -98,13 +100,23 @@
 </template>
 <script>
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
-import { faCompress, faExpand, faMapMarked, faMinus, faPlus, faRedo, faSave, faUndo } from '@fortawesome/free-solid-svg-icons';
+import {
+  faCompress,
+  faExpand,
+  faMapMarked,
+  faMinus,
+  faPlus,
+  faRedo,
+  faSave,
+  faUndo,
+} from '@fortawesome/free-solid-svg-icons';
 import undoRedoStore from '@/undoRedoStore';
 import Breadcrumb from '@/components/toolbar/breadcrumb/Breadcrumb';
+import AlignButtons from '@/components/toolbar/alignButtons/AlignButtons';
 
 export default {
   name: 'tool-bar',
-  components: { Breadcrumb, FontAwesomeIcon },
+  components: { Breadcrumb, FontAwesomeIcon, AlignButtons },
   props: {
     canvasDragPosition: {},
     cursor: {},
diff --git a/src/components/toolbar/alignButtons/AlignButtons.vue b/src/components/toolbar/alignButtons/AlignButtons.vue
new file mode 100644
index 00000000..2019dc17
--- /dev/null
+++ b/src/components/toolbar/alignButtons/AlignButtons.vue
@@ -0,0 +1,139 @@
+<template>
+  <div class="btn-group btn-group-sm mr-2" role="group">
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-left"
+      data-test="align-left"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.left"
+      :title="$t('Align Left')"
+      @click="undoableAction(selectedShapes.align.left)"
+    >
+      <font-awesome-icon :icon="alignLeftIcon" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-center"
+      data-test="align-center"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.horizontalCenter"
+      :title="$t('Center Horizontally')"
+      @click="undoableAction(selectedShapes.align.horizontalCenter)"
+    >
+      <font-awesome-icon :icon="alignCenterIcon" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-right mr-1"
+      data-test="align-right"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.right"
+      :title="$t('Align Right')"
+      @click="undoableAction(selectedShapes.align.right)"
+    >
+      <font-awesome-icon :icon="alignRightIcon" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-bottom"
+      data-test="align-bottom"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.bottom"
+      :title="$t('Align Bottom')"
+      @click="undoableAction(selectedShapes.align.bottom)"
+    >
+      <font-awesome-icon :icon="alignRightIcon" class="rotate-90-degrees-right" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-vertical-center"
+      data-test="align-vertical-center"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.verticalCenter"
+      :title="$t('Center Vertically')"
+      @click="undoableAction(selectedShapes.align.verticalCenter)"
+    >
+      <font-awesome-icon :icon="alignCenterIcon" class="rotate-90-degrees-right" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-top mr-1"
+      data-test="align-top"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.top"
+      :title="$t('Align Top')"
+      @click="undoableAction(selectedShapes.align.top)"
+    >
+      <font-awesome-icon :icon="alignLeftIcon" class="rotate-90-degrees-right" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-distribute-horizontal d-flex align-items-center"
+      data-test="distribute-horizontal"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.distribute.horizontally"
+      :title="$t('Distribute Horizontally')"
+      @click="undoableAction(selectedShapes.distribute.horizontally)"
+    >
+      <img
+        :src="distHIcon"
+        alt=""
+        height="16"
+      >
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-distribute-vertical d-flex align-items-center"
+      data-test="distribute-vertical"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.distribute.vertically"
+      :title="$t('Distribute Vertically')"
+      @click="undoableAction(selectedShapes.distribute.vertically)"
+    >
+      <img
+        :src="distVIcon"
+        alt=""
+        height="16"
+      >
+    </b-button>
+  </div>
+</template>
+<script>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
+import { faAlignCenter, faAlignLeft, faAlignRight } from '@fortawesome/free-solid-svg-icons';
+import store from '@/store';
+import { getShapesOptions } from '@/components/nodes/utilities/shapeGroup';
+import distributeHorizontallyIcon from '@/assets/distribute-horizontally-icon.svg';
+import distributeVerticallyIcon from '@/assets/distribute-vertically-icon.svg';
+
+
+export default {
+  components: { FontAwesomeIcon },
+  data() {
+    return {
+      alignCenterIcon: faAlignCenter,
+      alignLeftIcon: faAlignLeft,
+      alignRightIcon: faAlignRight,
+      distHIcon: distributeHorizontallyIcon,
+      distVIcon: distributeVerticallyIcon,
+    };
+  },
+  methods: {
+    undoableAction(actionFn) {
+      actionFn();
+      this.$emit('save-state');
+    },
+  },
+  computed: {
+    selectedShapes() {
+      return getShapesOptions(store.getters.highlightedShapes);
+    },
+  },
+  watch: {
+    selectedShapes() {
+      this.$root.$emit('bv::hide::tooltip');
+    },
+  },
+};
+</script>
+
+<style lang="scss" src="./alignButtons.scss" />
diff --git a/src/components/toolbar/alignButtons/alignButtons.scss b/src/components/toolbar/alignButtons/alignButtons.scss
new file mode 100644
index 00000000..0753cf04
--- /dev/null
+++ b/src/components/toolbar/alignButtons/alignButtons.scss
@@ -0,0 +1,3 @@
+.rotate-90-degrees-right {
+  transform: rotate(90deg);
+}


Test patch:
diff --git a/tests/e2e/support/constants.js b/tests/e2e/support/constants.js
index ad7068fb..7c4e883d 100644
--- a/tests/e2e/support/constants.js
+++ b/tests/e2e/support/constants.js
@@ -7,6 +7,11 @@ export const defaultViewportDimensions = {
   height: 768,
 };
 
+/**
+ * Node types are strings returned by
+ * shape.component.node.type - they are not the string
+ * same as the shape type.
+ */
 export const nodeTypes = {
   startEvent: 'processmaker-modeler-start-event',
   messageStartEvent: 'processmaker-modeler-message-start-event',
@@ -39,6 +44,14 @@ export const nodeTypes = {
   poolLane: 'processmaker-modeler-lane',
 };
 
+/**
+ * Shape types are returned by shape.get('type').
+ */
+export const shapeTypes = {
+  poolLane: 'PoolLane',
+  boundaryEvent: 'processmaker.components.nodes.boundaryEvent.Shape',
+};
+
 export const boundaryEventSelector = '.main-paper ' +
   '[data-type="processmaker.components.nodes.boundaryEvent.Shape"]';
 
diff --git a/tests/unit/components/nodes/utilities/distributeShapes.spec.js b/tests/unit/components/nodes/utilities/distributeShapes.spec.js
index be076825..c95b680d 100644
--- a/tests/unit/components/nodes/utilities/distributeShapes.spec.js
+++ b/tests/unit/components/nodes/utilities/distributeShapes.spec.js
@@ -1,5 +1,4 @@
 import {
-  distributeHorizontalCentersEvenly,
   distributeHorizontalSpacingEvenly,
   distributeVerticalCentersEvenly,
 } from '@/components/nodes/utilities/distribute';
@@ -14,19 +13,19 @@ const expect = require('expect');
 expect.extend({ toHaveBeenProgrammaticallyMoved, toHaveBeenProgrammaticallyMovedBy });
 
 describe('Shape Distribution', () => {
-  it('can distribute vertical centers', () => {
+  it('can distribute vertical spacing', () => {
     const shapes = [
       dummyShape(0, 0, 100, 50),
       dummyShape(100, 50, 100, 50),
-      dummyShape(100, 100, 100, 50),
-      dummyShape(200, 600, 100, 50),
+      dummyShape(100, 100, 100, 100),
+      dummyShape(200, 750, 100, 50),
     ];
 
     distributeVerticalCentersEvenly(shapes);
 
     expect(shapes[0]).not.toHaveBeenProgrammaticallyMoved();
-    expect(shapes[1]).toHaveBeenProgrammaticallyMovedBy(0, 150);
-    expect(shapes[2]).toHaveBeenProgrammaticallyMovedBy(0, 300);
+    expect(shapes[1]).toHaveBeenProgrammaticallyMovedBy(0, 200);
+    expect(shapes[2]).toHaveBeenProgrammaticallyMovedBy(0, 375);
     expect(shapes[3]).not.toHaveBeenProgrammaticallyMoved();
   });
 
@@ -38,29 +37,12 @@ describe('Shape Distribution', () => {
     ];
 
     distributeVerticalCentersEvenly(shapes);
-    distributeHorizontalCentersEvenly(shapes);
     distributeHorizontalSpacingEvenly(shapes);
 
     expect(shapes[0]).not.toHaveBeenProgrammaticallyMoved();
     expect(shapes[1]).not.toHaveBeenProgrammaticallyMoved();
   });
 
-  it('can distribute horizontal centers', () => {
-    const shapes = [
-      dummyShape(0, 0, 100, 50),
-      dummyShape(100, 0, 50, 50),
-      dummyShape(150, 0, 100, 50),
-      dummyShape(300, 0, 100, 50),
-    ];
-
-    distributeHorizontalCentersEvenly(shapes);
-
-    expect(shapes[0]).not.toHaveBeenProgrammaticallyMoved();
-    expect(shapes[1]).toHaveBeenProgrammaticallyMovedBy(25, 0);
-    expect(shapes[2]).toHaveBeenProgrammaticallyMovedBy(50, 0);
-    expect(shapes[3]).not.toHaveBeenProgrammaticallyMoved();
-  });
-
   it('can distribute horizontal spacing', () => {
     const shapes = [
       dummyShape(0, 0, 100, 50),
diff --git a/tests/unit/components/nodes/utilities/shapeGroup.spec.js b/tests/unit/components/nodes/utilities/shapeGroup.spec.js
index d3e8bbdd..844f9a0d 100644
--- a/tests/unit/components/nodes/utilities/shapeGroup.spec.js
+++ b/tests/unit/components/nodes/utilities/shapeGroup.spec.js
@@ -1,7 +1,22 @@
 import { dummyShape } from '../../../utilities/dummies';
-import { getBoundingBox } from '@/components/nodes/utilities/shapeGroup';
+import { getBoundingBox, getShapesOptions } from '@/components/nodes/utilities/shapeGroup';
+import { shapeTypes } from '../../../../e2e/support/constants';
+import { canAlign } from '@/components/nodes/utilities/shapeMovement';
+
+describe('bounding box metrics', () => {
+  it('handles non-shape objects correctly without breaking', () => {
+    const bounds = getBoundingBox({});
+
+    expect(bounds.left).toBe(0);
+    expect(bounds.top).toBe(0);
+    expect(bounds.bottom).toBe(0);
+    expect(bounds.right).toBe(0);
+    expect(bounds.width).toBe(0);
+    expect(bounds.height).toBe(0);
+    expect(bounds.vMiddle).toBe(0);
+    expect(bounds.hMiddle).toBe(0);
+  });
 
-describe('shape group', () => {
   it('can calculate a correct bounding box with one shape', () => {
     const shapes = [
       dummyShape(0, 0, 100, 50),
@@ -37,3 +52,189 @@ describe('shape group', () => {
     expect(bounds.hMiddle).toBe(150);
   });
 });
+
+describe('shape group alignment options', () => {
+  it('cannot align when no shapes are selected', () => {
+    expect(getShapesOptions([]).can.align.left).toBe(false);
+    expect(getShapesOptions([]).can.align.horizontalCenter).toBe(false);
+    expect(getShapesOptions([]).can.align.right).toBe(false);
+    expect(getShapesOptions([]).can.align.bottom).toBe(false);
+    expect(getShapesOptions([]).can.align.verticalCenter).toBe(false);
+    expect(getShapesOptions([]).can.align.top).toBe(false);
+  });
+
+  it('cannot align when there are too few shapes selected', () => {
+    const shapes = [dummyShape(0, 0, 100, 50)];
+    expect(getShapesOptions(shapes).can.align.left).toBe(false);
+    expect(getShapesOptions(shapes).can.align.horizontalCenter).toBe(false);
+    expect(getShapesOptions(shapes).can.align.right).toBe(false);
+    expect(getShapesOptions(shapes).can.align.bottom).toBe(false);
+    expect(getShapesOptions(shapes).can.align.verticalCenter).toBe(false);
+    expect(getShapesOptions(shapes).can.align.top).toBe(false);
+  });
+
+  it('cannot align left when shapes are already left-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.left).toBe(false);
+  });
+
+  it('can align left when shapes are not left-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(20, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.left).toBe(true);
+  });
+
+
+  it('cannot align right when shapes are already right-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(30, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.right).toBe(false);
+  });
+
+  it('can align right when shapes are not right-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.right).toBe(true);
+  });
+
+  it('cannot align horizontal-center when shapes are already horizontal-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(15, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.horizontalCenter).toBe(false);
+  });
+
+  it('can align horizontal-center when shapes are not horizontal-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.horizontalCenter).toBe(true);
+  });
+
+  it('cannot align right when shapes are already bottom-aligned', () => {
+    const shapes = [
+      dummyShape(0, 20, 100, 30),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.bottom).toBe(false);
+  });
+
+  it('can align bottom when shapes are not bottom-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 10, 70, 30),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.bottom).toBe(true);
+  });
+
+
+  it('cannot align right when shapes are already top-aligned', () => {
+    const shapes = [
+      dummyShape(0, 20, 100, 30),
+      dummyShape(0, 20, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.top).toBe(false);
+  });
+
+  it('can align top when shapes are not top-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 10, 70, 30),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.top).toBe(true);
+  });
+
+  it('cannot align vertical-center when shapes are already vertical-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 10, 70, 30),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.verticalCenter).toBe(false);
+  });
+
+  it('can align vertical-center when shapes are not vertical-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 20, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.verticalCenter).toBe(true);
+  });
+
+  it('cannot align dimensionless shapes', () => {
+    expect(canAlign({})).toBe(false);
+  });
+
+  it('cannot align poolLanes', () => {
+    const poolLane = dummyShape(0, 0, 100, 50);
+    poolLane.get = () => shapeTypes.poolLane;
+
+    expect(canAlign(poolLane)).toBe(false);
+  });
+
+  it('cannot align boundaryEvents', () => {
+    const boundaryEvent = dummyShape(0, 0, 100, 50);
+    boundaryEvent.get = () => shapeTypes.boundaryEvent;
+
+    expect(canAlign(boundaryEvent)).toBe(false);
+  });
+});
+
+
+describe('shape group distribution options', () => {
+  it('cannot distribute when no shapes are selected', () => {
+    expect(getShapesOptions([]).can.distribute.horizontally).toBe(false);
+    expect(getShapesOptions([]).can.distribute.vertically).toBe(false);
+  });
+
+  it('cannot distribute when there are too few shapes selected', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(100, 100, 100, 50),
+    ];
+    expect(getShapesOptions(shapes).can.distribute.horizontally).toBe(false);
+    expect(getShapesOptions(shapes).can.distribute.vertically).toBe(false);
+  });
+
+  it('can horizontally distribute three or more items', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(100, 100, 70, 50),
+      dummyShape(200, 200, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.distribute.horizontally).toBe(true);
+  });
+
+  it('can vertically distribute three or more items', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(100, 100, 70, 50),
+      dummyShape(200, 200, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.distribute.horizontally).toBe(true);
+  });
+});
diff --git a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js
index 6dd9ded8..e9e04ef9 100644
--- a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js
+++ b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js
@@ -1,22 +1,22 @@
 import get from 'lodash/get';
 import passes from './predicate.js';
 
-const failMessage = (shape) => () => {
-  return this.utils.matcherHint('.toHaveBeenProgrammaticallyMoved', 'shape', '') +
+const failMessage = (shape, utils) => () => {
+  return utils.matcherHint('.toHaveBeenProgrammaticallyMoved', 'shape', '') +
     '\n\nExpected:' +
     '\n Shape to have been programmatically moved.' +
     '\n\nReceived: ' +
     (get(shape, 'translate.mock.calls', []).length === 0
       ? '\n Shape has not been moved.'
-      : `\n Move has been called, but with dx==0, dy==0: ${this.utils.printReceived(shape.translate.mock.calls)}`);
+      : `\n Move has been called, but with dx==0, dy==0: ${utils.printReceived(shape.translate.mock.calls)}`);
 };
 
-const passMessage = (shape) => () => {
+const passMessage = (shape, utils) => () => {
   return this.utils.matcherHint('.not.toHaveBeenProgrammaticallyMoved', 'shape', '') +
     '\n\nExpected:' +
     '\n Shape to not have been programmatically moved.' +
     '\n\nReceived: ' +
-    `\n Shape has been moved by: ${this.utils.printReceived(shape.translate.mock.calls)}`;
+    `\n Shape has been moved by: ${utils.printReceived(shape.translate.mock.calls)}`;
 };
 
 /**
@@ -29,8 +29,8 @@ export default function toHaveBeenProgrammaticallyMoved(shape) {
   }
 
   if (passes(shape)) {
-    return { pass: true, message: passMessage(shape) };
+    return { pass: true, message: passMessage(shape, this.utils) };
   }
 
-  return { pass: false, message: failMessage(shape) };
+  return { pass: false, message: failMessage(shape, this.utils) };
 }
diff --git a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js
index 1ef6c4a7..7cbf5b14 100644
--- a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js
+++ b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js
@@ -1,23 +1,23 @@
 import passes from './predicate';
 
-const failMessage = (shape, dx, dy) => () => {
+const failMessage = (shape, dx, dy, utils) => () => {
   const expected = [dx, dy];
-  return this.utils.matcherHint('.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
+  return utils.matcherHint('.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
     '\n\n' +
-    `Expected:\n Shape to have been programmatically moved by ${this.utils.printReceived(expected)}.` +
+    `Expected:\n Shape to have been programmatically moved by ${utils.printReceived(expected)}.` +
     '\n\nReceived: ' +
     (shape.translate.mock.calls.length === 0
       ? '\n Shape has not been moved.'
-      : `\n Shape has been moved by: ${this.utils.printReceived(shape.translate.mock.calls)}`);
+      : `\n Shape has been moved by: ${utils.printReceived(shape.translate.mock.calls)}`);
 };
 
-const passMessage = (shape, dx, dy) => () => {
+const passMessage = (shape, dx, dy, utils) => () => {
   const expected = [dx, dy];
-  return this.utils.matcherHint('.not.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
+  return utils.matcherHint('.not.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
     '\n\n' +
-    `Expected:\n Shape to not have been programmatically moved by ${this.utils.printReceived(expected)}.` +
+    `Expected:\n Shape to not have been programmatically moved by ${utils.printReceived(expected)}.` +
     '\n\nReceived: ' +
-    `\n Shape has been moved by: ${this.utils.printReceived(shape.translate.mock.calls)}`;
+    `\n Shape has been moved by: ${utils.printReceived(shape.translate.mock.calls)}`;
 };
 
 /**
@@ -32,8 +32,8 @@ export default function toHaveBeenProgrammaticallyMovedBy(shape, dx, dy) {
   }
 
   if (passes(shape, dx, dy)) {
-    return { pass: true, message: passMessage(shape, dx, dy) };
+    return { pass: true, message: passMessage(shape, dx, dy, this.utils) };
   }
 
-  return { pass: false, message: failMessage(shape, dx, dy) };
+  return { pass: false, message: failMessage(shape, dx, dy, this.utils) };
 }
diff --git a/tests/unit/utilities/dummies.js b/tests/unit/utilities/dummies.js
index 2de3cd56..f0e82b12 100644
--- a/tests/unit/utilities/dummies.js
+++ b/tests/unit/utilities/dummies.js
@@ -6,6 +6,7 @@ export function dummyShape(x, y, width, height) {
     size: () => {
       return { width, height };
     },
+    get: () => 'dummy',
     translate: jest.fn(),
   };
 }