Pārlūkot izejas kodu

feat: introduce frames (#6123)

Co-authored-by: dwelle <[email protected]>
Ryan Di 2 gadi atpakaļ
vecāks
revīzija
81ebf82979
78 mainītis faili ar 3540 papildinājumiem un 414 dzēšanām
  1. 4 1
      src/actions/actionAddToLibrary.ts
  2. 28 12
      src/actions/actionAlign.tsx
  3. 1 0
      src/actions/actionBoundText.tsx
  4. 7 3
      src/actions/actionCanvas.tsx
  5. 19 6
      src/actions/actionClipboard.tsx
  6. 13 1
      src/actions/actionDeleteSelected.tsx
  7. 15 3
      src/actions/actionDistribute.tsx
  8. 68 12
      src/actions/actionDuplicateSelection.tsx
  9. 17 4
      src/actions/actionElementLock.ts
  10. 12 2
      src/actions/actionFlip.ts
  11. 140 0
      src/actions/actionFrame.ts
  12. 70 16
      src/actions/actionGroup.tsx
  13. 6 2
      src/actions/actionLinearEditor.ts
  14. 0 1
      src/actions/actionMenu.tsx
  15. 4 1
      src/actions/actionProperties.tsx
  16. 11 11
      src/actions/actionSelectAll.ts
  17. 11 1
      src/actions/actionStyles.ts
  18. 5 0
      src/actions/types.ts
  19. 2 2
      src/align.ts
  20. 14 0
      src/appState.ts
  21. 20 1
      src/clipboard.ts
  22. 114 36
      src/components/Actions.tsx
  23. 504 63
      src/components/App.tsx
  24. 1 0
      src/components/HelpDialog.tsx
  25. 4 1
      src/components/ImageExportDialog.tsx
  26. 2 7
      src/components/LayerUI.tsx
  27. 5 1
      src/components/LibraryMenu.tsx
  28. 3 1
      src/components/ToolButton.tsx
  29. 18 1
      src/components/Toolbar.scss
  30. 6 4
      src/components/dropdownMenu/DropdownMenuTrigger.tsx
  31. 12 12
      src/components/hoc/withInternalFallback.test.tsx
  32. 21 0
      src/components/icons.tsx
  33. 1 0
      src/components/main-menu/MainMenu.tsx
  34. 11 0
      src/constants.ts
  35. 6 0
      src/data/restore.ts
  36. 4 2
      src/element/Hyperlink.tsx
  37. 1 1
      src/element/binding.ts
  38. 205 61
      src/element/bounds.ts
  39. 146 22
      src/element/collision.ts
  40. 32 3
      src/element/dragElements.ts
  41. 13 1
      src/element/index.ts
  42. 1 1
      src/element/linearElementEditor.ts
  43. 25 0
      src/element/newElement.ts
  44. 45 37
      src/element/resizeElements.ts
  45. 6 4
      src/element/textElement.ts
  46. 13 1
      src/element/transformHandles.ts
  47. 7 0
      src/element/typeChecks.ts
  48. 10 2
      src/element/types.ts
  49. 705 0
      src/frame.ts
  50. 40 0
      src/groups.ts
  51. 1 0
      src/keys.ts
  52. 5 1
      src/locales/en.json
  53. 1 1
      src/math.ts
  54. 1 0
      src/packages/excalidraw/example/App.tsx
  55. 44 0
      src/packages/excalidraw/example/initialData.js
  56. 134 29
      src/renderer/renderElement.ts
  57. 196 7
      src/renderer/renderScene.ts
  58. 51 1
      src/scene/Scene.ts
  59. 2 1
      src/scene/comparisons.ts
  60. 75 8
      src/scene/export.ts
  61. 89 7
      src/scene/selection.ts
  62. 8 0
      src/shapes.tsx
  63. 153 0
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  64. 5 0
      src/tests/__snapshots__/dragCreate.test.tsx.snap
  65. 2 1
      src/tests/__snapshots__/export.test.tsx.snap
  66. 6 0
      src/tests/__snapshots__/move.test.tsx.snap
  67. 2 0
      src/tests/__snapshots__/multiPointCreate.test.tsx.snap
  68. 136 0
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  69. 5 0
      src/tests/__snapshots__/selection.test.tsx.snap
  70. 9 0
      src/tests/data/__snapshots__/restore.test.ts.snap
  71. 1 0
      src/tests/fixtures/elementFixture.ts
  72. 6 1
      src/tests/helpers/api.ts
  73. 5 0
      src/tests/packages/__snapshots__/utils.test.ts.snap
  74. 1 0
      src/tests/queries/toolQueries.ts
  75. 2 0
      src/tests/scene/__snapshots__/export.test.ts.snap
  76. 33 2
      src/types.ts
  77. 19 2
      src/utils.ts
  78. 120 13
      src/zindex.ts

+ 4 - 1
src/actions/actionAddToLibrary.ts

@@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
     const selectedElements = getSelectedElements(
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+        includeElementsInFrames: true,
+      },
     );
     );
     if (selectedElements.some((element) => element.type === "image")) {
     if (selectedElements.some((element) => element.type === "image")) {
       return {
       return {

+ 28 - 12
src/actions/actionAlign.tsx

@@ -10,6 +10,7 @@ import {
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
+import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
@@ -17,10 +18,20 @@ import { AppState } from "../types";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 import { register } from "./register";
 
 
-const enableActionGroup = (
+const alignActionsPredicate = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
-) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+  return (
+    selectedElements.length > 1 &&
+    // TODO enable aligning frames when implemented properly
+    !selectedElements.some((el) => el.type === "frame")
+  );
+};
 
 
 const alignSelectedElements = (
 const alignSelectedElements = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
@@ -36,14 +47,16 @@ const alignSelectedElements = (
 
 
   const updatedElementsMap = arrayToMap(updatedElements);
   const updatedElementsMap = arrayToMap(updatedElements);
 
 
-  return elements.map(
-    (element) => updatedElementsMap.get(element.id) || element,
+  return updateFrameMembershipOfSelectedElements(
+    elements.map((element) => updatedElementsMap.get(element.id) || element),
+    appState,
   );
   );
 };
 };
 
 
 export const actionAlignTop = register({
 export const actionAlignTop = register({
   name: "alignTop",
   name: "alignTop",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
+  predicate: alignActionsPredicate,
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
       appState,
       appState,
@@ -58,7 +71,7 @@ export const actionAlignTop = register({
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
   PanelComponent: ({ elements, appState, updateData }) => (
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState)}
       type="button"
       type="button"
       icon={AlignTopIcon}
       icon={AlignTopIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -74,6 +87,7 @@ export const actionAlignTop = register({
 export const actionAlignBottom = register({
 export const actionAlignBottom = register({
   name: "alignBottom",
   name: "alignBottom",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
+  predicate: alignActionsPredicate,
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
       appState,
       appState,
@@ -88,7 +102,7 @@ export const actionAlignBottom = register({
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
   PanelComponent: ({ elements, appState, updateData }) => (
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState)}
       type="button"
       type="button"
       icon={AlignBottomIcon}
       icon={AlignBottomIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -104,6 +118,7 @@ export const actionAlignBottom = register({
 export const actionAlignLeft = register({
 export const actionAlignLeft = register({
   name: "alignLeft",
   name: "alignLeft",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
+  predicate: alignActionsPredicate,
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
       appState,
       appState,
@@ -118,7 +133,7 @@ export const actionAlignLeft = register({
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
   PanelComponent: ({ elements, appState, updateData }) => (
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState)}
       type="button"
       type="button"
       icon={AlignLeftIcon}
       icon={AlignLeftIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -134,7 +149,7 @@ export const actionAlignLeft = register({
 export const actionAlignRight = register({
 export const actionAlignRight = register({
   name: "alignRight",
   name: "alignRight",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
-
+  predicate: alignActionsPredicate,
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
       appState,
       appState,
@@ -149,7 +164,7 @@ export const actionAlignRight = register({
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
     event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
   PanelComponent: ({ elements, appState, updateData }) => (
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState)}
       type="button"
       type="button"
       icon={AlignRightIcon}
       icon={AlignRightIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -165,7 +180,7 @@ export const actionAlignRight = register({
 export const actionAlignVerticallyCentered = register({
 export const actionAlignVerticallyCentered = register({
   name: "alignVerticallyCentered",
   name: "alignVerticallyCentered",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
-
+  predicate: alignActionsPredicate,
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
       appState,
       appState,
@@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
   },
   },
   PanelComponent: ({ elements, appState, updateData }) => (
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState)}
       type="button"
       type="button"
       icon={CenterVerticallyIcon}
       icon={CenterVerticallyIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
@@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
 export const actionAlignHorizontallyCentered = register({
 export const actionAlignHorizontallyCentered = register({
   name: "alignHorizontallyCentered",
   name: "alignHorizontallyCentered",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
+  predicate: alignActionsPredicate,
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
       appState,
       appState,
@@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
   },
   },
   PanelComponent: ({ elements, appState, updateData }) => (
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton
     <ToolButton
-      hidden={!enableActionGroup(elements, appState)}
+      hidden={!alignActionsPredicate(elements, appState)}
       type="button"
       type="button"
       icon={CenterHorizontallyIcon}
       icon={CenterHorizontallyIcon}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}

+ 1 - 0
src/actions/actionBoundText.tsx

@@ -249,6 +249,7 @@ export const actionWrapTextInContainer = register({
             "rectangle",
             "rectangle",
           ),
           ),
           groupIds: textElement.groupIds,
           groupIds: textElement.groupIds,
+          frameId: textElement.frameId,
         });
         });
 
 
         // update bindings
         // update bindings

+ 7 - 3
src/actions/actionCanvas.tsx

@@ -20,6 +20,8 @@ import {
   isHandToolActive,
   isHandToolActive,
 } from "../appState";
 } from "../appState";
 import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
+import { excludeElementsInFramesFromSelection } from "../scene/selection";
+import { Bounds } from "../element/bounds";
 
 
 export const actionChangeViewBackgroundColor = register({
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
   name: "changeViewBackgroundColor",
@@ -206,7 +208,7 @@ export const actionResetZoom = register({
 });
 });
 
 
 const zoomValueToFitBoundsOnViewport = (
 const zoomValueToFitBoundsOnViewport = (
-  bounds: [number, number, number, number],
+  bounds: Bounds,
   viewportDimensions: { width: number; height: number },
   viewportDimensions: { width: number; height: number },
 ) => {
 ) => {
   const [x1, y1, x2, y2] = bounds;
   const [x1, y1, x2, y2] = bounds;
@@ -234,8 +236,10 @@ export const zoomToFitElements = (
 
 
   const commonBounds =
   const commonBounds =
     zoomToSelection && selectedElements.length > 0
     zoomToSelection && selectedElements.length > 0
-      ? getCommonBounds(selectedElements)
-      : getCommonBounds(nonDeletedElements);
+      ? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
+      : getCommonBounds(
+          excludeElementsInFramesFromSelection(nonDeletedElements),
+        );
 
 
   const newZoom = {
   const newZoom = {
     value: zoomValueToFitBoundsOnViewport(commonBounds, {
     value: zoomValueToFitBoundsOnViewport(commonBounds, {

+ 19 - 6
src/actions/actionClipboard.tsx

@@ -16,9 +16,12 @@ export const actionCopy = register({
   name: "copy",
   name: "copy",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
   perform: (elements, appState, _, app) => {
-    const selectedElements = getSelectedElements(elements, appState, true);
+    const elementsToCopy = getSelectedElements(elements, appState, {
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
 
 
-    copyToClipboard(selectedElements, app.files);
+    copyToClipboard(elementsToCopy, app.files);
 
 
     return {
     return {
       commitToHistory: false,
       commitToHistory: false,
@@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
     const selectedElements = getSelectedElements(
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+        includeElementsInFrames: true,
+      },
     );
     );
     try {
     try {
       await exportCanvas(
       await exportCanvas(
@@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
     const selectedElements = getSelectedElements(
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+        includeElementsInFrames: true,
+      },
     );
     );
     try {
     try {
       await exportCanvas(
       await exportCanvas(
@@ -172,7 +181,9 @@ export const copyText = register({
     const selectedElements = getSelectedElements(
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+      },
     );
     );
 
 
     const text = selectedElements
     const text = selectedElements
@@ -191,7 +202,9 @@ export const copyText = register({
   predicate: (elements, appState) => {
   predicate: (elements, appState) => {
     return (
     return (
       probablySupportsClipboardWriteText &&
       probablySupportsClipboardWriteText &&
-      getSelectedElements(elements, appState, true).some(isTextElement)
+      getSelectedElements(elements, appState, {
+        includeBoundTextElement: true,
+      }).some(isTextElement)
     );
     );
   },
   },
   contextItemLabel: "labels.copyText",
   contextItemLabel: "labels.copyText",

+ 13 - 1
src/actions/actionDeleteSelected.tsx

@@ -1,4 +1,4 @@
-import { isSomeElementSelected } from "../scene";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import { t } from "../i18n";
@@ -18,11 +18,23 @@ const deleteSelectedElements = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
 ) => {
 ) => {
+  const framesToBeDeleted = new Set(
+    getSelectedElements(
+      elements.filter((el) => el.type === "frame"),
+      appState,
+    ).map((el) => el.id),
+  );
+
   return {
   return {
     elements: elements.map((el) => {
     elements: elements.map((el) => {
       if (appState.selectedElementIds[el.id]) {
       if (appState.selectedElementIds[el.id]) {
         return newElementWith(el, { isDeleted: true });
         return newElementWith(el, { isDeleted: true });
       }
       }
+
+      if (el.frameId && framesToBeDeleted.has(el.frameId)) {
+        return newElementWith(el, { isDeleted: true });
+      }
+
       if (
       if (
         isBoundToContainer(el) &&
         isBoundToContainer(el) &&
         appState.selectedElementIds[el.containerId]
         appState.selectedElementIds[el.containerId]

+ 15 - 3
src/actions/actionDistribute.tsx

@@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
 import { distributeElements, Distribution } from "../distribute";
 import { distributeElements, Distribution } from "../distribute";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
+import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { CODES, KEYS } from "../keys";
 import { CODES, KEYS } from "../keys";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
@@ -16,7 +17,17 @@ import { register } from "./register";
 const enableActionGroup = (
 const enableActionGroup = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
-) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+  return (
+    selectedElements.length > 1 &&
+    // TODO enable distributing frames when implemented properly
+    !selectedElements.some((el) => el.type === "frame")
+  );
+};
 
 
 const distributeSelectedElements = (
 const distributeSelectedElements = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
@@ -32,8 +43,9 @@ const distributeSelectedElements = (
 
 
   const updatedElementsMap = arrayToMap(updatedElements);
   const updatedElementsMap = arrayToMap(updatedElements);
 
 
-  return elements.map(
-    (element) => updatedElementsMap.get(element.id) || element,
+  return updateFrameMembershipOfSelectedElements(
+    elements.map((element) => updatedElementsMap.get(element.id) || element),
+    appState,
   );
   );
 };
 };
 
 

+ 68 - 12
src/actions/actionDuplicateSelection.tsx

@@ -2,7 +2,7 @@ import { KEYS } from "../keys";
 import { register } from "./register";
 import { register } from "./register";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
 import { duplicateElement, getNonDeletedElements } from "../element";
 import { duplicateElement, getNonDeletedElements } from "../element";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { isSomeElementSelected } from "../scene";
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { arrayToMap, getShortcutKey } from "../utils";
@@ -20,9 +20,17 @@ import {
   bindTextToShapeAfterDuplication,
   bindTextToShapeAfterDuplication,
   getBoundTextElement,
   getBoundTextElement,
 } from "../element/textElement";
 } from "../element/textElement";
-import { isBoundToContainer } from "../element/typeChecks";
+import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
 import { normalizeElementOrder } from "../element/sortElements";
 import { normalizeElementOrder } from "../element/sortElements";
 import { DuplicateIcon } from "../components/icons";
 import { DuplicateIcon } from "../components/icons";
+import {
+  bindElementsToFramesAfterDuplication,
+  getFrameElements,
+} from "../frame";
+import {
+  excludeElementsInFramesFromSelection,
+  getSelectedElements,
+} from "../scene/selection";
 
 
 export const actionDuplicateSelection = register({
 export const actionDuplicateSelection = register({
   name: "duplicateSelection",
   name: "duplicateSelection",
@@ -94,8 +102,11 @@ const duplicateElements = (
     return newElement;
     return newElement;
   };
   };
 
 
-  const selectedElementIds = arrayToMap(
-    getSelectedElements(sortedElements, appState, true),
+  const idsOfElementsToDuplicate = arrayToMap(
+    getSelectedElements(sortedElements, appState, {
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    }),
   );
   );
 
 
   // Ids of elements that have already been processed so we don't push them
   // Ids of elements that have already been processed so we don't push them
@@ -129,12 +140,25 @@ const duplicateElements = (
     }
     }
 
 
     const boundTextElement = getBoundTextElement(element);
     const boundTextElement = getBoundTextElement(element);
-    if (selectedElementIds.get(element.id)) {
-      // if a group or a container/bound-text, duplicate atomically
-      if (element.groupIds.length || boundTextElement) {
+    const isElementAFrame = isFrameElement(element);
+
+    if (idsOfElementsToDuplicate.get(element.id)) {
+      // if a group or a container/bound-text or frame, duplicate atomically
+      if (element.groupIds.length || boundTextElement || isElementAFrame) {
         const groupId = getSelectedGroupForElement(appState, element);
         const groupId = getSelectedGroupForElement(appState, element);
         if (groupId) {
         if (groupId) {
-          const groupElements = getElementsInGroup(sortedElements, groupId);
+          // TODO:
+          // remove `.flatMap...`
+          // if the elements in a frame are grouped when the frame is grouped
+          const groupElements = getElementsInGroup(
+            sortedElements,
+            groupId,
+          ).flatMap((element) =>
+            isFrameElement(element)
+              ? [...getFrameElements(elements, element.id), element]
+              : [element],
+          );
+
           elementsWithClones.push(
           elementsWithClones.push(
             ...markAsProcessed([
             ...markAsProcessed([
               ...groupElements,
               ...groupElements,
@@ -156,10 +180,34 @@ const duplicateElements = (
           );
           );
           continue;
           continue;
         }
         }
+        if (isElementAFrame) {
+          const elementsInFrame = getFrameElements(sortedElements, element.id);
+
+          elementsWithClones.push(
+            ...markAsProcessed([
+              ...elementsInFrame,
+              element,
+              ...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
+              duplicateAndOffsetElement(element),
+            ]),
+          );
+
+          continue;
+        }
+      }
+      // since elements in frames have a lower z-index than the frame itself,
+      // they will be looped first and if their frames are selected as well,
+      // they will have been copied along with the frame atomically in the
+      // above branch, so we must skip those elements here
+      //
+      // now, for elements do not belong any frames or elements whose frames
+      // are selected (or elements that are left out from the above
+      // steps for whatever reason) we (should at least) duplicate them here
+      if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
+        elementsWithClones.push(
+          ...markAsProcessed([element, duplicateAndOffsetElement(element)]),
+        );
       }
       }
-      elementsWithClones.push(
-        ...markAsProcessed([element, duplicateAndOffsetElement(element)]),
-      );
     } else {
     } else {
       elementsWithClones.push(...markAsProcessed([element]));
       elementsWithClones.push(...markAsProcessed([element]));
     }
     }
@@ -200,6 +248,14 @@ const duplicateElements = (
     oldElements,
     oldElements,
     oldIdToDuplicatedId,
     oldIdToDuplicatedId,
   );
   );
+  bindElementsToFramesAfterDuplication(
+    finalElements,
+    oldElements,
+    oldIdToDuplicatedId,
+  );
+
+  const nextElementsToSelect =
+    excludeElementsInFramesFromSelection(newElements);
 
 
   return {
   return {
     elements: finalElements,
     elements: finalElements,
@@ -207,7 +263,7 @@ const duplicateElements = (
       {
       {
         ...appState,
         ...appState,
         selectedGroupIds: {},
         selectedGroupIds: {},
-        selectedElementIds: newElements.reduce(
+        selectedElementIds: nextElementsToSelect.reduce(
           (acc: Record<ExcalidrawElement["id"], true>, element) => {
           (acc: Record<ExcalidrawElement["id"], true>, element) => {
             if (!isBoundToContainer(element)) {
             if (!isBoundToContainer(element)) {
               acc[element.id] = true;
               acc[element.id] = true;

+ 17 - 4
src/actions/actionElementLock.ts

@@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
 export const actionToggleElementLock = register({
 export const actionToggleElementLock = register({
   name: "toggleElementLock",
   name: "toggleElementLock",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
+  predicate: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return !selectedElements.some(
+      (element) => element.locked && element.frameId,
+    );
+  },
   perform: (elements, appState) => {
   perform: (elements, appState) => {
-    const selectedElements = getSelectedElements(elements, appState, true);
+    const selectedElements = getSelectedElements(elements, appState, {
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
 
 
     if (!selectedElements.length) {
     if (!selectedElements.length) {
       return false;
       return false;
@@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
     };
     };
   },
   },
   contextItemLabel: (elements, appState) => {
   contextItemLabel: (elements, appState) => {
-    const selected = getSelectedElements(elements, appState, false);
-    if (selected.length === 1) {
+    const selected = getSelectedElements(elements, appState, {
+      includeBoundTextElement: false,
+    });
+    if (selected.length === 1 && selected[0].type !== "frame") {
       return selected[0].locked
       return selected[0].locked
         ? "labels.elementLock.unlock"
         ? "labels.elementLock.unlock"
         : "labels.elementLock.lock";
         : "labels.elementLock.lock";
@@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
       event.key.toLocaleLowerCase() === KEYS.L &&
       event.key.toLocaleLowerCase() === KEYS.L &&
       event[KEYS.CTRL_OR_CMD] &&
       event[KEYS.CTRL_OR_CMD] &&
       event.shiftKey &&
       event.shiftKey &&
-      getSelectedElements(elements, appState, false).length > 0
+      getSelectedElements(elements, appState, {
+        includeBoundTextElement: false,
+      }).length > 0
     );
     );
   },
   },
 });
 });

+ 12 - 2
src/actions/actionFlip.ts

@@ -12,13 +12,17 @@ import {
   isBindingEnabled,
   isBindingEnabled,
   unbindLinearElements,
   unbindLinearElements,
 } from "../element/binding";
 } from "../element/binding";
+import { updateFrameMembershipOfSelectedElements } from "../frame";
 
 
 export const actionFlipHorizontal = register({
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
   name: "flipHorizontal",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
-      elements: flipSelectedElements(elements, appState, "horizontal"),
+      elements: updateFrameMembershipOfSelectedElements(
+        flipSelectedElements(elements, appState, "horizontal"),
+        appState,
+      ),
       appState,
       appState,
       commitToHistory: true,
       commitToHistory: true,
     };
     };
@@ -32,7 +36,10 @@ export const actionFlipVertical = register({
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
   perform: (elements, appState) => {
   perform: (elements, appState) => {
     return {
     return {
-      elements: flipSelectedElements(elements, appState, "vertical"),
+      elements: updateFrameMembershipOfSelectedElements(
+        flipSelectedElements(elements, appState, "vertical"),
+        appState,
+      ),
       appState,
       appState,
       commitToHistory: true,
       commitToHistory: true,
     };
     };
@@ -50,6 +57,9 @@ const flipSelectedElements = (
   const selectedElements = getSelectedElements(
   const selectedElements = getSelectedElements(
     getNonDeletedElements(elements),
     getNonDeletedElements(elements),
     appState,
     appState,
+    {
+      includeElementsInFrames: true,
+    },
   );
   );
 
 
   const updatedElements = flipElements(
   const updatedElements = flipElements(

+ 140 - 0
src/actions/actionFrame.ts

@@ -0,0 +1,140 @@
+import { getNonDeletedElements } from "../element";
+import { ExcalidrawElement } from "../element/types";
+import { removeAllElementsFromFrame } from "../frame";
+import { getFrameElements } from "../frame";
+import { KEYS } from "../keys";
+import { getSelectedElements } from "../scene";
+import { AppState } from "../types";
+import { setCursorForShape, updateActiveTool } from "../utils";
+import { register } from "./register";
+
+const isSingleFrameSelected = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  return selectedElements.length === 1 && selectedElements[0].type === "frame";
+};
+
+export const actionSelectAllElementsInFrame = register({
+  name: "selectAllElementsInFrame",
+  trackEvent: { category: "canvas" },
+  perform: (elements, appState) => {
+    const selectedFrame = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    )[0];
+
+    if (selectedFrame && selectedFrame.type === "frame") {
+      const elementsInFrame = getFrameElements(
+        getNonDeletedElements(elements),
+        selectedFrame.id,
+      ).filter((element) => !(element.type === "text" && element.containerId));
+
+      return {
+        elements,
+        appState: {
+          ...appState,
+          selectedElementIds: elementsInFrame.reduce((acc, element) => {
+            acc[element.id] = true;
+            return acc;
+          }, {} as Record<ExcalidrawElement["id"], true>),
+        },
+        commitToHistory: false,
+      };
+    }
+
+    return {
+      elements,
+      appState,
+      commitToHistory: false,
+    };
+  },
+  contextItemLabel: "labels.selectAllElementsInFrame",
+  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+});
+
+export const actionRemoveAllElementsFromFrame = register({
+  name: "removeAllElementsFromFrame",
+  trackEvent: { category: "history" },
+  perform: (elements, appState) => {
+    const selectedFrame = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    )[0];
+
+    if (selectedFrame && selectedFrame.type === "frame") {
+      return {
+        elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
+        appState: {
+          ...appState,
+          selectedElementIds: {
+            [selectedFrame.id]: true,
+          },
+        },
+        commitToHistory: true,
+      };
+    }
+
+    return {
+      elements,
+      appState,
+      commitToHistory: false,
+    };
+  },
+  contextItemLabel: "labels.removeAllElementsFromFrame",
+  predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
+});
+
+export const actionToggleFrameRendering = register({
+  name: "toggleFrameRendering",
+  viewMode: true,
+  trackEvent: { category: "canvas" },
+  perform: (elements, appState) => {
+    return {
+      elements,
+      appState: {
+        ...appState,
+        shouldRenderFrames: !appState.shouldRenderFrames,
+      },
+      commitToHistory: false,
+    };
+  },
+  contextItemLabel: "labels.toggleFrameRendering",
+  checked: (appState: AppState) => appState.shouldRenderFrames,
+});
+
+export const actionSetFrameAsActiveTool = register({
+  name: "setFrameAsActiveTool",
+  trackEvent: { category: "toolbar" },
+  perform: (elements, appState, _, app) => {
+    const nextActiveTool = updateActiveTool(appState, {
+      type: "frame",
+    });
+
+    setCursorForShape(app.canvas, {
+      ...appState,
+      activeTool: nextActiveTool,
+    });
+
+    return {
+      elements,
+      appState: {
+        ...appState,
+        activeTool: updateActiveTool(appState, {
+          type: "frame",
+        }),
+      },
+      commitToHistory: false,
+    };
+  },
+  keyTest: (event) =>
+    !event[KEYS.CTRL_OR_CMD] &&
+    !event.shiftKey &&
+    !event.altKey &&
+    event.key.toLocaleLowerCase() === KEYS.F,
+});

+ 70 - 16
src/actions/actionGroup.tsx

@@ -17,9 +17,19 @@ import {
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
 import { randomId } from "../random";
 import { randomId } from "../random";
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
-import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
+import {
+  ExcalidrawElement,
+  ExcalidrawFrameElement,
+  ExcalidrawTextElement,
+} from "../element/types";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import { isBoundToContainer } from "../element/typeChecks";
+import {
+  getElementsInResizingFrame,
+  groupByFrames,
+  removeElementsFromFrame,
+  replaceAllElementsInFrame,
+} from "../frame";
 
 
 const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
 const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
   if (elements.length >= 2) {
   if (elements.length >= 2) {
@@ -45,7 +55,9 @@ const enableActionGroup = (
   const selectedElements = getSelectedElements(
   const selectedElements = getSelectedElements(
     getNonDeletedElements(elements),
     getNonDeletedElements(elements),
     appState,
     appState,
-    true,
+    {
+      includeBoundTextElement: true,
+    },
   );
   );
   return (
   return (
     selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
     selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -55,11 +67,13 @@ const enableActionGroup = (
 export const actionGroup = register({
 export const actionGroup = register({
   name: "group",
   name: "group",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     const selectedElements = getSelectedElements(
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+      },
     );
     );
     if (selectedElements.length < 2) {
     if (selectedElements.length < 2) {
       // nothing to group
       // nothing to group
@@ -86,9 +100,31 @@ export const actionGroup = register({
         return { appState, elements, commitToHistory: false };
         return { appState, elements, commitToHistory: false };
       }
       }
     }
     }
+
+    let nextElements = [...elements];
+
+    // this includes the case where we are grouping elements inside a frame
+    // and elements outside that frame
+    const groupingElementsFromDifferentFrames =
+      new Set(selectedElements.map((element) => element.frameId)).size > 1;
+    // when it happens, we want to remove elements that are in the frame
+    // and are going to be grouped from the frame (mouthful, I know)
+    if (groupingElementsFromDifferentFrames) {
+      const frameElementsMap = groupByFrames(selectedElements);
+
+      frameElementsMap.forEach((elementsInFrame, frameId) => {
+        nextElements = removeElementsFromFrame(
+          nextElements,
+          elementsInFrame,
+          appState,
+        );
+      });
+    }
+
     const newGroupId = randomId();
     const newGroupId = randomId();
     const selectElementIds = arrayToMap(selectedElements);
     const selectElementIds = arrayToMap(selectedElements);
-    const updatedElements = elements.map((element) => {
+
+    nextElements = nextElements.map((element) => {
       if (!selectElementIds.get(element.id)) {
       if (!selectElementIds.get(element.id)) {
         return element;
         return element;
       }
       }
@@ -102,17 +138,16 @@ export const actionGroup = register({
     });
     });
     // keep the z order within the group the same, but move them
     // keep the z order within the group the same, but move them
     // to the z order of the highest element in the layer stack
     // to the z order of the highest element in the layer stack
-    const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
+    const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
     const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
     const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
-    const lastGroupElementIndex =
-      updatedElements.lastIndexOf(lastElementInGroup);
-    const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
-    const elementsBeforeGroup = updatedElements
+    const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
+    const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
+    const elementsBeforeGroup = nextElements
       .slice(0, lastGroupElementIndex)
       .slice(0, lastGroupElementIndex)
       .filter(
       .filter(
         (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
         (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
       );
       );
-    const updatedElementsInOrder = [
+    nextElements = [
       ...elementsBeforeGroup,
       ...elementsBeforeGroup,
       ...elementsInGroup,
       ...elementsInGroup,
       ...elementsAfterGroup,
       ...elementsAfterGroup,
@@ -122,9 +157,9 @@ export const actionGroup = register({
       appState: selectGroup(
       appState: selectGroup(
         newGroupId,
         newGroupId,
         { ...appState, selectedGroupIds: {} },
         { ...appState, selectedGroupIds: {} },
-        getNonDeletedElements(updatedElementsInOrder),
+        getNonDeletedElements(nextElements),
       ),
       ),
-      elements: updatedElementsInOrder,
+      elements: nextElements,
       commitToHistory: true,
       commitToHistory: true,
     };
     };
   },
   },
@@ -148,14 +183,23 @@ export const actionGroup = register({
 export const actionUngroup = register({
 export const actionUngroup = register({
   name: "ungroup",
   name: "ungroup",
   trackEvent: { category: "element" },
   trackEvent: { category: "element" },
-  perform: (elements, appState) => {
+  perform: (elements, appState, _, app) => {
     const groupIds = getSelectedGroupIds(appState);
     const groupIds = getSelectedGroupIds(appState);
     if (groupIds.length === 0) {
     if (groupIds.length === 0) {
       return { appState, elements, commitToHistory: false };
       return { appState, elements, commitToHistory: false };
     }
     }
 
 
+    let nextElements = [...elements];
+
+    const selectedElements = getSelectedElements(nextElements, appState);
+    const frames = selectedElements
+      .filter((element) => element.frameId)
+      .map((element) =>
+        app.scene.getElement(element.frameId!),
+      ) as ExcalidrawFrameElement[];
+
     const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
     const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
-    const nextElements = elements.map((element) => {
+    nextElements = nextElements.map((element) => {
       if (isBoundToContainer(element)) {
       if (isBoundToContainer(element)) {
         boundTextElementIds.push(element.id);
         boundTextElementIds.push(element.id);
       }
       }
@@ -176,13 +220,23 @@ export const actionUngroup = register({
       getNonDeletedElements(nextElements),
       getNonDeletedElements(nextElements),
     );
     );
 
 
+    frames.forEach((frame) => {
+      if (frame) {
+        nextElements = replaceAllElementsInFrame(
+          nextElements,
+          getElementsInResizingFrame(nextElements, frame, appState),
+          frame,
+          appState,
+        );
+      }
+    });
+
     // remove binded text elements from selection
     // remove binded text elements from selection
     boundTextElementIds.forEach(
     boundTextElementIds.forEach(
       (id) => (updateAppState.selectedElementIds[id] = false),
       (id) => (updateAppState.selectedElementIds[id] = false),
     );
     );
     return {
     return {
       appState: updateAppState,
       appState: updateAppState,
-
       elements: nextElements,
       elements: nextElements,
       commitToHistory: true,
       commitToHistory: true,
     };
     };

+ 6 - 2
src/actions/actionLinearEditor.ts

@@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
     const selectedElement = getSelectedElements(
     const selectedElement = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+      },
     )[0] as ExcalidrawLinearElement;
     )[0] as ExcalidrawLinearElement;
 
 
     const editingLinearElement =
     const editingLinearElement =
@@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
     const selectedElement = getSelectedElements(
     const selectedElement = getSelectedElements(
       getNonDeletedElements(elements),
       getNonDeletedElements(elements),
       appState,
       appState,
-      true,
+      {
+        includeBoundTextElement: true,
+      },
     )[0] as ExcalidrawLinearElement;
     )[0] as ExcalidrawLinearElement;
     return appState.editingLinearElement?.elementId === selectedElement.id
     return appState.editingLinearElement?.elementId === selectedElement.id
       ? "labels.lineEditor.exit"
       ? "labels.lineEditor.exit"

+ 0 - 1
src/actions/actionMenu.tsx

@@ -67,7 +67,6 @@ export const actionFullScreen = register({
       commitToHistory: false,
       commitToHistory: false,
     };
     };
   },
   },
-  keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
 });
 });
 
 
 export const actionShortcuts = register({
 export const actionShortcuts = register({

+ 4 - 1
src/actions/actionProperties.tsx

@@ -102,8 +102,11 @@ const changeProperty = (
   includeBoundText = false,
   includeBoundText = false,
 ) => {
 ) => {
   const selectedElementIds = arrayToMap(
   const selectedElementIds = arrayToMap(
-    getSelectedElements(elements, appState, includeBoundText),
+    getSelectedElements(elements, appState, {
+      includeBoundTextElement: includeBoundText,
+    }),
   );
   );
+
   return elements.map((element) => {
   return elements.map((element) => {
     if (
     if (
       selectedElementIds.get(element.id) ||
       selectedElementIds.get(element.id) ||

+ 11 - 11
src/actions/actionSelectAll.ts

@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
 import { isLinearElement } from "../element/typeChecks";
 import { isLinearElement } from "../element/typeChecks";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { excludeElementsInFramesFromSelection } from "../scene/selection";
 
 
 export const actionSelectAll = register({
 export const actionSelectAll = register({
   name: "selectAll",
   name: "selectAll",
@@ -13,19 +14,18 @@ export const actionSelectAll = register({
     if (appState.editingLinearElement) {
     if (appState.editingLinearElement) {
       return false;
       return false;
     }
     }
-    const selectedElementIds = elements.reduce(
-      (map: Record<ExcalidrawElement["id"], true>, element) => {
-        if (
+
+    const selectedElementIds = excludeElementsInFramesFromSelection(
+      elements.filter(
+        (element) =>
           !element.isDeleted &&
           !element.isDeleted &&
           !(isTextElement(element) && element.containerId) &&
           !(isTextElement(element) && element.containerId) &&
-          !element.locked
-        ) {
-          map[element.id] = true;
-        }
-        return map;
-      },
-      {},
-    );
+          !element.locked,
+      ),
+    ).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
+      map[element.id] = true;
+      return map;
+    }, {});
 
 
     return {
     return {
       appState: selectGroupsForSelectedElements(
       appState: selectGroupsForSelectedElements(

+ 11 - 1
src/actions/actionStyles.ts

@@ -20,6 +20,7 @@ import {
   hasBoundTextElement,
   hasBoundTextElement,
   canApplyRoundnessTypeToElement,
   canApplyRoundnessTypeToElement,
   getDefaultRoundnessTypeForElement,
   getDefaultRoundnessTypeForElement,
+  isFrameElement,
 } from "../element/typeChecks";
 } from "../element/typeChecks";
 import { getSelectedElements } from "../scene";
 import { getSelectedElements } from "../scene";
 
 
@@ -64,7 +65,9 @@ export const actionPasteStyles = register({
       return { elements, commitToHistory: false };
       return { elements, commitToHistory: false };
     }
     }
 
 
-    const selectedElements = getSelectedElements(elements, appState, true);
+    const selectedElements = getSelectedElements(elements, appState, {
+      includeBoundTextElement: true,
+    });
     const selectedElementIds = selectedElements.map((element) => element.id);
     const selectedElementIds = selectedElements.map((element) => element.id);
     return {
     return {
       elements: elements.map((element) => {
       elements: elements.map((element) => {
@@ -127,6 +130,13 @@ export const actionPasteStyles = register({
             });
             });
           }
           }
 
 
+          if (isFrameElement(element)) {
+            newElement = newElementWith(newElement, {
+              roundness: null,
+              backgroundColor: "transparent",
+            });
+          }
+
           return newElement;
           return newElement;
         }
         }
         return element;
         return element;

+ 5 - 0
src/actions/types.ts

@@ -116,6 +116,11 @@ export type ActionName =
   | "toggleLinearEditor"
   | "toggleLinearEditor"
   | "toggleEraserTool"
   | "toggleEraserTool"
   | "toggleHandTool"
   | "toggleHandTool"
+  | "selectAllElementsInFrame"
+  | "removeAllElementsFromFrame"
+  | "toggleFrameRendering"
+  | "setFrameAsActiveTool"
+  | "createContainerFromText"
   | "wrapTextInContainer";
   | "wrapTextInContainer";
 
 
 export type PanelComponentProps = {
 export type PanelComponentProps = {

+ 2 - 2
src/align.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "./element/types";
 import { ExcalidrawElement } from "./element/types";
 import { newElementWith } from "./element/mutateElement";
 import { newElementWith } from "./element/mutateElement";
-import { Box, getCommonBoundingBox } from "./element/bounds";
+import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
 import { getMaximumGroups } from "./groups";
 import { getMaximumGroups } from "./groups";
 
 
 export interface Alignment {
 export interface Alignment {
@@ -33,7 +33,7 @@ export const alignElements = (
 
 
 const calculateTranslation = (
 const calculateTranslation = (
   group: ExcalidrawElement[],
   group: ExcalidrawElement[],
-  selectionBoundingBox: Box,
+  selectionBoundingBox: BoundingBox,
   { axis, position }: Alignment,
   { axis, position }: Alignment,
 ): { x: number; y: number } => {
 ): { x: number; y: number } => {
   const groupBoundingBox = getCommonBoundingBox(group);
   const groupBoundingBox = getCommonBoundingBox(group);

+ 14 - 0
src/appState.ts

@@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
     scrollY: 0,
     scrollY: 0,
     selectedElementIds: {},
     selectedElementIds: {},
     selectedGroupIds: {},
     selectedGroupIds: {},
+    selectedElementsAreBeingDragged: false,
     selectionElement: null,
     selectionElement: null,
     shouldCacheIgnoreZoom: false,
     shouldCacheIgnoreZoom: false,
     showStats: false,
     showStats: false,
     startBoundElement: null,
     startBoundElement: null,
     suggestedBindings: [],
     suggestedBindings: [],
+    shouldRenderFrames: true,
+    frameToHighlight: null,
+    editingFrame: null,
+    elementsToHighlight: null,
     toast: null,
     toast: null,
     viewBackgroundColor: COLOR_PALETTE.white,
     viewBackgroundColor: COLOR_PALETTE.white,
     zenModeEnabled: false,
     zenModeEnabled: false,
@@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
   scrollY: { browser: true, export: false, server: false },
   scrollY: { browser: true, export: false, server: false },
   selectedElementIds: { browser: true, export: false, server: false },
   selectedElementIds: { browser: true, export: false, server: false },
   selectedGroupIds: { browser: true, export: false, server: false },
   selectedGroupIds: { browser: true, export: false, server: false },
+  selectedElementsAreBeingDragged: {
+    browser: false,
+    export: false,
+    server: false,
+  },
   selectionElement: { browser: false, export: false, server: false },
   selectionElement: { browser: false, export: false, server: false },
   shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
   shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
   showStats: { browser: true, export: false, server: false },
   showStats: { browser: true, export: false, server: false },
   startBoundElement: { browser: false, export: false, server: false },
   startBoundElement: { browser: false, export: false, server: false },
   suggestedBindings: { browser: false, export: false, server: false },
   suggestedBindings: { browser: false, export: false, server: false },
+  shouldRenderFrames: { browser: false, export: false, server: false },
+  frameToHighlight: { browser: false, export: false, server: false },
+  editingFrame: { browser: false, export: false, server: false },
+  elementsToHighlight: { browser: false, export: false, server: false },
   toast: { browser: false, export: false, server: false },
   toast: { browser: false, export: false, server: false },
   viewBackgroundColor: { browser: true, export: true, server: true },
   viewBackgroundColor: { browser: true, export: true, server: true },
   width: { browser: false, export: false, server: false },
   width: { browser: false, export: false, server: false },

+ 20 - 1
src/clipboard.ts

@@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
 import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 import { isInitializedImageElement } from "./element/typeChecks";
 import { isInitializedImageElement } from "./element/typeChecks";
+import { deepCopyElement } from "./element/newElement";
+import { mutateElement } from "./element/mutateElement";
+import { getContainingFrame } from "./frame";
 import { isPromiseLike, isTestEnv } from "./utils";
 import { isPromiseLike, isTestEnv } from "./utils";
 
 
 type ElementsClipboard = {
 type ElementsClipboard = {
@@ -57,6 +60,9 @@ export const copyToClipboard = async (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
   files: BinaryFiles | null,
   files: BinaryFiles | null,
 ) => {
 ) => {
+  const framesToCopy = new Set(
+    elements.filter((element) => element.type === "frame"),
+  );
   let foundFile = false;
   let foundFile = false;
 
 
   const _files = elements.reduce((acc, element) => {
   const _files = elements.reduce((acc, element) => {
@@ -78,7 +84,20 @@ export const copyToClipboard = async (
   // select binded text elements when copying
   // select binded text elements when copying
   const contents: ElementsClipboard = {
   const contents: ElementsClipboard = {
     type: EXPORT_DATA_TYPES.excalidrawClipboard,
     type: EXPORT_DATA_TYPES.excalidrawClipboard,
-    elements,
+    elements: elements.map((element) => {
+      if (
+        getContainingFrame(element) &&
+        !framesToCopy.has(getContainingFrame(element)!)
+      ) {
+        const copiedElement = deepCopyElement(element);
+        mutateElement(copiedElement, {
+          frameId: null,
+        });
+        return copiedElement;
+      }
+
+      return element;
+    }),
     files: files ? _files : undefined,
     files: files ? _files : undefined,
   };
   };
   const json = JSON.stringify(contents);
   const json = JSON.stringify(contents);

+ 114 - 36
src/components/Actions.tsx

@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useState } from "react";
 import { ActionManager } from "../actions/manager";
 import { ActionManager } from "../actions/manager";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement, PointerType } from "../element/types";
 import { ExcalidrawElement, PointerType } from "../element/types";
@@ -35,6 +35,9 @@ import {
 } from "../element/textElement";
 } from "../element/textElement";
 
 
 import "./Actions.scss";
 import "./Actions.scss";
+import DropdownMenu from "./dropdownMenu/DropdownMenu";
+import { extraToolsIcon, frameToolIcon } from "./icons";
+import { KEYS } from "../keys";
 
 
 export const SelectedShapeActions = ({
 export const SelectedShapeActions = ({
   appState,
   appState,
@@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
       <div>
       <div>
         {((hasStrokeColor(appState.activeTool.type) &&
         {((hasStrokeColor(appState.activeTool.type) &&
           appState.activeTool.type !== "image" &&
           appState.activeTool.type !== "image" &&
-          commonSelectedType !== "image") ||
+          commonSelectedType !== "image" &&
+          commonSelectedType !== "frame") ||
           targetElements.some((element) => hasStrokeColor(element.type))) &&
           targetElements.some((element) => hasStrokeColor(element.type))) &&
           renderAction("changeStrokeColor")}
           renderAction("changeStrokeColor")}
       </div>
       </div>
@@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
   setAppState: React.Component<any, UIAppState>["setState"];
   setAppState: React.Component<any, UIAppState>["setState"];
   onImageAction: (data: { pointerType: PointerType | null }) => void;
   onImageAction: (data: { pointerType: PointerType | null }) => void;
   appState: UIAppState;
   appState: UIAppState;
-}) => (
-  <>
-    {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
-      const label = t(`toolBar.${value}`);
-      const letter =
-        key && capitalizeString(typeof key === "string" ? key : key[0]);
-      const shortcut = letter
-        ? `${letter} ${t("helpDialog.or")} ${numericKey}`
-        : `${numericKey}`;
-      return (
+}) => {
+  const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
+  const device = useDevice();
+  return (
+    <>
+      {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
+        const label = t(`toolBar.${value}`);
+        const letter =
+          key && capitalizeString(typeof key === "string" ? key : key[0]);
+        const shortcut = letter
+          ? `${letter} ${t("helpDialog.or")} ${numericKey}`
+          : `${numericKey}`;
+        return (
+          <ToolButton
+            className={clsx("Shape", { fillable })}
+            key={value}
+            type="radio"
+            icon={icon}
+            checked={activeTool.type === value}
+            name="editor-current-shape"
+            title={`${capitalizeString(label)} — ${shortcut}`}
+            keyBindingLabel={numericKey || letter}
+            aria-label={capitalizeString(label)}
+            aria-keyshortcuts={shortcut}
+            data-testid={`toolbar-${value}`}
+            onPointerDown={({ pointerType }) => {
+              if (!appState.penDetected && pointerType === "pen") {
+                setAppState({
+                  penDetected: true,
+                  penMode: true,
+                });
+              }
+            }}
+            onChange={({ pointerType }) => {
+              if (appState.activeTool.type !== value) {
+                trackEvent("toolbar", value, "ui");
+              }
+              const nextActiveTool = updateActiveTool(appState, {
+                type: value,
+              });
+              setAppState({
+                activeTool: nextActiveTool,
+                multiElement: null,
+                selectedElementIds: {},
+              });
+              setCursorForShape(canvas, {
+                ...appState,
+                activeTool: nextActiveTool,
+              });
+              if (value === "image") {
+                onImageAction({ pointerType });
+              }
+            }}
+          />
+        );
+      })}
+      <div className="App-toolbar__divider" />
+      {/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
+      {device.isMobile ? (
         <ToolButton
         <ToolButton
-          className={clsx("Shape", { fillable })}
-          key={value}
+          className={clsx("Shape", { fillable: false })}
           type="radio"
           type="radio"
-          icon={icon}
-          checked={activeTool.type === value}
+          icon={frameToolIcon}
+          checked={activeTool.type === "frame"}
           name="editor-current-shape"
           name="editor-current-shape"
-          title={`${capitalizeString(label)} — ${shortcut}`}
-          keyBindingLabel={numericKey || letter}
-          aria-label={capitalizeString(label)}
-          aria-keyshortcuts={shortcut}
-          data-testid={`toolbar-${value}`}
+          title={`${capitalizeString(
+            t("toolBar.frame"),
+          )} — ${KEYS.F.toLocaleUpperCase()}`}
+          keyBindingLabel={KEYS.F.toLocaleUpperCase()}
+          aria-label={capitalizeString(t("toolBar.frame"))}
+          aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
+          data-testid={`toolbar-frame`}
           onPointerDown={({ pointerType }) => {
           onPointerDown={({ pointerType }) => {
             if (!appState.penDetected && pointerType === "pen") {
             if (!appState.penDetected && pointerType === "pen") {
               setAppState({
               setAppState({
@@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
             }
             }
           }}
           }}
           onChange={({ pointerType }) => {
           onChange={({ pointerType }) => {
-            if (appState.activeTool.type !== value) {
-              trackEvent("toolbar", value, "ui");
-            }
+            trackEvent("toolbar", "frame", "ui");
             const nextActiveTool = updateActiveTool(appState, {
             const nextActiveTool = updateActiveTool(appState, {
-              type: value,
+              type: "frame",
             });
             });
             setAppState({
             setAppState({
               activeTool: nextActiveTool,
               activeTool: nextActiveTool,
               multiElement: null,
               multiElement: null,
               selectedElementIds: {},
               selectedElementIds: {},
             });
             });
-            setCursorForShape(canvas, {
-              ...appState,
-              activeTool: nextActiveTool,
-            });
-            if (value === "image") {
-              onImageAction({ pointerType });
-            }
           }}
           }}
         />
         />
-      );
-    })}
-  </>
-);
+      ) : (
+        <DropdownMenu open={isExtraToolsMenuOpen}>
+          <DropdownMenu.Trigger
+            className="App-toolbar__extra-tools-trigger"
+            onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
+            title={t("toolBar.extraTools")}
+          >
+            {extraToolsIcon}
+          </DropdownMenu.Trigger>
+          <DropdownMenu.Content
+            onClickOutside={() => setIsExtraToolsMenuOpen(false)}
+            onSelect={() => setIsExtraToolsMenuOpen(false)}
+            className="App-toolbar__extra-tools-dropdown"
+          >
+            <DropdownMenu.Item
+              onSelect={() => {
+                const nextActiveTool = updateActiveTool(appState, {
+                  type: "frame",
+                });
+                setAppState({
+                  activeTool: nextActiveTool,
+                  multiElement: null,
+                  selectedElementIds: {},
+                });
+              }}
+              icon={frameToolIcon}
+              shortcut={KEYS.F.toLocaleUpperCase()}
+              data-testid="toolbar-frame"
+            >
+              {t("toolBar.frame")}
+            </DropdownMenu.Item>
+          </DropdownMenu.Content>
+        </DropdownMenu>
+      )}
+    </>
+  );
+};
 
 
 export const ZoomActions = ({
 export const ZoomActions = ({
   renderAction,
   renderAction,

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 504 - 63
src/components/App.tsx


+ 1 - 0
src/components/HelpDialog.tsx

@@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("toolBar.eraser")}
               label={t("toolBar.eraser")}
               shortcuts={[KEYS.E, KEYS["0"]]}
               shortcuts={[KEYS.E, KEYS["0"]]}
             />
             />
+            <Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
             <Shortcut
             <Shortcut
               label={t("labels.eyeDropper")}
               label={t("labels.eyeDropper")}
               shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
               shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

+ 4 - 1
src/components/ImageExportDialog.tsx

@@ -84,7 +84,10 @@ const ImageExportModal = ({
   const [renderError, setRenderError] = useState<Error | null>(null);
   const [renderError, setRenderError] = useState<Error | null>(null);
 
 
   const exportedElements = exportSelected
   const exportedElements = exportSelected
-    ? getSelectedElements(elements, appState, true)
+    ? getSelectedElements(elements, appState, {
+        includeBoundTextElement: true,
+        includeElementsInFrames: true,
+      })
     : elements;
     : elements;
 
 
   useEffect(() => {
   useEffect(() => {

+ 2 - 7
src/components/LayerUI.tsx

@@ -204,12 +204,7 @@ const LayerUI = ({
     return (
     return (
       <FixedSideContainer side="top">
       <FixedSideContainer side="top">
         <div className="App-menu App-menu_top">
         <div className="App-menu App-menu_top">
-          <Stack.Col
-            gap={6}
-            className={clsx("App-menu_top__left", {
-              "disable-pointerEvents": appState.zenModeEnabled,
-            })}
-          >
+          <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
             {renderCanvasActions()}
             {renderCanvasActions()}
             {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
             {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
           </Stack.Col>
           </Stack.Col>
@@ -254,7 +249,7 @@ const LayerUI = ({
                             title={t("toolBar.lock")}
                             title={t("toolBar.lock")}
                           />
                           />
 
 
-                          <div className="App-toolbar__divider"></div>
+                          <div className="App-toolbar__divider" />
 
 
                           <HandButton
                           <HandButton
                             checked={isHandToolActive(appState)}
                             checked={isHandToolActive(appState)}

+ 5 - 1
src/components/LibraryMenu.tsx

@@ -148,7 +148,11 @@ const usePendingElementsMemo = (
   appState: UIAppState,
   appState: UIAppState,
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
 ) => {
 ) => {
-  const create = () => getSelectedElements(elements, appState, true);
+  const create = () =>
+    getSelectedElements(elements, appState, {
+      includeBoundTextElement: true,
+      includeElementsInFrames: true,
+    });
   const val = useRef(create());
   const val = useRef(create());
   const prevAppState = useRef<UIAppState>(appState);
   const prevAppState = useRef<UIAppState>(appState);
   const prevElements = useRef(elements);
   const prevElements = useRef(elements);

+ 3 - 1
src/components/ToolButton.tsx

@@ -1,6 +1,6 @@
 import "./ToolIcon.scss";
 import "./ToolIcon.scss";
 
 
-import React, { useEffect, useRef, useState } from "react";
+import React, { CSSProperties, useEffect, useRef, useState } from "react";
 import clsx from "clsx";
 import clsx from "clsx";
 import { useExcalidrawContainer } from "./App";
 import { useExcalidrawContainer } from "./App";
 import { AbortError } from "../errors";
 import { AbortError } from "../errors";
@@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
   visible?: boolean;
   visible?: boolean;
   selected?: boolean;
   selected?: boolean;
   className?: string;
   className?: string;
+  style?: CSSProperties;
   isLoading?: boolean;
   isLoading?: boolean;
 };
 };
 
 
@@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
             "ToolIcon--plain": props.type === "icon",
             "ToolIcon--plain": props.type === "icon",
           },
           },
         )}
         )}
+        style={props.style}
         data-testid={props["data-testid"]}
         data-testid={props["data-testid"]}
         hidden={props.hidden}
         hidden={props.hidden}
         title={props.title}
         title={props.title}

+ 18 - 1
src/components/Toolbar.scss

@@ -15,7 +15,24 @@
       height: 1.5rem;
       height: 1.5rem;
       align-self: center;
       align-self: center;
       background-color: var(--default-border-color);
       background-color: var(--default-border-color);
-      margin: 0 0.5rem;
+      margin: 0 0.25rem;
     }
     }
   }
   }
+
+  .App-toolbar__extra-tools-trigger {
+    box-shadow: none;
+    border: 0;
+
+    &:active {
+      background-color: var(--button-hover-bg);
+      box-shadow: 0 0 0 1px
+        var(--button-active-border, var(--color-primary-darkest)) inset;
+    }
+  }
+
+  .App-toolbar__extra-tools-dropdown {
+    margin-top: 0.375rem;
+    right: 0;
+    min-width: 11.875rem;
+  }
 }
 }

+ 6 - 4
src/components/dropdownMenu/DropdownMenuTrigger.tsx

@@ -1,23 +1,23 @@
 import clsx from "clsx";
 import clsx from "clsx";
-import { useUIAppState } from "../../context/ui-appState";
 import { useDevice } from "../App";
 import { useDevice } from "../App";
 
 
 const MenuTrigger = ({
 const MenuTrigger = ({
   className = "",
   className = "",
   children,
   children,
   onToggle,
   onToggle,
+  title,
+  ...rest
 }: {
 }: {
   className?: string;
   className?: string;
   children: React.ReactNode;
   children: React.ReactNode;
   onToggle: () => void;
   onToggle: () => void;
-}) => {
-  const appState = useUIAppState();
+  title?: string;
+} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
   const device = useDevice();
   const device = useDevice();
   const classNames = clsx(
   const classNames = clsx(
     `dropdown-menu-button ${className}`,
     `dropdown-menu-button ${className}`,
     "zen-mode-transition",
     "zen-mode-transition",
     {
     {
-      "transition-left": appState.zenModeEnabled,
       "dropdown-menu-button--mobile": device.isMobile,
       "dropdown-menu-button--mobile": device.isMobile,
     },
     },
   ).trim();
   ).trim();
@@ -28,6 +28,8 @@ const MenuTrigger = ({
       onClick={onToggle}
       onClick={onToggle}
       type="button"
       type="button"
       data-testid="dropdown-menu-button"
       data-testid="dropdown-menu-button"
+      title={title}
+      {...rest}
     >
     >
       {children}
       {children}
     </button>
     </button>

+ 12 - 12
src/components/hoc/withInternalFallback.test.tsx

@@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
       </div>,
       </div>,
     );
     );
 
 
-    expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
+    expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
 
 
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
       ".excalidraw-container",
       ".excalidraw-container",
     );
     );
 
 
     expect(
     expect(
-      queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
     expect(
     expect(
-      queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
   });
   });
 
 
@@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
       </div>,
       </div>,
     );
     );
 
 
-    expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
+    expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
 
 
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
       ".excalidraw-container",
       ".excalidraw-container",
     );
     );
 
 
     expect(
     expect(
-      queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
     expect(
     expect(
-      queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
   });
   });
 
 
@@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
       </div>,
       </div>,
     );
     );
 
 
-    expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
+    expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
 
 
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
       ".excalidraw-container",
       ".excalidraw-container",
     );
     );
 
 
     expect(
     expect(
-      queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
     expect(
     expect(
-      queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
   });
   });
 
 
@@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
       </div>,
       </div>,
     );
     );
 
 
-    expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
+    expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
 
 
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
     const excalContainers = container.querySelectorAll<HTMLDivElement>(
       ".excalidraw-container",
       ".excalidraw-container",
     );
     );
 
 
     expect(
     expect(
-      queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
     expect(
     expect(
-      queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
+      queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
     ).toBe(1);
     ).toBe(1);
   });
   });
 });
 });

+ 21 - 0
src/components/icons.tsx

@@ -1616,3 +1616,24 @@ export const eyeDropperIcon = createIcon(
   </g>,
   </g>,
   tablerIconProps,
   tablerIconProps,
 );
 );
+
+export const extraToolsIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <path d="M12 3l-4 7h8z"></path>
+    <path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
+    <path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
+  </g>,
+  tablerIconProps,
+);
+
+export const frameToolIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <path d="M4 7l16 0"></path>
+    <path d="M4 17l16 0"></path>
+    <path d="M7 4l0 16"></path>
+    <path d="M17 4l0 16"></path>
+  </g>,
+  tablerIconProps,
+);

+ 1 - 0
src/components/main-menu/MainMenu.tsx

@@ -42,6 +42,7 @@ const MainMenu = Object.assign(
                   openMenu: appState.openMenu === "canvas" ? null : "canvas",
                   openMenu: appState.openMenu === "canvas" ? null : "canvas",
                 });
                 });
               }}
               }}
+              data-testid="main-menu-trigger"
             >
             >
               {HamburgerMenuIcon}
               {HamburgerMenuIcon}
             </DropdownMenu.Trigger>
             </DropdownMenu.Trigger>

+ 11 - 0
src/constants.ts

@@ -94,6 +94,17 @@ export const THEME = {
   DARK: "dark",
   DARK: "dark",
 };
 };
 
 
+export const FRAME_STYLE = {
+  strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
+  strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
+  strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
+  fillStyle: "solid" as ExcalidrawElement["fillStyle"],
+  roughness: 0 as ExcalidrawElement["roughness"],
+  roundness: null as ExcalidrawElement["roundness"],
+  backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
+  radius: 8,
+};
+
 export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 
 
 export const DEFAULT_FONT_SIZE = 20;
 export const DEFAULT_FONT_SIZE = 20;

+ 6 - 0
src/data/restore.ts

@@ -62,6 +62,7 @@ export const AllowedExcalidrawActiveTools: Record<
   freedraw: true,
   freedraw: true,
   eraser: false,
   eraser: false,
   custom: true,
   custom: true,
+  frame: true,
   hand: true,
   hand: true,
 };
 };
 
 
@@ -125,6 +126,7 @@ const restoreElementWithProperties = <
     height: element.height || 0,
     height: element.height || 0,
     seed: element.seed ?? 1,
     seed: element.seed ?? 1,
     groupIds: element.groupIds ?? [],
     groupIds: element.groupIds ?? [],
+    frameId: element.frameId ?? null,
     roundness: element.roundness
     roundness: element.roundness
       ? element.roundness
       ? element.roundness
       : element.strokeSharpness === "round"
       : element.strokeSharpness === "round"
@@ -272,6 +274,10 @@ const restoreElement = (
       return restoreElementWithProperties(element, {});
       return restoreElementWithProperties(element, {});
     case "diamond":
     case "diamond":
       return restoreElementWithProperties(element, {});
       return restoreElementWithProperties(element, {});
+    case "frame":
+      return restoreElementWithProperties(element, {
+        name: element.name ?? null,
+      });
 
 
     // Don't use default case so as to catch a missing an element type case.
     // Don't use default case so as to catch a missing an element type case.
     // We also don't want to throw, but instead return void so we filter
     // We also don't want to throw, but instead return void so we filter

+ 4 - 2
src/element/Hyperlink.tsx

@@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
   if (
   if (
     !isMobile &&
     !isMobile &&
     appState.viewModeEnabled &&
     appState.viewModeEnabled &&
-    isPointHittingElementBoundingBox(element, [x, y], threshold)
+    isPointHittingElementBoundingBox(element, [x, y], threshold, null)
   ) {
   ) {
     return true;
     return true;
   }
   }
@@ -440,7 +440,9 @@ export const shouldHideLinkPopup = (
 
 
   const threshold = 15 / appState.zoom.value;
   const threshold = 15 / appState.zoom.value;
   // hitbox to prevent hiding when hovered in element bounding box
   // hitbox to prevent hiding when hovered in element bounding box
-  if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
+  if (
+    isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
+  ) {
     return false;
     return false;
   }
   }
   const [x1, y1, x2] = getElementAbsoluteCoords(element);
   const [x1, y1, x2] = getElementAbsoluteCoords(element);

+ 1 - 1
src/element/binding.ts

@@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
 ];
 ];
 
 
 export const shouldEnableBindingForPointerEvent = (
 export const shouldEnableBindingForPointerEvent = (
-  event: React.PointerEvent<HTMLCanvasElement>,
+  event: React.PointerEvent<HTMLElement>,
 ) => {
 ) => {
   return !event[KEYS.CTRL_OR_CMD];
   return !event[KEYS.CTRL_OR_CMD];
 };
 };

+ 205 - 61
src/element/bounds.ts

@@ -6,7 +6,7 @@ import {
   NonDeleted,
   NonDeleted,
   ExcalidrawTextElementWithContainer,
   ExcalidrawTextElementWithContainer,
 } from "./types";
 } from "./types";
-import { distance2d, rotate } from "../math";
+import { distance2d, rotate, rotatePoint } from "../math";
 import rough from "roughjs/bin/rough";
 import rough from "roughjs/bin/rough";
 import { Drawable, Op } from "roughjs/bin/core";
 import { Drawable, Op } from "roughjs/bin/core";
 import { Point } from "../types";
 import { Point } from "../types";
@@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
 import { Mutable } from "../utility-types";
 import { Mutable } from "../utility-types";
 
 
-// x and y position of top left corner, x and y position of bottom right corner
-export type Bounds = readonly [number, number, number, number];
+export type RectangleBox = {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  angle: number;
+};
+
 type MaybeQuadraticSolution = [number | null, number | null] | false;
 type MaybeQuadraticSolution = [number | null, number | null] | false;
 
 
+// x and y position of top left corner, x and y position of bottom right corner
+export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
+
+export class ElementBounds {
+  private static boundsCache = new WeakMap<
+    ExcalidrawElement,
+    {
+      bounds: Bounds;
+      version: ExcalidrawElement["version"];
+    }
+  >();
+
+  static getBounds(element: ExcalidrawElement) {
+    const cachedBounds = ElementBounds.boundsCache.get(element);
+
+    if (cachedBounds?.version && cachedBounds.version === element.version) {
+      return cachedBounds.bounds;
+    }
+
+    const bounds = ElementBounds.calculateBounds(element);
+
+    ElementBounds.boundsCache.set(element, {
+      version: element.version,
+      bounds,
+    });
+
+    return bounds;
+  }
+
+  private static calculateBounds(element: ExcalidrawElement): Bounds {
+    let bounds: [number, number, number, number];
+
+    const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
+
+    if (isFreeDrawElement(element)) {
+      const [minX, minY, maxX, maxY] = getBoundsFromPoints(
+        element.points.map(([x, y]) =>
+          rotate(x, y, cx - element.x, cy - element.y, element.angle),
+        ),
+      );
+
+      return [
+        minX + element.x,
+        minY + element.y,
+        maxX + element.x,
+        maxY + element.y,
+      ];
+    } else if (isLinearElement(element)) {
+      bounds = getLinearElementRotatedBounds(element, cx, cy);
+    } else if (element.type === "diamond") {
+      const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
+      const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
+      const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
+      const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
+      const minX = Math.min(x11, x12, x22, x21);
+      const minY = Math.min(y11, y12, y22, y21);
+      const maxX = Math.max(x11, x12, x22, x21);
+      const maxY = Math.max(y11, y12, y22, y21);
+      bounds = [minX, minY, maxX, maxY];
+    } else if (element.type === "ellipse") {
+      const w = (x2 - x1) / 2;
+      const h = (y2 - y1) / 2;
+      const cos = Math.cos(element.angle);
+      const sin = Math.sin(element.angle);
+      const ww = Math.hypot(w * cos, h * sin);
+      const hh = Math.hypot(h * cos, w * sin);
+      bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
+    } else {
+      const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
+      const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
+      const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
+      const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
+      const minX = Math.min(x11, x12, x22, x21);
+      const minY = Math.min(y11, y12, y22, y21);
+      const maxX = Math.max(x11, x12, x22, x21);
+      const maxY = Math.max(y11, y12, y22, y21);
+      bounds = [minX, minY, maxX, maxY];
+    }
+
+    return bounds;
+  }
+}
+
+// Scene -> Scene coords, but in x1,x2,y1,y2 format.
+//
 // If the element is created from right to left, the width is going to be negative
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
 // This set of functions retrieves the absolute position of the 4 points.
 export const getElementAbsoluteCoords = (
 export const getElementAbsoluteCoords = (
@@ -69,6 +160,111 @@ export const getElementAbsoluteCoords = (
   ];
   ];
 };
 };
 
 
+/**
+ * for a given element, `getElementLineSegments` returns line segments
+ * that can be used for visual collision detection (useful for frames)
+ * as opposed to bounding box collision detection
+ */
+export const getElementLineSegments = (
+  element: ExcalidrawElement,
+): [Point, Point][] => {
+  const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
+
+  const center: Point = [cx, cy];
+
+  if (isLinearElement(element) || isFreeDrawElement(element)) {
+    const segments: [Point, Point][] = [];
+
+    let i = 0;
+
+    while (i < element.points.length - 1) {
+      segments.push([
+        rotatePoint(
+          [
+            element.points[i][0] + element.x,
+            element.points[i][1] + element.y,
+          ] as Point,
+          center,
+          element.angle,
+        ),
+        rotatePoint(
+          [
+            element.points[i + 1][0] + element.x,
+            element.points[i + 1][1] + element.y,
+          ] as Point,
+          center,
+          element.angle,
+        ),
+      ]);
+      i++;
+    }
+
+    return segments;
+  }
+
+  const [nw, ne, sw, se, n, s, w, e] = (
+    [
+      [x1, y1],
+      [x2, y1],
+      [x1, y2],
+      [x2, y2],
+      [cx, y1],
+      [cx, y2],
+      [x1, cy],
+      [x2, cy],
+    ] as Point[]
+  ).map((point) => rotatePoint(point, center, element.angle));
+
+  if (element.type === "diamond") {
+    return [
+      [n, w],
+      [n, e],
+      [s, w],
+      [s, e],
+    ];
+  }
+
+  if (element.type === "ellipse") {
+    return [
+      [n, w],
+      [n, e],
+      [s, w],
+      [s, e],
+      [n, w],
+      [n, e],
+      [s, w],
+      [s, e],
+    ];
+  }
+
+  return [
+    [nw, ne],
+    [sw, se],
+    [nw, sw],
+    [ne, se],
+    [nw, e],
+    [sw, e],
+    [ne, w],
+    [se, w],
+  ];
+};
+
+/**
+ * Scene -> Scene coords, but in x1,x2,y1,y2 format.
+ *
+ * Rectangle here means any rectangular frame, not an excalidraw element.
+ */
+export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
+  return [
+    boxSceneCoords.x,
+    boxSceneCoords.y,
+    boxSceneCoords.x + boxSceneCoords.width,
+    boxSceneCoords.y + boxSceneCoords.height,
+    boxSceneCoords.x + boxSceneCoords.width / 2,
+    boxSceneCoords.y + boxSceneCoords.height / 2,
+  ];
+};
+
 export const pointRelativeTo = (
 export const pointRelativeTo = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   absoluteCoords: Point,
   absoluteCoords: Point,
@@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
   return coords;
   return coords;
 };
 };
 
 
-// We could cache this stuff
-export const getElementBounds = (
-  element: ExcalidrawElement,
-): [number, number, number, number] => {
-  let bounds: [number, number, number, number];
-
-  const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
-  if (isFreeDrawElement(element)) {
-    const [minX, minY, maxX, maxY] = getBoundsFromPoints(
-      element.points.map(([x, y]) =>
-        rotate(x, y, cx - element.x, cy - element.y, element.angle),
-      ),
-    );
-
-    return [
-      minX + element.x,
-      minY + element.y,
-      maxX + element.x,
-      maxY + element.y,
-    ];
-  } else if (isLinearElement(element)) {
-    bounds = getLinearElementRotatedBounds(element, cx, cy);
-  } else if (element.type === "diamond") {
-    const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
-    const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
-    const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
-    const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
-    const minX = Math.min(x11, x12, x22, x21);
-    const minY = Math.min(y11, y12, y22, y21);
-    const maxX = Math.max(x11, x12, x22, x21);
-    const maxY = Math.max(y11, y12, y22, y21);
-    bounds = [minX, minY, maxX, maxY];
-  } else if (element.type === "ellipse") {
-    const w = (x2 - x1) / 2;
-    const h = (y2 - y1) / 2;
-    const cos = Math.cos(element.angle);
-    const sin = Math.sin(element.angle);
-    const ww = Math.hypot(w * cos, h * sin);
-    const hh = Math.hypot(h * cos, w * sin);
-    bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
-  } else {
-    const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
-    const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
-    const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
-    const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
-    const minX = Math.min(x11, x12, x22, x21);
-    const minY = Math.min(y11, y12, y22, y21);
-    const maxX = Math.max(x11, x12, x22, x21);
-    const maxY = Math.max(y11, y12, y22, y21);
-    bounds = [minX, minY, maxX, maxY];
-  }
-
-  return bounds;
+export const getElementBounds = (element: ExcalidrawElement): Bounds => {
+  return ElementBounds.getBounds(element);
 };
 };
-
 export const getCommonBounds = (
 export const getCommonBounds = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
-): [number, number, number, number] => {
+): Bounds => {
   if (!elements.length) {
   if (!elements.length) {
     return [0, 0, 0, 0];
     return [0, 0, 0, 0];
   }
   }
@@ -608,7 +752,7 @@ export const getElementPointsCoords = (
 export const getClosestElementBounds = (
 export const getClosestElementBounds = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   from: { x: number; y: number },
   from: { x: number; y: number },
-): [number, number, number, number] => {
+): Bounds => {
   if (!elements.length) {
   if (!elements.length) {
     return [0, 0, 0, 0];
     return [0, 0, 0, 0];
   }
   }
@@ -629,7 +773,7 @@ export const getClosestElementBounds = (
   return getElementBounds(closestElement);
   return getElementBounds(closestElement);
 };
 };
 
 
-export interface Box {
+export interface BoundingBox {
   minX: number;
   minX: number;
   minY: number;
   minY: number;
   maxX: number;
   maxX: number;
@@ -642,7 +786,7 @@ export interface Box {
 
 
 export const getCommonBoundingBox = (
 export const getCommonBoundingBox = (
   elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
   elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
-): Box => {
+): BoundingBox => {
   const [minX, minY, maxX, maxY] = getCommonBounds(elements);
   const [minX, minY, maxX, maxY] = getCommonBounds(elements);
   return {
   return {
     minX,
     minX,

+ 146 - 22
src/element/collision.ts

@@ -26,10 +26,16 @@ import {
   ExcalidrawImageElement,
   ExcalidrawImageElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
   StrokeRoundness,
   StrokeRoundness,
+  ExcalidrawFrameElement,
 } from "./types";
 } from "./types";
 
 
-import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
-import { Point } from "../types";
+import {
+  getElementAbsoluteCoords,
+  getCurvePathOps,
+  getRectangleBoxAbsoluteCoords,
+  RectangleBox,
+} from "./bounds";
+import { FrameNameBoundsCache, Point } from "../types";
 import { Drawable } from "roughjs/bin/core";
 import { Drawable } from "roughjs/bin/core";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { getShapeForElement } from "../renderer/renderElement";
 import { getShapeForElement } from "../renderer/renderElement";
@@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
 export const hitTest = (
 export const hitTest = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
   appState: AppState,
   appState: AppState,
+  frameNameBoundsCache: FrameNameBoundsCache,
   x: number,
   x: number,
   y: number,
   y: number,
 ): boolean => {
 ): boolean => {
@@ -72,22 +79,39 @@ export const hitTest = (
     isElementSelected(appState, element) &&
     isElementSelected(appState, element) &&
     shouldShowBoundingBox([element], appState)
     shouldShowBoundingBox([element], appState)
   ) {
   ) {
-    return isPointHittingElementBoundingBox(element, point, threshold);
+    return isPointHittingElementBoundingBox(
+      element,
+      point,
+      threshold,
+      frameNameBoundsCache,
+    );
   }
   }
 
 
   const boundTextElement = getBoundTextElement(element);
   const boundTextElement = getBoundTextElement(element);
   if (boundTextElement) {
   if (boundTextElement) {
-    const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
+    const isHittingBoundTextElement = hitTest(
+      boundTextElement,
+      appState,
+      frameNameBoundsCache,
+      x,
+      y,
+    );
     if (isHittingBoundTextElement) {
     if (isHittingBoundTextElement) {
       return true;
       return true;
     }
     }
   }
   }
-  return isHittingElementNotConsideringBoundingBox(element, appState, point);
+  return isHittingElementNotConsideringBoundingBox(
+    element,
+    appState,
+    frameNameBoundsCache,
+    point,
+  );
 };
 };
 
 
 export const isHittingElementBoundingBoxWithoutHittingElement = (
 export const isHittingElementBoundingBoxWithoutHittingElement = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
   appState: AppState,
   appState: AppState,
+  frameNameBoundsCache: FrameNameBoundsCache,
   x: number,
   x: number,
   y: number,
   y: number,
 ): boolean => {
 ): boolean => {
@@ -96,19 +120,33 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
   // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
   // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
   // eg for linear elements text can be outside the element bounding box
   // eg for linear elements text can be outside the element bounding box
   const boundTextElement = getBoundTextElement(element);
   const boundTextElement = getBoundTextElement(element);
-  if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
+  if (
+    boundTextElement &&
+    hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
+  ) {
     return false;
     return false;
   }
   }
 
 
   return (
   return (
-    !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
-    isPointHittingElementBoundingBox(element, [x, y], threshold)
+    !isHittingElementNotConsideringBoundingBox(
+      element,
+      appState,
+      frameNameBoundsCache,
+      [x, y],
+    ) &&
+    isPointHittingElementBoundingBox(
+      element,
+      [x, y],
+      threshold,
+      frameNameBoundsCache,
+    )
   );
   );
 };
 };
 
 
 export const isHittingElementNotConsideringBoundingBox = (
 export const isHittingElementNotConsideringBoundingBox = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
   appState: AppState,
   appState: AppState,
+  frameNameBoundsCache: FrameNameBoundsCache | null,
   point: Point,
   point: Point,
 ): boolean => {
 ): boolean => {
   const threshold = 10 / appState.zoom.value;
   const threshold = 10 / appState.zoom.value;
@@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
     : isElementDraggableFromInside(element)
     : isElementDraggableFromInside(element)
     ? isInsideCheck
     ? isInsideCheck
     : isNearCheck;
     : isNearCheck;
-  return hitTestPointAgainstElement({ element, point, threshold, check });
+  return hitTestPointAgainstElement({
+    element,
+    point,
+    threshold,
+    check,
+    frameNameBoundsCache,
+  });
 };
 };
 
 
 const isElementSelected = (
 const isElementSelected = (
@@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
   element: NonDeleted<ExcalidrawElement>,
   element: NonDeleted<ExcalidrawElement>,
   [x, y]: Point,
   [x, y]: Point,
   threshold: number,
   threshold: number,
+  frameNameBoundsCache: FrameNameBoundsCache | null,
 ) => {
 ) => {
+  // frames needs be checked differently so as to be able to drag it
+  // by its frame, whether it has been selected or not
+  // this logic here is not ideal
+  // TODO: refactor it later...
+  if (element.type === "frame") {
+    return hitTestPointAgainstElement({
+      element,
+      point: [x, y],
+      threshold,
+      check: isInsideCheck,
+      frameNameBoundsCache,
+    });
+  }
+
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const elementCenterX = (x1 + x2) / 2;
   const elementCenterX = (x1 + x2) / 2;
   const elementCenterY = (y1 + y2) / 2;
   const elementCenterY = (y1 + y2) / 2;
@@ -157,7 +216,13 @@ export const bindingBorderTest = (
   const threshold = maxBindingGap(element, element.width, element.height);
   const threshold = maxBindingGap(element, element.width, element.height);
   const check = isOutsideCheck;
   const check = isOutsideCheck;
   const point: Point = [x, y];
   const point: Point = [x, y];
-  return hitTestPointAgainstElement({ element, point, threshold, check });
+  return hitTestPointAgainstElement({
+    element,
+    point,
+    threshold,
+    check,
+    frameNameBoundsCache: null,
+  });
 };
 };
 
 
 export const maxBindingGap = (
 export const maxBindingGap = (
@@ -177,6 +242,7 @@ type HitTestArgs = {
   point: Point;
   point: Point;
   threshold: number;
   threshold: number;
   check: (distance: number, threshold: number) => boolean;
   check: (distance: number, threshold: number) => boolean;
+  frameNameBoundsCache: FrameNameBoundsCache | null;
 };
 };
 
 
 const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
 const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
@@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
         "This should not happen, we need to investigate why it does.",
         "This should not happen, we need to investigate why it does.",
       );
       );
       return false;
       return false;
+    case "frame": {
+      // check distance to frame element first
+      if (
+        args.check(
+          distanceToBindableElement(args.element, args.point),
+          args.threshold,
+        )
+      ) {
+        return true;
+      }
+
+      const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
+
+      if (frameNameBounds) {
+        return args.check(
+          distanceToRectangleBox(frameNameBounds, args.point),
+          args.threshold,
+        );
+      }
+      return false;
+    }
   }
   }
 };
 };
 
 
@@ -219,6 +306,7 @@ export const distanceToBindableElement = (
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "frame":
       return distanceToRectangle(element, point);
       return distanceToRectangle(element, point);
     case "diamond":
     case "diamond":
       return distanceToDiamond(element, point);
       return distanceToDiamond(element, point);
@@ -248,7 +336,8 @@ const distanceToRectangle = (
     | ExcalidrawRectangleElement
     | ExcalidrawRectangleElement
     | ExcalidrawTextElement
     | ExcalidrawTextElement
     | ExcalidrawFreeDrawElement
     | ExcalidrawFreeDrawElement
-    | ExcalidrawImageElement,
+    | ExcalidrawImageElement
+    | ExcalidrawFrameElement,
   point: Point,
   point: Point,
 ): number => {
 ): number => {
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -258,6 +347,14 @@ const distanceToRectangle = (
   );
   );
 };
 };
 
 
+const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
+  return Math.max(
+    GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
+    GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
+  );
+};
+
 const distanceToDiamond = (
 const distanceToDiamond = (
   element: ExcalidrawDiamondElement,
   element: ExcalidrawDiamondElement,
   point: Point,
   point: Point,
@@ -457,8 +554,7 @@ const pointRelativeToElement = (
 ): [GA.Point, GA.Point, number, number] => {
 ): [GA.Point, GA.Point, number, number] => {
   const point = GAPoint.from(pointTuple);
   const point = GAPoint.from(pointTuple);
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  const elementCoords = getElementAbsoluteCoords(element);
-  const center = coordsCenter([x1, y1, x2, y2]);
+  const center = coordsCenter(x1, y1, x2, y2);
   // GA has angle orientation opposite to `rotate`
   // GA has angle orientation opposite to `rotate`
   const rotate = GATransform.rotation(center, element.angle);
   const rotate = GATransform.rotation(center, element.angle);
   const pointRotated = GATransform.apply(rotate, point);
   const pointRotated = GATransform.apply(rotate, point);
@@ -466,9 +562,26 @@ const pointRelativeToElement = (
   const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
   const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
   const elementPos = GA.offset(element.x, element.y);
   const elementPos = GA.offset(element.x, element.y);
   const pointRelToPos = GA.sub(pointRotated, elementPos);
   const pointRelToPos = GA.sub(pointRotated, elementPos);
-  const [ax, ay, bx, by] = elementCoords;
-  const halfWidth = (bx - ax) / 2;
-  const halfHeight = (by - ay) / 2;
+  const halfWidth = (x2 - x1) / 2;
+  const halfHeight = (y2 - y1) / 2;
+  return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
+};
+
+const pointRelativeToDivElement = (
+  pointTuple: Point,
+  rectangle: RectangleBox,
+): [GA.Point, GA.Point, number, number] => {
+  const point = GAPoint.from(pointTuple);
+  const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
+  const center = coordsCenter(x1, y1, x2, y2);
+  const rotate = GATransform.rotation(center, rectangle.angle);
+  const pointRotated = GATransform.apply(rotate, point);
+  const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
+  const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
+  const elementPos = GA.offset(rectangle.x, rectangle.y);
+  const pointRelToPos = GA.sub(pointRotated, elementPos);
+  const halfWidth = (x2 - x1) / 2;
+  const halfHeight = (y2 - y1) / 2;
   return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
   return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
 };
 };
 
 
@@ -490,7 +603,7 @@ const relativizationToElementCenter = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
 ): GA.Transform => {
 ): GA.Transform => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  const center = coordsCenter([x1, y1, x2, y2]);
+  const center = coordsCenter(x1, y1, x2, y2);
   // GA has angle orientation opposite to `rotate`
   // GA has angle orientation opposite to `rotate`
   const rotate = GATransform.rotation(center, element.angle);
   const rotate = GATransform.rotation(center, element.angle);
   const translate = GA.reverse(
   const translate = GA.reverse(
@@ -499,8 +612,13 @@ const relativizationToElementCenter = (
   return GATransform.compose(rotate, translate);
   return GATransform.compose(rotate, translate);
 };
 };
 
 
-const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
-  return GA.point((ax + bx) / 2, (ay + by) / 2);
+const coordsCenter = (
+  x1: number,
+  y1: number,
+  x2: number,
+  y2: number,
+): GA.Point => {
+  return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
 };
 };
 
 
 // The focus distance is the oriented ratio between the size of
 // The focus distance is the oriented ratio between the size of
@@ -531,6 +649,7 @@ export const determineFocusDistance = (
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "frame":
       return c / (hwidth * (nabs + q * mabs));
       return c / (hwidth * (nabs + q * mabs));
     case "diamond":
     case "diamond":
       return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
       return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@@ -548,7 +667,7 @@ export const determineFocusPoint = (
 ): Point => {
 ): Point => {
   if (focus === 0) {
   if (focus === 0) {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-    const center = coordsCenter([x1, y1, x2, y2]);
+    const center = coordsCenter(x1, y1, x2, y2);
     return GAPoint.toTuple(center);
     return GAPoint.toTuple(center);
   }
   }
   const relateToCenter = relativizationToElementCenter(element);
   const relateToCenter = relativizationToElementCenter(element);
@@ -563,6 +682,7 @@ export const determineFocusPoint = (
     case "image":
     case "image":
     case "text":
     case "text":
     case "diamond":
     case "diamond":
+    case "frame":
       point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
       point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
       break;
       break;
     case "ellipse":
     case "ellipse":
@@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
     case "image":
     case "image":
     case "text":
     case "text":
     case "diamond":
     case "diamond":
+    case "frame":
       const corners = getCorners(element);
       const corners = getCorners(element);
       intersections = corners
       intersections = corners
         .flatMap((point, i) => {
         .flatMap((point, i) => {
@@ -646,7 +767,8 @@ const getCorners = (
     | ExcalidrawRectangleElement
     | ExcalidrawRectangleElement
     | ExcalidrawImageElement
     | ExcalidrawImageElement
     | ExcalidrawDiamondElement
     | ExcalidrawDiamondElement
-    | ExcalidrawTextElement,
+    | ExcalidrawTextElement
+    | ExcalidrawFrameElement,
   scale: number = 1,
   scale: number = 1,
 ): GA.Point[] => {
 ): GA.Point[] => {
   const hx = (scale * element.width) / 2;
   const hx = (scale * element.width) / 2;
@@ -655,6 +777,7 @@ const getCorners = (
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "frame":
       return [
       return [
         GA.point(hx, hy),
         GA.point(hx, hy),
         GA.point(hx, -hy),
         GA.point(hx, -hy),
@@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
     | ExcalidrawRectangleElement
     | ExcalidrawRectangleElement
     | ExcalidrawImageElement
     | ExcalidrawImageElement
     | ExcalidrawDiamondElement
     | ExcalidrawDiamondElement
-    | ExcalidrawTextElement,
+    | ExcalidrawTextElement
+    | ExcalidrawFrameElement,
   // Between -1 and 1 for how far away should the focus point be relative
   // Between -1 and 1 for how far away should the focus point be relative
   // to the size of the element. Sign determines orientation.
   // to the size of the element. Sign determines orientation.
   relativeDistance: number,
   relativeDistance: number,

+ 32 - 3
src/element/dragElements.ts

@@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
 import { AppState, PointerDownState } from "../types";
 import { AppState, PointerDownState } from "../types";
 import { getBoundTextElement } from "./textElement";
 import { getBoundTextElement } from "./textElement";
 import { isSelectedViaGroup } from "../groups";
 import { isSelectedViaGroup } from "../groups";
+import Scene from "../scene/Scene";
+import { isFrameElement } from "./typeChecks";
 
 
 export const dragSelectedElements = (
 export const dragSelectedElements = (
   pointerDownState: PointerDownState,
   pointerDownState: PointerDownState,
@@ -16,10 +18,31 @@ export const dragSelectedElements = (
   distanceX: number = 0,
   distanceX: number = 0,
   distanceY: number = 0,
   distanceY: number = 0,
   appState: AppState,
   appState: AppState,
+  scene: Scene,
 ) => {
 ) => {
   const [x1, y1] = getCommonBounds(selectedElements);
   const [x1, y1] = getCommonBounds(selectedElements);
   const offset = { x: pointerX - x1, y: pointerY - y1 };
   const offset = { x: pointerX - x1, y: pointerY - y1 };
-  selectedElements.forEach((element) => {
+
+  // we do not want a frame and its elements to be selected at the same time
+  // but when it happens (due to some bug), we want to avoid updating element
+  // in the frame twice, hence the use of set
+  const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
+    selectedElements,
+  );
+  const frames = selectedElements
+    .filter((e) => isFrameElement(e))
+    .map((f) => f.id);
+
+  if (frames.length > 0) {
+    const elementsInFrames = scene
+      .getNonDeletedElements()
+      .filter((e) => e.frameId !== null)
+      .filter((e) => frames.includes(e.frameId!));
+
+    elementsInFrames.forEach((element) => elementsToUpdate.add(element));
+  }
+
+  elementsToUpdate.forEach((element) => {
     updateElementCoords(
     updateElementCoords(
       lockDirection,
       lockDirection,
       distanceX,
       distanceX,
@@ -38,7 +61,13 @@ export const dragSelectedElements = (
       (appState.editingGroupId && !isSelectedViaGroup(appState, element))
       (appState.editingGroupId && !isSelectedViaGroup(appState, element))
     ) {
     ) {
       const textElement = getBoundTextElement(element);
       const textElement = getBoundTextElement(element);
-      if (textElement) {
+      if (
+        textElement &&
+        // when container is added to a frame, so will its bound text
+        // so the text is already in `elementsToUpdate` and we should avoid
+        // updating its coords again
+        (!textElement.frameId || !frames.includes(textElement.frameId))
+      ) {
         updateElementCoords(
         updateElementCoords(
           lockDirection,
           lockDirection,
           distanceX,
           distanceX,
@@ -50,7 +79,7 @@ export const dragSelectedElements = (
       }
       }
     }
     }
     updateBoundElements(element, {
     updateBoundElements(element, {
-      simultaneouslyUpdated: selectedElements,
+      simultaneouslyUpdated: Array.from(elementsToUpdate),
     });
     });
   });
   });
 };
 };

+ 13 - 1
src/element/index.ts

@@ -2,6 +2,7 @@ import {
   ExcalidrawElement,
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
   NonDeleted,
+  ExcalidrawFrameElement,
 } from "./types";
 } from "./types";
 import { isInvisiblySmallElement } from "./sizeHelpers";
 import { isInvisiblySmallElement } from "./sizeHelpers";
 import { isLinearElementType } from "./typeChecks";
 import { isLinearElementType } from "./typeChecks";
@@ -49,7 +50,11 @@ export {
   getDragOffsetXY,
   getDragOffsetXY,
   dragNewElement,
   dragNewElement,
 } from "./dragElements";
 } from "./dragElements";
-export { isTextElement, isExcalidrawElement } from "./typeChecks";
+export {
+  isTextElement,
+  isExcalidrawElement,
+  isFrameElement,
+} from "./typeChecks";
 export { textWysiwyg } from "./textWysiwyg";
 export { textWysiwyg } from "./textWysiwyg";
 export { redrawTextBoundingBox } from "./textElement";
 export { redrawTextBoundingBox } from "./textElement";
 export {
 export {
@@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
     (element) => !element.isDeleted,
     (element) => !element.isDeleted,
   ) as readonly NonDeletedExcalidrawElement[];
   ) as readonly NonDeletedExcalidrawElement[];
 
 
+export const getNonDeletedFrames = (
+  frames: readonly ExcalidrawFrameElement[],
+) =>
+  frames.filter(
+    (frame) => !frame.isDeleted,
+  ) as readonly NonDeleted<ExcalidrawFrameElement>[];
+
 export const isNonDeletedElement = <T extends ExcalidrawElement>(
 export const isNonDeletedElement = <T extends ExcalidrawElement>(
   element: T,
   element: T,
 ): element is NonDeleted<T> => !element.isDeleted;
 ): element is NonDeleted<T> => !element.isDeleted;

+ 1 - 1
src/element/linearElementEditor.ts

@@ -594,7 +594,7 @@ export class LinearElementEditor {
   }
   }
 
 
   static handlePointerDown(
   static handlePointerDown(
-    event: React.PointerEvent<HTMLCanvasElement>,
+    event: React.PointerEvent<HTMLElement>,
     appState: AppState,
     appState: AppState,
     history: History,
     history: History,
     scenePointer: { x: number; y: number },
     scenePointer: { x: number; y: number },

+ 25 - 0
src/element/newElement.ts

@@ -12,6 +12,7 @@ import {
   ExcalidrawFreeDrawElement,
   ExcalidrawFreeDrawElement,
   FontFamilyValues,
   FontFamilyValues,
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
+  ExcalidrawFrameElement,
 } from "../element/types";
 } from "../element/types";
 import {
 import {
   arrayToMap,
   arrayToMap,
@@ -50,6 +51,7 @@ type ElementConstructorOpts = MarkOptional<
   | "height"
   | "height"
   | "angle"
   | "angle"
   | "groupIds"
   | "groupIds"
+  | "frameId"
   | "boundElements"
   | "boundElements"
   | "seed"
   | "seed"
   | "version"
   | "version"
@@ -82,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     height = 0,
     height = 0,
     angle = 0,
     angle = 0,
     groupIds = [],
     groupIds = [],
+    frameId = null,
     roundness = null,
     roundness = null,
     boundElements = null,
     boundElements = null,
     link = null,
     link = null,
@@ -106,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     roughness,
     roughness,
     opacity,
     opacity,
     groupIds,
     groupIds,
+    frameId,
     roundness,
     roundness,
     seed: rest.seed ?? randomInteger(),
     seed: rest.seed ?? randomInteger(),
     version: rest.version || 1,
     version: rest.version || 1,
@@ -126,6 +130,21 @@ export const newElement = (
 ): NonDeleted<ExcalidrawGenericElement> =>
 ): NonDeleted<ExcalidrawGenericElement> =>
   _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
   _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
 
 
+export const newFrameElement = (
+  opts: ElementConstructorOpts,
+): NonDeleted<ExcalidrawFrameElement> => {
+  const frameElement = newElementWith(
+    {
+      ..._newElementBase<ExcalidrawFrameElement>("frame", opts),
+      type: "frame",
+      name: null,
+    },
+    {},
+  );
+
+  return frameElement;
+};
+
 /** computes element x/y offset based on textAlign/verticalAlign */
 /** computes element x/y offset based on textAlign/verticalAlign */
 const getTextElementPositionOffsets = (
 const getTextElementPositionOffsets = (
   opts: {
   opts: {
@@ -158,6 +177,7 @@ export const newTextElement = (
     containerId?: ExcalidrawTextContainer["id"];
     containerId?: ExcalidrawTextContainer["id"];
     lineHeight?: ExcalidrawTextElement["lineHeight"];
     lineHeight?: ExcalidrawTextElement["lineHeight"];
     strokeWidth?: ExcalidrawTextElement["strokeWidth"];
     strokeWidth?: ExcalidrawTextElement["strokeWidth"];
+    isFrameName?: boolean;
   } & ElementConstructorOpts,
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawTextElement> => {
 ): NonDeleted<ExcalidrawTextElement> => {
   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@@ -192,6 +212,7 @@ export const newTextElement = (
       containerId: opts.containerId || null,
       containerId: opts.containerId || null,
       originalText: text,
       originalText: text,
       lineHeight,
       lineHeight,
+      isFrameName: opts.isFrameName || false,
     },
     },
     {},
     {},
   );
   );
@@ -612,6 +633,10 @@ export const duplicateElements = (
         : null;
         : null;
     }
     }
 
 
+    if (clonedElement.frameId) {
+      clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
+    }
+
     clonedElements.push(clonedElement);
     clonedElements.push(clonedElement);
   }
   }
 
 

+ 45 - 37
src/element/resizeElements.ts

@@ -27,6 +27,7 @@ import {
 import {
 import {
   isArrowElement,
   isArrowElement,
   isBoundToContainer,
   isBoundToContainer,
+  isFrameElement,
   isFreeDrawElement,
   isFreeDrawElement,
   isImageElement,
   isImageElement,
   isLinearElement,
   isLinearElement,
@@ -160,12 +161,17 @@ const rotateSingleElement = (
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const cx = (x1 + x2) / 2;
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
   const cy = (y1 + y2) / 2;
-  let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
-  if (shouldRotateWithDiscreteAngle) {
-    angle += SHIFT_LOCKING_ANGLE / 2;
-    angle -= angle % SHIFT_LOCKING_ANGLE;
+  let angle: number;
+  if (isFrameElement(element)) {
+    angle = 0;
+  } else {
+    angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
+    if (shouldRotateWithDiscreteAngle) {
+      angle += SHIFT_LOCKING_ANGLE / 2;
+      angle -= angle % SHIFT_LOCKING_ANGLE;
+    }
+    angle = normalizeAngle(angle);
   }
   }
-  angle = normalizeAngle(angle);
   const boundTextElementId = getBoundTextElementId(element);
   const boundTextElementId = getBoundTextElementId(element);
 
 
   mutateElement(element, { angle });
   mutateElement(element, { angle });
@@ -877,45 +883,47 @@ const rotateMultipleElements = (
     centerAngle += SHIFT_LOCKING_ANGLE / 2;
     centerAngle += SHIFT_LOCKING_ANGLE / 2;
     centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
     centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
   }
   }
-  elements.forEach((element) => {
-    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-    const cx = (x1 + x2) / 2;
-    const cy = (y1 + y2) / 2;
-    const origAngle =
-      pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
-    const [rotatedCX, rotatedCY] = rotate(
-      cx,
-      cy,
-      centerX,
-      centerY,
-      centerAngle + origAngle - element.angle,
-    );
-
-    mutateElement(
-      element,
-      {
-        x: element.x + (rotatedCX - cx),
-        y: element.y + (rotatedCY - cy),
-        angle: normalizeAngle(centerAngle + origAngle),
-      },
-      false,
-    );
-
-    updateBoundElements(element, { simultaneouslyUpdated: elements });
 
 
-    const boundText = getBoundTextElement(element);
-    if (boundText && !isArrowElement(element)) {
+  elements
+    .filter((element) => element.type !== "frame")
+    .forEach((element) => {
+      const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+      const cx = (x1 + x2) / 2;
+      const cy = (y1 + y2) / 2;
+      const origAngle =
+        pointerDownState.originalElements.get(element.id)?.angle ??
+        element.angle;
+      const [rotatedCX, rotatedCY] = rotate(
+        cx,
+        cy,
+        centerX,
+        centerY,
+        centerAngle + origAngle - element.angle,
+      );
       mutateElement(
       mutateElement(
-        boundText,
+        element,
         {
         {
-          x: boundText.x + (rotatedCX - cx),
-          y: boundText.y + (rotatedCY - cy),
+          x: element.x + (rotatedCX - cx),
+          y: element.y + (rotatedCY - cy),
           angle: normalizeAngle(centerAngle + origAngle),
           angle: normalizeAngle(centerAngle + origAngle),
         },
         },
         false,
         false,
       );
       );
-    }
-  });
+      updateBoundElements(element, { simultaneouslyUpdated: elements });
+
+      const boundText = getBoundTextElement(element);
+      if (boundText && !isArrowElement(element)) {
+        mutateElement(
+          boundText,
+          {
+            x: boundText.x + (rotatedCX - cx),
+            y: boundText.y + (rotatedCY - cy),
+            angle: normalizeAngle(centerAngle + origAngle),
+          },
+          false,
+        );
+      }
+    });
 
 
   Scene.getScene(elements[0])?.informMutation();
   Scene.getScene(elements[0])?.informMutation();
 };
 };

+ 6 - 4
src/element/textElement.ts

@@ -840,10 +840,12 @@ export const getTextBindableContainerAtPosition = (
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
     if (
     if (
       isArrowElement(elements[index]) &&
       isArrowElement(elements[index]) &&
-      isHittingElementNotConsideringBoundingBox(elements[index], appState, [
-        x,
-        y,
-      ])
+      isHittingElementNotConsideringBoundingBox(
+        elements[index],
+        appState,
+        null,
+        [x, y],
+      )
     ) {
     ) {
       hitElement = elements[index];
       hitElement = elements[index];
       break;
       break;

+ 13 - 1
src/element/transformHandles.ts

@@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
 import { rotate } from "../math";
 import { AppState, Zoom } from "../types";
 import { AppState, Zoom } from "../types";
 import { isTextElement } from ".";
 import { isTextElement } from ".";
-import { isLinearElement } from "./typeChecks";
+import { isFrameElement, isLinearElement } from "./typeChecks";
 import { DEFAULT_SPACING } from "../renderer/renderScene";
 import { DEFAULT_SPACING } from "../renderer/renderScene";
 
 
 export type TransformHandleDirection =
 export type TransformHandleDirection =
@@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
   w: true,
   w: true,
 };
 };
 
 
+export const OMIT_SIDES_FOR_FRAME = {
+  e: true,
+  s: true,
+  n: true,
+  w: true,
+  rotation: true,
+};
+
 const OMIT_SIDES_FOR_TEXT_ELEMENT = {
 const OMIT_SIDES_FOR_TEXT_ELEMENT = {
   e: true,
   e: true,
   s: true,
   s: true,
@@ -249,6 +257,10 @@ export const getTransformHandles = (
     }
     }
   } else if (isTextElement(element)) {
   } else if (isTextElement(element)) {
     omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
     omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
+  } else if (isFrameElement(element)) {
+    omitSides = {
+      rotation: true,
+    };
   }
   }
   const dashedLineMargin = isLinearElement(element)
   const dashedLineMargin = isLinearElement(element)
     ? DEFAULT_SPACING + 8
     ? DEFAULT_SPACING + 8

+ 7 - 0
src/element/typeChecks.ts

@@ -12,6 +12,7 @@ import {
   ExcalidrawImageElement,
   ExcalidrawImageElement,
   ExcalidrawTextElementWithContainer,
   ExcalidrawTextElementWithContainer,
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
+  ExcalidrawFrameElement,
   RoundnessType,
   RoundnessType,
 } from "./types";
 } from "./types";
 
 
@@ -45,6 +46,12 @@ export const isTextElement = (
   return element != null && element.type === "text";
   return element != null && element.type === "text";
 };
 };
 
 
+export const isFrameElement = (
+  element: ExcalidrawElement | null,
+): element is ExcalidrawFrameElement => {
+  return element != null && element.type === "frame";
+};
+
 export const isFreeDrawElement = (
 export const isFreeDrawElement = (
   element?: ExcalidrawElement | null,
   element?: ExcalidrawElement | null,
 ): element is ExcalidrawFreeDrawElement => {
 ): element is ExcalidrawFreeDrawElement => {

+ 10 - 2
src/element/types.ts

@@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
   /** List of groups the element belongs to.
   /** List of groups the element belongs to.
       Ordered from deepest to shallowest. */
       Ordered from deepest to shallowest. */
   groupIds: readonly GroupId[];
   groupIds: readonly GroupId[];
+  frameId: string | null;
   /** other elements that are bound to this element */
   /** other elements that are bound to this element */
   boundElements:
   boundElements:
     | readonly Readonly<{
     | readonly Readonly<{
@@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
   "fileId"
   "fileId"
 >;
 >;
 
 
+export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
+  type: "frame";
+  name: string | null;
+};
+
 /**
 /**
  * These are elements that don't have any additional properties.
  * These are elements that don't have any additional properties.
  */
  */
@@ -117,7 +123,8 @@ export type ExcalidrawElement =
   | ExcalidrawTextElement
   | ExcalidrawTextElement
   | ExcalidrawLinearElement
   | ExcalidrawLinearElement
   | ExcalidrawFreeDrawElement
   | ExcalidrawFreeDrawElement
-  | ExcalidrawImageElement;
+  | ExcalidrawImageElement
+  | ExcalidrawFrameElement;
 
 
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
   isDeleted: boolean;
   isDeleted: boolean;
@@ -148,7 +155,8 @@ export type ExcalidrawBindableElement =
   | ExcalidrawDiamondElement
   | ExcalidrawDiamondElement
   | ExcalidrawEllipseElement
   | ExcalidrawEllipseElement
   | ExcalidrawTextElement
   | ExcalidrawTextElement
-  | ExcalidrawImageElement;
+  | ExcalidrawImageElement
+  | ExcalidrawFrameElement;
 
 
 export type ExcalidrawTextContainer =
 export type ExcalidrawTextContainer =
   | ExcalidrawRectangleElement
   | ExcalidrawRectangleElement

+ 705 - 0
src/frame.ts

@@ -0,0 +1,705 @@
+import {
+  getCommonBounds,
+  getElementAbsoluteCoords,
+  isTextElement,
+} from "./element";
+import {
+  ExcalidrawElement,
+  ExcalidrawFrameElement,
+  NonDeleted,
+  NonDeletedExcalidrawElement,
+} from "./element/types";
+import { isPointWithinBounds } from "./math";
+import {
+  getBoundTextElement,
+  getContainerElement,
+} from "./element/textElement";
+import { arrayToMap, findIndex } from "./utils";
+import { mutateElement } from "./element/mutateElement";
+import { AppState } from "./types";
+import { getElementsWithinSelection, getSelectedElements } from "./scene";
+import { isFrameElement } from "./element";
+import { moveOneRight } from "./zindex";
+import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
+import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
+import { getElementLineSegments } from "./element/bounds";
+
+// --------------------------- Frame State ------------------------------------
+export const bindElementsToFramesAfterDuplication = (
+  nextElements: ExcalidrawElement[],
+  oldElements: readonly ExcalidrawElement[],
+  oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
+) => {
+  const nextElementMap = arrayToMap(nextElements) as Map<
+    ExcalidrawElement["id"],
+    ExcalidrawElement
+  >;
+
+  for (const element of oldElements) {
+    if (element.frameId) {
+      // use its frameId to get the new frameId
+      const nextElementId = oldIdToDuplicatedId.get(element.id);
+      const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
+      if (nextElementId) {
+        const nextElement = nextElementMap.get(nextElementId);
+        if (nextElement) {
+          mutateElement(
+            nextElement,
+            {
+              frameId: nextFrameId ?? element.frameId,
+            },
+            false,
+          );
+        }
+      }
+    }
+  }
+};
+
+// --------------------------- Frame Geometry ---------------------------------
+class Point {
+  x: number;
+  y: number;
+
+  constructor(x: number, y: number) {
+    this.x = x;
+    this.y = y;
+  }
+}
+
+class LineSegment {
+  first: Point;
+  second: Point;
+
+  constructor(pointA: Point, pointB: Point) {
+    this.first = pointA;
+    this.second = pointB;
+  }
+
+  public getBoundingBox(): [Point, Point] {
+    return [
+      new Point(
+        Math.min(this.first.x, this.second.x),
+        Math.min(this.first.y, this.second.y),
+      ),
+      new Point(
+        Math.max(this.first.x, this.second.x),
+        Math.max(this.first.y, this.second.y),
+      ),
+    ];
+  }
+}
+
+// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
+class FrameGeometry {
+  private static EPSILON = 0.000001;
+
+  private static crossProduct(a: Point, b: Point) {
+    return a.x * b.y - b.x * a.y;
+  }
+
+  private static doBoundingBoxesIntersect(
+    a: [Point, Point],
+    b: [Point, Point],
+  ) {
+    return (
+      a[0].x <= b[1].x &&
+      a[1].x >= b[0].x &&
+      a[0].y <= b[1].y &&
+      a[1].y >= b[0].y
+    );
+  }
+
+  private static isPointOnLine(a: LineSegment, b: Point) {
+    const aTmp = new LineSegment(
+      new Point(0, 0),
+      new Point(a.second.x - a.first.x, a.second.y - a.first.y),
+    );
+    const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
+    const r = this.crossProduct(aTmp.second, bTmp);
+    return Math.abs(r) < this.EPSILON;
+  }
+
+  private static isPointRightOfLine(a: LineSegment, b: Point) {
+    const aTmp = new LineSegment(
+      new Point(0, 0),
+      new Point(a.second.x - a.first.x, a.second.y - a.first.y),
+    );
+    const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
+    return this.crossProduct(aTmp.second, bTmp) < 0;
+  }
+
+  private static lineSegmentTouchesOrCrossesLine(
+    a: LineSegment,
+    b: LineSegment,
+  ) {
+    return (
+      this.isPointOnLine(a, b.first) ||
+      this.isPointOnLine(a, b.second) ||
+      (this.isPointRightOfLine(a, b.first)
+        ? !this.isPointRightOfLine(a, b.second)
+        : this.isPointRightOfLine(a, b.second))
+    );
+  }
+
+  private static doLineSegmentsIntersect(
+    a: [readonly [number, number], readonly [number, number]],
+    b: [readonly [number, number], readonly [number, number]],
+  ) {
+    const aSegment = new LineSegment(
+      new Point(a[0][0], a[0][1]),
+      new Point(a[1][0], a[1][1]),
+    );
+    const bSegment = new LineSegment(
+      new Point(b[0][0], b[0][1]),
+      new Point(b[1][0], b[1][1]),
+    );
+
+    const box1 = aSegment.getBoundingBox();
+    const box2 = bSegment.getBoundingBox();
+    return (
+      this.doBoundingBoxesIntersect(box1, box2) &&
+      this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
+      this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
+    );
+  }
+
+  public static isElementIntersectingFrame(
+    element: ExcalidrawElement,
+    frame: ExcalidrawFrameElement,
+  ) {
+    const frameLineSegments = getElementLineSegments(frame);
+
+    const elementLineSegments = getElementLineSegments(element);
+
+    const intersecting = frameLineSegments.some((frameLineSegment) =>
+      elementLineSegments.some((elementLineSegment) =>
+        this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
+      ),
+    );
+
+    return intersecting;
+  }
+}
+
+export const getElementsCompletelyInFrame = (
+  elements: readonly ExcalidrawElement[],
+  frame: ExcalidrawFrameElement,
+) =>
+  omitGroupsContainingFrames(
+    getElementsWithinSelection(elements, frame, false),
+  ).filter(
+    (element) =>
+      (element.type !== "frame" && !element.frameId) ||
+      element.frameId === frame.id,
+  );
+
+export const isElementContainingFrame = (
+  elements: readonly ExcalidrawElement[],
+  element: ExcalidrawElement,
+  frame: ExcalidrawFrameElement,
+) => {
+  return getElementsWithinSelection(elements, element).some(
+    (e) => e.id === frame.id,
+  );
+};
+
+export const getElementsIntersectingFrame = (
+  elements: readonly ExcalidrawElement[],
+  frame: ExcalidrawFrameElement,
+) =>
+  elements.filter((element) =>
+    FrameGeometry.isElementIntersectingFrame(element, frame),
+  );
+
+export const elementsAreInFrameBounds = (
+  elements: readonly ExcalidrawElement[],
+  frame: ExcalidrawFrameElement,
+) => {
+  const [selectionX1, selectionY1, selectionX2, selectionY2] =
+    getElementAbsoluteCoords(frame);
+
+  const [elementX1, elementY1, elementX2, elementY2] =
+    getCommonBounds(elements);
+
+  return (
+    selectionX1 <= elementX1 &&
+    selectionY1 <= elementY1 &&
+    selectionX2 >= elementX2 &&
+    selectionY2 >= elementY2
+  );
+};
+
+export const elementOverlapsWithFrame = (
+  element: ExcalidrawElement,
+  frame: ExcalidrawFrameElement,
+) => {
+  return (
+    elementsAreInFrameBounds([element], frame) ||
+    FrameGeometry.isElementIntersectingFrame(element, frame) ||
+    isElementContainingFrame([frame], element, frame)
+  );
+};
+
+export const isCursorInFrame = (
+  cursorCoords: {
+    x: number;
+    y: number;
+  },
+  frame: NonDeleted<ExcalidrawFrameElement>,
+) => {
+  const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
+
+  return isPointWithinBounds(
+    [fx1, fy1],
+    [cursorCoords.x, cursorCoords.y],
+    [fx2, fy2],
+  );
+};
+
+export const groupsAreAtLeastIntersectingTheFrame = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  groupIds: readonly string[],
+  frame: ExcalidrawFrameElement,
+) => {
+  const elementsInGroup = groupIds.flatMap((groupId) =>
+    getElementsInGroup(elements, groupId),
+  );
+
+  if (elementsInGroup.length === 0) {
+    return true;
+  }
+
+  return !!elementsInGroup.find(
+    (element) =>
+      elementsAreInFrameBounds([element], frame) ||
+      FrameGeometry.isElementIntersectingFrame(element, frame),
+  );
+};
+
+export const groupsAreCompletelyOutOfFrame = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  groupIds: readonly string[],
+  frame: ExcalidrawFrameElement,
+) => {
+  const elementsInGroup = groupIds.flatMap((groupId) =>
+    getElementsInGroup(elements, groupId),
+  );
+
+  if (elementsInGroup.length === 0) {
+    return true;
+  }
+
+  return (
+    elementsInGroup.find(
+      (element) =>
+        elementsAreInFrameBounds([element], frame) ||
+        FrameGeometry.isElementIntersectingFrame(element, frame),
+    ) === undefined
+  );
+};
+
+// --------------------------- Frame Utils ------------------------------------
+
+/**
+ * Returns a map of frameId to frame elements. Includes empty frames.
+ */
+export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
+  const frameElementsMap = new Map<
+    ExcalidrawElement["id"],
+    ExcalidrawElement[]
+  >();
+
+  for (const element of elements) {
+    const frameId = isFrameElement(element) ? element.id : element.frameId;
+    if (frameId && !frameElementsMap.has(frameId)) {
+      frameElementsMap.set(frameId, getFrameElements(elements, frameId));
+    }
+  }
+
+  return frameElementsMap;
+};
+
+export const getFrameElements = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  frameId: string,
+) => allElements.filter((element) => element.frameId === frameId);
+
+export const getElementsInResizingFrame = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  frame: ExcalidrawFrameElement,
+  appState: AppState,
+): ExcalidrawElement[] => {
+  const prevElementsInFrame = getFrameElements(allElements, frame.id);
+  const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
+
+  const elementsCompletelyInFrame = new Set([
+    ...getElementsCompletelyInFrame(allElements, frame),
+    ...prevElementsInFrame.filter((element) =>
+      isElementContainingFrame(allElements, element, frame),
+    ),
+  ]);
+
+  const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
+    (element) => !elementsCompletelyInFrame.has(element),
+  );
+
+  // for elements that are completely in the frame
+  // if they are part of some groups, then those groups are still
+  // considered to belong to the frame
+  const groupsToKeep = new Set<string>(
+    Array.from(elementsCompletelyInFrame).flatMap(
+      (element) => element.groupIds,
+    ),
+  );
+
+  for (const element of elementsNotCompletelyInFrame) {
+    if (!FrameGeometry.isElementIntersectingFrame(element, frame)) {
+      if (element.groupIds.length === 0) {
+        nextElementsInFrame.delete(element);
+      }
+    } else if (element.groupIds.length > 0) {
+      // group element intersects with the frame, we should keep the groups
+      // that this element is part of
+      for (const id of element.groupIds) {
+        groupsToKeep.add(id);
+      }
+    }
+  }
+
+  for (const element of elementsNotCompletelyInFrame) {
+    if (element.groupIds.length > 0) {
+      let shouldRemoveElement = true;
+
+      for (const id of element.groupIds) {
+        if (groupsToKeep.has(id)) {
+          shouldRemoveElement = false;
+        }
+      }
+
+      if (shouldRemoveElement) {
+        nextElementsInFrame.delete(element);
+      }
+    }
+  }
+
+  const individualElementsCompletelyInFrame = Array.from(
+    elementsCompletelyInFrame,
+  ).filter((element) => element.groupIds.length === 0);
+
+  for (const element of individualElementsCompletelyInFrame) {
+    nextElementsInFrame.add(element);
+  }
+
+  const newGroupElementsCompletelyInFrame = Array.from(
+    elementsCompletelyInFrame,
+  ).filter((element) => element.groupIds.length > 0);
+
+  const groupIds = selectGroupsFromGivenElements(
+    newGroupElementsCompletelyInFrame,
+    appState,
+  );
+
+  // new group elements
+  for (const [id, isSelected] of Object.entries(groupIds)) {
+    if (isSelected) {
+      const elementsInGroup = getElementsInGroup(allElements, id);
+
+      if (elementsAreInFrameBounds(elementsInGroup, frame)) {
+        for (const element of elementsInGroup) {
+          nextElementsInFrame.add(element);
+        }
+      }
+    }
+  }
+
+  return [...nextElementsInFrame].filter((element) => {
+    return !(isTextElement(element) && element.containerId);
+  });
+};
+
+export const getElementsInNewFrame = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  frame: ExcalidrawFrameElement,
+) => {
+  return omitGroupsContainingFrames(
+    allElements,
+    getElementsCompletelyInFrame(allElements, frame),
+  );
+};
+
+export const getContainingFrame = (
+  element: ExcalidrawElement,
+  /**
+   * Optionally an elements map, in case the elements aren't in the Scene yet.
+   * Takes precedence over Scene elements, even if the element exists
+   * in Scene elements and not the supplied elements map.
+   */
+  elementsMap?: Map<string, ExcalidrawElement>,
+) => {
+  if (element.frameId) {
+    if (elementsMap) {
+      return (elementsMap.get(element.frameId) ||
+        null) as null | ExcalidrawFrameElement;
+    }
+    return (
+      (Scene.getScene(element)?.getElement(
+        element.frameId,
+      ) as ExcalidrawFrameElement) || null
+    );
+  }
+  return null;
+};
+
+// --------------------------- Frame Operations -------------------------------
+export const addElementsToFrame = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  elementsToAdd: NonDeletedExcalidrawElement[],
+  frame: ExcalidrawFrameElement,
+) => {
+  const _elementsToAdd: ExcalidrawElement[] = [];
+
+  for (const element of elementsToAdd) {
+    _elementsToAdd.push(element);
+
+    const boundTextElement = getBoundTextElement(element);
+    if (boundTextElement) {
+      _elementsToAdd.push(boundTextElement);
+    }
+  }
+
+  let nextElements = allElements.slice();
+
+  const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
+
+  for (const element of omitGroupsContainingFrames(
+    allElements,
+    _elementsToAdd,
+  )) {
+    if (element.frameId !== frame.id && !isFrameElement(element)) {
+      mutateElement(
+        element,
+        {
+          frameId: frame.id,
+        },
+        false,
+      );
+
+      const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
+      const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
+
+      if (elementIndex < frameBoundary) {
+        nextElements = [
+          ...nextElements.slice(0, elementIndex),
+          ...nextElements.slice(elementIndex + 1, frameBoundary),
+          element,
+          ...nextElements.slice(frameBoundary),
+        ];
+      } else if (elementIndex > frameIndex) {
+        nextElements = [
+          ...nextElements.slice(0, frameIndex),
+          element,
+          ...nextElements.slice(frameIndex, elementIndex),
+          ...nextElements.slice(elementIndex + 1),
+        ];
+      }
+    }
+  }
+
+  return nextElements;
+};
+
+export const removeElementsFromFrame = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  elementsToRemove: NonDeletedExcalidrawElement[],
+  appState: AppState,
+) => {
+  const _elementsToRemove: ExcalidrawElement[] = [];
+
+  for (const element of elementsToRemove) {
+    if (element.frameId) {
+      _elementsToRemove.push(element);
+      const boundTextElement = getBoundTextElement(element);
+      if (boundTextElement) {
+        _elementsToRemove.push(boundTextElement);
+      }
+    }
+  }
+
+  for (const element of _elementsToRemove) {
+    mutateElement(
+      element,
+      {
+        frameId: null,
+      },
+      false,
+    );
+  }
+
+  const nextElements = moveOneRight(
+    allElements,
+    appState,
+    Array.from(_elementsToRemove),
+  );
+
+  return nextElements;
+};
+
+export const removeAllElementsFromFrame = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  frame: ExcalidrawFrameElement,
+  appState: AppState,
+) => {
+  const elementsInFrame = getFrameElements(allElements, frame.id);
+  return removeElementsFromFrame(allElements, elementsInFrame, appState);
+};
+
+export const replaceAllElementsInFrame = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  nextElementsInFrame: ExcalidrawElement[],
+  frame: ExcalidrawFrameElement,
+  appState: AppState,
+) => {
+  return addElementsToFrame(
+    removeAllElementsFromFrame(allElements, frame, appState),
+    nextElementsInFrame,
+    frame,
+  );
+};
+
+/** does not mutate elements, but return new ones */
+export const updateFrameMembershipOfSelectedElements = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  appState: AppState,
+) => {
+  const selectedElements = getSelectedElements(allElements, appState);
+  const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
+
+  if (appState.editingGroupId) {
+    for (const element of selectedElements) {
+      if (element.groupIds.length === 0) {
+        elementsToFilter.add(element);
+      } else {
+        element.groupIds
+          .flatMap((gid) => getElementsInGroup(allElements, gid))
+          .forEach((element) => elementsToFilter.add(element));
+      }
+    }
+  }
+
+  const elementsToRemove = new Set<ExcalidrawElement>();
+
+  elementsToFilter.forEach((element) => {
+    if (
+      !isFrameElement(element) &&
+      !isElementInFrame(element, allElements, appState)
+    ) {
+      elementsToRemove.add(element);
+    }
+  });
+
+  return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
+};
+
+/**
+ * filters out elements that are inside groups that contain a frame element
+ * anywhere in the group tree
+ */
+export const omitGroupsContainingFrames = (
+  allElements: ExcalidrawElementsIncludingDeleted,
+  /** subset of elements you want to filter. Optional perf optimization so we
+   * don't have to filter all elements unnecessarily
+   */
+  selectedElements?: readonly ExcalidrawElement[],
+) => {
+  const uniqueGroupIds = new Set<string>();
+  for (const el of selectedElements || allElements) {
+    const topMostGroupId = el.groupIds[el.groupIds.length - 1];
+    if (topMostGroupId) {
+      uniqueGroupIds.add(topMostGroupId);
+    }
+  }
+
+  const rejectedGroupIds = new Set<string>();
+  for (const groupId of uniqueGroupIds) {
+    if (
+      getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
+    ) {
+      rejectedGroupIds.add(groupId);
+    }
+  }
+
+  return (selectedElements || allElements).filter(
+    (el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
+  );
+};
+
+/**
+ * depending on the appState, return target frame, which is the frame the given element
+ * is going to be added to or remove from
+ */
+export const getTargetFrame = (
+  element: ExcalidrawElement,
+  appState: AppState,
+) => {
+  const _element = isTextElement(element)
+    ? getContainerElement(element) || element
+    : element;
+
+  return appState.selectedElementIds[_element.id] &&
+    appState.selectedElementsAreBeingDragged
+    ? appState.frameToHighlight
+    : getContainingFrame(_element);
+};
+
+// given an element, return if the element is in some frame
+export const isElementInFrame = (
+  element: ExcalidrawElement,
+  allElements: ExcalidrawElementsIncludingDeleted,
+  appState: AppState,
+) => {
+  const frame = getTargetFrame(element, appState);
+  const _element = isTextElement(element)
+    ? getContainerElement(element) || element
+    : element;
+
+  if (frame) {
+    if (_element.groupIds.length === 0) {
+      return elementOverlapsWithFrame(_element, frame);
+    }
+
+    const allElementsInGroup = new Set(
+      _element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
+    );
+
+    if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
+      const selectedElements = new Set(
+        getSelectedElements(allElements, appState),
+      );
+
+      const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
+
+      if (editingGroupOverlapsFrame) {
+        return true;
+      }
+
+      selectedElements.forEach((selectedElement) => {
+        allElementsInGroup.delete(selectedElement);
+      });
+    }
+
+    for (const elementInGroup of allElementsInGroup) {
+      if (isFrameElement(elementInGroup)) {
+        return false;
+      }
+    }
+
+    for (const elementInGroup of allElementsInGroup) {
+      if (elementOverlapsWithFrame(elementInGroup, frame)) {
+        return true;
+      }
+    }
+  }
+
+  return false;
+};

+ 40 - 0
src/groups.ts

@@ -94,6 +94,31 @@ export const selectGroupsForSelectedElements = (
   return nextAppState;
   return nextAppState;
 };
 };
 
 
+// given a list of elements, return the the actual group ids that should be selected
+// or used to update the elements
+export const selectGroupsFromGivenElements = (
+  elements: readonly NonDeleted<ExcalidrawElement>[],
+  appState: AppState,
+) => {
+  let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
+
+  for (const element of elements) {
+    let groupIds = element.groupIds;
+    if (appState.editingGroupId) {
+      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.selectedGroupIds;
+};
+
 export const editGroupForSelectedElement = (
 export const editGroupForSelectedElement = (
   appState: AppState,
   appState: AppState,
   element: NonDeleted<ExcalidrawElement>,
   element: NonDeleted<ExcalidrawElement>,
@@ -186,3 +211,18 @@ export const getMaximumGroups = (
 
 
   return Array.from(groups.values());
   return Array.from(groups.values());
 };
 };
+
+export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
+  const allGroups = elements.flatMap((element) => element.groupIds);
+  const groupCount = new Map<string, number>();
+  let maxGroup = 0;
+
+  for (const group of allGroups) {
+    groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
+    if (groupCount.get(group)! > maxGroup) {
+      maxGroup = groupCount.get(group)!;
+    }
+  }
+
+  return maxGroup === elements.length;
+};

+ 1 - 0
src/keys.ts

@@ -42,6 +42,7 @@ export const KEYS = {
   CHEVRON_RIGHT: ">",
   CHEVRON_RIGHT: ">",
   PERIOD: ".",
   PERIOD: ".",
   COMMA: ",",
   COMMA: ",",
+  SUBTRACT: "-",
 
 
   A: "a",
   A: "a",
   C: "c",
   C: "c",

+ 5 - 1
src/locales/en.json

@@ -124,6 +124,8 @@
     },
     },
     "statusPublished": "Published",
     "statusPublished": "Published",
     "sidebarLock": "Keep sidebar open",
     "sidebarLock": "Keep sidebar open",
+    "selectAllElementsInFrame": "Select all elements in frame",
+    "removeAllElementsFromFrame": "Remove all elements from frame",
     "eyeDropper": "Pick color from canvas"
     "eyeDropper": "Pick color from canvas"
   },
   },
   "library": {
   "library": {
@@ -221,7 +223,9 @@
     "penMode": "Pen mode - prevent touch",
     "penMode": "Pen mode - prevent touch",
     "link": "Add/ Update link for a selected shape",
     "link": "Add/ Update link for a selected shape",
     "eraser": "Eraser",
     "eraser": "Eraser",
-    "hand": "Hand (panning tool)"
+    "frame": "Frame tool",
+    "hand": "Hand (panning tool)",
+    "extraTools": "More tools"
   },
   },
   "headings": {
   "headings": {
     "canvasActions": "Canvas actions",
     "canvasActions": "Canvas actions",

+ 1 - 1
src/math.ts

@@ -206,7 +206,7 @@ export const isPointInPolygon = (
 
 
 // Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
 // Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
 // This is an approximation to "does `q` lie on a segment `pr`" check.
 // This is an approximation to "does `q` lie on a segment `pr`" check.
-const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
+export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
   return (
   return (
     q[0] <= Math.max(p[0], r[0]) &&
     q[0] <= Math.max(p[0], r[0]) &&
     q[0] >= Math.min(p[0], r[0]) &&
     q[0] >= Math.min(p[0], r[0]) &&

+ 1 - 0
src/packages/excalidraw/example/App.tsx

@@ -205,6 +205,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
             height: 141.9765625,
             height: 141.9765625,
             seed: 1968410350,
             seed: 1968410350,
             groupIds: [],
             groupIds: [],
+            frameId: null,
             boundElements: null,
             boundElements: null,
             locked: false,
             locked: false,
             link: null,
             link: null,

+ 44 - 0
src/packages/excalidraw/example/initialData.js

@@ -20,6 +20,7 @@ export default {
       height: 141.9765625,
       height: 141.9765625,
       seed: 1968410350,
       seed: 1968410350,
       groupIds: [],
       groupIds: [],
+      frameId: null,
     },
     },
     {
     {
       id: "-xMIs_0jIFqvpx-R9UnaG",
       id: "-xMIs_0jIFqvpx-R9UnaG",
@@ -37,6 +38,7 @@ export default {
       roughness: 1,
       roughness: 1,
       opacity: 100,
       opacity: 100,
       groupIds: [],
       groupIds: [],
+      frameId: null,
       seed: 957947807,
       seed: 957947807,
       version: 47,
       version: 47,
       versionNonce: 1128618623,
       versionNonce: 1128618623,
@@ -58,6 +60,7 @@ export default {
       roughness: 1,
       roughness: 1,
       opacity: 100,
       opacity: 100,
       groupIds: [],
       groupIds: [],
+      frameId: null,
       strokeSharpness: "round",
       strokeSharpness: "round",
       seed: 707269846,
       seed: 707269846,
       version: 143,
       version: 143,
@@ -94,6 +97,7 @@ export default {
         height: 103.65107323746608,
         height: 103.65107323746608,
         seed: 1445523839,
         seed: 1445523839,
         groupIds: [],
         groupIds: [],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -133,6 +137,7 @@ export default {
         height: 113.8575037534261,
         height: 113.8575037534261,
         seed: 1513238033,
         seed: 1513238033,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -182,6 +187,7 @@ export default {
         height: 9.797916664247975,
         height: 9.797916664247975,
         seed: 683951089,
         seed: 683951089,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -220,6 +226,7 @@ export default {
         height: 9.797916664247975,
         height: 9.797916664247975,
         seed: 1817746897,
         seed: 1817746897,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -258,6 +265,7 @@ export default {
         height: 17.72670397681366,
         height: 17.72670397681366,
         seed: 1409727409,
         seed: 1409727409,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
         boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
       },
       },
@@ -281,6 +289,7 @@ export default {
         height: 13.941904362416096,
         height: 13.941904362416096,
         seed: 1073094033,
         seed: 1073094033,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -304,6 +313,7 @@ export default {
         height: 13.941904362416096,
         height: 13.941904362416096,
         seed: 526271345,
         seed: 526271345,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -327,6 +337,7 @@ export default {
         height: 13.941904362416096,
         height: 13.941904362416096,
         seed: 243707217,
         seed: 243707217,
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
         groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -352,6 +363,7 @@ export default {
         height: 36.77344700318558,
         height: 36.77344700318558,
         seed: 511870335,
         seed: 511870335,
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -375,6 +387,7 @@ export default {
         height: 36.77344700318558,
         height: 36.77344700318558,
         seed: 1283079231,
         seed: 1283079231,
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -398,6 +411,7 @@ export default {
         height: 36.77344700318558,
         height: 36.77344700318558,
         seed: 996251633,
         seed: 996251633,
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -421,6 +435,7 @@ export default {
         height: 36.77344700318558,
         height: 36.77344700318558,
         seed: 1764842481,
         seed: 1764842481,
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
         groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -446,6 +461,7 @@ export default {
         height: 154.56722543646003,
         height: 154.56722543646003,
         seed: 1424381745,
         seed: 1424381745,
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -495,6 +511,7 @@ export default {
         height: 12.698053371678215,
         height: 12.698053371678215,
         seed: 726657713,
         seed: 726657713,
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -533,6 +550,7 @@ export default {
         height: 10.178760037658167,
         height: 10.178760037658167,
         seed: 1977326481,
         seed: 1977326481,
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -571,6 +589,7 @@ export default {
         height: 22.797152568995934,
         height: 22.797152568995934,
         seed: 1774660383,
         seed: 1774660383,
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
         groupIds: ["HSrtfEf-CssQTf160Fb6R"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
         boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
       },
       },
@@ -596,6 +615,7 @@ export default {
         height: 107.25081879410921,
         height: 107.25081879410921,
         seed: 371096063,
         seed: 371096063,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [
         boundElementIds: [
           "CFu0B4Mw_1wC1Hbgx8Fs0",
           "CFu0B4Mw_1wC1Hbgx8Fs0",
@@ -623,6 +643,7 @@ export default {
         height: 107.25081879410921,
         height: 107.25081879410921,
         seed: 685932433,
         seed: 685932433,
         groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [
         boundElementIds: [
           "CFu0B4Mw_1wC1Hbgx8Fs0",
           "CFu0B4Mw_1wC1Hbgx8Fs0",
@@ -650,6 +671,7 @@ export default {
         height: 107.25081879410921,
         height: 107.25081879410921,
         seed: 58634943,
         seed: 58634943,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [
         boundElementIds: [
           "CFu0B4Mw_1wC1Hbgx8Fs0",
           "CFu0B4Mw_1wC1Hbgx8Fs0",
@@ -677,6 +699,7 @@ export default {
         height: 3.249953844290203,
         height: 3.249953844290203,
         seed: 1673003743,
         seed: 1673003743,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         points: [
         points: [
@@ -708,6 +731,7 @@ export default {
         height: 2.8032978840147194,
         height: 2.8032978840147194,
         seed: 1821527807,
         seed: 1821527807,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         points: [
         points: [
@@ -739,6 +763,7 @@ export default {
         height: 4.280657518731036,
         height: 4.280657518731036,
         seed: 1485707039,
         seed: 1485707039,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         points: [
         points: [
@@ -771,6 +796,7 @@ export default {
         height: 2.9096445412231735,
         height: 2.9096445412231735,
         seed: 1042012991,
         seed: 1042012991,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         points: [
         points: [
@@ -804,6 +830,7 @@ export default {
         height: 2.4757501798128,
         height: 2.4757501798128,
         seed: 295443295,
         seed: 295443295,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         points: [
         points: [
@@ -835,6 +862,7 @@ export default {
         height: 2.4757501798128,
         height: 2.4757501798128,
         seed: 1734301567,
         seed: 1734301567,
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
         groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         points: [
         points: [
@@ -869,6 +897,7 @@ export default {
         height: 76.53703389977764,
         height: 76.53703389977764,
         seed: 106569279,
         seed: 106569279,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -892,6 +921,7 @@ export default {
         height: 0,
         height: 0,
         seed: 73916127,
         seed: 73916127,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -924,6 +954,7 @@ export default {
         height: 5.001953125,
         height: 5.001953125,
         seed: 387857791,
         seed: 387857791,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -947,6 +978,7 @@ export default {
         height: 5.001953125,
         height: 5.001953125,
         seed: 1486370207,
         seed: 1486370207,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -970,6 +1002,7 @@ export default {
         height: 5.001953125,
         height: 5.001953125,
         seed: 610150847,
         seed: 610150847,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -993,6 +1026,7 @@ export default {
         height: 42.72020253937572,
         height: 42.72020253937572,
         seed: 144280593,
         seed: 144280593,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -1016,6 +1050,7 @@ export default {
         height: 24.44112284281997,
         height: 24.44112284281997,
         seed: 29167967,
         seed: 29167967,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -1068,6 +1103,7 @@ export default {
         height: 0,
         height: 0,
         seed: 1443027377,
         seed: 1443027377,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -1100,6 +1136,7 @@ export default {
         height: 5.711199931375845,
         height: 5.711199931375845,
         seed: 244310513,
         seed: 244310513,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -1138,6 +1175,7 @@ export default {
         height: 44.82230388130942,
         height: 44.82230388130942,
         seed: 683572113,
         seed: 683572113,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -1161,6 +1199,7 @@ export default {
         height: 5.896061363392446,
         height: 5.896061363392446,
         seed: 318798801,
         seed: 318798801,
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
         groupIds: ["TC0RSM64Cxmu17MlE12-o"],
+        frameId: null,
         strokeSharpness: "round",
         strokeSharpness: "round",
         boundElementIds: [],
         boundElementIds: [],
         startBinding: null,
         startBinding: null,
@@ -1200,6 +1239,7 @@ export default {
         height: 108.30428902193904,
         height: 108.30428902193904,
         seed: 1914896753,
         seed: 1914896753,
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -1223,6 +1263,7 @@ export default {
         height: 82.83278895375764,
         height: 82.83278895375764,
         seed: 1306468145,
         seed: 1306468145,
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -1246,6 +1287,7 @@ export default {
         height: 11.427824006438863,
         height: 11.427824006438863,
         seed: 93422161,
         seed: 93422161,
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -1269,6 +1311,7 @@ export default {
         height: 19.889460471185775,
         height: 19.889460471185775,
         seed: 11646495,
         seed: 11646495,
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },
@@ -1292,6 +1335,7 @@ export default {
         height: 19.889460471185775,
         height: 19.889460471185775,
         seed: 291717649,
         seed: 291717649,
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
         groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
+        frameId: null,
         strokeSharpness: "sharp",
         strokeSharpness: "sharp",
         boundElementIds: [],
         boundElementIds: [],
       },
       },

+ 134 - 29
src/renderer/renderElement.ts

@@ -34,6 +34,7 @@ import { AppState, BinaryFiles, Zoom } from "../types";
 import { getDefaultAppState } from "../appState";
 import { getDefaultAppState } from "../appState";
 import {
 import {
   BOUND_TEXT_PADDING,
   BOUND_TEXT_PADDING,
+  FRAME_STYLE,
   MAX_DECIMALS_FOR_SVG_EXPORT,
   MAX_DECIMALS_FOR_SVG_EXPORT,
   MIME_TYPES,
   MIME_TYPES,
   SVG_NS,
   SVG_NS,
@@ -48,6 +49,7 @@ import {
   getBoundTextMaxWidth,
   getBoundTextMaxWidth,
 } from "../element/textElement";
 } from "../element/textElement";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { getContainingFrame } from "../frame";
 
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original
 // as a temp hack to make images in dark theme look closer to original
@@ -92,6 +94,7 @@ export interface ExcalidrawElementWithCanvas {
   canvasOffsetX: number;
   canvasOffsetX: number;
   canvasOffsetY: number;
   canvasOffsetY: number;
   boundTextElementVersion: number | null;
   boundTextElementVersion: number | null;
+  containingFrameOpacity: number;
 }
 }
 
 
 const cappedElementCanvasSize = (
 const cappedElementCanvasSize = (
@@ -207,6 +210,7 @@ const generateElementCanvas = (
     canvasOffsetX,
     canvasOffsetX,
     canvasOffsetY,
     canvasOffsetY,
     boundTextElementVersion: getBoundTextElement(element)?.version || null,
     boundTextElementVersion: getBoundTextElement(element)?.version || null,
+    containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
   };
   };
 };
 };
 
 
@@ -253,7 +257,8 @@ const drawElementOnCanvas = (
   context: CanvasRenderingContext2D,
   context: CanvasRenderingContext2D,
   renderConfig: RenderConfig,
   renderConfig: RenderConfig,
 ) => {
 ) => {
-  context.globalAlpha = element.opacity / 100;
+  context.globalAlpha =
+    ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
     case "diamond":
     case "diamond":
@@ -469,7 +474,7 @@ const generateElementShape = (
     elementWithCanvasCache.delete(element);
     elementWithCanvasCache.delete(element);
 
 
     switch (element.type) {
     switch (element.type) {
-      case "rectangle":
+      case "rectangle": {
         if (element.roundness) {
         if (element.roundness) {
           const w = element.width;
           const w = element.width;
           const h = element.height;
           const h = element.height;
@@ -494,6 +499,7 @@ const generateElementShape = (
         setShapeForElement(element, shape);
         setShapeForElement(element, shape);
 
 
         break;
         break;
+      }
       case "diamond": {
       case "diamond": {
         const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
         const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
           getDiamondPoints(element);
           getDiamondPoints(element);
@@ -717,12 +723,14 @@ const generateElementWithCanvas = (
     prevElementWithCanvas.zoomValue !== zoom.value &&
     prevElementWithCanvas.zoomValue !== zoom.value &&
     !renderConfig?.shouldCacheIgnoreZoom;
     !renderConfig?.shouldCacheIgnoreZoom;
   const boundTextElementVersion = getBoundTextElement(element)?.version || null;
   const boundTextElementVersion = getBoundTextElement(element)?.version || null;
+  const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
 
 
   if (
   if (
     !prevElementWithCanvas ||
     !prevElementWithCanvas ||
     shouldRegenerateBecauseZoom ||
     shouldRegenerateBecauseZoom ||
     prevElementWithCanvas.theme !== renderConfig.theme ||
     prevElementWithCanvas.theme !== renderConfig.theme ||
-    prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
+    prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
+    prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
   ) {
   ) {
     const elementWithCanvas = generateElementCanvas(
     const elementWithCanvas = generateElementCanvas(
       element,
       element,
@@ -897,25 +905,59 @@ export const renderElement = (
   const generator = rc.generator;
   const generator = rc.generator;
   switch (element.type) {
   switch (element.type) {
     case "selection": {
     case "selection": {
-      context.save();
-      context.translate(
-        element.x + renderConfig.scrollX,
-        element.y + renderConfig.scrollY,
-      );
-      context.fillStyle = "rgba(0, 0, 200, 0.04)";
+      // do not render selection when exporting
+      if (!renderConfig.isExporting) {
+        context.save();
+        context.translate(
+          element.x + renderConfig.scrollX,
+          element.y + renderConfig.scrollY,
+        );
+        context.fillStyle = "rgba(0, 0, 200, 0.04)";
 
 
-      // render from 0.5px offset  to get 1px wide line
-      // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
-      // TODO can be be improved by offseting to the negative when user selects
-      // from right to left
-      const offset = 0.5 / renderConfig.zoom.value;
+        // render from 0.5px offset  to get 1px wide line
+        // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
+        // TODO can be be improved by offseting to the negative when user selects
+        // from right to left
+        const offset = 0.5 / renderConfig.zoom.value;
 
 
-      context.fillRect(offset, offset, element.width, element.height);
-      context.lineWidth = 1 / renderConfig.zoom.value;
-      context.strokeStyle = "rgb(105, 101, 219)";
-      context.strokeRect(offset, offset, element.width, element.height);
+        context.fillRect(offset, offset, element.width, element.height);
+        context.lineWidth = 1 / renderConfig.zoom.value;
+        context.strokeStyle = " rgb(105, 101, 219)";
+        context.strokeRect(offset, offset, element.width, element.height);
 
 
-      context.restore();
+        context.restore();
+      }
+      break;
+    }
+    case "frame": {
+      if (!renderConfig.isExporting && appState.shouldRenderFrames) {
+        context.save();
+        context.translate(
+          element.x + renderConfig.scrollX,
+          element.y + renderConfig.scrollY,
+        );
+        context.fillStyle = "rgba(0, 0, 200, 0.04)";
+
+        context.lineWidth = 2 / renderConfig.zoom.value;
+        context.strokeStyle = FRAME_STYLE.strokeColor;
+
+        if (FRAME_STYLE.radius && context.roundRect) {
+          context.beginPath();
+          context.roundRect(
+            0,
+            0,
+            element.width,
+            element.height,
+            FRAME_STYLE.radius / renderConfig.zoom.value,
+          );
+          context.stroke();
+          context.closePath();
+        } else {
+          context.strokeRect(0, 0, element.width, element.height);
+        }
+
+        context.restore();
+      }
       break;
       break;
     }
     }
     case "freedraw": {
     case "freedraw": {
@@ -1107,6 +1149,23 @@ const roughSVGDrawWithPrecision = (
   return rsvg.draw(pshape);
   return rsvg.draw(pshape);
 };
 };
 
 
+const maybeWrapNodesInFrameClipPath = (
+  element: NonDeletedExcalidrawElement,
+  root: SVGElement,
+  nodes: SVGElement[],
+  exportedFrameId?: string | null,
+) => {
+  const frame = getContainingFrame(element);
+  if (frame && frame.id === exportedFrameId) {
+    const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
+    g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
+    nodes.forEach((node) => g.appendChild(node));
+    return g;
+  }
+
+  return null;
+};
+
 export const renderElementToSvg = (
 export const renderElementToSvg = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
   rsvg: RoughSVG,
   rsvg: RoughSVG,
@@ -1115,6 +1174,7 @@ export const renderElementToSvg = (
   offsetX: number,
   offsetX: number,
   offsetY: number,
   offsetY: number,
   exportWithDarkMode?: boolean,
   exportWithDarkMode?: boolean,
+  exportingFrameId?: string | null,
 ) => {
 ) => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   let cx = (x2 - x1) / 2 - (element.x - x1);
   let cx = (x2 - x1) / 2 - (element.x - x1);
@@ -1148,6 +1208,9 @@ export const renderElementToSvg = (
     root = anchorTag;
     root = anchorTag;
   }
   }
 
 
+  const opacity =
+    ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
+
   switch (element.type) {
   switch (element.type) {
     case "selection": {
     case "selection": {
       // Since this is used only during editing experience, which is canvas based,
       // Since this is used only during editing experience, which is canvas based,
@@ -1163,7 +1226,6 @@ export const renderElementToSvg = (
         getShapeForElement(element)!,
         getShapeForElement(element)!,
         MAX_DECIMALS_FOR_SVG_EXPORT,
         MAX_DECIMALS_FOR_SVG_EXPORT,
       );
       );
-      const opacity = element.opacity / 100;
       if (opacity !== 1) {
       if (opacity !== 1) {
         node.setAttribute("stroke-opacity", `${opacity}`);
         node.setAttribute("stroke-opacity", `${opacity}`);
         node.setAttribute("fill-opacity", `${opacity}`);
         node.setAttribute("fill-opacity", `${opacity}`);
@@ -1175,7 +1237,15 @@ export const renderElementToSvg = (
           offsetY || 0
           offsetY || 0
         }) rotate(${degree} ${cx} ${cy})`,
         }) rotate(${degree} ${cx} ${cy})`,
       );
       );
-      root.appendChild(node);
+
+      const g = maybeWrapNodesInFrameClipPath(
+        element,
+        root,
+        [node],
+        exportingFrameId,
+      );
+
+      g ? root.appendChild(g) : root.appendChild(node);
       break;
       break;
     }
     }
     case "line":
     case "line":
@@ -1228,7 +1298,6 @@ export const renderElementToSvg = (
       if (boundText) {
       if (boundText) {
         group.setAttribute("mask", `url(#mask-${element.id})`);
         group.setAttribute("mask", `url(#mask-${element.id})`);
       }
       }
-      const opacity = element.opacity / 100;
       group.setAttribute("stroke-linecap", "round");
       group.setAttribute("stroke-linecap", "round");
 
 
       getShapeForElement(element)!.forEach((shape) => {
       getShapeForElement(element)!.forEach((shape) => {
@@ -1256,14 +1325,24 @@ export const renderElementToSvg = (
         }
         }
         group.appendChild(node);
         group.appendChild(node);
       });
       });
-      root.appendChild(group);
-      root.append(maskPath);
+
+      const g = maybeWrapNodesInFrameClipPath(
+        element,
+        root,
+        [group, maskPath],
+        exportingFrameId,
+      );
+      if (g) {
+        root.appendChild(g);
+      } else {
+        root.appendChild(group);
+        root.append(maskPath);
+      }
       break;
       break;
     }
     }
     case "freedraw": {
     case "freedraw": {
       generateElementShape(element, generator);
       generateElementShape(element, generator);
       generateFreeDrawShape(element);
       generateFreeDrawShape(element);
-      const opacity = element.opacity / 100;
       const shape = getShapeForElement(element);
       const shape = getShapeForElement(element);
       const node = shape
       const node = shape
         ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
         ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
@@ -1283,7 +1362,15 @@ export const renderElementToSvg = (
       path.setAttribute("fill", element.strokeColor);
       path.setAttribute("fill", element.strokeColor);
       path.setAttribute("d", getFreeDrawSvgPath(element));
       path.setAttribute("d", getFreeDrawSvgPath(element));
       node.appendChild(path);
       node.appendChild(path);
-      root.appendChild(node);
+
+      const g = maybeWrapNodesInFrameClipPath(
+        element,
+        root,
+        [node],
+        exportingFrameId,
+      );
+
+      g ? root.appendChild(g) : root.appendChild(node);
       break;
       break;
     }
     }
     case "image": {
     case "image": {
@@ -1319,6 +1406,7 @@ export const renderElementToSvg = (
 
 
         use.setAttribute("width", `${width}`);
         use.setAttribute("width", `${width}`);
         use.setAttribute("height", `${height}`);
         use.setAttribute("height", `${height}`);
+        use.setAttribute("opacity", `${opacity}`);
 
 
         // We first apply `scale` transforms (horizontal/vertical mirroring)
         // We first apply `scale` transforms (horizontal/vertical mirroring)
         // on the <use> element, then apply translation and rotation
         // on the <use> element, then apply translation and rotation
@@ -1344,13 +1432,22 @@ export const renderElementToSvg = (
           }) rotate(${degree} ${cx} ${cy})`,
           }) rotate(${degree} ${cx} ${cy})`,
         );
         );
 
 
-        root.appendChild(g);
+        const clipG = maybeWrapNodesInFrameClipPath(
+          element,
+          root,
+          [g],
+          exportingFrameId,
+        );
+        clipG ? root.appendChild(clipG) : root.appendChild(g);
       }
       }
       break;
       break;
     }
     }
+    // frames are not rendered and only acts as a container
+    case "frame": {
+      break;
+    }
     default: {
     default: {
       if (isTextElement(element)) {
       if (isTextElement(element)) {
-        const opacity = element.opacity / 100;
         const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
         const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
         if (opacity !== 1) {
         if (opacity !== 1) {
           node.setAttribute("stroke-opacity", `${opacity}`);
           node.setAttribute("stroke-opacity", `${opacity}`);
@@ -1395,7 +1492,15 @@ export const renderElementToSvg = (
           text.setAttribute("dominant-baseline", "text-before-edge");
           text.setAttribute("dominant-baseline", "text-before-edge");
           node.appendChild(text);
           node.appendChild(text);
         }
         }
-        root.appendChild(node);
+
+        const g = maybeWrapNodesInFrameClipPath(
+          element,
+          root,
+          [node],
+          exportingFrameId,
+        );
+
+        g ? root.appendChild(g) : root.appendChild(node);
       } else {
       } else {
         // @ts-ignore
         // @ts-ignore
         throw new Error(`Unimplemented type ${element.type}`);
         throw new Error(`Unimplemented type ${element.type}`);

+ 196 - 7
src/renderer/renderScene.ts

@@ -10,6 +10,7 @@ import {
   NonDeleted,
   NonDeleted,
   GroupId,
   GroupId,
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
+  ExcalidrawFrameElement,
 } from "../element/types";
 } from "../element/types";
 import {
 import {
   getElementAbsoluteCoords,
   getElementAbsoluteCoords,
@@ -36,6 +37,7 @@ import {
   isSelectedViaGroup,
   isSelectedViaGroup,
   getSelectedGroupIds,
   getSelectedGroupIds,
   getElementsInGroup,
   getElementsInGroup,
+  selectGroupsFromGivenElements,
 } from "../groups";
 } from "../groups";
 import { maxBindingGap } from "../element/collision";
 import { maxBindingGap } from "../element/collision";
 import {
 import {
@@ -44,18 +46,28 @@ import {
   isBindingEnabled,
   isBindingEnabled,
 } from "../element/binding";
 } from "../element/binding";
 import {
 import {
+  OMIT_SIDES_FOR_FRAME,
   shouldShowBoundingBox,
   shouldShowBoundingBox,
   TransformHandles,
   TransformHandles,
   TransformHandleType,
   TransformHandleType,
 } from "../element/transformHandles";
 } from "../element/transformHandles";
-import { viewportCoordsToSceneCoords, throttleRAF } from "../utils";
+import {
+  viewportCoordsToSceneCoords,
+  throttleRAF,
+  isOnlyExportingSingleFrame,
+} from "../utils";
 import { UserIdleState } from "../types";
 import { UserIdleState } from "../types";
-import { THEME_FILTER } from "../constants";
+import { FRAME_STYLE, THEME_FILTER } from "../constants";
 import {
 import {
   EXTERNAL_LINK_IMG,
   EXTERNAL_LINK_IMG,
   getLinkHandleFromCoords,
   getLinkHandleFromCoords,
 } from "../element/Hyperlink";
 } from "../element/Hyperlink";
-import { isLinearElement } from "../element/typeChecks";
+import { isFrameElement, isLinearElement } from "../element/typeChecks";
+import {
+  elementOverlapsWithFrame,
+  getTargetFrame,
+  isElementInFrame,
+} from "../frame";
 import "canvas-roundrect-polyfill";
 import "canvas-roundrect-polyfill";
 
 
 export const DEFAULT_SPACING = 2;
 export const DEFAULT_SPACING = 2;
@@ -70,6 +82,8 @@ const strokeRectWithRotation = (
   cy: number,
   cy: number,
   angle: number,
   angle: number,
   fill: boolean = false,
   fill: boolean = false,
+  /** should account for zoom */
+  radius: number = 0,
 ) => {
 ) => {
   context.save();
   context.save();
   context.translate(cx, cy);
   context.translate(cx, cy);
@@ -77,7 +91,14 @@ const strokeRectWithRotation = (
   if (fill) {
   if (fill) {
     context.fillRect(x - cx, y - cy, width, height);
     context.fillRect(x - cx, y - cy, width, height);
   }
   }
-  context.strokeRect(x - cx, y - cy, width, height);
+  if (radius && context.roundRect) {
+    context.beginPath();
+    context.roundRect(x - cx, y - cy, width, height, radius);
+    context.stroke();
+    context.closePath();
+  } else {
+    context.strokeRect(x - cx, y - cy, width, height);
+  }
   context.restore();
   context.restore();
 };
 };
 
 
@@ -299,6 +320,34 @@ const renderLinearElementPointHighlight = (
   context.restore();
   context.restore();
 };
 };
 
 
+const frameClip = (
+  frame: ExcalidrawFrameElement,
+  context: CanvasRenderingContext2D,
+  renderConfig: RenderConfig,
+) => {
+  context.translate(
+    frame.x + renderConfig.scrollX,
+    frame.y + renderConfig.scrollY,
+  );
+  context.beginPath();
+  if (context.roundRect && !renderConfig.isExporting) {
+    context.roundRect(
+      0,
+      0,
+      frame.width,
+      frame.height,
+      FRAME_STYLE.radius / renderConfig.zoom.value,
+    );
+  } else {
+    context.rect(0, 0, frame.width, frame.height);
+  }
+  context.clip();
+  context.translate(
+    -(frame.x + renderConfig.scrollX),
+    -(frame.y + renderConfig.scrollY),
+  );
+};
+
 export const _renderScene = ({
 export const _renderScene = ({
   elements,
   elements,
   appState,
   appState,
@@ -390,11 +439,51 @@ export const _renderScene = ({
       }),
       }),
     );
     );
 
 
+    const groupsToBeAddedToFrame = new Set<string>();
+
+    visibleElements.forEach((element) => {
+      if (
+        element.groupIds.length > 0 &&
+        appState.frameToHighlight &&
+        appState.selectedElementIds[element.id] &&
+        (elementOverlapsWithFrame(element, appState.frameToHighlight) ||
+          element.groupIds.find((groupId) =>
+            groupsToBeAddedToFrame.has(groupId),
+          ))
+      ) {
+        element.groupIds.forEach((groupId) =>
+          groupsToBeAddedToFrame.add(groupId),
+        );
+      }
+    });
+
     let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
     let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
       undefined;
       undefined;
+
     visibleElements.forEach((element) => {
     visibleElements.forEach((element) => {
       try {
       try {
-        renderElement(element, rc, context, renderConfig, appState);
+        // - when exporting the whole canvas, we DO NOT apply clipping
+        // - when we are exporting a particular frame, apply clipping
+        //   if the containing frame is not selected, apply clipping
+        const frameId = element.frameId || appState.frameToHighlight?.id;
+
+        if (
+          frameId &&
+          ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
+            (!renderConfig.isExporting && appState.shouldRenderFrames))
+        ) {
+          context.save();
+
+          const frame = getTargetFrame(element, appState);
+
+          if (frame && isElementInFrame(element, elements, appState)) {
+            frameClip(frame, context, renderConfig);
+          }
+          renderElement(element, rc, context, renderConfig, appState);
+          context.restore();
+        } else {
+          renderElement(element, rc, context, renderConfig, appState);
+        }
         // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
         // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
         // ShapeCache returns empty hence making sure that we get the
         // ShapeCache returns empty hence making sure that we get the
         // correct element from visible elements
         // correct element from visible elements
@@ -443,7 +532,24 @@ export const _renderScene = ({
           renderBindingHighlight(context, renderConfig, suggestedBinding!);
           renderBindingHighlight(context, renderConfig, suggestedBinding!);
         });
         });
     }
     }
+
+    if (appState.frameToHighlight) {
+      renderFrameHighlight(context, renderConfig, appState.frameToHighlight);
+    }
+
+    if (appState.elementsToHighlight) {
+      renderElementsBoxHighlight(
+        context,
+        renderConfig,
+        appState.elementsToHighlight,
+        appState,
+      );
+    }
+
     const locallySelectedElements = getSelectedElements(elements, appState);
     const locallySelectedElements = getSelectedElements(elements, appState);
+    const isFrameSelected = locallySelectedElements.some((element) =>
+      isFrameElement(element),
+    );
 
 
     // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
     // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
     // ShapeCache returns empty hence making sure that we get the
     // ShapeCache returns empty hence making sure that we get the
@@ -613,7 +719,9 @@ export const _renderScene = ({
           0,
           0,
           renderConfig.zoom,
           renderConfig.zoom,
           "mouse",
           "mouse",
-          OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+          isFrameSelected
+            ? OMIT_SIDES_FOR_FRAME
+            : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
         );
         );
         if (locallySelectedElements.some((element) => !element.locked)) {
         if (locallySelectedElements.some((element) => !element.locked)) {
           renderTransformHandles(context, renderConfig, transformHandles, 0);
           renderTransformHandles(context, renderConfig, transformHandles, 0);
@@ -974,6 +1082,7 @@ const renderBindingHighlightForBindableElement = (
     case "rectangle":
     case "rectangle":
     case "text":
     case "text":
     case "image":
     case "image":
+    case "frame":
       strokeRectWithRotation(
       strokeRectWithRotation(
         context,
         context,
         x1 - padding,
         x1 - padding,
@@ -1011,6 +1120,82 @@ const renderBindingHighlightForBindableElement = (
   }
   }
 };
 };
 
 
+const renderFrameHighlight = (
+  context: CanvasRenderingContext2D,
+  renderConfig: RenderConfig,
+  frame: NonDeleted<ExcalidrawFrameElement>,
+) => {
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
+  const width = x2 - x1;
+  const height = y2 - y1;
+
+  context.strokeStyle = "rgb(0,118,255)";
+  context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / renderConfig.zoom.value;
+
+  context.save();
+  context.translate(renderConfig.scrollX, renderConfig.scrollY);
+  strokeRectWithRotation(
+    context,
+    x1,
+    y1,
+    width,
+    height,
+    x1 + width / 2,
+    y1 + height / 2,
+    frame.angle,
+    false,
+    FRAME_STYLE.radius / renderConfig.zoom.value,
+  );
+  context.restore();
+};
+
+const renderElementsBoxHighlight = (
+  context: CanvasRenderingContext2D,
+  renderConfig: RenderConfig,
+  elements: NonDeleted<ExcalidrawElement>[],
+  appState: AppState,
+) => {
+  const individualElements = elements.filter(
+    (element) => element.groupIds.length === 0,
+  );
+
+  const elementsInGroups = elements.filter(
+    (element) => element.groupIds.length > 0,
+  );
+
+  const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
+    const [elementX1, elementY1, elementX2, elementY2] =
+      getCommonBounds(elements);
+    return {
+      angle: 0,
+      elementX1,
+      elementX2,
+      elementY1,
+      elementY2,
+      selectionColors: ["rgb(0,118,255)"],
+      dashed: false,
+      cx: elementX1 + (elementX2 - elementX1) / 2,
+      cy: elementY1 + (elementY2 - elementY1) / 2,
+    };
+  };
+
+  const getSelectionForGroupId = (groupId: GroupId) => {
+    const groupElements = getElementsInGroup(elements, groupId);
+    return getSelectionFromElements(groupElements);
+  };
+
+  Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
+    .filter(([id, isSelected]) => isSelected)
+    .map(([id, isSelected]) => id)
+    .map((groupId) => getSelectionForGroupId(groupId))
+    .concat(
+      individualElements.map((element) => getSelectionFromElements([element])),
+    )
+    .forEach((selection) =>
+      renderSelectionBorder(context, renderConfig, selection),
+    );
+};
+
 const renderBindingHighlightForSuggestedPointBinding = (
 const renderBindingHighlightForSuggestedPointBinding = (
   context: CanvasRenderingContext2D,
   context: CanvasRenderingContext2D,
   suggestedBinding: SuggestedPointBinding,
   suggestedBinding: SuggestedPointBinding,
@@ -1092,7 +1277,7 @@ const renderLinkIcon = (
   }
   }
 };
 };
 
 
-const isVisibleElement = (
+export const isVisibleElement = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   canvasWidth: number,
   canvasWidth: number,
   canvasHeight: number,
   canvasHeight: number,
@@ -1138,15 +1323,18 @@ export const renderSceneToSvg = (
     offsetX = 0,
     offsetX = 0,
     offsetY = 0,
     offsetY = 0,
     exportWithDarkMode = false,
     exportWithDarkMode = false,
+    exportingFrameId = null,
   }: {
   }: {
     offsetX?: number;
     offsetX?: number;
     offsetY?: number;
     offsetY?: number;
     exportWithDarkMode?: boolean;
     exportWithDarkMode?: boolean;
+    exportingFrameId?: string | null;
   } = {},
   } = {},
 ) => {
 ) => {
   if (!svgRoot) {
   if (!svgRoot) {
     return;
     return;
   }
   }
+
   // render elements
   // render elements
   elements.forEach((element) => {
   elements.forEach((element) => {
     if (!element.isDeleted) {
     if (!element.isDeleted) {
@@ -1159,6 +1347,7 @@ export const renderSceneToSvg = (
           element.x + offsetX,
           element.x + offsetX,
           element.y + offsetY,
           element.y + offsetY,
           exportWithDarkMode,
           exportWithDarkMode,
+          exportingFrameId,
         );
         );
       } catch (error: any) {
       } catch (error: any) {
         console.error(error);
         console.error(error);

+ 51 - 1
src/scene/Scene.ts

@@ -2,9 +2,15 @@ import {
   ExcalidrawElement,
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
   NonDeleted,
+  ExcalidrawFrameElement,
 } from "../element/types";
 } from "../element/types";
-import { getNonDeletedElements, isNonDeletedElement } from "../element";
+import {
+  getNonDeletedElements,
+  getNonDeletedFrames,
+  isNonDeletedElement,
+} from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { isFrameElement } from "../element/typeChecks";
 
 
 type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 type ElementKey = ExcalidrawElement | ElementIdKey;
 type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -12,6 +18,10 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
 type SceneStateCallback = () => void;
 type SceneStateCallback = () => void;
 type SceneStateCallbackRemover = () => void;
 type SceneStateCallbackRemover = () => void;
 
 
+// ideally this would be a branded type but it'd be insanely hard to work with
+// in our codebase
+export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
+
 const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
 const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
   if (typeof elementKey === "string") {
   if (typeof elementKey === "string") {
     return true;
     return true;
@@ -55,6 +65,8 @@ class Scene {
 
 
   private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
   private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
   private elements: readonly ExcalidrawElement[] = [];
   private elements: readonly ExcalidrawElement[] = [];
+  private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
+  private frames: readonly ExcalidrawFrameElement[] = [];
   private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
   private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
 
 
   getElementsIncludingDeleted() {
   getElementsIncludingDeleted() {
@@ -65,6 +77,14 @@ class Scene {
     return this.nonDeletedElements;
     return this.nonDeletedElements;
   }
   }
 
 
+  getFramesIncludingDeleted() {
+    return this.frames;
+  }
+
+  getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
+    return this.nonDeletedFrames;
+  }
+
   getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
   getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
     return (this.elementsMap.get(id) as T | undefined) || null;
     return (this.elementsMap.get(id) as T | undefined) || null;
   }
   }
@@ -110,12 +130,19 @@ class Scene {
 
 
   replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
   replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
     this.elements = nextElements;
     this.elements = nextElements;
+    const nextFrames: ExcalidrawFrameElement[] = [];
     this.elementsMap.clear();
     this.elementsMap.clear();
     nextElements.forEach((element) => {
     nextElements.forEach((element) => {
+      if (isFrameElement(element)) {
+        nextFrames.push(element);
+      }
       this.elementsMap.set(element.id, element);
       this.elementsMap.set(element.id, element);
       Scene.mapElementToScene(element, this);
       Scene.mapElementToScene(element, this);
     });
     });
     this.nonDeletedElements = getNonDeletedElements(this.elements);
     this.nonDeletedElements = getNonDeletedElements(this.elements);
+    this.frames = nextFrames;
+    this.nonDeletedFrames = getNonDeletedFrames(this.frames);
+
     this.informMutation();
     this.informMutation();
   }
   }
 
 
@@ -165,6 +192,29 @@ class Scene {
     this.replaceAllElements(nextElements);
     this.replaceAllElements(nextElements);
   }
   }
 
 
+  insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
+    if (!Number.isFinite(index) || index < 0) {
+      throw new Error(
+        "insertElementAtIndex can only be called with index >= 0",
+      );
+    }
+    const nextElements = [
+      ...this.elements.slice(0, index),
+      ...elements,
+      ...this.elements.slice(index),
+    ];
+
+    this.replaceAllElements(nextElements);
+  }
+
+  addNewElement = (element: ExcalidrawElement) => {
+    if (element.frameId) {
+      this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
+    } else {
+      this.replaceAllElements([...this.elements, element]);
+    }
+  };
+
   getElementIndex(elementId: string) {
   getElementIndex(elementId: string) {
     return this.elements.findIndex((element) => element.id === elementId);
     return this.elements.findIndex((element) => element.id === elementId);
   }
   }

+ 2 - 1
src/scene/comparisons.ts

@@ -7,7 +7,8 @@ export const hasBackground = (type: string) =>
   type === "line" ||
   type === "line" ||
   type === "freedraw";
   type === "freedraw";
 
 
-export const hasStrokeColor = (type: string) => type !== "image";
+export const hasStrokeColor = (type: string) =>
+  type !== "image" && type !== "frame";
 
 
 export const hasStrokeWidth = (type: string) =>
 export const hasStrokeWidth = (type: string) =>
   type === "rectangle" ||
   type === "rectangle" ||

+ 75 - 8
src/scene/export.ts

@@ -1,8 +1,8 @@
 import rough from "roughjs/bin/rough";
 import rough from "roughjs/bin/rough";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
-import { getCommonBounds } from "../element/bounds";
+import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
 import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
 import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
-import { distance } from "../utils";
+import { distance, isOnlyExportingSingleFrame } from "../utils";
 import { AppState, BinaryFiles } from "../types";
 import { AppState, BinaryFiles } from "../types";
 import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
 import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
 import { getDefaultAppState } from "../appState";
 import { getDefaultAppState } from "../appState";
@@ -11,6 +11,7 @@ import {
   getInitializedImageElements,
   getInitializedImageElements,
   updateImageCache,
   updateImageCache,
 } from "../element/image";
 } from "../element/image";
+import Scene from "./Scene";
 
 
 export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
 
@@ -51,6 +52,8 @@ export const exportToCanvas = async (
     files,
     files,
   });
   });
 
 
+  const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
+
   renderScene({
   renderScene({
     elements,
     elements,
     appState,
     appState,
@@ -59,8 +62,8 @@ export const exportToCanvas = async (
     canvas,
     canvas,
     renderConfig: {
     renderConfig: {
       viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
       viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
-      scrollX: -minX + exportPadding,
-      scrollY: -minY + exportPadding,
+      scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
+      scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
       zoom: defaultAppState.zoom,
       zoom: defaultAppState.zoom,
       remotePointerViewportCoords: {},
       remotePointerViewportCoords: {},
       remoteSelectedElementIds: {},
       remoteSelectedElementIds: {},
@@ -88,6 +91,7 @@ export const exportToSvg = async (
     viewBackgroundColor: string;
     viewBackgroundColor: string;
     exportWithDarkMode?: boolean;
     exportWithDarkMode?: boolean;
     exportEmbedScene?: boolean;
     exportEmbedScene?: boolean;
+    renderFrame?: boolean;
   },
   },
   files: BinaryFiles | null,
   files: BinaryFiles | null,
   opts?: {
   opts?: {
@@ -140,6 +144,39 @@ export const exportToSvg = async (
     }
     }
     assetPath = `${assetPath}/dist/excalidraw-assets/`;
     assetPath = `${assetPath}/dist/excalidraw-assets/`;
   }
   }
+
+  // do not apply clipping when we're exporting the whole scene
+  const isExportingWholeCanvas =
+    Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
+    elements.length;
+
+  const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
+
+  const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
+  const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
+
+  const exportingFrame =
+    isExportingWholeCanvas || !onlyExportingSingleFrame
+      ? undefined
+      : elements.find((element) => element.type === "frame");
+
+  let exportingFrameClipPath = "";
+  if (exportingFrame) {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
+    const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
+    const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
+
+    exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
+            <rect transform="translate(${exportingFrame.x + offsetX} ${
+      exportingFrame.y + offsetY
+    }) rotate(${exportingFrame.angle} ${cx} ${cy})"
+          width="${exportingFrame.width}"
+          height="${exportingFrame.height}"
+          >
+          </rect>
+        </clipPath>`;
+  }
+
   svgRoot.innerHTML = `
   svgRoot.innerHTML = `
   ${SVG_EXPORT_TAG}
   ${SVG_EXPORT_TAG}
   ${metadata}
   ${metadata}
@@ -154,8 +191,10 @@ export const exportToSvg = async (
         src: url("${assetPath}Cascadia.woff2");
         src: url("${assetPath}Cascadia.woff2");
       }
       }
     </style>
     </style>
+    ${exportingFrameClipPath}
   </defs>
   </defs>
   `;
   `;
+
   // render background rect
   // render background rect
   if (appState.exportBackground && viewBackgroundColor) {
   if (appState.exportBackground && viewBackgroundColor) {
     const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
     const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
@@ -169,9 +208,10 @@ export const exportToSvg = async (
 
 
   const rsvg = rough.svg(svgRoot);
   const rsvg = rough.svg(svgRoot);
   renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
   renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
-    offsetX: -minX + exportPadding,
-    offsetY: -minY + exportPadding,
+    offsetX,
+    offsetY,
     exportWithDarkMode: appState.exportWithDarkMode,
     exportWithDarkMode: appState.exportWithDarkMode,
+    exportingFrameId: exportingFrame?.id || null,
   });
   });
 
 
   return svgRoot;
   return svgRoot;
@@ -182,9 +222,36 @@ const getCanvasSize = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
   exportPadding: number,
   exportPadding: number,
 ): [number, number, number, number] => {
 ): [number, number, number, number] => {
+  // we should decide if we are exporting the whole canvas
+  // if so, we are not clipping elements in the frame
+  // and therefore, we should not do anything special
+
+  const isExportingWholeCanvas =
+    Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
+    elements.length;
+
+  const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
+
+  if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
+    const frames = elements.filter((element) => element.type === "frame");
+
+    const exportedFrameIds = frames.reduce((acc, frame) => {
+      acc[frame.id] = true;
+      return acc;
+    }, {} as Record<string, true>);
+
+    // elements in a frame do not affect the canvas size if we're not exporting
+    // the whole canvas
+    elements = elements.filter(
+      (element) => !exportedFrameIds[element.frameId ?? ""],
+    );
+  }
+
   const [minX, minY, maxX, maxY] = getCommonBounds(elements);
   const [minX, minY, maxX, maxY] = getCommonBounds(elements);
-  const width = distance(minX, maxX) + exportPadding * 2;
-  const height = distance(minY, maxY) + exportPadding + exportPadding;
+  const width =
+    distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
+  const height =
+    distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
 
 
   return [minX, minY, width, height];
   return [minX, minY, width, height];
 };
 };

+ 89 - 7
src/scene/selection.ts

@@ -5,17 +5,61 @@ import {
 import { getElementAbsoluteCoords, getElementBounds } from "../element";
 import { getElementAbsoluteCoords, getElementBounds } from "../element";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import { isBoundToContainer } from "../element/typeChecks";
+import {
+  elementOverlapsWithFrame,
+  getContainingFrame,
+  getFrameElements,
+} from "../frame";
+
+/**
+ * Frames and their containing elements are not to be selected at the same time.
+ * Given an array of selected elements, if there are frames and their containing elements
+ * we only keep the frames.
+ * @param selectedElements
+ */
+export const excludeElementsInFramesFromSelection = <
+  T extends ExcalidrawElement,
+>(
+  selectedElements: readonly T[],
+) => {
+  const framesInSelection = new Set<T["id"]>();
+
+  selectedElements.forEach((element) => {
+    if (element.type === "frame") {
+      framesInSelection.add(element.id);
+    }
+  });
+
+  return selectedElements.filter((element) => {
+    if (element.frameId && framesInSelection.has(element.frameId)) {
+      return false;
+    }
+    return true;
+  });
+};
 
 
 export const getElementsWithinSelection = (
 export const getElementsWithinSelection = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
   selection: NonDeletedExcalidrawElement,
   selection: NonDeletedExcalidrawElement,
+  excludeElementsInFrames: boolean = true,
 ) => {
 ) => {
   const [selectionX1, selectionY1, selectionX2, selectionY2] =
   const [selectionX1, selectionY1, selectionX2, selectionY2] =
     getElementAbsoluteCoords(selection);
     getElementAbsoluteCoords(selection);
-  return elements.filter((element) => {
-    const [elementX1, elementY1, elementX2, elementY2] =
+
+  let elementsInSelection = elements.filter((element) => {
+    let [elementX1, elementY1, elementX2, elementY2] =
       getElementBounds(element);
       getElementBounds(element);
 
 
+    const containingFrame = getContainingFrame(element);
+    if (containingFrame) {
+      const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame);
+
+      elementX1 = Math.max(fx1, elementX1);
+      elementY1 = Math.max(fy1, elementY1);
+      elementX2 = Math.min(fx2, elementX2);
+      elementY2 = Math.min(fy2, elementY2);
+    }
+
     return (
     return (
       element.locked === false &&
       element.locked === false &&
       element.type !== "selection" &&
       element.type !== "selection" &&
@@ -26,6 +70,22 @@ export const getElementsWithinSelection = (
       selectionY2 >= elementY2
       selectionY2 >= elementY2
     );
     );
   });
   });
+
+  elementsInSelection = excludeElementsInFrames
+    ? excludeElementsInFramesFromSelection(elementsInSelection)
+    : elementsInSelection;
+
+  elementsInSelection = elementsInSelection.filter((element) => {
+    const containingFrame = getContainingFrame(element);
+
+    if (containingFrame) {
+      return elementOverlapsWithFrame(element, containingFrame);
+    }
+
+    return true;
+  });
+
+  return elementsInSelection;
 };
 };
 
 
 export const isSomeElementSelected = (
 export const isSomeElementSelected = (
@@ -56,14 +116,17 @@ export const getCommonAttributeOfSelectedElements = <T>(
 export const getSelectedElements = (
 export const getSelectedElements = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
   appState: Pick<AppState, "selectedElementIds">,
   appState: Pick<AppState, "selectedElementIds">,
-  includeBoundTextElement: boolean = false,
-) =>
-  elements.filter((element) => {
+  opts?: {
+    includeBoundTextElement?: boolean;
+    includeElementsInFrames?: boolean;
+  },
+) => {
+  const selectedElements = elements.filter((element) => {
     if (appState.selectedElementIds[element.id]) {
     if (appState.selectedElementIds[element.id]) {
       return element;
       return element;
     }
     }
     if (
     if (
-      includeBoundTextElement &&
+      opts?.includeBoundTextElement &&
       isBoundToContainer(element) &&
       isBoundToContainer(element) &&
       appState.selectedElementIds[element?.containerId]
       appState.selectedElementIds[element?.containerId]
     ) {
     ) {
@@ -72,10 +135,29 @@ export const getSelectedElements = (
     return null;
     return null;
   });
   });
 
 
+  if (opts?.includeElementsInFrames) {
+    const elementsToInclude: ExcalidrawElement[] = [];
+    selectedElements.forEach((element) => {
+      if (element.type === "frame") {
+        getFrameElements(elements, element.id).forEach((e) =>
+          elementsToInclude.push(e),
+        );
+      }
+      elementsToInclude.push(element);
+    });
+
+    return elementsToInclude;
+  }
+
+  return selectedElements;
+};
+
 export const getTargetElements = (
 export const getTargetElements = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
   appState: Pick<AppState, "selectedElementIds" | "editingElement">,
   appState: Pick<AppState, "selectedElementIds" | "editingElement">,
 ) =>
 ) =>
   appState.editingElement
   appState.editingElement
     ? [appState.editingElement]
     ? [appState.editingElement]
-    : getSelectedElements(elements, appState, true);
+    : getSelectedElements(elements, appState, {
+        includeBoundTextElement: true,
+      });

+ 8 - 0
src/shapes.tsx

@@ -83,6 +83,14 @@ export const SHAPES = [
     numericKey: KEYS["0"],
     numericKey: KEYS["0"],
     fillable: false,
     fillable: false,
   },
   },
+  // TODO: frame, create icon and set up numeric key
+  // {
+  //   icon: RectangleIcon,
+  //   value: "frame",
+  //   key: KEYS.F,
+  //   numericKey: KEYS.SUBTRACT,
+  //   fillable: false,
+  // },
 ] as const;
 ] as const;
 
 
 export const findShapeByKey = (key: string) => {
 export const findShapeByKey = (key: string) => {

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 153 - 0
src/tests/__snapshots__/contextmenu.test.tsx.snap


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

@@ -10,6 +10,7 @@ Object {
   "endArrowhead": "arrow",
   "endArrowhead": "arrow",
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -56,6 +57,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -89,6 +91,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -122,6 +125,7 @@ Object {
   "endArrowhead": null,
   "endArrowhead": null,
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -168,6 +172,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",

+ 2 - 1
src/tests/__snapshots__/export.test.tsx.snap

@@ -15,6 +15,7 @@ exports[`export exporting svg containing transformed images: svg export output 1
         src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
         src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
       }
       }
     </style>
     </style>
+    
   </defs>
   </defs>
-  <g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
+  <g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
 `;
 `;

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

@@ -6,6 +6,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0_copy",
   "id": "id0_copy",
@@ -37,6 +38,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -68,6 +70,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -104,6 +107,7 @@ Object {
     },
     },
   ],
   ],
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 100,
   "height": 100,
   "id": "id0",
   "id": "id0",
@@ -140,6 +144,7 @@ Object {
     },
     },
   ],
   ],
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 300,
   "height": 300,
   "id": "id1",
   "id": "id1",
@@ -177,6 +182,7 @@ Object {
     "gap": 10,
     "gap": 10,
   },
   },
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 81.48231043525051,
   "height": 81.48231043525051,
   "id": "id2",
   "id": "id2",

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

@@ -8,6 +8,7 @@ Object {
   "endArrowhead": "arrow",
   "endArrowhead": "arrow",
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 110,
   "height": 110,
   "id": "id0",
   "id": "id0",
@@ -61,6 +62,7 @@ Object {
   "endArrowhead": null,
   "endArrowhead": null,
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 110,
   "height": 110,
   "id": "id0",
   "id": "id0",

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 136 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


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

@@ -8,6 +8,7 @@ Object {
   "endArrowhead": "arrow",
   "endArrowhead": "arrow",
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -54,6 +55,7 @@ Object {
   "endArrowhead": null,
   "endArrowhead": null,
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -98,6 +100,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -129,6 +132,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",
@@ -160,6 +164,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 50,
   "height": 50,
   "id": "id0",
   "id": "id0",

+ 9 - 0
src/tests/data/__snapshots__/restore.test.ts.snap

@@ -8,6 +8,7 @@ Object {
   "endArrowhead": null,
   "endArrowhead": null,
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 100,
   "height": 100,
   "id": "id-arrow01",
   "id": "id-arrow01",
@@ -52,6 +53,7 @@ Object {
   "backgroundColor": "blue",
   "backgroundColor": "blue",
   "boundElements": Array [],
   "boundElements": Array [],
   "fillStyle": "cross-hatch",
   "fillStyle": "cross-hatch",
+  "frameId": null,
   "groupIds": Array [
   "groupIds": Array [
     "1",
     "1",
     "2",
     "2",
@@ -87,6 +89,7 @@ Object {
   "backgroundColor": "blue",
   "backgroundColor": "blue",
   "boundElements": Array [],
   "boundElements": Array [],
   "fillStyle": "cross-hatch",
   "fillStyle": "cross-hatch",
+  "frameId": null,
   "groupIds": Array [
   "groupIds": Array [
     "1",
     "1",
     "2",
     "2",
@@ -122,6 +125,7 @@ Object {
   "backgroundColor": "blue",
   "backgroundColor": "blue",
   "boundElements": Array [],
   "boundElements": Array [],
   "fillStyle": "cross-hatch",
   "fillStyle": "cross-hatch",
+  "frameId": null,
   "groupIds": Array [
   "groupIds": Array [
     "1",
     "1",
     "2",
     "2",
@@ -157,6 +161,7 @@ Object {
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": Array [],
   "boundElements": Array [],
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 0,
   "height": 0,
   "id": "id-freedraw01",
   "id": "id-freedraw01",
@@ -194,6 +199,7 @@ Object {
   "endArrowhead": null,
   "endArrowhead": null,
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 100,
   "height": 100,
   "id": "id-line01",
   "id": "id-line01",
@@ -240,6 +246,7 @@ Object {
   "endArrowhead": null,
   "endArrowhead": null,
   "endBinding": null,
   "endBinding": null,
   "fillStyle": "hachure",
   "fillStyle": "hachure",
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 100,
   "height": 100,
   "id": "id-draw01",
   "id": "id-draw01",
@@ -288,6 +295,7 @@ Object {
   "fillStyle": "hachure",
   "fillStyle": "hachure",
   "fontFamily": 1,
   "fontFamily": 1,
   "fontSize": 14,
   "fontSize": 14,
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 100,
   "height": 100,
   "id": "id-text01",
   "id": "id-text01",
@@ -328,6 +336,7 @@ Object {
   "fillStyle": "hachure",
   "fillStyle": "hachure",
   "fontFamily": 1,
   "fontFamily": 1,
   "fontSize": 10,
   "fontSize": 10,
+  "frameId": null,
   "groupIds": Array [],
   "groupIds": Array [],
   "height": 100,
   "height": 100,
   "id": "id-text01",
   "id": "id-text01",

+ 1 - 0
src/tests/fixtures/elementFixture.ts

@@ -15,6 +15,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
   roughness: 1,
   roughness: 1,
   opacity: 100,
   opacity: 100,
   groupIds: [],
   groupIds: [],
+  frameId: null,
   roundness: null,
   roundness: null,
   seed: 1041657908,
   seed: 1041657908,
   version: 120,
   version: 120,

+ 6 - 1
src/tests/helpers/api.ts

@@ -37,8 +37,12 @@ export class API {
 
 
   static getSelectedElements = (
   static getSelectedElements = (
     includeBoundTextElement: boolean = false,
     includeBoundTextElement: boolean = false,
+    includeElementsInFrames: boolean = false,
   ): ExcalidrawElement[] => {
   ): ExcalidrawElement[] => {
-    return getSelectedElements(h.elements, h.state, includeBoundTextElement);
+    return getSelectedElements(h.elements, h.state, {
+      includeBoundTextElement,
+      includeElementsInFrames,
+    });
   };
   };
 
 
   static getSelectedElement = (): ExcalidrawElement => {
   static getSelectedElement = (): ExcalidrawElement => {
@@ -141,6 +145,7 @@ export class API {
       | "versionNonce"
       | "versionNonce"
       | "isDeleted"
       | "isDeleted"
       | "groupIds"
       | "groupIds"
+      | "frameId"
       | "link"
       | "link"
       | "updated"
       | "updated"
     > = {
     > = {

+ 5 - 0
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -28,8 +28,10 @@ Object {
   "defaultSidebarDockedPreference": false,
   "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
+  "editingFrame": null,
   "editingGroupId": null,
   "editingGroupId": null,
   "editingLinearElement": null,
   "editingLinearElement": null,
+  "elementsToHighlight": null,
   "errorMessage": null,
   "errorMessage": null,
   "exportBackground": true,
   "exportBackground": true,
   "exportEmbedScene": false,
   "exportEmbedScene": false,
@@ -37,6 +39,7 @@ Object {
   "exportScale": 1,
   "exportScale": 1,
   "exportWithDarkMode": false,
   "exportWithDarkMode": false,
   "fileHandle": null,
   "fileHandle": null,
+  "frameToHighlight": null,
   "gridSize": null,
   "gridSize": null,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
   "isLoading": false,
   "isLoading": false,
@@ -62,10 +65,12 @@ Object {
   "scrollY": 0,
   "scrollY": 0,
   "scrolledOutside": false,
   "scrolledOutside": false,
   "selectedElementIds": Object {},
   "selectedElementIds": Object {},
+  "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": Object {},
   "selectedGroupIds": Object {},
   "selectedLinearElement": null,
   "selectedLinearElement": null,
   "selectionElement": null,
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "shouldCacheIgnoreZoom": false,
+  "shouldRenderFrames": true,
   "showHyperlinkPopup": false,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showStats": false,
   "showWelcomeScreen": false,
   "showWelcomeScreen": false,

+ 1 - 0
src/tests/queries/toolQueries.ts

@@ -11,6 +11,7 @@ const toolMap = {
   freedraw: "freedraw",
   freedraw: "freedraw",
   text: "text",
   text: "text",
   eraser: "eraser",
   eraser: "eraser",
+  frame: "frame",
 };
 };
 
 
 export type ToolName = keyof typeof toolMap;
 export type ToolName = keyof typeof toolMap;

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 2 - 0
src/tests/scene/__snapshots__/export.test.ts.snap


+ 33 - 2
src/types.ts

@@ -15,6 +15,7 @@ import {
   ExcalidrawImageElement,
   ExcalidrawImageElement,
   Theme,
   Theme,
   StrokeRoundness,
   StrokeRoundness,
+  ExcalidrawFrameElement,
 } from "./element/types";
 } from "./element/types";
 import { SHAPES } from "./shapes";
 import { SHAPES } from "./shapes";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -85,7 +86,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
 
 
 export type LastActiveTool =
 export type LastActiveTool =
   | {
   | {
-      type: typeof SHAPES[number]["value"] | "eraser" | "hand";
+      type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
       customType: null;
       customType: null;
     }
     }
   | {
   | {
@@ -113,6 +114,10 @@ export type AppState = {
   isBindingEnabled: boolean;
   isBindingEnabled: boolean;
   startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
   startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
   suggestedBindings: SuggestedBinding[];
   suggestedBindings: SuggestedBinding[];
+  frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
+  shouldRenderFrames: boolean;
+  editingFrame: string | null;
+  elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null;
   // element being edited, but not necessarily added to elements array yet
   // element being edited, but not necessarily added to elements array yet
   // (e.g. text element when typing into the input)
   // (e.g. text element when typing into the input)
   editingElement: NonDeletedExcalidrawElement | null;
   editingElement: NonDeletedExcalidrawElement | null;
@@ -126,7 +131,7 @@ export type AppState = {
     locked: boolean;
     locked: boolean;
   } & (
   } & (
     | {
     | {
-        type: typeof SHAPES[number]["value"] | "eraser" | "hand";
+        type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
         customType: null;
         customType: null;
       }
       }
     | {
     | {
@@ -178,6 +183,7 @@ export type AppState = {
   lastPointerDownWith: PointerType;
   lastPointerDownWith: PointerType;
   selectedElementIds: { [id: string]: boolean };
   selectedElementIds: { [id: string]: boolean };
   previousSelectedElementIds: { [id: string]: boolean };
   previousSelectedElementIds: { [id: string]: boolean };
+  selectedElementsAreBeingDragged: boolean;
   shouldCacheIgnoreZoom: boolean;
   shouldCacheIgnoreZoom: boolean;
   toast: { message: string; closable?: boolean; duration?: number } | null;
   toast: { message: string; closable?: boolean; duration?: number } | null;
   zenModeEnabled: boolean;
   zenModeEnabled: boolean;
@@ -532,6 +538,12 @@ export type ExcalidrawImperativeAPI = {
   setCursor: InstanceType<typeof App>["setCursor"];
   setCursor: InstanceType<typeof App>["setCursor"];
   resetCursor: InstanceType<typeof App>["resetCursor"];
   resetCursor: InstanceType<typeof App>["resetCursor"];
   toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
   toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
+  /**
+   * Disables rendering of frames (including element clipping), but currently
+   * the frames are still interactive in edit mode. As such, this API should be
+   * used in conjunction with view mode (props.viewModeEnabled).
+   */
+  toggleFrameRendering: InstanceType<typeof App>["toggleFrameRendering"];
 };
 };
 
 
 export type Device = Readonly<{
 export type Device = Readonly<{
@@ -541,3 +553,22 @@ export type Device = Readonly<{
   canDeviceFitSidebar: boolean;
   canDeviceFitSidebar: boolean;
   isLandscape: boolean;
   isLandscape: boolean;
 }>;
 }>;
+
+type FrameNameBounds = {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  angle: number;
+};
+
+export type FrameNameBoundsCache = {
+  get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null;
+  _cache: Map<
+    string,
+    FrameNameBounds & {
+      zoom: AppState["zoom"]["value"];
+      versionNonce: ExcalidrawFrameElement["versionNonce"];
+    }
+  >;
+};

+ 19 - 2
src/utils.ts

@@ -10,7 +10,11 @@ import {
   THEME,
   THEME,
   WINDOWS_EMOJI_FALLBACK_FONT,
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
 } from "./constants";
-import { FontFamilyValues, FontString } from "./element/types";
+import {
+  FontFamilyValues,
+  FontString,
+  NonDeletedExcalidrawElement,
+} from "./element/types";
 import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
 import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { unstable_batchedUpdates } from "react-dom";
 import { SHAPES } from "./shapes";
 import { SHAPES } from "./shapes";
@@ -299,7 +303,7 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
 export const updateActiveTool = (
 export const updateActiveTool = (
   appState: Pick<AppState, "activeTool">,
   appState: Pick<AppState, "activeTool">,
   data: (
   data: (
-    | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
+    | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
     | { type: "custom"; customType: string }
     | { type: "custom"; customType: string }
   ) & { lastActiveToolBeforeEraser?: LastActiveTool },
   ) & { lastActiveToolBeforeEraser?: LastActiveTool },
 ): AppState["activeTool"] => {
 ): AppState["activeTool"] => {
@@ -824,3 +828,16 @@ export const composeEventHandlers = <E>(
     }
     }
   };
   };
 };
 };
+
+export const isOnlyExportingSingleFrame = (
+  elements: readonly NonDeletedExcalidrawElement[],
+) => {
+  const frames = elements.filter((element) => element.type === "frame");
+
+  return (
+    frames.length === 1 &&
+    elements.every(
+      (element) => element.type === "frame" || element.frameId === frames[0].id,
+    )
+  );
+};

+ 120 - 13
src/zindex.ts

@@ -1,36 +1,52 @@
 import { bumpVersion } from "./element/mutateElement";
 import { bumpVersion } from "./element/mutateElement";
+import { isFrameElement } from "./element/typeChecks";
 import { ExcalidrawElement } from "./element/types";
 import { ExcalidrawElement } from "./element/types";
+import { groupByFrames } from "./frame";
 import { getElementsInGroup } from "./groups";
 import { getElementsInGroup } from "./groups";
 import { getSelectedElements } from "./scene";
 import { getSelectedElements } from "./scene";
 import Scene from "./scene/Scene";
 import Scene from "./scene/Scene";
 import { AppState } from "./types";
 import { AppState } from "./types";
 import { arrayToMap, findIndex, findLastIndex } from "./utils";
 import { arrayToMap, findIndex, findLastIndex } from "./utils";
 
 
+// elements that do not belong to a frame are considered a root element
+const isRootElement = (element: ExcalidrawElement) => {
+  return !element.frameId;
+};
+
 /**
 /**
  * Returns indices of elements to move based on selected elements.
  * Returns indices of elements to move based on selected elements.
  * Includes contiguous deleted elements that are between two selected elements,
  * Includes contiguous deleted elements that are between two selected elements,
  *  e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
  *  e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
+ *
+ * Specified elements (elementsToBeMoved) take precedence over
+ * appState.selectedElementsIds
  */
  */
 const getIndicesToMove = (
 const getIndicesToMove = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
   let selectedIndices: number[] = [];
   let selectedIndices: number[] = [];
   let deletedIndices: number[] = [];
   let deletedIndices: number[] = [];
   let includeDeletedIndex = null;
   let includeDeletedIndex = null;
   let index = -1;
   let index = -1;
   const selectedElementIds = arrayToMap(
   const selectedElementIds = arrayToMap(
-    getSelectedElements(elements, appState, true),
+    elementsToBeMoved
+      ? elementsToBeMoved
+      : getSelectedElements(elements, appState, {
+          includeBoundTextElement: true,
+        }),
   );
   );
   while (++index < elements.length) {
   while (++index < elements.length) {
-    if (selectedElementIds.get(elements[index].id)) {
+    const element = elements[index];
+    if (selectedElementIds.get(element.id)) {
       if (deletedIndices.length) {
       if (deletedIndices.length) {
         selectedIndices = selectedIndices.concat(deletedIndices);
         selectedIndices = selectedIndices.concat(deletedIndices);
         deletedIndices = [];
         deletedIndices = [];
       }
       }
       selectedIndices.push(index);
       selectedIndices.push(index);
       includeDeletedIndex = index + 1;
       includeDeletedIndex = index + 1;
-    } else if (elements[index].isDeleted && includeDeletedIndex === index) {
+    } else if (element.isDeleted && includeDeletedIndex === index) {
       includeDeletedIndex = index + 1;
       includeDeletedIndex = index + 1;
       deletedIndices.push(index);
       deletedIndices.push(index);
     } else {
     } else {
@@ -168,8 +184,8 @@ const getTargetIndex = (
   return candidateIndex;
   return candidateIndex;
 };
 };
 
 
-const getTargetElementsMap = (
-  elements: readonly ExcalidrawElement[],
+const getTargetElementsMap = <T extends ExcalidrawElement>(
+  elements: readonly T[],
   indices: number[],
   indices: number[],
 ) => {
 ) => {
   return indices.reduce((acc, index) => {
   return indices.reduce((acc, index) => {
@@ -179,12 +195,13 @@ const getTargetElementsMap = (
   }, {} as Record<string, ExcalidrawElement>);
   }, {} as Record<string, ExcalidrawElement>);
 };
 };
 
 
-const shiftElements = (
-  appState: AppState,
+const _shiftElements = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
+  appState: AppState,
   direction: "left" | "right",
   direction: "left" | "right",
+  elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
-  const indicesToMove = getIndicesToMove(elements, appState);
+  const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
   const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
   const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
   let groupedIndices = toContiguousGroups(indicesToMove);
   let groupedIndices = toContiguousGroups(indicesToMove);
 
 
@@ -246,7 +263,22 @@ const shiftElements = (
   });
   });
 };
 };
 
 
-const shiftElementsToEnd = (
+const shiftElements = (
+  appState: AppState,
+  elements: readonly ExcalidrawElement[],
+  direction: "left" | "right",
+  elementsToBeMoved?: readonly ExcalidrawElement[],
+) => {
+  return shift(
+    elements,
+    appState,
+    direction,
+    _shiftElements,
+    elementsToBeMoved,
+  );
+};
+
+const _shiftElementsToEnd = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
   direction: "left" | "right",
   direction: "left" | "right",
@@ -317,33 +349,108 @@ const shiftElementsToEnd = (
       ];
       ];
 };
 };
 
 
+const shiftElementsToEnd = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  direction: "left" | "right",
+  elementsToBeMoved?: readonly ExcalidrawElement[],
+) => {
+  return shift(
+    elements,
+    appState,
+    direction,
+    _shiftElementsToEnd,
+    elementsToBeMoved,
+  );
+};
+
+function shift(
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  direction: "left" | "right",
+  shiftFunction: (
+    elements: ExcalidrawElement[],
+    appState: AppState,
+    direction: "left" | "right",
+    elementsToBeMoved?: readonly ExcalidrawElement[],
+  ) => ExcalidrawElement[] | readonly ExcalidrawElement[],
+  elementsToBeMoved?: readonly ExcalidrawElement[],
+) {
+  let rootElements = elements.filter((element) => isRootElement(element));
+  const frameElementsMap = groupByFrames(elements);
+
+  // shift the root elements first
+  rootElements = shiftFunction(
+    rootElements,
+    appState,
+    direction,
+    elementsToBeMoved,
+  ) as ExcalidrawElement[];
+
+  // shift the elements in frames if needed
+  frameElementsMap.forEach((frameElements, frameId) => {
+    if (!appState.selectedElementIds[frameId]) {
+      frameElementsMap.set(
+        frameId,
+        shiftFunction(
+          frameElements,
+          appState,
+          direction,
+          elementsToBeMoved,
+        ) as ExcalidrawElement[],
+      );
+    }
+  });
+
+  // return the final elements
+  let finalElements: ExcalidrawElement[] = [];
+
+  rootElements.forEach((element) => {
+    if (isFrameElement(element)) {
+      finalElements = [
+        ...finalElements,
+        ...(frameElementsMap.get(element.id) ?? []),
+        element,
+      ];
+    } else {
+      finalElements = [...finalElements, element];
+    }
+  });
+
+  return finalElements;
+}
+
 // public API
 // public API
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------
 
 
 export const moveOneLeft = (
 export const moveOneLeft = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
-  return shiftElements(appState, elements, "left");
+  return shiftElements(appState, elements, "left", elementsToBeMoved);
 };
 };
 
 
 export const moveOneRight = (
 export const moveOneRight = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
-  return shiftElements(appState, elements, "right");
+  return shiftElements(appState, elements, "right", elementsToBeMoved);
 };
 };
 
 
 export const moveAllLeft = (
 export const moveAllLeft = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
-  return shiftElementsToEnd(elements, appState, "left");
+  return shiftElementsToEnd(elements, appState, "left", elementsToBeMoved);
 };
 };
 
 
 export const moveAllRight = (
 export const moveAllRight = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
+  elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
-  return shiftElementsToEnd(elements, appState, "right");
+  return shiftElementsToEnd(elements, appState, "right", elementsToBeMoved);
 };
 };

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels