|  | @@ -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;
 | 
	
		
			
				|  |  |        }
 | 
	
		
			
				|  |  |  
 |