Browse Source

Feature: Align elements (#2267)

Co-authored-by: Maximilian Massing <maximilian.massing@googlemail.com>
Co-authored-by: Sven Kube <github@sven-kube.de>
Co-authored-by: Maximilian Massing <massing@sipgate.de>
Sven Kube 4 years ago
parent
commit
856ab50090

+ 221 - 0
src/actions/actionAlign.tsx

@@ -0,0 +1,221 @@
+import React from "react";
+import { KEYS } from "../keys";
+import { t } from "../i18n";
+import { register } from "./register";
+import {
+  AlignBottomIcon,
+  AlignLeftIcon,
+  AlignRightIcon,
+  AlignTopIcon,
+  CenterHorizontallyIcon,
+  CenterVerticallyIcon,
+} from "../components/icons";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { getElementMap, getNonDeletedElements } from "../element";
+import { ToolButton } from "../components/ToolButton";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+import { alignElements, Alignment } from "../align";
+import { getShortcutKey } from "../utils";
+
+const enableActionGroup = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
+
+function alignSelectedElements(
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  alignment: Alignment,
+) {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  const updatedElements = alignElements(selectedElements, alignment);
+
+  const updatedElementsMap = getElementMap(updatedElements);
+
+  return elements.map((element) => updatedElementsMap[element.id] || element);
+}
+
+export const actionAlignTop = register({
+  name: "alignTop",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "start",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return (
+      event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP
+    );
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignTopIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignTop")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Up",
+      )}`}
+      aria-label={t("labels.alignTop")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignBottom = register({
+  name: "alignBottom",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "end",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return (
+      event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN
+    );
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignBottomIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignBottom")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Down",
+      )}`}
+      aria-label={t("labels.alignBottom")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignLeft = register({
+  name: "alignLeft",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "start",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return (
+      event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT
+    );
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignLeftIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignLeft")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Left",
+      )}`}
+      aria-label={t("labels.alignLeft")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignRight = register({
+  name: "alignRight",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "end",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return (
+      event[KEYS.CTRL_OR_CMD] &&
+      event.shiftKey &&
+      event.key === KEYS.ARROW_RIGHT
+    );
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<AlignRightIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.alignRight")} — ${getShortcutKey(
+        "CtrlOrCmd+Shift+Right",
+      )}`}
+      aria-label={t("labels.alignRight")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignVerticallyCentered = register({
+  name: "alignVerticallyCentered",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "center",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<CenterVerticallyIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={t("labels.centerVertically")}
+      aria-label={t("labels.centerVertically")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const actionAlignHorizontallyCentered = register({
+  name: "alignHorizontallyCentered",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: alignSelectedElements(elements, appState, {
+        position: "center",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={t("labels.centerHorizontally")}
+      aria-label={t("labels.centerHorizontally")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});

+ 9 - 0
src/actions/index.ts

@@ -51,3 +51,12 @@ export { actionGroup, actionUngroup } from "./actionGroup";
 export { actionGoToCollaborator } from "./actionNavigate";
 export { actionGoToCollaborator } from "./actionNavigate";
 
 
 export { actionAddToLibrary } from "./actionAddToLibrary";
 export { actionAddToLibrary } from "./actionAddToLibrary";
+
+export {
+  actionAlignTop,
+  actionAlignBottom,
+  actionAlignLeft,
+  actionAlignRight,
+  actionAlignVerticallyCentered,
+  actionAlignHorizontallyCentered,
+} from "./actionAlign";

+ 7 - 1
src/actions/types.ts

@@ -65,7 +65,13 @@ export type ActionName =
   | "ungroup"
   | "ungroup"
   | "goToCollaborator"
   | "goToCollaborator"
   | "addToLibrary"
   | "addToLibrary"
-  | "changeSharpness";
+  | "changeSharpness"
+  | "alignTop"
+  | "alignBottom"
+  | "alignLeft"
+  | "alignRight"
+  | "alignVerticallyCentered"
+  | "alignHorizontallyCentered";
 
 
 export interface Action {
 export interface Action {
   name: ActionName;
   name: ActionName;

+ 95 - 0
src/align.ts

@@ -0,0 +1,95 @@
+import { ExcalidrawElement } from "./element/types";
+import { newElementWith } from "./element/mutateElement";
+import { getCommonBounds } from "./element";
+
+interface Box {
+  minX: number;
+  minY: number;
+  maxX: number;
+  maxY: number;
+}
+
+export interface Alignment {
+  position: "start" | "center" | "end";
+  axis: "x" | "y";
+}
+
+export const alignElements = (
+  selectedElements: ExcalidrawElement[],
+  alignment: Alignment,
+): ExcalidrawElement[] => {
+  const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
+
+  const selectionBoundingBox = getCommonBoundingBox(selectedElements);
+
+  return groups.flatMap((group) => {
+    const translation = calculateTranslation(
+      group,
+      selectionBoundingBox,
+      alignment,
+    );
+    return group.map((element) =>
+      newElementWith(element, {
+        x: element.x + translation.x,
+        y: element.y + translation.y,
+      }),
+    );
+  });
+};
+
+export const getMaximumGroups = (
+  elements: ExcalidrawElement[],
+): ExcalidrawElement[][] => {
+  const groups: Map<String, ExcalidrawElement[]> = new Map<
+    String,
+    ExcalidrawElement[]
+  >();
+
+  elements.forEach((element: ExcalidrawElement) => {
+    const groupId =
+      element.groupIds.length === 0
+        ? element.id
+        : element.groupIds[element.groupIds.length - 1];
+
+    const currentGroupMembers = groups.get(groupId) || [];
+
+    groups.set(groupId, [...currentGroupMembers, element]);
+  });
+
+  return Array.from(groups.values());
+};
+
+const calculateTranslation = (
+  group: ExcalidrawElement[],
+  selectionBoundingBox: Box,
+  { axis, position }: Alignment,
+): { x: number; y: number } => {
+  const groupBoundingBox = getCommonBoundingBox(group);
+
+  const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
+    axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
+
+  const noTranslation = { x: 0, y: 0 };
+  if (position === "start") {
+    return {
+      ...noTranslation,
+      [axis]: selectionBoundingBox[min] - groupBoundingBox[min],
+    };
+  } else if (position === "end") {
+    return {
+      ...noTranslation,
+      [axis]: selectionBoundingBox[max] - groupBoundingBox[max],
+    };
+  } // else if (position === "center") {
+  return {
+    ...noTranslation,
+    [axis]:
+      (selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
+      (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
+  };
+};
+
+function getCommonBoundingBox(elements: ExcalidrawElement[]): Box {
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+  return { minX, minY, maxX, maxY };
+}

+ 15 - 0
src/components/Actions.tsx

@@ -83,6 +83,21 @@ export const SelectedShapeActions = ({
           {renderAction("bringForward")}
           {renderAction("bringForward")}
         </div>
         </div>
       </fieldset>
       </fieldset>
+
+      {targetElements.length > 1 && (
+        <fieldset>
+          <legend>{t("labels.align")}</legend>
+          <div className="buttonList">
+            {renderAction("alignLeft")}
+            {renderAction("alignHorizontallyCentered")}
+            {renderAction("alignRight")}
+            {renderAction("alignTop")}
+            {renderAction("alignVerticallyCentered")}
+            {renderAction("alignBottom")}
+          </div>
+        </fieldset>
+      )}
+
       {!isMobile && !isEditing && targetElements.length > 0 && (
       {!isMobile && !isEditing && targetElements.length > 0 && (
         <fieldset>
         <fieldset>
           <legend>{t("labels.actions")}</legend>
           <legend>{t("labels.actions")}</legend>

+ 16 - 0
src/components/ShortcutsDialog.tsx

@@ -281,6 +281,22 @@ export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
                 shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
                 shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
               />
               />
               <Shortcut
               <Shortcut
+                label={t("labels.alignTop")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
+              />
+              <Shortcut
+                label={t("labels.alignBottom")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
+              />
+              <Shortcut
+                label={t("labels.alignLeft")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
+              />
+              <Shortcut
+                label={t("labels.alignRight")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
+              />
+              <Shortcut
                 label={t("labels.duplicateSelection")}
                 label={t("labels.duplicateSelection")}
                 shortcuts={[
                 shortcuts={[
                   getShortcutKey("CtrlOrCmd+D"),
                   getShortcutKey("CtrlOrCmd+D"),

+ 137 - 0
src/components/icons.tsx

@@ -202,6 +202,143 @@ export const SendToBackIcon = React.memo(
     ),
     ),
 );
 );
 
 
+//
+// Align action icons created from scratch to match those of z-index actions
+//
+export const AlignTopIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="M 2,5 H 22"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeLinecap="round"
+        />
+        <path
+          d="M 6,7 C 5.446,7 5,7.446 5,8 v 9.999992 c 0,0.554 0.446,1 1,1 h 3.0000001 c 0.554,0 0.9999999,-0.446 0.9999999,-1 V 8 C 10,7.446 9.5540001,7 9.0000001,7 Z m 9,0 c -0.554,0 -1,0.446 -1,1 v 5.999992 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 V 8 C 19,7.446 18.554,7 18,7 Z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
+export const AlignBottomIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="M 2,19 H 22"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeLinecap="round"
+        />
+        <path
+          d="m 6,16.999992 c -0.554,0 -1,-0.446 -1,-1 V 6 C 5,5.446 5.446,5 6,5 H 9.0000001 C 9.5540001,5 10,5.446 10,6 v 9.999992 c 0,0.554 -0.4459999,1 -0.9999999,1 z m 9,0 c -0.554,0 -1,-0.446 -1,-1 V 10 c 0,-0.554 0.446,-1 1,-1 h 3 c 0.554,0 1,0.446 1,1 v 5.999992 c 0,0.554 -0.446,1 -1,1 z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
+export const AlignLeftIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="M 5,2 V 22"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeLinecap="round"
+        />
+        <path
+          d="m 7.000004,5.999996 c 0,-0.554 0.446,-1 1,-1 h 9.999992 c 0.554,0 1,0.446 1,1 v 3.0000001 c 0,0.554 -0.446,0.9999999 -1,0.9999999 H 8.000004 c -0.554,0 -1,-0.4459999 -1,-0.9999999 z m 0,9 c 0,-0.554 0.446,-1 1,-1 h 5.999992 c 0.554,0 1,0.446 1,1 v 3 c 0,0.554 -0.446,1 -1,1 H 8.000004 c -0.554,0 -1,-0.446 -1,-1 z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
+export const AlignRightIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="M 19,2 V 22"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeLinecap="round"
+        />
+        <path
+          d="m 16.999996,5.999996 c 0,-0.554 -0.446,-1 -1,-1 H 6.000004 c -0.554,0 -1,0.446 -1,1 v 3.0000001 c 0,0.554 0.446,0.9999999 1,0.9999999 h 9.999992 c 0.554,0 1,-0.4459999 1,-0.9999999 z m 0,9 c 0,-0.554 -0.446,-1 -1,-1 h -5.999992 c -0.554,0 -1,0.446 -1,1 v 3 c 0,0.554 0.446,1 1,1 h 5.999992 c 0.554,0 1,-0.446 1,-1 z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
+export const CenterVerticallyIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="m 5.000004,16.999996 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -10 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z m 9,-2 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -6 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+        <path
+          d="M 2,12 H 22"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeDasharray="1, 2.8"
+          strokeLinecap="round"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
+export const CenterHorizontallyIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="M 7 5 C 6.446 5 6 5.446 6 6 L 6 9 C 6 9.554 6.446 10 7 10 L 17 10 C 17.554 10 18 9.554 18 9 L 18 6 C 18 5.446 17.554 5 17 5 L 7 5 z M 9 14 C 8.446 14 8 14.446 8 15 L 8 18 C 8 18.554 8.446 19 9 19 L 15 19 C 15.554 19 16 18.554 16 18 L 16 15 C 16 14.446 15.554 14 15 14 L 9 14 z "
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+        <path
+          d="M 12,2 V 22"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeDasharray="1, 2.8"
+          strokeLinecap="round"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
 export const users = createIcon(
 export const users = createIcon(
   "M192 256c61.9 0 112-50.1 112-112S253.9 32 192 32 80 82.1 80 144s50.1 112 112 112zm76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C51.6 288 0 339.6 0 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2zM480 256c53 0 96-43 96-96s-43-96-96-96-96 43-96 96 43 96 96 96zm48 32h-3.8c-13.9 4.8-28.6 8-44.2 8s-30.3-3.2-44.2-8H432c-20.4 0-39.2 5.9-55.7 15.4 24.4 26.3 39.7 61.2 39.7 99.8v38.4c0 2.2-.5 4.3-.6 6.4H592c26.5 0 48-21.5 48-48 0-61.9-50.1-112-112-112z",
   "M192 256c61.9 0 112-50.1 112-112S253.9 32 192 32 80 82.1 80 144s50.1 112 112 112zm76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C51.6 288 0 339.6 0 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2zM480 256c53 0 96-43 96-96s-43-96-96-96-96 43-96 96 43 96 96 96zm48 32h-3.8c-13.9 4.8-28.6 8-44.2 8s-30.3-3.2-44.2-8H432c-20.4 0-39.2 5.9-55.7 15.4 24.4 26.3 39.7 61.2 39.7 99.8v38.4c0 2.2-.5 4.3-.6 6.4H592c26.5 0 48-21.5 48-48 0-61.9-50.1-112-112-112z",
   { width: 640, height: 512, mirror: true },
   { width: 640, height: 512, mirror: true },

+ 8 - 1
src/locales/en.json

@@ -74,7 +74,14 @@
     "addToLibrary": "Add to library",
     "addToLibrary": "Add to library",
     "removeFromLibrary": "Remove from library",
     "removeFromLibrary": "Remove from library",
     "libraryLoadingMessage": "Loading library...",
     "libraryLoadingMessage": "Loading library...",
-    "loadingScene": "Loading scene..."
+    "loadingScene": "Loading scene...",
+    "align": "Align",
+    "alignTop": "Align top",
+    "alignBottom": "Align bottom",
+    "alignLeft": "Align left",
+    "alignRight": "Align right",
+    "centerVertically": "Center vertically",
+    "centerHorizontally": "Center horizontally"
   },
   },
   "buttons": {
   "buttons": {
     "clearReset": "Reset the canvas",
     "clearReset": "Reset the canvas",

+ 579 - 0
src/tests/align.test.tsx

@@ -0,0 +1,579 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { render } from "./test-utils";
+import App from "../components/App";
+import { setLanguage } from "../i18n";
+import { UI, Pointer, Keyboard } from "./helpers/ui";
+import { API } from "./helpers/api";
+import { KEYS } from "../keys";
+import {
+  actionAlignVerticallyCentered,
+  actionAlignHorizontallyCentered,
+  actionGroup,
+  actionAlignTop,
+  actionAlignBottom,
+  actionAlignLeft,
+  actionAlignRight,
+} from "../actions";
+
+const { h } = window;
+
+const mouse = new Pointer("mouse");
+
+beforeEach(async () => {
+  // Unmount ReactDOM from root
+  ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+  mouse.reset();
+
+  await setLanguage("en.json");
+  render(<App />);
+});
+
+function createAndSelectTwoRectangles() {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+
+  UI.clickTool("rectangle");
+  mouse.down(10, 10);
+  mouse.up(100, 100);
+
+  // Select the first element.
+  // The second rectangle is already reselected because it was the last element created
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+}
+
+function createAndSelectTwoRectanglesWithDifferentSizes() {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+
+  UI.clickTool("rectangle");
+  mouse.down(10, 10);
+  mouse.up(110, 110);
+
+  // Select the first element.
+  // The second rectangle is already reselected because it was the last element created
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+}
+
+it("aligns two objects correctly to the top", () => {
+  createAndSelectTwoRectangles();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+
+  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+    Keyboard.keyPress(KEYS.ARROW_UP);
+  });
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(0);
+});
+
+it("aligns two objects correctly to the bottom", () => {
+  createAndSelectTwoRectangles();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+
+  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+    Keyboard.keyPress(KEYS.ARROW_DOWN);
+  });
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(110);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+});
+
+it("aligns two objects correctly to the left", () => {
+  createAndSelectTwoRectangles();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+
+  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+    Keyboard.keyPress(KEYS.ARROW_LEFT);
+  });
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(0);
+
+  // Check if y position did not change
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+});
+
+it("aligns two objects correctly to the right", () => {
+  createAndSelectTwoRectangles();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+
+  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+    Keyboard.keyPress(KEYS.ARROW_RIGHT);
+  });
+
+  expect(API.getSelectedElements()[0].x).toEqual(110);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  // Check if y position did not change
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+});
+
+it("centers two objects with different sizes correctly vertically", () => {
+  createAndSelectTwoRectanglesWithDifferentSizes();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+
+  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(60);
+  expect(API.getSelectedElements()[1].y).toEqual(55);
+});
+
+it("centers two objects with different sizes correctly horizontally", () => {
+  createAndSelectTwoRectanglesWithDifferentSizes();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(110);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+
+  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+
+  expect(API.getSelectedElements()[0].x).toEqual(60);
+  expect(API.getSelectedElements()[1].x).toEqual(55);
+
+  // Check if y position did not change
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(110);
+});
+
+function createAndSelectGroupAndRectangle() {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+
+  UI.clickTool("rectangle");
+  mouse.down(0, 0);
+  mouse.up(100, 100);
+
+  // Select the first element.
+  // The second rectangle is already reselected because it was the last element created
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+
+  h.app.actionManager.executeAction(actionGroup);
+
+  mouse.reset();
+  UI.clickTool("rectangle");
+  mouse.down(200, 200);
+  mouse.up(100, 100);
+
+  // Add the created group to the current selection
+  mouse.restorePosition(0, 0);
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+}
+
+it("aligns a group with another element correctly to the top", () => {
+  createAndSelectGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+
+  h.app.actionManager.executeAction(actionAlignTop);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(0);
+});
+
+it("aligns a group with another element correctly to the bottom", () => {
+  createAndSelectGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+
+  h.app.actionManager.executeAction(actionAlignBottom);
+
+  expect(API.getSelectedElements()[0].y).toEqual(100);
+  expect(API.getSelectedElements()[1].y).toEqual(200);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+});
+
+it("aligns a group with another element correctly to the left", () => {
+  createAndSelectGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+
+  h.app.actionManager.executeAction(actionAlignLeft);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(0);
+});
+
+it("aligns a group with another element correctly to the right", () => {
+  createAndSelectGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+
+  h.app.actionManager.executeAction(actionAlignRight);
+
+  expect(API.getSelectedElements()[0].x).toEqual(100);
+  expect(API.getSelectedElements()[1].x).toEqual(200);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+});
+
+it("centers a group with another element correctly vertically", () => {
+  createAndSelectGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+
+  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+
+  expect(API.getSelectedElements()[0].y).toEqual(50);
+  expect(API.getSelectedElements()[1].y).toEqual(150);
+  expect(API.getSelectedElements()[2].y).toEqual(100);
+});
+
+it("centers a group with another element correctly horizontally", () => {
+  createAndSelectGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+
+  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+
+  expect(API.getSelectedElements()[0].x).toEqual(50);
+  expect(API.getSelectedElements()[1].x).toEqual(150);
+  expect(API.getSelectedElements()[2].x).toEqual(100);
+});
+
+function createAndSelectTwoGroups() {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+
+  UI.clickTool("rectangle");
+  mouse.down(0, 0);
+  mouse.up(100, 100);
+
+  // Select the first element.
+  // The second rectangle is already selected because it was the last element created
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+
+  h.app.actionManager.executeAction(actionGroup);
+
+  mouse.reset();
+  UI.clickTool("rectangle");
+  mouse.down(200, 200);
+  mouse.up(100, 100);
+
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+
+  mouse.restorePosition(200, 200);
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+
+  h.app.actionManager.executeAction(actionGroup);
+
+  // Select the first group.
+  // The second group is already selected because it was the last group created
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+}
+
+it("aligns two groups correctly to the top", () => {
+  createAndSelectTwoGroups();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignTop);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(0);
+  expect(API.getSelectedElements()[3].y).toEqual(100);
+});
+
+it("aligns two groups correctly to the bottom", () => {
+  createAndSelectTwoGroups();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignBottom);
+
+  expect(API.getSelectedElements()[0].y).toEqual(200);
+  expect(API.getSelectedElements()[1].y).toEqual(300);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+});
+
+it("aligns two groups correctly to the left", () => {
+  createAndSelectTwoGroups();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignLeft);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(0);
+  expect(API.getSelectedElements()[3].x).toEqual(100);
+});
+
+it("aligns two groups correctly to the right", () => {
+  createAndSelectTwoGroups();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignRight);
+
+  expect(API.getSelectedElements()[0].x).toEqual(200);
+  expect(API.getSelectedElements()[1].x).toEqual(300);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+});
+
+it("centers two groups correctly vertically", () => {
+  createAndSelectTwoGroups();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+
+  expect(API.getSelectedElements()[0].y).toEqual(100);
+  expect(API.getSelectedElements()[1].y).toEqual(200);
+  expect(API.getSelectedElements()[2].y).toEqual(100);
+  expect(API.getSelectedElements()[3].y).toEqual(200);
+});
+
+it("centers two groups correctly horizontally", () => {
+  createAndSelectTwoGroups();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+
+  expect(API.getSelectedElements()[0].x).toEqual(100);
+  expect(API.getSelectedElements()[1].x).toEqual(200);
+  expect(API.getSelectedElements()[2].x).toEqual(100);
+  expect(API.getSelectedElements()[3].x).toEqual(200);
+});
+
+function createAndSelectNestedGroupAndRectangle() {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+
+  UI.clickTool("rectangle");
+  mouse.down(0, 0);
+  mouse.up(100, 100);
+
+  // Select the first element.
+  // The second rectangle is already reselected because it was the last element created
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+
+  // Create first group of rectangles
+  h.app.actionManager.executeAction(actionGroup);
+
+  mouse.reset();
+  UI.clickTool("rectangle");
+  mouse.down(200, 200);
+  mouse.up(100, 100);
+
+  // Add group to current selection
+  mouse.restorePosition(0, 0);
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+
+  // Create the nested group
+  h.app.actionManager.executeAction(actionGroup);
+
+  mouse.reset();
+  UI.clickTool("rectangle");
+  mouse.down(300, 300);
+  mouse.up(100, 100);
+
+  // Select the nested group, the rectangle is already selected
+  mouse.reset();
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click();
+  });
+}
+
+it("aligns nested group and other element correctly to the top", () => {
+  createAndSelectNestedGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignTop);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(0);
+});
+
+it("aligns nested group and other element correctly to the bottom", () => {
+  createAndSelectNestedGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignBottom);
+
+  expect(API.getSelectedElements()[0].y).toEqual(100);
+  expect(API.getSelectedElements()[1].y).toEqual(200);
+  expect(API.getSelectedElements()[2].y).toEqual(300);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+});
+
+it("aligns nested group and other element correctly to the left", () => {
+  createAndSelectNestedGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignLeft);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(0);
+});
+
+it("aligns nested group and other element correctly to the right", () => {
+  createAndSelectNestedGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignRight);
+
+  expect(API.getSelectedElements()[0].x).toEqual(100);
+  expect(API.getSelectedElements()[1].x).toEqual(200);
+  expect(API.getSelectedElements()[2].x).toEqual(300);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+});
+
+it("centers nested group and other element correctly vertically", () => {
+  createAndSelectNestedGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+  expect(API.getSelectedElements()[1].y).toEqual(100);
+  expect(API.getSelectedElements()[2].y).toEqual(200);
+  expect(API.getSelectedElements()[3].y).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignVerticallyCentered);
+
+  expect(API.getSelectedElements()[0].y).toEqual(50);
+  expect(API.getSelectedElements()[1].y).toEqual(150);
+  expect(API.getSelectedElements()[2].y).toEqual(250);
+  expect(API.getSelectedElements()[3].y).toEqual(150);
+});
+
+it("centers nested group and other element correctly horizontally", () => {
+  createAndSelectNestedGroupAndRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+  expect(API.getSelectedElements()[1].x).toEqual(100);
+  expect(API.getSelectedElements()[2].x).toEqual(200);
+  expect(API.getSelectedElements()[3].x).toEqual(300);
+
+  h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
+
+  expect(API.getSelectedElements()[0].x).toEqual(50);
+  expect(API.getSelectedElements()[1].x).toEqual(150);
+  expect(API.getSelectedElements()[2].x).toEqual(250);
+  expect(API.getSelectedElements()[3].x).toEqual(150);
+});