From 00b5b0a0ca556a527feb3f768fbec5842df86549 Mon Sep 17 00:00:00 2001
From: Ryan Di <ryan.weihao.di@gmail.com>
Date: Tue, 14 Jan 2025 02:03:56 +1100
Subject: [PATCH] feat: add action to wrap selected items in a frame (#9005)

* feat: add action to wrap selected items in a frame

* fix type

* select frame on wrap & refactor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
---
 packages/excalidraw/actions/actionFrame.ts    | 49 +++++++++++++++++-
 packages/excalidraw/actions/shortcuts.ts      |  2 +
 packages/excalidraw/actions/types.ts          |  3 +-
 packages/excalidraw/components/App.tsx        |  3 ++
 .../CommandPalette/CommandPalette.tsx         |  1 +
 .../components/Stats/MultiPosition.tsx        |  1 +
 packages/excalidraw/locales/en.json           |  3 +-
 .../__snapshots__/contextmenu.test.tsx.snap   | 50 +++++++++++++++++++
 .../excalidraw/tests/contextmenu.test.tsx     |  3 ++
 9 files changed, 111 insertions(+), 4 deletions(-)

diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts
index ffed9197ac..cafb68d95d 100644
--- a/packages/excalidraw/actions/actionFrame.ts
+++ b/packages/excalidraw/actions/actionFrame.ts
@@ -1,6 +1,6 @@
-import { getNonDeletedElements } from "../element";
+import { getCommonBounds, getNonDeletedElements } from "../element";
 import type { ExcalidrawElement } from "../element/types";
-import { removeAllElementsFromFrame } from "../frame";
+import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
 import { getFrameChildren } from "../frame";
 import { KEYS } from "../keys";
 import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -10,6 +10,8 @@ import { register } from "./register";
 import { isFrameLikeElement } from "../element/typeChecks";
 import { frameToolIcon } from "../components/icons";
 import { StoreAction } from "../store";
+import { getSelectedElements } from "../scene";
+import { newFrameElement } from "../element/newElement";
 
 const isSingleFrameSelected = (
   appState: UIAppState,
@@ -144,3 +146,46 @@ export const actionSetFrameAsActiveTool = register({
     !event.altKey &&
     event.key.toLocaleLowerCase() === KEYS.F,
 });
+
+export const actionWrapSelectionInFrame = register({
+  name: "wrapSelectionInFrame",
+  label: "labels.wrapSelectionInFrame",
+  trackEvent: { category: "element" },
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = getSelectedElements(elements, appState);
+
+    return (
+      selectedElements.length > 0 &&
+      !selectedElements.some((element) => isFrameLikeElement(element))
+    );
+  },
+  perform: (elements, appState, _, app) => {
+    const selectedElements = getSelectedElements(elements, appState);
+
+    const [x1, y1, x2, y2] = getCommonBounds(
+      selectedElements,
+      app.scene.getNonDeletedElementsMap(),
+    );
+    const PADDING = 16;
+    const frame = newFrameElement({
+      x: x1 - PADDING,
+      y: y1 - PADDING,
+      width: x2 - x1 + PADDING * 2,
+      height: y2 - y1 + PADDING * 2,
+    });
+
+    const nextElements = addElementsToFrame(
+      [...app.scene.getElementsIncludingDeleted(), frame],
+      selectedElements,
+      frame,
+    );
+
+    return {
+      elements: nextElements,
+      appState: {
+        selectedElementIds: { [frame.id]: true },
+      },
+      storeAction: StoreAction.CAPTURE,
+    };
+  },
+});
diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts
index e992f06a16..451609dff5 100644
--- a/packages/excalidraw/actions/shortcuts.ts
+++ b/packages/excalidraw/actions/shortcuts.ts
@@ -47,6 +47,7 @@ export type ShortcutName =
       | "saveFileToDisk"
       | "saveToActiveFile"
       | "toggleShortcuts"
+      | "wrapSelectionInFrame"
     >
   | "saveScene"
   | "imageExport"
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
   toggleShortcuts: [getShortcutKey("?")],
   searchMenu: [getShortcutKey("CtrlOrCmd+F")],
+  wrapSelectionInFrame: [],
 };
 
 export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
index bb504b9d64..1627b2fcae 100644
--- a/packages/excalidraw/actions/types.ts
+++ b/packages/excalidraw/actions/types.ts
@@ -137,7 +137,8 @@ export type ActionName =
   | "searchMenu"
   | "copyElementLink"
   | "linkToElement"
-  | "cropEditor";
+  | "cropEditor"
+  | "wrapSelectionInFrame";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index 7a19eeee05..ced1186003 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -378,6 +378,7 @@ import { actionPaste } from "../actions/actionClipboard";
 import {
   actionRemoveAllElementsFromFrame,
   actionSelectAllElementsInFrame,
+  actionWrapSelectionInFrame,
 } from "../actions/actionFrame";
 import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
 import { jotaiStore } from "../jotai";
@@ -10664,8 +10665,10 @@ class App extends React.Component<AppProps, AppState> {
       actionCut,
       actionCopy,
       actionPaste,
+      CONTEXT_MENU_SEPARATOR,
       actionSelectAllElementsInFrame,
       actionRemoveAllElementsFromFrame,
+      actionWrapSelectionInFrame,
       CONTEXT_MENU_SEPARATOR,
       actionToggleCropEditor,
       CONTEXT_MENU_SEPARATOR,
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx
index 039ac88c11..43a29883c8 100644
--- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx
+++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx
@@ -263,6 +263,7 @@ function CommandPaletteInner({
         actionManager.actions.cut,
         actionManager.actions.copy,
         actionManager.actions.deleteSelectedElements,
+        actionManager.actions.wrapSelectionInFrame,
         actionManager.actions.copyStyles,
         actionManager.actions.pasteStyles,
         actionManager.actions.bringToFront,
diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx
index 3285faf6ab..3b5db064e4 100644
--- a/packages/excalidraw/components/Stats/MultiPosition.tsx
+++ b/packages/excalidraw/components/Stats/MultiPosition.tsx
@@ -237,6 +237,7 @@ const MultiPosition = ({
           const [x1, y1] = getCommonBounds(elementsInUnit);
           return Math.round((property === "x" ? x1 : y1) * 100) / 100;
         }
+
         const [el] = elementsInUnit;
         const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
 
diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json
index bf99dd58d5..f14b797055 100644
--- a/packages/excalidraw/locales/en.json
+++ b/packages/excalidraw/locales/en.json
@@ -164,7 +164,8 @@
     "imageCropping": "Image cropping",
     "unCroppedDimension": "Uncropped dimension",
     "copyElementLink": "Copy link to object",
-    "linkToElement": "Link to object"
+    "linkToElement": "Link to object",
+    "wrapSelectionInFrame": "Wrap selection in frame"
   },
   "elementLink": {
     "title": "Link to object",
diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
index b79ced0ed3..36666faec8 100644
--- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -97,6 +97,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
           "category": "element",
         },
       },
+      "separator",
       {
         "label": "labels.selectAllElementsInFrame",
         "name": "selectAllElementsInFrame",
@@ -115,6 +116,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
           "category": "history",
         },
       },
+      {
+        "label": "labels.wrapSelectionInFrame",
+        "name": "wrapSelectionInFrame",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       "separator",
       {
         "PanelComponent": [Function],
@@ -4731,6 +4741,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
           "category": "element",
         },
       },
+      "separator",
       {
         "label": "labels.selectAllElementsInFrame",
         "name": "selectAllElementsInFrame",
@@ -4749,6 +4760,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
           "category": "history",
         },
       },
+      {
+        "label": "labels.wrapSelectionInFrame",
+        "name": "wrapSelectionInFrame",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       "separator",
       {
         "PanelComponent": [Function],
@@ -5942,6 +5962,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
           "category": "element",
         },
       },
+      "separator",
       {
         "label": "labels.selectAllElementsInFrame",
         "name": "selectAllElementsInFrame",
@@ -5960,6 +5981,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
           "category": "history",
         },
       },
+      {
+        "label": "labels.wrapSelectionInFrame",
+        "name": "wrapSelectionInFrame",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       "separator",
       {
         "PanelComponent": [Function],
@@ -7876,6 +7906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
           "category": "element",
         },
       },
+      "separator",
       {
         "label": "labels.selectAllElementsInFrame",
         "name": "selectAllElementsInFrame",
@@ -7894,6 +7925,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
           "category": "history",
         },
       },
+      {
+        "label": "labels.wrapSelectionInFrame",
+        "name": "wrapSelectionInFrame",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       "separator",
       {
         "PanelComponent": [Function],
@@ -8854,6 +8894,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
           "category": "element",
         },
       },
+      "separator",
       {
         "label": "labels.selectAllElementsInFrame",
         "name": "selectAllElementsInFrame",
@@ -8872,6 +8913,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
           "category": "history",
         },
       },
+      {
+        "label": "labels.wrapSelectionInFrame",
+        "name": "wrapSelectionInFrame",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       "separator",
       {
         "PanelComponent": [Function],
diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx
index 3c4c1d6d2c..42466c4f01 100644
--- a/packages/excalidraw/tests/contextmenu.test.tsx
+++ b/packages/excalidraw/tests/contextmenu.test.tsx
@@ -120,6 +120,7 @@ describe("contextMenu element", () => {
       "cut",
       "copy",
       "paste",
+      "wrapSelectionInFrame",
       "copyStyles",
       "pasteStyles",
       "deleteSelectedElements",
@@ -213,6 +214,7 @@ describe("contextMenu element", () => {
       "cut",
       "copy",
       "paste",
+      "wrapSelectionInFrame",
       "copyStyles",
       "pasteStyles",
       "deleteSelectedElements",
@@ -269,6 +271,7 @@ describe("contextMenu element", () => {
       "cut",
       "copy",
       "paste",
+      "wrapSelectionInFrame",
       "copyStyles",
       "pasteStyles",
       "deleteSelectedElements",