diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index ffed9197a..cafb68d95 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 e992f06a1..451609dff 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 = { 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 bb504b9d6..1627b2fca 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 7a19eeee0..ced118600 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 { 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 039ac88c1..43a29883c 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 3285faf6a..3b5db064e 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 bf99dd58d..f14b79705 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 b79ced0ed..36666faec 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 3c4c1d6d2..42466c4f0 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",