Browse Source

Group/ungroup (#1648)

Co-authored-by: dwelle <luzar.david@gmail.com>
Pete Hunt 5 years ago
parent
commit
61e5b66dac

+ 10 - 4
src/actions/actionDuplicateSelection.tsx

@@ -12,15 +12,21 @@ import { getShortcutKey } from "../utils";
 export const actionDuplicateSelection = register({
   name: "duplicateSelection",
   perform: (elements, appState) => {
+    const groupIdMap = new Map();
     return {
       appState,
       elements: elements.reduce(
         (acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
           if (appState.selectedElementIds[element.id]) {
-            const newElement = duplicateElement(element, {
-              x: element.x + 10,
-              y: element.y + 10,
-            });
+            const newElement = duplicateElement(
+              appState.editingGroupId,
+              groupIdMap,
+              element,
+              {
+                x: element.x + 10,
+                y: element.y + 10,
+              },
+            );
             appState.selectedElementIds[newElement.id] = true;
             delete appState.selectedElementIds[element.id];
             return acc.concat([element, newElement]);

+ 119 - 0
src/actions/actionGroup.ts

@@ -0,0 +1,119 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import nanoid from "nanoid";
+import { newElementWith } from "../element/mutateElement";
+import { getSelectedElements } from "../scene";
+import {
+  getSelectedGroupIds,
+  selectGroup,
+  selectGroupsForSelectedElements,
+  getElementsInGroup,
+  addToGroup,
+  removeFromSelectedGroups,
+} from "../groups";
+import { getNonDeletedElements } from "../element";
+
+export const actionGroup = register({
+  name: "group",
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+    if (selectedElements.length < 2) {
+      // nothing to group
+      return { appState, elements, commitToHistory: false };
+    }
+    // if everything is already grouped into 1 group, there is nothing to do
+    const selectedGroupIds = getSelectedGroupIds(appState);
+    if (selectedGroupIds.length === 1) {
+      const selectedGroupId = selectedGroupIds[0];
+      const elementIdsInGroup = new Set(
+        getElementsInGroup(elements, selectedGroupId).map(
+          (element) => element.id,
+        ),
+      );
+      const selectedElementIds = new Set(
+        selectedElements.map((element) => element.id),
+      );
+      const combinedSet = new Set([
+        ...Array.from(elementIdsInGroup),
+        ...Array.from(selectedElementIds),
+      ]);
+      if (combinedSet.size === elementIdsInGroup.size) {
+        // no incremental ids in the selected ids
+        return { appState, elements, commitToHistory: false };
+      }
+    }
+    const newGroupId = nanoid();
+    const updatedElements = elements.map((element) => {
+      if (!appState.selectedElementIds[element.id]) {
+        return element;
+      }
+      return newElementWith(element, {
+        groupIds: addToGroup(
+          element.groupIds,
+          newGroupId,
+          appState.editingGroupId,
+        ),
+      });
+    });
+    return {
+      appState: selectGroup(
+        newGroupId,
+        { ...appState, selectedGroupIds: {} },
+        getNonDeletedElements(updatedElements),
+      ),
+      elements: updatedElements,
+      commitToHistory: true,
+    };
+  },
+  contextMenuOrder: 4,
+  contextItemLabel: "labels.group",
+  keyTest: (event) => {
+    return (
+      !event.shiftKey &&
+      event[KEYS.CTRL_OR_CMD] &&
+      event.keyCode === KEYS.G_KEY_CODE
+    );
+  },
+});
+
+export const actionUngroup = register({
+  name: "ungroup",
+  perform: (elements, appState) => {
+    const groupIds = getSelectedGroupIds(appState);
+    if (groupIds.length === 0) {
+      return { appState, elements, commitToHistory: false };
+    }
+    const nextElements = elements.map((element) => {
+      const nextGroupIds = removeFromSelectedGroups(
+        element.groupIds,
+        appState.selectedGroupIds,
+      );
+      if (nextGroupIds.length === element.groupIds.length) {
+        return element;
+      }
+      return newElementWith(element, {
+        groupIds: nextGroupIds,
+      });
+    });
+    return {
+      appState: selectGroupsForSelectedElements(
+        { ...appState, selectedGroupIds: {} },
+        getNonDeletedElements(nextElements),
+      ),
+      elements: nextElements,
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return (
+      event.shiftKey &&
+      event[KEYS.CTRL_OR_CMD] &&
+      event.keyCode === KEYS.G_KEY_CODE
+    );
+  },
+  contextMenuOrder: 5,
+  contextItemLabel: "labels.ungroup",
+});

+ 15 - 9
src/actions/actionSelectAll.ts

@@ -1,19 +1,25 @@
 import { KEYS } from "../keys";
 import { register } from "./register";
+import { selectGroupsForSelectedElements } from "../groups";
+import { getNonDeletedElements } from "../element";
 
 export const actionSelectAll = register({
   name: "selectAll",
   perform: (elements, appState) => {
     return {
-      appState: {
-        ...appState,
-        selectedElementIds: elements.reduce((map, element) => {
-          if (!element.isDeleted) {
-            map[element.id] = true;
-          }
-          return map;
-        }, {} as any),
-      },
+      appState: selectGroupsForSelectedElements(
+        {
+          ...appState,
+          editingGroupId: null,
+          selectedElementIds: elements.reduce((map, element) => {
+            if (!element.isDeleted) {
+              map[element.id] = true;
+            }
+            return map;
+          }, {} as any),
+        },
+        getNonDeletedElements(elements),
+      ),
       commitToHistory: true,
     };
   },

+ 2 - 0
src/actions/index.ts

@@ -44,3 +44,5 @@ export {
   actionFullScreen,
   actionShortcuts,
 } from "./actionMenu";
+
+export { actionGroup, actionUngroup } from "./actionGroup";

+ 3 - 1
src/actions/types.ts

@@ -55,7 +55,9 @@ export type ActionName =
   | "changeFontFamily"
   | "changeTextAlign"
   | "toggleFullScreen"
-  | "toggleShortcuts";
+  | "toggleShortcuts"
+  | "group"
+  | "ungroup";
 
 export interface Action {
   name: ActionName;

+ 2 - 0
src/appState.ts

@@ -48,6 +48,8 @@ export const getDefaultAppState = (): AppState => {
     shouldCacheIgnoreZoom: false,
     showShortcutsDialog: false,
     zenModeEnabled: false,
+    editingGroupId: null,
+    selectedGroupIds: {},
   };
 };
 

+ 136 - 27
src/components/App.tsx

@@ -131,6 +131,12 @@ import {
 } from "../data/localStorage";
 
 import throttle from "lodash.throttle";
+import {
+  getSelectedGroupIds,
+  selectGroupsForSelectedElements,
+  isElementInGroup,
+  getSelectedGroupIdForElement,
+} from "../groups";
 
 /**
  * @param func handler taking at most single parameter (event).
@@ -704,9 +710,10 @@ class App extends React.Component<any, AppState> {
 
     const dx = x - elementsCenterX;
     const dy = y - elementsCenterY;
+    const groupIdMap = new Map();
 
     const newElements = clipboardElements.map((element) =>
-      duplicateElement(element, {
+      duplicateElement(this.state.editingGroupId, groupIdMap, element, {
         x: element.x + dx - minX,
         y: element.y + dy - minY,
       }),
@@ -1212,7 +1219,11 @@ class App extends React.Component<any, AppState> {
         resetCursor();
       } else {
         setCursorForShape(this.state.elementType);
-        this.setState({ selectedElementIds: {} });
+        this.setState({
+          selectedElementIds: {},
+          selectedGroupIds: {},
+          editingGroupId: null,
+        });
       }
       isHoldingSpace = false;
     }
@@ -1226,7 +1237,12 @@ class App extends React.Component<any, AppState> {
       document.activeElement.blur();
     }
     if (elementType !== "selection") {
-      this.setState({ elementType, selectedElementIds: {} });
+      this.setState({
+        elementType,
+        selectedElementIds: {},
+        selectedGroupIds: {},
+        editingGroupId: null,
+      });
     } else {
       this.setState({ elementType });
     }
@@ -1337,7 +1353,11 @@ class App extends React.Component<any, AppState> {
       }),
     });
     // deselect all other elements when inserting text
-    this.setState({ selectedElementIds: {} });
+    this.setState({
+      selectedElementIds: {},
+      selectedGroupIds: {},
+      editingGroupId: null,
+    });
 
     // do an initial update to re-initialize element position since we were
     //  modifying element's x/y for sake of editor (case: syncing to remote)
@@ -1459,8 +1479,6 @@ class App extends React.Component<any, AppState> {
       return;
     }
 
-    resetCursor();
-
     const { x, y } = viewportCoordsToSceneCoords(
       event,
       this.state,
@@ -1468,6 +1486,40 @@ class App extends React.Component<any, AppState> {
       window.devicePixelRatio,
     );
 
+    const selectedGroupIds = getSelectedGroupIds(this.state);
+
+    if (selectedGroupIds.length > 0) {
+      const elements = globalSceneState.getElements();
+      const hitElement = getElementAtPosition(
+        elements,
+        this.state,
+        x,
+        y,
+        this.state.zoom,
+      );
+
+      const selectedGroupId =
+        hitElement &&
+        getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
+
+      if (selectedGroupId) {
+        this.setState((prevState) =>
+          selectGroupsForSelectedElements(
+            {
+              ...prevState,
+              editingGroupId: selectedGroupId,
+              selectedElementIds: { [hitElement!.id]: true },
+              selectedGroupIds: {},
+            },
+            globalSceneState.getElements(),
+          ),
+        );
+        return;
+      }
+    }
+
+    resetCursor();
+
     this.startTextEditing({
       x: x,
       y: y,
@@ -1942,7 +1994,16 @@ class App extends React.Component<any, AppState> {
           !(hitElement && this.state.selectedElementIds[hitElement.id]) &&
           !event.shiftKey
         ) {
-          this.setState({ selectedElementIds: {} });
+          this.setState((prevState) => ({
+            selectedElementIds: {},
+            selectedGroupIds: {},
+            editingGroupId:
+              prevState.editingGroupId &&
+              hitElement &&
+              isElementInGroup(hitElement, prevState.editingGroupId)
+                ? prevState.editingGroupId
+                : null,
+          }));
         }
 
         // If we click on something
@@ -1952,12 +2013,32 @@ class App extends React.Component<any, AppState> {
           // otherwise, it will trigger selection based on current
           // state of the box
           if (!this.state.selectedElementIds[hitElement.id]) {
-            this.setState((prevState) => ({
-              selectedElementIds: {
-                ...prevState.selectedElementIds,
-                [hitElement!.id]: true,
-              },
-            }));
+            // if we are currently editing a group, treat all selections outside of the group
+            // as exiting editing mode.
+            if (
+              this.state.editingGroupId &&
+              !isElementInGroup(hitElement, this.state.editingGroupId)
+            ) {
+              this.setState({
+                selectedElementIds: {},
+                selectedGroupIds: {},
+                editingGroupId: null,
+              });
+              return;
+            }
+            this.setState((prevState) => {
+              return selectGroupsForSelectedElements(
+                {
+                  ...prevState,
+                  selectedElementIds: {
+                    ...prevState.selectedElementIds,
+                    [hitElement!.id]: true,
+                  },
+                },
+                globalSceneState.getElements(),
+              );
+            });
+            // TODO: this is strange...
             globalSceneState.replaceAllElements(
               globalSceneState.getElementsIncludingDeleted(),
             );
@@ -1966,7 +2047,11 @@ class App extends React.Component<any, AppState> {
         }
       }
     } else {
-      this.setState({ selectedElementIds: {} });
+      this.setState({
+        selectedElementIds: {},
+        selectedGroupIds: {},
+        editingGroupId: null,
+      });
     }
 
     if (this.state.elementType === "text") {
@@ -2218,6 +2303,7 @@ class App extends React.Component<any, AppState> {
 
             const nextElements = [];
             const elementsToAppend = [];
+            const groupIdMap = new Map();
             for (const element of globalSceneState.getElementsIncludingDeleted()) {
               if (
                 this.state.selectedElementIds[element.id] ||
@@ -2225,7 +2311,11 @@ class App extends React.Component<any, AppState> {
                 //  updated yet by the time this mousemove event is fired
                 (element.id === hitElement.id && hitElementWasAddedToSelection)
               ) {
-                const duplicatedElement = duplicateElement(element);
+                const duplicatedElement = duplicateElement(
+                  this.state.editingGroupId,
+                  groupIdMap,
+                  element,
+                );
                 mutateElement(duplicatedElement, {
                   x: duplicatedElement.x + (originX - lastX),
                   y: duplicatedElement.y + (originY - lastY),
@@ -2316,21 +2406,31 @@ class App extends React.Component<any, AppState> {
       if (this.state.elementType === "selection") {
         const elements = globalSceneState.getElements();
         if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
-          this.setState({ selectedElementIds: {} });
+          this.setState({
+            selectedElementIds: {},
+            selectedGroupIds: {},
+            editingGroupId: null,
+          });
         }
         const elementsWithinSelection = getElementsWithinSelection(
           elements,
           draggingElement,
         );
-        this.setState((prevState) => ({
-          selectedElementIds: {
-            ...prevState.selectedElementIds,
-            ...elementsWithinSelection.reduce((map, element) => {
-              map[element.id] = true;
-              return map;
-            }, {} as any),
-          },
-        }));
+        this.setState((prevState) =>
+          selectGroupsForSelectedElements(
+            {
+              ...prevState,
+              selectedElementIds: {
+                ...prevState.selectedElementIds,
+                ...elementsWithinSelection.reduce((map, element) => {
+                  map[element.id] = true;
+                  return map;
+                }, {} as any),
+              },
+            },
+            globalSceneState.getElements(),
+          ),
+        );
       }
     });
 
@@ -2445,7 +2545,12 @@ class App extends React.Component<any, AppState> {
       // If click occurred and elements were dragged or some element
       // was added to selection (on pointerdown phase) we need to keep
       // selection unchanged
-      if (hitElement && !draggingOccurred && !hitElementWasAddedToSelection) {
+      if (
+        getSelectedGroupIds(this.state).length === 0 &&
+        hitElement &&
+        !draggingOccurred &&
+        !hitElementWasAddedToSelection
+      ) {
         if (childEvent.shiftKey) {
           this.setState((prevState) => ({
             selectedElementIds: {
@@ -2462,7 +2567,11 @@ class App extends React.Component<any, AppState> {
 
       if (draggingElement === null) {
         // if no element is clicked, clear the selection and redraw
-        this.setState({ selectedElementIds: {} });
+        this.setState({
+          selectedElementIds: {},
+          selectedGroupIds: {},
+          editingGroupId: null,
+        });
         return;
       }
 

+ 8 - 0
src/components/ShortcutsDialog.tsx

@@ -318,6 +318,14 @@ export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
                 label={t("buttons.redo")}
                 shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
               />
+              <Shortcut
+                label={t("labels.group")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
+              />
+              <Shortcut
+                label={t("labels.ungroup")}
+                shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
+              />
             </ShortcutIsland>
           </Column>
         </Columns>

+ 3 - 1
src/data/restore.ts

@@ -71,7 +71,8 @@ export const restore = (
 
       return {
         ...element,
-        // all elements must have version > 0 so getDrawingVersion() will pick up newly added elements
+        // all elements must have version > 0 so getDrawingVersion() will pick
+        //  up newly added elements
         version: element.version || 1,
         id: element.id || randomId(),
         isDeleted: false,
@@ -84,6 +85,7 @@ export const restore = (
             ? 100
             : element.opacity,
         angle: element.angle ?? 0,
+        groupIds: element.groupIds || [],
       };
     });
 

+ 2 - 2
src/element/newElement.test.ts

@@ -45,7 +45,7 @@ it("clones arrow element", () => {
     ],
   });
 
-  const copy = duplicateElement(element);
+  const copy = duplicateElement(null, new Map(), element);
 
   assertCloneObjects(element, copy);
 
@@ -82,7 +82,7 @@ it("clones text element", () => {
     textAlign: "left",
   });
 
-  const copy = duplicateElement(element);
+  const copy = duplicateElement(null, new Map(), element);
 
   assertCloneObjects(element, copy);
 

+ 30 - 0
src/element/newElement.ts

@@ -5,10 +5,13 @@ import {
   ExcalidrawGenericElement,
   NonDeleted,
   TextAlign,
+  GroupId,
 } from "../element/types";
 import { measureText } from "../utils";
 import { randomInteger, randomId } from "../random";
 import { newElementWith } from "./mutateElement";
+import nanoid from "nanoid";
+import { getNewGroupIdsForDuplication } from "../groups";
 
 type ElementConstructorOpts = {
   x: ExcalidrawGenericElement["x"];
@@ -61,6 +64,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
   version: rest.version || 1,
   versionNonce: rest.versionNonce ?? 0,
   isDeleted: false as false,
+  groupIds: [],
 });
 
 export const newElement = (
@@ -148,13 +152,39 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
   return val;
 };
 
+/**
+ * Duplicate an element, often used in the alt-drag operation.
+ * Note that this method has gotten a bit complicated since the
+ * introduction of gruoping/ungrouping elements.
+ * @param editingGroupId The current group being edited. The new
+ *                       element will inherit this group and its
+ *                       parents.
+ * @param groupIdMapForOperation A Map that maps old group IDs to
+ *                               duplicated ones. If you are duplicating
+ *                               multiple elements at once, share this map
+ *                               amongst all of them
+ * @param element Element to duplicate
+ * @param overrides Any element properties to override
+ */
 export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
+  editingGroupId: GroupId | null,
+  groupIdMapForOperation: Map<GroupId, GroupId>,
   element: TElement,
   overrides?: Partial<TElement>,
 ): TElement => {
   let copy: TElement = deepCopyElement(element);
   copy.id = randomId();
   copy.seed = randomInteger();
+  copy.groupIds = getNewGroupIdsForDuplication(
+    copy.groupIds,
+    editingGroupId,
+    (groupId) => {
+      if (!groupIdMapForOperation.has(groupId)) {
+        groupIdMapForOperation.set(groupId, nanoid());
+      }
+      return groupIdMapForOperation.get(groupId)!;
+    },
+  );
   if (overrides) {
     copy = Object.assign(copy, overrides);
   }

+ 6 - 0
src/element/types.ts

@@ -1,5 +1,7 @@
 import { Point } from "../types";
 
+export type GroupId = string;
+
 type _ExcalidrawElementBase = Readonly<{
   id: string;
   x: number;
@@ -18,8 +20,12 @@ type _ExcalidrawElementBase = Readonly<{
   version: number;
   versionNonce: number;
   isDeleted: boolean;
+  groupIds: GroupId[];
 }>;
 
+/**
+ * These are elements that don't have any additional properties.
+ */
 export type ExcalidrawGenericElement = _ExcalidrawElementBase & {
   type: "selection" | "rectangle" | "diamond" | "ellipse";
 };

+ 130 - 0
src/groups.ts

@@ -0,0 +1,130 @@
+import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
+import { AppState } from "./types";
+import { getSelectedElements } from "./scene";
+
+export function selectGroup(
+  groupId: GroupId,
+  appState: AppState,
+  elements: readonly NonDeleted<ExcalidrawElement>[],
+): AppState {
+  return {
+    ...appState,
+    selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
+    selectedElementIds: {
+      ...appState.selectedElementIds,
+      ...Object.fromEntries(
+        elements
+          .filter((element) => element.groupIds.includes(groupId))
+          .map((element) => [element.id, true]),
+      ),
+    },
+  };
+}
+
+/**
+ * If the element's group is selected, don't render an individual
+ * selection border around it.
+ */
+export function isSelectedViaGroup(
+  appState: AppState,
+  element: ExcalidrawElement,
+) {
+  return !!element.groupIds
+    .filter((groupId) => groupId !== appState.editingGroupId)
+    .find((groupId) => appState.selectedGroupIds[groupId]);
+}
+
+export function getSelectedGroupIds(appState: AppState): GroupId[] {
+  return Object.entries(appState.selectedGroupIds)
+    .filter(([groupId, isSelected]) => isSelected)
+    .map(([groupId, isSelected]) => groupId);
+}
+
+/**
+ * When you select an element, you often want to actually select the whole group it's in, unless
+ * you're currently editing that group.
+ */
+export function selectGroupsForSelectedElements(
+  appState: AppState,
+  elements: readonly NonDeleted<ExcalidrawElement>[],
+): AppState {
+  let nextAppState = { ...appState };
+
+  const selectedElements = getSelectedElements(elements, appState);
+
+  for (const selectedElement of selectedElements) {
+    let groupIds = selectedElement.groupIds;
+    if (appState.editingGroupId) {
+      // handle the case where a group is nested within a group
+      const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
+      if (indexOfEditingGroup > -1) {
+        groupIds = groupIds.slice(0, indexOfEditingGroup);
+      }
+    }
+    if (groupIds.length > 0) {
+      const groupId = groupIds[groupIds.length - 1];
+      nextAppState = selectGroup(groupId, nextAppState, elements);
+    }
+  }
+
+  return nextAppState;
+}
+
+export function isElementInGroup(element: ExcalidrawElement, groupId: string) {
+  return element.groupIds.includes(groupId);
+}
+
+export function getElementsInGroup(
+  elements: readonly ExcalidrawElement[],
+  groupId: string,
+) {
+  return elements.filter((element) => isElementInGroup(element, groupId));
+}
+
+export function getSelectedGroupIdForElement(
+  element: ExcalidrawElement,
+  selectedGroupIds: { [groupId: string]: boolean },
+) {
+  return element.groupIds.find((groupId) => selectedGroupIds[groupId]);
+}
+
+export function getNewGroupIdsForDuplication(
+  groupIds: GroupId[],
+  editingGroupId: GroupId | null,
+  mapper: (groupId: GroupId) => GroupId,
+) {
+  const copy = [...groupIds];
+  const positionOfEditingGroupId = editingGroupId
+    ? groupIds.indexOf(editingGroupId)
+    : -1;
+  const endIndex =
+    positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
+  for (let i = 0; i < endIndex; i++) {
+    copy[i] = mapper(copy[i]);
+  }
+
+  return copy;
+}
+
+export function addToGroup(
+  prevGroupIds: GroupId[],
+  newGroupId: GroupId,
+  editingGroupId: GroupId | null,
+) {
+  // insert before the editingGroupId, or push to the end.
+  const groupIds = [...prevGroupIds];
+  const positionOfEditingGroupId = editingGroupId
+    ? groupIds.indexOf(editingGroupId)
+    : -1;
+  const positionToInsert =
+    positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
+  groupIds.splice(positionToInsert, 0, newGroupId);
+  return groupIds;
+}
+
+export function removeFromSelectedGroups(
+  groupIds: GroupId[],
+  selectedGroupIds: { [groupId: string]: boolean },
+) {
+  return groupIds.filter((groupId) => !selectedGroupIds[groupId]);
+}

+ 1 - 0
src/keys.ts

@@ -16,6 +16,7 @@ export const KEYS = {
   F_KEY_CODE: 70,
   ALT_KEY_CODE: 18,
   Z_KEY_CODE: 90,
+  G_KEY_CODE: 71,
 } as const;
 
 export type Key = keyof typeof KEYS;

+ 3 - 1
src/locales/en.json

@@ -59,7 +59,9 @@
     "untitled": "Untitled",
     "name": "Name",
     "yourName": "Your name",
-    "madeWithExcalidraw": "Made with Excalidraw"
+    "madeWithExcalidraw": "Made with Excalidraw",
+    "group": "Group selection",
+    "ungroup": "Ungroup selection"
   },
   "buttons": {
     "clearReset": "Reset the canvas",

+ 93 - 45
src/renderer/renderScene.ts

@@ -6,6 +6,7 @@ import { FlooredNumber, AppState } from "../types";
 import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
+  GroupId,
 } from "../element/types";
 import {
   getElementAbsoluteCoords,
@@ -27,6 +28,11 @@ import { getSelectedElements } from "../scene/selection";
 
 import { renderElement, renderElementToSvg } from "./renderElement";
 import colors from "../colors";
+import {
+  isSelectedViaGroup,
+  getSelectedGroupIds,
+  getElementsInGroup,
+} from "../groups";
 
 type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 
@@ -167,7 +173,10 @@ export const renderScene = (
     const selections = elements.reduce((acc, element) => {
       const selectionColors = [];
       // local user
-      if (appState.selectedElementIds[element.id]) {
+      if (
+        appState.selectedElementIds[element.id] &&
+        !isSelectedViaGroup(appState, element)
+      ) {
         selectionColors.push(oc.black);
       }
       // remote users
@@ -180,57 +189,96 @@ export const renderScene = (
         );
       }
       if (selectionColors.length) {
-        acc.push({ element, selectionColors });
+        const [
+          elementX1,
+          elementY1,
+          elementX2,
+          elementY2,
+        ] = getElementAbsoluteCoords(element);
+        acc.push({
+          angle: element.angle,
+          elementX1,
+          elementY1,
+          elementX2,
+          elementY2,
+          selectionColors,
+        });
       }
       return acc;
-    }, [] as { element: ExcalidrawElement; selectionColors: string[] }[]);
+    }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
+
+    function addSelectionForGroupId(groupId: GroupId) {
+      const groupElements = getElementsInGroup(elements, groupId);
+      const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(
+        groupElements,
+      );
+      selections.push({
+        angle: 0,
+        elementX1,
+        elementX2,
+        elementY1,
+        elementY2,
+        selectionColors: [oc.black],
+      });
+    }
+
+    for (const groupId of getSelectedGroupIds(appState)) {
+      // TODO: support multiplayer selected group IDs
+      addSelectionForGroupId(groupId);
+    }
 
-    selections.forEach(({ element, selectionColors }) => {
-      const [
+    if (appState.editingGroupId) {
+      addSelectionForGroupId(appState.editingGroupId);
+    }
+
+    selections.forEach(
+      ({
+        angle,
         elementX1,
         elementY1,
         elementX2,
         elementY2,
-      ] = getElementAbsoluteCoords(element);
-
-      const elementWidth = elementX2 - elementX1;
-      const elementHeight = elementY2 - elementY1;
-
-      const initialLineDash = context.getLineDash();
-      const lineWidth = context.lineWidth;
-      const lineDashOffset = context.lineDashOffset;
-      const strokeStyle = context.strokeStyle;
-
-      const dashedLinePadding = 4 / sceneState.zoom;
-      const dashWidth = 8 / sceneState.zoom;
-      const spaceWidth = 4 / sceneState.zoom;
-
-      context.lineWidth = 1 / sceneState.zoom;
-
-      const count = selectionColors.length;
-      for (var i = 0; i < count; ++i) {
-        context.strokeStyle = selectionColors[i];
-        context.setLineDash([
-          dashWidth,
-          spaceWidth + (dashWidth + spaceWidth) * (count - 1),
-        ]);
-        context.lineDashOffset = (dashWidth + spaceWidth) * i;
-        strokeRectWithRotation(
-          context,
-          elementX1 - dashedLinePadding,
-          elementY1 - dashedLinePadding,
-          elementWidth + dashedLinePadding * 2,
-          elementHeight + dashedLinePadding * 2,
-          elementX1 + elementWidth / 2,
-          elementY1 + elementHeight / 2,
-          element.angle,
-        );
-      }
-      context.lineDashOffset = lineDashOffset;
-      context.strokeStyle = strokeStyle;
-      context.lineWidth = lineWidth;
-      context.setLineDash(initialLineDash);
-    });
+        selectionColors,
+      }) => {
+        const elementWidth = elementX2 - elementX1;
+        const elementHeight = elementY2 - elementY1;
+
+        const initialLineDash = context.getLineDash();
+        const lineWidth = context.lineWidth;
+        const lineDashOffset = context.lineDashOffset;
+        const strokeStyle = context.strokeStyle;
+
+        const dashedLinePadding = 4 / sceneState.zoom;
+        const dashWidth = 8 / sceneState.zoom;
+        const spaceWidth = 4 / sceneState.zoom;
+
+        context.lineWidth = 1 / sceneState.zoom;
+
+        const count = selectionColors.length;
+        for (var i = 0; i < count; ++i) {
+          context.strokeStyle = selectionColors[i];
+          context.setLineDash([
+            dashWidth,
+            spaceWidth + (dashWidth + spaceWidth) * (count - 1),
+          ]);
+          context.lineDashOffset = (dashWidth + spaceWidth) * i;
+          strokeRectWithRotation(
+            context,
+            elementX1 - dashedLinePadding,
+            elementY1 - dashedLinePadding,
+            elementWidth + dashedLinePadding * 2,
+            elementHeight + dashedLinePadding * 2,
+            elementX1 + elementWidth / 2,
+            elementY1 + elementHeight / 2,
+            angle,
+          );
+        }
+        context.lineDashOffset = lineDashOffset;
+        context.strokeStyle = strokeStyle;
+        context.lineWidth = lineWidth;
+        context.setLineDash(initialLineDash);
+      },
+    );
     context.translate(-sceneState.scrollX, -sceneState.scrollY);
 
     const locallySelectedElements = getSelectedElements(elements, appState);

+ 5 - 0
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -7,6 +7,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -43,6 +44,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -68,6 +70,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -91,6 +94,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -127,6 +131,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,

+ 3 - 0
src/tests/__snapshots__/move.test.tsx.snap

@@ -5,6 +5,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id2",
   "isDeleted": false,
@@ -28,6 +29,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -51,6 +53,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,

+ 2 - 0
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -5,6 +5,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 110,
   "id": "id0",
   "isDeleted": false,
@@ -46,6 +47,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 110,
   "id": "id0",
   "isDeleted": false,

File diff suppressed because it is too large
+ 126 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 2 - 0
src/tests/__snapshots__/resize.test.tsx.snap

@@ -5,6 +5,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -28,6 +29,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,

+ 5 - 0
src/tests/__snapshots__/selection.test.tsx.snap

@@ -5,6 +5,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -39,6 +40,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -73,6 +75,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -96,6 +99,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,
@@ -119,6 +123,7 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
+  "groupIds": Array [],
   "height": 50,
   "id": "id0",
   "isDeleted": false,

+ 5 - 0
src/types.ts

@@ -5,6 +5,7 @@ import {
   NonDeleted,
   TextAlign,
   ExcalidrawElement,
+  GroupId,
 } from "./element/types";
 import { SHAPES } from "./shapes";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -67,6 +68,10 @@ export type AppState = {
   shouldCacheIgnoreZoom: boolean;
   showShortcutsDialog: boolean;
   zenModeEnabled: boolean;
+
+  // groups
+  selectedGroupIds: { [groupId: string]: boolean };
+  editingGroupId: GroupId | null;
 };
 
 export type PointerCoords = Readonly<{

Some files were not shown because too many files changed in this diff