Просмотр исходного кода

feat: editable element stats (#6382)

Co-authored-by: dwelle <[email protected]>
Ryan Di 1 год назад
Родитель
Сommit
d2f67e619f
40 измененных файлов с 3588 добавлено и 405 удалено
  1. 1 0
      excalidraw-app/index.scss
  2. 1 1
      packages/excalidraw/actions/actionCanvas.tsx
  3. 4 3
      packages/excalidraw/actions/actionToggleStats.tsx
  4. 2 1
      packages/excalidraw/actions/types.ts
  5. 6 2
      packages/excalidraw/appState.ts
  6. 77 76
      packages/excalidraw/components/App.tsx
  7. 1 1
      packages/excalidraw/components/HelpDialog.tsx
  8. 93 0
      packages/excalidraw/components/LayerUI.scss
  9. 16 13
      packages/excalidraw/components/LayerUI.tsx
  10. 0 13
      packages/excalidraw/components/MobileMenu.tsx
  11. 0 54
      packages/excalidraw/components/Stats.scss
  12. 0 108
      packages/excalidraw/components/Stats.tsx
  13. 77 0
      packages/excalidraw/components/Stats/Angle.tsx
  14. 39 0
      packages/excalidraw/components/Stats/Collapsible.tsx
  15. 126 0
      packages/excalidraw/components/Stats/Dimension.tsx
  16. 75 0
      packages/excalidraw/components/Stats/DragInput.scss
  17. 247 0
      packages/excalidraw/components/Stats/DragInput.tsx
  18. 75 0
      packages/excalidraw/components/Stats/FontSize.tsx
  19. 114 0
      packages/excalidraw/components/Stats/MultiAngle.tsx
  20. 377 0
      packages/excalidraw/components/Stats/MultiDimension.tsx
  21. 115 0
      packages/excalidraw/components/Stats/MultiFontSize.tsx
  22. 239 0
      packages/excalidraw/components/Stats/MultiPosition.tsx
  23. 101 0
      packages/excalidraw/components/Stats/Position.tsx
  24. 306 0
      packages/excalidraw/components/Stats/index.tsx
  25. 658 0
      packages/excalidraw/components/Stats/stats.test.tsx
  26. 238 0
      packages/excalidraw/components/Stats/utils.ts
  27. 28 0
      packages/excalidraw/components/icons.tsx
  28. 4 0
      packages/excalidraw/constants.ts
  29. 6 0
      packages/excalidraw/css/styles.scss
  30. 2 2
      packages/excalidraw/element/resizeElements.ts
  31. 3 1
      packages/excalidraw/groups.ts
  32. 20 1
      packages/excalidraw/locales/en.json
  33. 8 0
      packages/excalidraw/math.ts
  34. 3 0
      packages/excalidraw/scene/Scene.ts
  35. 74 18
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  36. 228 57
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  37. 208 52
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  38. 6 0
      packages/excalidraw/tests/helpers/ui.ts
  39. 6 1
      packages/excalidraw/types.ts
  40. 4 1
      packages/utils/__snapshots__/export.test.ts.snap

+ 1 - 0
excalidraw-app/index.scss

@@ -25,6 +25,7 @@
     margin-bottom: auto;
     margin-inline-start: auto;
     margin-inline-end: 0.6em;
+    z-index: var(--zIndex-layerUI);
 
     svg {
       width: 1.2rem;

+ 1 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -104,7 +104,7 @@ export const actionClearCanvas = register({
         exportBackground: appState.exportBackground,
         exportEmbedScene: appState.exportEmbedScene,
         gridSize: appState.gridSize,
-        showStats: appState.showStats,
+        stats: appState.stats,
         pasteDialog: appState.pasteDialog,
         activeTool:
           appState.activeTool.type === "image"

+ 4 - 3
packages/excalidraw/actions/actionToggleStats.tsx

@@ -5,21 +5,22 @@ import { StoreAction } from "../store";
 
 export const actionToggleStats = register({
   name: "stats",
-  label: "stats.title",
+  label: "stats.fullTitle",
   icon: abacusIcon,
   paletteName: "Toggle stats",
   viewMode: true,
   trackEvent: { category: "menu" },
+  keywords: ["edit", "attributes", "customize"],
   perform(elements, appState) {
     return {
       appState: {
         ...appState,
-        showStats: !this.checked!(appState),
+        stats: { ...appState.stats, open: !this.checked!(appState) },
       },
       storeAction: StoreAction.NONE,
     };
   },
-  checked: (appState) => appState.showStats,
+  checked: (appState) => appState.stats.open,
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
 });

+ 2 - 1
packages/excalidraw/actions/types.ts

@@ -135,7 +135,8 @@ export type ActionName =
   | "createContainerFromText"
   | "wrapTextInContainer"
   | "commandPalette"
-  | "autoResize";
+  | "autoResize"
+  | "elementStats";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 6 - 2
packages/excalidraw/appState.ts

@@ -5,6 +5,7 @@ import {
   DEFAULT_FONT_SIZE,
   DEFAULT_TEXT_ALIGN,
   EXPORT_SCALES,
+  STATS_PANELS,
   THEME,
 } from "./constants";
 import type { AppState, NormalizedZoomValue } from "./types";
@@ -80,7 +81,10 @@ export const getDefaultAppState = (): Omit<
     selectedElementsAreBeingDragged: false,
     selectionElement: null,
     shouldCacheIgnoreZoom: false,
-    showStats: false,
+    stats: {
+      open: false,
+      panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
+    },
     startBoundElement: null,
     suggestedBindings: [],
     frameRendering: { enabled: true, clip: true, name: true, outline: true },
@@ -196,7 +200,7 @@ const APP_STATE_STORAGE_CONF = (<
   },
   selectionElement: { browser: false, export: false, server: false },
   shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
-  showStats: { browser: true, export: false, server: false },
+  stats: { browser: true, export: false, server: false },
   startBoundElement: { browser: false, export: false, server: false },
   suggestedBindings: { browser: false, export: false, server: false },
   frameRendering: { browser: false, export: false, server: false },

+ 77 - 76
packages/excalidraw/components/App.tsx

@@ -2135,95 +2135,96 @@ class App extends React.Component<AppProps, AppState> {
     });
   };
 
-  private syncActionResult = withBatchedUpdates(
-    (actionResult: ActionResult) => {
-      if (this.unmounted || actionResult === false) {
-        return;
-      }
+  public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
+    if (this.unmounted || actionResult === false) {
+      return;
+    }
 
-      let editingElement: AppState["editingElement"] | null = null;
-      if (actionResult.elements) {
-        actionResult.elements.forEach((element) => {
-          if (
-            this.state.editingElement?.id === element.id &&
-            this.state.editingElement !== element &&
-            isNonDeletedElement(element)
-          ) {
-            editingElement = element;
-          }
-        });
+    if (actionResult.storeAction === StoreAction.UPDATE) {
+      this.store.shouldUpdateSnapshot();
+    } else if (actionResult.storeAction === StoreAction.CAPTURE) {
+      this.store.shouldCaptureIncrement();
+    }
 
-        if (actionResult.storeAction === StoreAction.UPDATE) {
-          this.store.shouldUpdateSnapshot();
-        } else if (actionResult.storeAction === StoreAction.CAPTURE) {
-          this.store.shouldCaptureIncrement();
+    let didUpdate = false;
+
+    let editingElement: AppState["editingElement"] | null = null;
+    if (actionResult.elements) {
+      actionResult.elements.forEach((element) => {
+        if (
+          this.state.editingElement?.id === element.id &&
+          this.state.editingElement !== element &&
+          isNonDeletedElement(element)
+        ) {
+          editingElement = element;
         }
+      });
 
-        this.scene.replaceAllElements(actionResult.elements);
-      }
+      this.scene.replaceAllElements(actionResult.elements);
+      didUpdate = true;
+    }
 
-      if (actionResult.files) {
-        this.files = actionResult.replaceFiles
-          ? actionResult.files
-          : { ...this.files, ...actionResult.files };
-        this.addNewImagesToImageCache();
+    if (actionResult.files) {
+      this.files = actionResult.replaceFiles
+        ? actionResult.files
+        : { ...this.files, ...actionResult.files };
+      this.addNewImagesToImageCache();
+    }
+
+    if (actionResult.appState || editingElement || this.state.contextMenu) {
+      let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
+      let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
+      let gridSize = actionResult?.appState?.gridSize || null;
+      const theme =
+        actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
+      const name = actionResult?.appState?.name ?? this.state.name;
+      const errorMessage =
+        actionResult?.appState?.errorMessage ?? this.state.errorMessage;
+      if (typeof this.props.viewModeEnabled !== "undefined") {
+        viewModeEnabled = this.props.viewModeEnabled;
       }
 
-      if (actionResult.appState || editingElement || this.state.contextMenu) {
-        if (actionResult.storeAction === StoreAction.UPDATE) {
-          this.store.shouldUpdateSnapshot();
-        } else if (actionResult.storeAction === StoreAction.CAPTURE) {
-          this.store.shouldCaptureIncrement();
-        }
+      if (typeof this.props.zenModeEnabled !== "undefined") {
+        zenModeEnabled = this.props.zenModeEnabled;
+      }
 
-        let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
-        let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
-        let gridSize = actionResult?.appState?.gridSize || null;
-        const theme =
-          actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
-        const name = actionResult?.appState?.name ?? this.state.name;
-        const errorMessage =
-          actionResult?.appState?.errorMessage ?? this.state.errorMessage;
-        if (typeof this.props.viewModeEnabled !== "undefined") {
-          viewModeEnabled = this.props.viewModeEnabled;
-        }
+      if (typeof this.props.gridModeEnabled !== "undefined") {
+        gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
+      }
 
-        if (typeof this.props.zenModeEnabled !== "undefined") {
-          zenModeEnabled = this.props.zenModeEnabled;
-        }
+      editingElement =
+        editingElement || actionResult.appState?.editingElement || null;
 
-        if (typeof this.props.gridModeEnabled !== "undefined") {
-          gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
-        }
+      if (editingElement?.isDeleted) {
+        editingElement = null;
+      }
 
-        editingElement =
-          editingElement || actionResult.appState?.editingElement || null;
+      this.setState((state) => {
+        // using Object.assign instead of spread to fool TS 4.2.2+ into
+        // regarding the resulting type as not containing undefined
+        // (which the following expression will never contain)
+        return Object.assign(actionResult.appState || {}, {
+          // NOTE this will prevent opening context menu using an action
+          // or programmatically from the host, so it will need to be
+          // rewritten later
+          contextMenu: null,
+          editingElement,
+          viewModeEnabled,
+          zenModeEnabled,
+          gridSize,
+          theme,
+          name,
+          errorMessage,
+        });
+      });
 
-        if (editingElement?.isDeleted) {
-          editingElement = null;
-        }
+      didUpdate = true;
+    }
 
-        this.setState((state) => {
-          // using Object.assign instead of spread to fool TS 4.2.2+ into
-          // regarding the resulting type as not containing undefined
-          // (which the following expression will never contain)
-          return Object.assign(actionResult.appState || {}, {
-            // NOTE this will prevent opening context menu using an action
-            // or programmatically from the host, so it will need to be
-            // rewritten later
-            contextMenu: null,
-            editingElement,
-            viewModeEnabled,
-            zenModeEnabled,
-            gridSize,
-            theme,
-            name,
-            errorMessage,
-          });
-        });
-      }
-    },
-  );
+    if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) {
+      this.scene.triggerUpdate();
+    }
+  });
 
   // Lifecycle
 

+ 1 - 1
packages/excalidraw/components/HelpDialog.tsx

@@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               shortcuts={[getShortcutKey("Alt+Shift+D")]}
             />
             <Shortcut
-              label={t("stats.title")}
+              label={t("stats.fullTitle")}
               shortcuts={[getShortcutKey("Alt+/")]}
             />
             <Shortcut

+ 93 - 0
packages/excalidraw/components/LayerUI.scss

@@ -27,6 +27,99 @@
       & > * {
         pointer-events: var(--ui-pointerEvents);
       }
+
+      & > .Stats {
+        width: 204px;
+        position: absolute;
+        top: 60px;
+        font-size: 12px;
+        z-index: var(--zIndex-layerUI);
+        pointer-events: var(--ui-pointerEvents);
+
+        .title {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          margin-bottom: 12px;
+
+          h2 {
+            margin: 0;
+          }
+        }
+
+        .sectionContent {
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          justify-content: center;
+        }
+
+        .elementType {
+          font-size: 12px;
+          font-weight: 700;
+          margin-top: 8px;
+        }
+
+        .elementsCount {
+          width: 100%;
+          font-size: 12px;
+          display: flex;
+          justify-content: space-between;
+          margin-top: 8px;
+        }
+
+        .statsItem {
+          margin-top: 8px;
+          width: 100%;
+          margin-bottom: 4px;
+          display: grid;
+          gap: 4px;
+
+          .label {
+            margin-right: 4px;
+          }
+        }
+
+        h3 {
+          white-space: nowrap;
+          margin: 0;
+        }
+
+        .close {
+          height: 16px;
+          width: 16px;
+          cursor: pointer;
+          svg {
+            width: 100%;
+            height: 100%;
+          }
+        }
+
+        table {
+          width: 100%;
+          th {
+            border-bottom: 1px solid var(--input-border-color);
+            padding: 4px;
+          }
+          tr {
+            td:nth-child(2) {
+              min-width: 24px;
+              text-align: right;
+            }
+          }
+        }
+
+        .divider {
+          width: 100%;
+          height: 1px;
+          background-color: var(--default-border-color);
+        }
+
+        :root[dir="rtl"] & {
+          left: 12px;
+          right: initial;
+        }
+      }
     }
 
     &__footer {

+ 16 - 13
packages/excalidraw/components/LayerUI.tsx

@@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog";
 import { PenModeButton } from "./PenModeButton";
 import { trackEvent } from "../analytics";
 import { useDevice } from "./App";
-import { Stats } from "./Stats";
-import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./footer/Footer";
 import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
@@ -64,6 +62,8 @@ import Scene from "../scene/Scene";
 import { LaserPointerButton } from "./LaserPointerButton";
 import { MagicSettings } from "./MagicSettings";
 import { TTDDialog } from "./TTDDialog/TTDDialog";
+import { Stats } from "./Stats";
+import { actionToggleStats } from "../actions";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -241,6 +241,11 @@ const LayerUI = ({
       elements,
     );
 
+    const shouldShowStats =
+      appState.stats.open &&
+      !appState.zenModeEnabled &&
+      !appState.viewModeEnabled;
+
     return (
       <FixedSideContainer side="top">
         <div className="App-menu App-menu_top">
@@ -353,6 +358,15 @@ const LayerUI = ({
                 appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
                 <tunnels.DefaultSidebarTriggerTunnel.Out />
               )}
+            {shouldShowStats && (
+              <Stats
+                scene={app.scene}
+                onClose={() => {
+                  actionManager.executeAction(actionToggleStats);
+                }}
+                renderCustomStats={renderCustomStats}
+              />
+            )}
           </div>
         </div>
       </FixedSideContainer>
@@ -542,17 +556,6 @@ const LayerUI = ({
               showExitZenModeBtn={showExitZenModeBtn}
               renderWelcomeScreen={renderWelcomeScreen}
             />
-            {appState.showStats && (
-              <Stats
-                appState={appState}
-                setAppState={setAppState}
-                elements={elements}
-                onClose={() => {
-                  actionManager.executeAction(actionToggleStats);
-                }}
-                renderCustomStats={renderCustomStats}
-              />
-            )}
             {appState.scrolledOutside && (
               <button
                 type="button"

+ 0 - 13
packages/excalidraw/components/MobileMenu.tsx

@@ -21,8 +21,6 @@ import { Section } from "./Section";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { LockButton } from "./LockButton";
 import { PenModeButton } from "./PenModeButton";
-import { Stats } from "./Stats";
-import { actionToggleStats } from "../actions";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
 import { useTunnels } from "../context/tunnels";
@@ -157,17 +155,6 @@ export const MobileMenu = ({
     <>
       {renderSidebars()}
       {!appState.viewModeEnabled && renderToolbar()}
-      {!appState.openMenu && appState.showStats && (
-        <Stats
-          appState={appState}
-          setAppState={setAppState}
-          elements={elements}
-          onClose={() => {
-            actionManager.executeAction(actionToggleStats);
-          }}
-          renderCustomStats={renderCustomStats}
-        />
-      )}
       <div
         className="App-bottom-bar"
         style={{

+ 0 - 54
packages/excalidraw/components/Stats.scss

@@ -1,54 +0,0 @@
-@import "../css/variables.module.scss";
-
-.excalidraw {
-  .Stats {
-    position: absolute;
-    top: 64px;
-    right: 12px;
-    font-size: 12px;
-    z-index: 10;
-    pointer-events: var(--ui-pointerEvents);
-
-    h3 {
-      margin: 0 24px 8px 0;
-      white-space: nowrap;
-    }
-
-    .close {
-      float: right;
-      height: 16px;
-      width: 16px;
-      cursor: pointer;
-      svg {
-        width: 100%;
-        height: 100%;
-      }
-    }
-
-    table {
-      width: 100%;
-      th {
-        border-bottom: 1px solid var(--input-border-color);
-        padding: 4px;
-      }
-      tr {
-        td:nth-child(2) {
-          min-width: 24px;
-          text-align: right;
-        }
-      }
-    }
-
-    :root[dir="rtl"] & {
-      left: 12px;
-      right: initial;
-
-      h3 {
-        margin: 0 0 8px 24px;
-      }
-      .close {
-        float: left;
-      }
-    }
-  }
-}

+ 0 - 108
packages/excalidraw/components/Stats.tsx

@@ -1,108 +0,0 @@
-import React from "react";
-import { getCommonBounds } from "../element/bounds";
-import type { NonDeletedExcalidrawElement } from "../element/types";
-import { t } from "../i18n";
-import { getTargetElements } from "../scene";
-import type { ExcalidrawProps, UIAppState } from "../types";
-import { CloseIcon } from "./icons";
-import { Island } from "./Island";
-import "./Stats.scss";
-
-export const Stats = (props: {
-  appState: UIAppState;
-  setAppState: React.Component<any, UIAppState>["setState"];
-  elements: readonly NonDeletedExcalidrawElement[];
-  onClose: () => void;
-  renderCustomStats: ExcalidrawProps["renderCustomStats"];
-}) => {
-  const boundingBox = getCommonBounds(props.elements);
-  const selectedElements = getTargetElements(props.elements, props.appState);
-  const selectedBoundingBox = getCommonBounds(selectedElements);
-
-  return (
-    <div className="Stats">
-      <Island padding={2}>
-        <div className="close" onClick={props.onClose}>
-          {CloseIcon}
-        </div>
-        <h3>{t("stats.title")}</h3>
-        <table>
-          <tbody>
-            <tr>
-              <th colSpan={2}>{t("stats.scene")}</th>
-            </tr>
-            <tr>
-              <td>{t("stats.elements")}</td>
-              <td>{props.elements.length}</td>
-            </tr>
-            <tr>
-              <td>{t("stats.width")}</td>
-              <td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
-            </tr>
-            <tr>
-              <td>{t("stats.height")}</td>
-              <td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
-            </tr>
-
-            {selectedElements.length === 1 && (
-              <tr>
-                <th colSpan={2}>{t("stats.element")}</th>
-              </tr>
-            )}
-
-            {selectedElements.length > 1 && (
-              <>
-                <tr>
-                  <th colSpan={2}>{t("stats.selected")}</th>
-                </tr>
-                <tr>
-                  <td>{t("stats.elements")}</td>
-                  <td>{selectedElements.length}</td>
-                </tr>
-              </>
-            )}
-            {selectedElements.length > 0 && (
-              <>
-                <tr>
-                  <td>{"x"}</td>
-                  <td>{Math.round(selectedBoundingBox[0])}</td>
-                </tr>
-                <tr>
-                  <td>{"y"}</td>
-                  <td>{Math.round(selectedBoundingBox[1])}</td>
-                </tr>
-                <tr>
-                  <td>{t("stats.width")}</td>
-                  <td>
-                    {Math.round(
-                      selectedBoundingBox[2] - selectedBoundingBox[0],
-                    )}
-                  </td>
-                </tr>
-                <tr>
-                  <td>{t("stats.height")}</td>
-                  <td>
-                    {Math.round(
-                      selectedBoundingBox[3] - selectedBoundingBox[1],
-                    )}
-                  </td>
-                </tr>
-              </>
-            )}
-            {selectedElements.length === 1 && (
-              <tr>
-                <td>{t("stats.angle")}</td>
-                <td>
-                  {`${Math.round(
-                    (selectedElements[0].angle * 180) / Math.PI,
-                  )}°`}
-                </td>
-              </tr>
-            )}
-            {props.renderCustomStats?.(props.elements, props.appState)}
-          </tbody>
-        </table>
-      </Island>
-    </div>
-  );
-};

+ 77 - 0
packages/excalidraw/components/Stats/Angle.tsx

@@ -0,0 +1,77 @@
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import { isArrowElement } from "../../element/typeChecks";
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { degreeToRadian, radianToDegree } from "../../math";
+import { angleIcon } from "../icons";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
+
+interface AngleProps {
+  element: ExcalidrawElement;
+  elementsMap: ElementsMap;
+}
+
+const STEP_SIZE = 15;
+
+const Angle = ({ element, elementsMap }: AngleProps) => {
+  const handleDegreeChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    const origElement = originalElements[0];
+    if (origElement) {
+      if (nextValue !== undefined) {
+        const nextAngle = degreeToRadian(nextValue);
+        mutateElement(element, {
+          angle: nextAngle,
+        });
+
+        const boundTextElement = getBoundTextElement(element, elementsMap);
+        if (boundTextElement && !isArrowElement(element)) {
+          mutateElement(boundTextElement, { angle: nextAngle });
+        }
+
+        return;
+      }
+
+      const originalAngleInDegrees =
+        Math.round(radianToDegree(origElement.angle) * 100) / 100;
+      const changeInDegrees = Math.round(accumulatedChange);
+      let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
+      if (shouldChangeByStepSize) {
+        nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
+      }
+
+      nextAngleInDegrees =
+        nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
+
+      const nextAngle = degreeToRadian(nextAngleInDegrees);
+
+      mutateElement(element, {
+        angle: nextAngle,
+      });
+
+      const boundTextElement = getBoundTextElement(element, elementsMap);
+      if (boundTextElement && !isArrowElement(element)) {
+        mutateElement(boundTextElement, { angle: nextAngle });
+      }
+    }
+  };
+
+  return (
+    <DragInput
+      label="A"
+      icon={angleIcon}
+      value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
+      elements={[element]}
+      dragInputCallback={handleDegreeChange}
+      editable={isPropertyEditable(element, "angle")}
+    />
+  );
+};
+
+export default Angle;

+ 39 - 0
packages/excalidraw/components/Stats/Collapsible.tsx

@@ -0,0 +1,39 @@
+import { InlineIcon } from "../InlineIcon";
+import { collapseDownIcon, collapseUpIcon } from "../icons";
+
+interface CollapsibleProps {
+  label: React.ReactNode;
+  // having it controlled so that the state is managed outside
+  // this is to keep the user's previous choice even when the
+  // Collapsible is unmounted
+  open: boolean;
+  openTrigger: () => void;
+  children: React.ReactNode;
+}
+
+const Collapsible = ({
+  label,
+  open,
+  openTrigger,
+  children,
+}: CollapsibleProps) => {
+  return (
+    <>
+      <div
+        style={{
+          cursor: "pointer",
+          display: "flex",
+          justifyContent: "space-between",
+          alignItems: "center",
+        }}
+        onClick={openTrigger}
+      >
+        {label}
+        <InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
+      </div>
+      {open && <>{children}</>}
+    </>
+  );
+};
+
+export default Collapsible;

+ 126 - 0
packages/excalidraw/components/Stats/Dimension.tsx

@@ -0,0 +1,126 @@
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
+import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+
+interface DimensionDragInputProps {
+  property: "width" | "height";
+  element: ExcalidrawElement;
+  elementsMap: ElementsMap;
+}
+
+const STEP_SIZE = 10;
+const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
+  return element.type === "image";
+};
+
+const DimensionDragInput = ({
+  property,
+  element,
+  elementsMap,
+}: DimensionDragInputProps) => {
+  const handleDimensionChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    originalElementsMap,
+    shouldKeepAspectRatio,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    const origElement = originalElements[0];
+    if (origElement) {
+      const keepAspectRatio =
+        shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
+      const aspectRatio = origElement.width / origElement.height;
+
+      if (nextValue !== undefined) {
+        const nextWidth = Math.max(
+          property === "width"
+            ? nextValue
+            : keepAspectRatio
+            ? nextValue * aspectRatio
+            : origElement.width,
+          MIN_WIDTH_OR_HEIGHT,
+        );
+        const nextHeight = Math.max(
+          property === "height"
+            ? nextValue
+            : keepAspectRatio
+            ? nextValue / aspectRatio
+            : origElement.height,
+          MIN_WIDTH_OR_HEIGHT,
+        );
+
+        resizeElement(
+          nextWidth,
+          nextHeight,
+          keepAspectRatio,
+          element,
+          origElement,
+          elementsMap,
+          originalElementsMap,
+        );
+
+        return;
+      }
+      const changeInWidth = property === "width" ? accumulatedChange : 0;
+      const changeInHeight = property === "height" ? accumulatedChange : 0;
+
+      let nextWidth = Math.max(0, origElement.width + changeInWidth);
+      if (property === "width") {
+        if (shouldChangeByStepSize) {
+          nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+        } else {
+          nextWidth = Math.round(nextWidth);
+        }
+      }
+
+      let nextHeight = Math.max(0, origElement.height + changeInHeight);
+      if (property === "height") {
+        if (shouldChangeByStepSize) {
+          nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+        } else {
+          nextHeight = Math.round(nextHeight);
+        }
+      }
+
+      if (keepAspectRatio) {
+        if (property === "width") {
+          nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
+        } else {
+          nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
+        }
+      }
+
+      nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+      nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+
+      resizeElement(
+        nextWidth,
+        nextHeight,
+        keepAspectRatio,
+        element,
+        origElement,
+        elementsMap,
+        originalElementsMap,
+      );
+    }
+  };
+
+  const value =
+    Math.round((property === "width" ? element.width : element.height) * 100) /
+    100;
+
+  return (
+    <DragInput
+      label={property === "width" ? "W" : "H"}
+      elements={[element]}
+      dragInputCallback={handleDimensionChange}
+      value={value}
+      editable={isPropertyEditable(element, property)}
+    />
+  );
+};
+
+export default DimensionDragInput;

+ 75 - 0
packages/excalidraw/components/Stats/DragInput.scss

@@ -0,0 +1,75 @@
+.excalidraw {
+  .drag-input-container {
+    display: flex;
+    width: 100%;
+
+    &:focus-within {
+      box-shadow: 0 0 0 1px var(--color-primary-darkest);
+      border-radius: var(--border-radius-lg);
+    }
+  }
+
+  .disabled {
+    opacity: 0.5;
+    pointer-events: none;
+  }
+
+  .drag-input-label {
+    flex-shrink: 0;
+    border: 1px solid var(--default-border-color);
+    border-right: 0;
+    width: 2rem;
+    height: 2rem;
+    box-sizing: border-box;
+    color: var(--popup-text-color);
+
+    :root[dir="ltr"] & {
+      border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
+    }
+
+    :root[dir="rtl"] & {
+      border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
+      border-right: 1px solid var(--default-border-color);
+      border-left: 0;
+    }
+
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: relative;
+  }
+
+  .drag-input {
+    box-sizing: border-box;
+    width: 100%;
+    margin: 0;
+    font-size: 0.875rem;
+    font-family: inherit;
+    background-color: transparent;
+    color: var(--text-primary-color);
+    border: 0;
+    outline: none;
+    height: 2rem;
+    border: 1px solid var(--default-border-color);
+    border-left: 0;
+    letter-spacing: 0.4px;
+
+    :root[dir="ltr"] & {
+      border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
+    }
+
+    :root[dir="rtl"] & {
+      border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
+      border-left: 1px solid var(--default-border-color);
+      border-right: 0;
+    }
+
+    padding: 0.5rem;
+    padding-left: 0.25rem;
+    appearance: none;
+
+    &:focus-visible {
+      box-shadow: none;
+    }
+  }
+}

+ 247 - 0
packages/excalidraw/components/Stats/DragInput.tsx

@@ -0,0 +1,247 @@
+import { useEffect, useRef, useState } from "react";
+import { EVENT } from "../../constants";
+import { KEYS } from "../../keys";
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { deepCopyElement } from "../../element/newElement";
+
+import "./DragInput.scss";
+import clsx from "clsx";
+import { useApp } from "../App";
+import { InlineIcon } from "../InlineIcon";
+import { SMALLEST_DELTA } from "./utils";
+import { StoreAction } from "../../store";
+
+export type DragInputCallbackType = ({
+  accumulatedChange,
+  instantChange,
+  originalElements,
+  originalElementsMap,
+  shouldKeepAspectRatio,
+  shouldChangeByStepSize,
+  nextValue,
+}: {
+  accumulatedChange: number;
+  instantChange: number;
+  originalElements: readonly ExcalidrawElement[];
+  originalElementsMap: ElementsMap;
+  shouldKeepAspectRatio: boolean;
+  shouldChangeByStepSize: boolean;
+  nextValue?: number;
+}) => void;
+
+interface StatsDragInputProps {
+  label: string | React.ReactNode;
+  icon?: React.ReactNode;
+  value: number | "Mixed";
+  elements: readonly ExcalidrawElement[];
+  editable?: boolean;
+  shouldKeepAspectRatio?: boolean;
+  dragInputCallback: DragInputCallbackType;
+}
+
+const StatsDragInput = ({
+  label,
+  icon,
+  dragInputCallback,
+  value,
+  elements,
+  editable = true,
+  shouldKeepAspectRatio,
+}: StatsDragInputProps) => {
+  const app = useApp();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const labelRef = useRef<HTMLDivElement>(null);
+
+  const [inputValue, setInputValue] = useState(value.toString());
+
+  useEffect(() => {
+    setInputValue(value.toString());
+  }, [value, elements]);
+
+  const handleInputValue = (v: string) => {
+    const parsed = Number(v);
+    if (isNaN(parsed)) {
+      setInputValue(value.toString());
+      return;
+    }
+
+    const rounded = Number(parsed.toFixed(2));
+    const original = Number(value);
+
+    // only update when
+    // 1. original was "Mixed" and we have a new value
+    // 2. original was not "Mixed" and the difference between a new value and previous value is greater
+    //    than the smallest delta allowed, which is 0.01
+    // reason: idempotent to avoid unnecessary
+    if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
+      dragInputCallback({
+        accumulatedChange: 0,
+        instantChange: 0,
+        originalElements: elements,
+        originalElementsMap: app.scene.getNonDeletedElementsMap(),
+        shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+        shouldChangeByStepSize: false,
+        nextValue: rounded,
+      });
+      app.syncActionResult({ storeAction: StoreAction.CAPTURE });
+    }
+  };
+
+  const handleInputValueRef = useRef(handleInputValue);
+  handleInputValueRef.current = handleInputValue;
+
+  // make sure that clicking on canvas (which umounts the component)
+  // updates current input value (blur isn't triggered)
+  useEffect(() => {
+    const input = inputRef.current;
+    return () => {
+      const nextValue = input?.value;
+      if (nextValue) {
+        handleInputValueRef.current(nextValue);
+      }
+    };
+  }, []);
+
+  return editable ? (
+    <div
+      className={clsx("drag-input-container", !editable && "disabled")}
+      data-testid={label}
+    >
+      <div
+        className="drag-input-label"
+        ref={labelRef}
+        onPointerDown={(event) => {
+          if (inputRef.current && editable) {
+            let startValue = Number(inputRef.current.value);
+            if (isNaN(startValue)) {
+              startValue = 0;
+            }
+
+            let lastPointer: {
+              x: number;
+              y: number;
+            } | null = null;
+
+            let originalElements: ExcalidrawElement[] | null = null;
+            let originalElementsMap: Map<string, ExcalidrawElement> | null =
+              null;
+
+            let accumulatedChange: number | null = null;
+
+            document.body.classList.add("excalidraw-cursor-resize");
+
+            const onPointerMove = (event: PointerEvent) => {
+              if (!originalElementsMap) {
+                originalElementsMap = app.scene
+                  .getNonDeletedElements()
+                  .reduce((acc, element) => {
+                    acc.set(element.id, deepCopyElement(element));
+                    return acc;
+                  }, new Map() as ElementsMap);
+              }
+
+              if (!originalElements) {
+                originalElements = elements.map(
+                  (element) => originalElementsMap!.get(element.id)!,
+                );
+              }
+
+              if (!accumulatedChange) {
+                accumulatedChange = 0;
+              }
+
+              if (
+                lastPointer &&
+                originalElementsMap !== null &&
+                accumulatedChange !== null
+              ) {
+                const instantChange = event.clientX - lastPointer.x;
+                accumulatedChange += instantChange;
+
+                dragInputCallback({
+                  accumulatedChange,
+                  instantChange,
+                  originalElements,
+                  originalElementsMap,
+                  shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+                  shouldChangeByStepSize: event.shiftKey,
+                });
+              }
+
+              lastPointer = {
+                x: event.clientX,
+                y: event.clientY,
+              };
+            };
+
+            window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
+            window.addEventListener(
+              EVENT.POINTER_UP,
+              () => {
+                window.removeEventListener(
+                  EVENT.POINTER_MOVE,
+                  onPointerMove,
+                  false,
+                );
+
+                app.syncActionResult({ storeAction: StoreAction.CAPTURE });
+
+                lastPointer = null;
+                accumulatedChange = null;
+                originalElements = null;
+                originalElementsMap = null;
+
+                document.body.classList.remove("excalidraw-cursor-resize");
+              },
+              false,
+            );
+          }
+        }}
+        onPointerEnter={() => {
+          if (labelRef.current) {
+            labelRef.current.style.cursor = "ew-resize";
+          }
+        }}
+      >
+        {icon ? <InlineIcon icon={icon} /> : label}
+      </div>
+      <input
+        className="drag-input"
+        autoComplete="off"
+        spellCheck="false"
+        onKeyDown={(event) => {
+          if (editable) {
+            const eventTarget = event.target;
+            if (
+              eventTarget instanceof HTMLInputElement &&
+              event.key === KEYS.ENTER
+            ) {
+              handleInputValue(eventTarget.value);
+              app.focusContainer();
+            }
+          }
+        }}
+        ref={inputRef}
+        value={inputValue}
+        onChange={(event) => {
+          setInputValue(event.target.value);
+        }}
+        onFocus={(event) => {
+          event.target.select();
+        }}
+        onBlur={(event) => {
+          if (!inputValue) {
+            setInputValue(value.toString());
+          } else if (editable) {
+            handleInputValue(event.target.value);
+          }
+        }}
+        disabled={!editable}
+      />
+    </div>
+  ) : (
+    <></>
+  );
+};
+
+export default StatsDragInput;

+ 75 - 0
packages/excalidraw/components/Stats/FontSize.tsx

@@ -0,0 +1,75 @@
+import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
+import { refreshTextDimensions } from "../../element/newElement";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { mutateElement } from "../../element/mutateElement";
+import { getStepSizedValue } from "./utils";
+import { fontSizeIcon } from "../icons";
+
+interface FontSizeProps {
+  element: ExcalidrawTextElement;
+  elementsMap: ElementsMap;
+}
+
+const MIN_FONT_SIZE = 4;
+const STEP_SIZE = 4;
+
+const FontSize = ({ element, elementsMap }: FontSizeProps) => {
+  const handleFontSizeChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    const origElement = originalElements[0];
+    if (origElement) {
+      if (nextValue !== undefined) {
+        const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+
+        const newElement = {
+          ...element,
+          fontSize: nextFontSize,
+        };
+        const updates = refreshTextDimensions(newElement, null, elementsMap);
+        mutateElement(element, {
+          ...updates,
+          fontSize: nextFontSize,
+        });
+        return;
+      }
+
+      if (origElement.type === "text") {
+        const originalFontSize = Math.round(origElement.fontSize);
+        const changeInFontSize = Math.round(accumulatedChange);
+        let nextFontSize = Math.max(
+          originalFontSize + changeInFontSize,
+          MIN_FONT_SIZE,
+        );
+        if (shouldChangeByStepSize) {
+          nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
+        }
+        const newElement = {
+          ...element,
+          fontSize: nextFontSize,
+        };
+        const updates = refreshTextDimensions(newElement, null, elementsMap);
+        mutateElement(element, {
+          ...updates,
+          fontSize: nextFontSize,
+        });
+      }
+    }
+  };
+
+  return (
+    <StatsDragInput
+      label="F"
+      value={Math.round(element.fontSize * 10) / 10}
+      elements={[element]}
+      dragInputCallback={handleFontSizeChange}
+      icon={fontSizeIcon}
+    />
+  );
+};
+
+export default FontSize;

+ 114 - 0
packages/excalidraw/components/Stats/MultiAngle.tsx

@@ -0,0 +1,114 @@
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import { isArrowElement } from "../../element/typeChecks";
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { isInGroup } from "../../groups";
+import { degreeToRadian, radianToDegree } from "../../math";
+import type Scene from "../../scene/Scene";
+import { angleIcon } from "../icons";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
+
+interface MultiAngleProps {
+  elements: readonly ExcalidrawElement[];
+  elementsMap: ElementsMap;
+  scene: Scene;
+}
+
+const STEP_SIZE = 15;
+
+const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
+  const handleDegreeChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    const editableLatestIndividualElements = elements.filter(
+      (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
+    );
+    const editableOriginalIndividualElements = originalElements.filter(
+      (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
+    );
+
+    if (nextValue !== undefined) {
+      const nextAngle = degreeToRadian(nextValue);
+
+      for (const element of editableLatestIndividualElements) {
+        mutateElement(
+          element,
+          {
+            angle: nextAngle,
+          },
+          false,
+        );
+
+        const boundTextElement = getBoundTextElement(element, elementsMap);
+        if (boundTextElement && !isArrowElement(element)) {
+          mutateElement(boundTextElement, { angle: nextAngle }, false);
+        }
+      }
+
+      scene.triggerUpdate();
+
+      return;
+    }
+
+    for (let i = 0; i < editableLatestIndividualElements.length; i++) {
+      const latestElement = editableLatestIndividualElements[i];
+      const originalElement = editableOriginalIndividualElements[i];
+      const originalAngleInDegrees =
+        Math.round(radianToDegree(originalElement.angle) * 100) / 100;
+      const changeInDegrees = Math.round(accumulatedChange);
+      let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
+      if (shouldChangeByStepSize) {
+        nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
+      }
+
+      nextAngleInDegrees =
+        nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
+
+      const nextAngle = degreeToRadian(nextAngleInDegrees);
+
+      mutateElement(
+        latestElement,
+        {
+          angle: nextAngle,
+        },
+        false,
+      );
+
+      const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+      if (boundTextElement && !isArrowElement(latestElement)) {
+        mutateElement(boundTextElement, { angle: nextAngle }, false);
+      }
+    }
+    scene.triggerUpdate();
+  };
+
+  const editableLatestIndividualElements = elements.filter(
+    (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
+  );
+  const angles = editableLatestIndividualElements.map(
+    (el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
+  );
+  const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
+
+  const editable = editableLatestIndividualElements.some((el) =>
+    isPropertyEditable(el, "angle"),
+  );
+
+  return (
+    <DragInput
+      label="A"
+      icon={angleIcon}
+      value={value}
+      elements={elements}
+      dragInputCallback={handleDegreeChange}
+      editable={editable}
+    />
+  );
+};
+
+export default MultiAngle;

+ 377 - 0
packages/excalidraw/components/Stats/MultiDimension.tsx

@@ -0,0 +1,377 @@
+import { useMemo } from "react";
+import { getCommonBounds, isTextElement } from "../../element";
+import { updateBoundElements } from "../../element/binding";
+import { mutateElement } from "../../element/mutateElement";
+import { rescalePointsInElement } from "../../element/resizeElements";
+import {
+  getBoundTextElement,
+  handleBindTextResize,
+} from "../../element/textElement";
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import type Scene from "../../scene/Scene";
+import type { Point } from "../../types";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
+import { getElementsInAtomicUnit, resizeElement } from "./utils";
+import type { AtomicUnit } from "./utils";
+import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+
+interface MultiDimensionProps {
+  property: "width" | "height";
+  elements: readonly ExcalidrawElement[];
+  elementsMap: ElementsMap;
+  atomicUnits: AtomicUnit[];
+  scene: Scene;
+}
+
+const STEP_SIZE = 10;
+
+const getResizedUpdates = (
+  anchorX: number,
+  anchorY: number,
+  scale: number,
+  origElement: ExcalidrawElement,
+) => {
+  const offsetX = origElement.x - anchorX;
+  const offsetY = origElement.y - anchorY;
+  const nextWidth = origElement.width * scale;
+  const nextHeight = origElement.height * scale;
+  const x = anchorX + offsetX * scale;
+  const y = anchorY + offsetY * scale;
+
+  return {
+    width: nextWidth,
+    height: nextHeight,
+    x,
+    y,
+    ...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
+    ...(isTextElement(origElement)
+      ? { fontSize: origElement.fontSize * scale }
+      : {}),
+  };
+};
+
+const resizeElementInGroup = (
+  anchorX: number,
+  anchorY: number,
+  property: MultiDimensionProps["property"],
+  scale: number,
+  latestElement: ExcalidrawElement,
+  origElement: ExcalidrawElement,
+  elementsMap: ElementsMap,
+  originalElementsMap: ElementsMap,
+) => {
+  const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
+
+  mutateElement(latestElement, updates, false);
+  const boundTextElement = getBoundTextElement(
+    origElement,
+    originalElementsMap,
+  );
+  if (boundTextElement) {
+    const newFontSize = boundTextElement.fontSize * scale;
+    updateBoundElements(latestElement, elementsMap, {
+      newSize: { width: updates.width, height: updates.height },
+    });
+    const latestBoundTextElement = elementsMap.get(boundTextElement.id);
+    if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
+      mutateElement(
+        latestBoundTextElement,
+        {
+          fontSize: newFontSize,
+        },
+        false,
+      );
+      handleBindTextResize(
+        latestElement,
+        elementsMap,
+        property === "width" ? "e" : "s",
+        true,
+      );
+    }
+  }
+};
+
+const resizeGroup = (
+  nextWidth: number,
+  nextHeight: number,
+  initialHeight: number,
+  aspectRatio: number,
+  anchor: Point,
+  property: MultiDimensionProps["property"],
+  latestElements: ExcalidrawElement[],
+  originalElements: ExcalidrawElement[],
+  elementsMap: ElementsMap,
+  originalElementsMap: ElementsMap,
+) => {
+  // keep aspect ratio for groups
+  if (property === "width") {
+    nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
+  } else {
+    nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
+  }
+
+  const scale = nextHeight / initialHeight;
+
+  for (let i = 0; i < originalElements.length; i++) {
+    const origElement = originalElements[i];
+    const latestElement = latestElements[i];
+
+    resizeElementInGroup(
+      anchor[0],
+      anchor[1],
+      property,
+      scale,
+      latestElement,
+      origElement,
+      elementsMap,
+      originalElementsMap,
+    );
+  }
+};
+
+const MultiDimension = ({
+  property,
+  elements,
+  elementsMap,
+  atomicUnits,
+  scene,
+}: MultiDimensionProps) => {
+  const sizes = useMemo(
+    () =>
+      atomicUnits.map((atomicUnit) => {
+        const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
+
+        if (elementsInUnit.length > 1) {
+          const [x1, y1, x2, y2] = getCommonBounds(
+            elementsInUnit.map((el) => el.latest),
+          );
+          return (
+            Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
+          );
+        }
+        const [el] = elementsInUnit;
+
+        return (
+          Math.round(
+            (property === "width" ? el.latest.width : el.latest.height) * 100,
+          ) / 100
+        );
+      }),
+    [elementsMap, atomicUnits, property],
+  );
+
+  const value =
+    new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
+
+  const editable = sizes.length > 0;
+
+  const handleDimensionChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElementsMap,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    if (nextValue !== undefined) {
+      for (const atomicUnit of atomicUnits) {
+        const elementsInUnit = getElementsInAtomicUnit(
+          atomicUnit,
+          elementsMap,
+          originalElementsMap,
+        );
+
+        if (elementsInUnit.length > 1) {
+          const latestElements = elementsInUnit.map((el) => el.latest!);
+          const originalElements = elementsInUnit.map((el) => el.original!);
+          const [x1, y1, x2, y2] = getCommonBounds(originalElements);
+          const initialWidth = x2 - x1;
+          const initialHeight = y2 - y1;
+          const aspectRatio = initialWidth / initialHeight;
+          const nextWidth = Math.max(
+            MIN_WIDTH_OR_HEIGHT,
+            property === "width" ? Math.max(0, nextValue) : initialWidth,
+          );
+          const nextHeight = Math.max(
+            MIN_WIDTH_OR_HEIGHT,
+            property === "height" ? Math.max(0, nextValue) : initialHeight,
+          );
+
+          resizeGroup(
+            nextWidth,
+            nextHeight,
+            initialHeight,
+            aspectRatio,
+            [x1, y1],
+            property,
+            latestElements,
+            originalElements,
+            elementsMap,
+            originalElementsMap,
+          );
+        } else {
+          const [el] = elementsInUnit;
+          const latestElement = el?.latest;
+          const origElement = el?.original;
+
+          if (
+            latestElement &&
+            origElement &&
+            isPropertyEditable(latestElement, property)
+          ) {
+            let nextWidth =
+              property === "width"
+                ? Math.max(0, nextValue)
+                : latestElement.width;
+            if (property === "width") {
+              if (shouldChangeByStepSize) {
+                nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+              } else {
+                nextWidth = Math.round(nextWidth);
+              }
+            }
+
+            let nextHeight =
+              property === "height"
+                ? Math.max(0, nextValue)
+                : latestElement.height;
+            if (property === "height") {
+              if (shouldChangeByStepSize) {
+                nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+              } else {
+                nextHeight = Math.round(nextHeight);
+              }
+            }
+
+            nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+            nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+
+            resizeElement(
+              nextWidth,
+              nextHeight,
+              false,
+              latestElement,
+              origElement,
+              elementsMap,
+              originalElementsMap,
+              false,
+            );
+          }
+        }
+      }
+
+      scene.triggerUpdate();
+
+      return;
+    }
+
+    const changeInWidth = property === "width" ? accumulatedChange : 0;
+    const changeInHeight = property === "height" ? accumulatedChange : 0;
+
+    for (const atomicUnit of atomicUnits) {
+      const elementsInUnit = getElementsInAtomicUnit(
+        atomicUnit,
+        elementsMap,
+        originalElementsMap,
+      );
+
+      if (elementsInUnit.length > 1) {
+        const latestElements = elementsInUnit.map((el) => el.latest!);
+        const originalElements = elementsInUnit.map((el) => el.original!);
+
+        const [x1, y1, x2, y2] = getCommonBounds(originalElements);
+        const initialWidth = x2 - x1;
+        const initialHeight = y2 - y1;
+        const aspectRatio = initialWidth / initialHeight;
+        let nextWidth = Math.max(0, initialWidth + changeInWidth);
+        if (property === "width") {
+          if (shouldChangeByStepSize) {
+            nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+          } else {
+            nextWidth = Math.round(nextWidth);
+          }
+        }
+
+        let nextHeight = Math.max(0, initialHeight + changeInHeight);
+        if (property === "height") {
+          if (shouldChangeByStepSize) {
+            nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+          } else {
+            nextHeight = Math.round(nextHeight);
+          }
+        }
+
+        nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+        nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+
+        resizeGroup(
+          nextWidth,
+          nextHeight,
+          initialHeight,
+          aspectRatio,
+          [x1, y1],
+          property,
+          latestElements,
+          originalElements,
+          elementsMap,
+          originalElementsMap,
+        );
+      } else {
+        const [el] = elementsInUnit;
+        const latestElement = el?.latest;
+        const origElement = el?.original;
+
+        if (
+          latestElement &&
+          origElement &&
+          isPropertyEditable(latestElement, property)
+        ) {
+          let nextWidth = Math.max(0, origElement.width + changeInWidth);
+          if (property === "width") {
+            if (shouldChangeByStepSize) {
+              nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+            } else {
+              nextWidth = Math.round(nextWidth);
+            }
+          }
+
+          let nextHeight = Math.max(0, origElement.height + changeInHeight);
+          if (property === "height") {
+            if (shouldChangeByStepSize) {
+              nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+            } else {
+              nextHeight = Math.round(nextHeight);
+            }
+          }
+
+          nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+          nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+
+          resizeElement(
+            nextWidth,
+            nextHeight,
+            false,
+            latestElement,
+            origElement,
+            elementsMap,
+            originalElementsMap,
+          );
+        }
+      }
+    }
+
+    scene.triggerUpdate();
+  };
+
+  return (
+    <DragInput
+      label={property === "width" ? "W" : "H"}
+      elements={elements}
+      dragInputCallback={handleDimensionChange}
+      value={value}
+      editable={editable}
+    />
+  );
+};
+
+export default MultiDimension;

+ 115 - 0
packages/excalidraw/components/Stats/MultiFontSize.tsx

@@ -0,0 +1,115 @@
+import { isTextElement, refreshTextDimensions } from "../../element";
+import { mutateElement } from "../../element/mutateElement";
+import { isBoundToContainer } from "../../element/typeChecks";
+import type {
+  ElementsMap,
+  ExcalidrawElement,
+  ExcalidrawTextElement,
+} from "../../element/types";
+import { isInGroup } from "../../groups";
+import type Scene from "../../scene/Scene";
+import { fontSizeIcon } from "../icons";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue } from "./utils";
+
+interface MultiFontSizeProps {
+  elements: readonly ExcalidrawElement[];
+  elementsMap: ElementsMap;
+  scene: Scene;
+}
+
+const MIN_FONT_SIZE = 4;
+const STEP_SIZE = 4;
+
+const MultiFontSize = ({
+  elements,
+  elementsMap,
+  scene,
+}: MultiFontSizeProps) => {
+  const latestTextElements = elements.filter(
+    (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
+  ) as ExcalidrawTextElement[];
+  const fontSizes = latestTextElements.map(
+    (textEl) => Math.round(textEl.fontSize * 10) / 10,
+  );
+  const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
+  const editable = fontSizes.length > 0;
+
+  const handleFontSizeChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    if (nextValue) {
+      const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+
+      for (const textElement of latestTextElements) {
+        const newElement = {
+          ...textElement,
+          fontSize: nextFontSize,
+        };
+        const updates = refreshTextDimensions(newElement, null, elementsMap);
+        mutateElement(
+          textElement,
+          {
+            ...updates,
+            fontSize: nextFontSize,
+          },
+          false,
+        );
+      }
+
+      scene.triggerUpdate();
+      return;
+    }
+
+    const originalTextElements = originalElements.filter(
+      (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
+    ) as ExcalidrawTextElement[];
+
+    for (let i = 0; i < latestTextElements.length; i++) {
+      const latestElement = latestTextElements[i];
+      const originalElement = originalTextElements[i];
+
+      const originalFontSize = Math.round(originalElement.fontSize);
+      const changeInFontSize = Math.round(accumulatedChange);
+      let nextFontSize = Math.max(
+        originalFontSize + changeInFontSize,
+        MIN_FONT_SIZE,
+      );
+      if (shouldChangeByStepSize) {
+        nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
+      }
+      const newElement = {
+        ...latestElement,
+        fontSize: nextFontSize,
+      };
+      const updates = refreshTextDimensions(newElement, null, elementsMap);
+      mutateElement(
+        latestElement,
+        {
+          ...updates,
+          fontSize: nextFontSize,
+        },
+        false,
+      );
+    }
+
+    scene.triggerUpdate();
+  };
+
+  return (
+    <StatsDragInput
+      label="F"
+      icon={fontSizeIcon}
+      elements={elements}
+      dragInputCallback={handleFontSizeChange}
+      value={value}
+      editable={editable}
+    />
+  );
+};
+
+export default MultiFontSize;

+ 239 - 0
packages/excalidraw/components/Stats/MultiPosition.tsx

@@ -0,0 +1,239 @@
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { rotate } from "../../math";
+import type Scene from "../../scene/Scene";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
+import { getCommonBounds, isTextElement } from "../../element";
+import { useMemo } from "react";
+import { getElementsInAtomicUnit, moveElement } from "./utils";
+import type { AtomicUnit } from "./utils";
+
+interface MultiPositionProps {
+  property: "x" | "y";
+  elements: readonly ExcalidrawElement[];
+  elementsMap: ElementsMap;
+  atomicUnits: AtomicUnit[];
+  scene: Scene;
+}
+
+const STEP_SIZE = 10;
+
+const moveElements = (
+  property: MultiPositionProps["property"],
+  changeInTopX: number,
+  changeInTopY: number,
+  elements: readonly ExcalidrawElement[],
+  originalElements: readonly ExcalidrawElement[],
+  elementsMap: ElementsMap,
+  originalElementsMap: ElementsMap,
+) => {
+  for (let i = 0; i < elements.length; i++) {
+    const origElement = originalElements[i];
+    const latestElement = elements[i];
+
+    const [cx, cy] = [
+      origElement.x + origElement.width / 2,
+      origElement.y + origElement.height / 2,
+    ];
+    const [topLeftX, topLeftY] = rotate(
+      origElement.x,
+      origElement.y,
+      cx,
+      cy,
+      origElement.angle,
+    );
+
+    const newTopLeftX =
+      property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX;
+
+    const newTopLeftY =
+      property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY;
+
+    moveElement(
+      newTopLeftX,
+      newTopLeftY,
+      latestElement,
+      origElement,
+      elementsMap,
+      originalElementsMap,
+      false,
+    );
+  }
+};
+
+const moveGroupTo = (
+  nextX: number,
+  nextY: number,
+  latestElements: ExcalidrawElement[],
+  originalElements: ExcalidrawElement[],
+  elementsMap: ElementsMap,
+  originalElementsMap: ElementsMap,
+) => {
+  const [x1, y1, ,] = getCommonBounds(originalElements);
+  const offsetX = nextX - x1;
+  const offsetY = nextY - y1;
+
+  for (let i = 0; i < latestElements.length; i++) {
+    const origElement = originalElements[i];
+    const latestElement = latestElements[i];
+
+    // bound texts are moved with their containers
+    if (!isTextElement(latestElement) || !latestElement.containerId) {
+      const [cx, cy] = [
+        latestElement.x + latestElement.width / 2,
+        latestElement.y + latestElement.height / 2,
+      ];
+
+      const [topLeftX, topLeftY] = rotate(
+        latestElement.x,
+        latestElement.y,
+        cx,
+        cy,
+        latestElement.angle,
+      );
+
+      moveElement(
+        topLeftX + offsetX,
+        topLeftY + offsetY,
+        latestElement,
+        origElement,
+        elementsMap,
+        originalElementsMap,
+        false,
+      );
+    }
+  }
+};
+
+const MultiPosition = ({
+  property,
+  elements,
+  elementsMap,
+  atomicUnits,
+  scene,
+}: MultiPositionProps) => {
+  const positions = useMemo(
+    () =>
+      atomicUnits.map((atomicUnit) => {
+        const elementsInUnit = Object.keys(atomicUnit)
+          .map((id) => elementsMap.get(id))
+          .filter((el) => el !== undefined) as ExcalidrawElement[];
+
+        // we're dealing with a group
+        if (elementsInUnit.length > 1) {
+          const [x1, y1] = getCommonBounds(elementsInUnit);
+          return Math.round((property === "x" ? x1 : y1) * 100) / 100;
+        }
+        const [el] = elementsInUnit;
+        const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
+
+        const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
+
+        return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
+      }),
+    [atomicUnits, elementsMap, property],
+  );
+
+  const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
+
+  const handlePositionChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    originalElementsMap,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    if (nextValue !== undefined) {
+      for (const atomicUnit of atomicUnits) {
+        const elementsInUnit = getElementsInAtomicUnit(
+          atomicUnit,
+          elementsMap,
+          originalElementsMap,
+        );
+
+        if (elementsInUnit.length > 1) {
+          const [x1, y1, ,] = getCommonBounds(
+            elementsInUnit.map((el) => el.latest!),
+          );
+          const newTopLeftX = property === "x" ? nextValue : x1;
+          const newTopLeftY = property === "y" ? nextValue : y1;
+
+          moveGroupTo(
+            newTopLeftX,
+            newTopLeftY,
+            elementsInUnit.map((el) => el.latest),
+            elementsInUnit.map((el) => el.original),
+            elementsMap,
+            originalElementsMap,
+          );
+        } else {
+          const origElement = elementsInUnit[0]?.original;
+          const latestElement = elementsInUnit[0]?.latest;
+          if (
+            origElement &&
+            latestElement &&
+            isPropertyEditable(latestElement, property)
+          ) {
+            const [cx, cy] = [
+              origElement.x + origElement.width / 2,
+              origElement.y + origElement.height / 2,
+            ];
+            const [topLeftX, topLeftY] = rotate(
+              origElement.x,
+              origElement.y,
+              cx,
+              cy,
+              origElement.angle,
+            );
+
+            const newTopLeftX = property === "x" ? nextValue : topLeftX;
+            const newTopLeftY = property === "y" ? nextValue : topLeftY;
+            moveElement(
+              newTopLeftX,
+              newTopLeftY,
+              latestElement,
+              origElement,
+              elementsMap,
+              originalElementsMap,
+              false,
+            );
+          }
+        }
+      }
+
+      scene.triggerUpdate();
+      return;
+    }
+
+    const change = shouldChangeByStepSize
+      ? getStepSizedValue(accumulatedChange, STEP_SIZE)
+      : accumulatedChange;
+
+    const changeInTopX = property === "x" ? change : 0;
+    const changeInTopY = property === "y" ? change : 0;
+
+    moveElements(
+      property,
+      changeInTopX,
+      changeInTopY,
+      elements,
+      originalElements,
+      elementsMap,
+      originalElementsMap,
+    );
+
+    scene.triggerUpdate();
+  };
+
+  return (
+    <StatsDragInput
+      label={property === "x" ? "X" : "Y"}
+      elements={elements}
+      dragInputCallback={handlePositionChange}
+      value={value}
+    />
+  );
+};
+
+export default MultiPosition;

+ 101 - 0
packages/excalidraw/components/Stats/Position.tsx

@@ -0,0 +1,101 @@
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { rotate } from "../../math";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, moveElement } from "./utils";
+
+interface PositionProps {
+  property: "x" | "y";
+  element: ExcalidrawElement;
+  elementsMap: ElementsMap;
+}
+
+const STEP_SIZE = 10;
+
+const Position = ({ property, element, elementsMap }: PositionProps) => {
+  const [topLeftX, topLeftY] = rotate(
+    element.x,
+    element.y,
+    element.x + element.width / 2,
+    element.y + element.height / 2,
+    element.angle,
+  );
+  const value =
+    Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
+
+  const handlePositionChange: DragInputCallbackType = ({
+    accumulatedChange,
+    originalElements,
+    originalElementsMap,
+    shouldChangeByStepSize,
+    nextValue,
+  }) => {
+    const origElement = originalElements[0];
+    const [cx, cy] = [
+      origElement.x + origElement.width / 2,
+      origElement.y + origElement.height / 2,
+    ];
+    const [topLeftX, topLeftY] = rotate(
+      origElement.x,
+      origElement.y,
+      cx,
+      cy,
+      origElement.angle,
+    );
+
+    if (nextValue !== undefined) {
+      const newTopLeftX = property === "x" ? nextValue : topLeftX;
+      const newTopLeftY = property === "y" ? nextValue : topLeftY;
+      moveElement(
+        newTopLeftX,
+        newTopLeftY,
+        element,
+        origElement,
+        elementsMap,
+        originalElementsMap,
+      );
+      return;
+    }
+
+    const changeInTopX = property === "x" ? accumulatedChange : 0;
+    const changeInTopY = property === "y" ? accumulatedChange : 0;
+
+    const newTopLeftX =
+      property === "x"
+        ? Math.round(
+            shouldChangeByStepSize
+              ? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
+              : topLeftX + changeInTopX,
+          )
+        : topLeftX;
+
+    const newTopLeftY =
+      property === "y"
+        ? Math.round(
+            shouldChangeByStepSize
+              ? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
+              : topLeftY + changeInTopY,
+          )
+        : topLeftY;
+
+    moveElement(
+      newTopLeftX,
+      newTopLeftY,
+      element,
+      origElement,
+      elementsMap,
+      originalElementsMap,
+    );
+  };
+
+  return (
+    <StatsDragInput
+      label={property === "x" ? "X" : "Y"}
+      elements={[element]}
+      dragInputCallback={handlePositionChange}
+      value={value}
+    />
+  );
+};
+
+export default Position;

+ 306 - 0
packages/excalidraw/components/Stats/index.tsx

@@ -0,0 +1,306 @@
+import { useEffect, useMemo, useState, memo } from "react";
+import { getCommonBounds } from "../../element/bounds";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+import { t } from "../../i18n";
+import type { AppState, ExcalidrawProps } from "../../types";
+import { CloseIcon } from "../icons";
+import { Island } from "../Island";
+import { throttle } from "lodash";
+import Dimension from "./Dimension";
+import Angle from "./Angle";
+
+import FontSize from "./FontSize";
+import MultiDimension from "./MultiDimension";
+import {
+  elementsAreInSameGroup,
+  getElementsInGroup,
+  getSelectedGroupIds,
+  isInGroup,
+} from "../../groups";
+import MultiAngle from "./MultiAngle";
+import MultiFontSize from "./MultiFontSize";
+import Position from "./Position";
+import MultiPosition from "./MultiPosition";
+import Collapsible from "./Collapsible";
+import type Scene from "../../scene/Scene";
+import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
+import type { AtomicUnit } from "./utils";
+import { STATS_PANELS } from "../../constants";
+
+interface StatsProps {
+  scene: Scene;
+  onClose: () => void;
+  renderCustomStats: ExcalidrawProps["renderCustomStats"];
+}
+
+const STATS_TIMEOUT = 50;
+
+export const Stats = (props: StatsProps) => {
+  const appState = useExcalidrawAppState();
+  const sceneNonce = props.scene.getSceneNonce() || 1;
+  const selectedElements = props.scene.getSelectedElements({
+    selectedElementIds: appState.selectedElementIds,
+    includeBoundTextElement: false,
+  });
+
+  return (
+    <StatsInner
+      {...props}
+      appState={appState}
+      sceneNonce={sceneNonce}
+      selectedElements={selectedElements}
+    />
+  );
+};
+
+export const StatsInner = memo(
+  ({
+    scene,
+    onClose,
+    renderCustomStats,
+    selectedElements,
+    appState,
+    sceneNonce,
+  }: StatsProps & {
+    sceneNonce: number;
+    selectedElements: readonly NonDeletedExcalidrawElement[];
+    appState: AppState;
+  }) => {
+    const elements = scene.getNonDeletedElements();
+    const elementsMap = scene.getNonDeletedElementsMap();
+    const setAppState = useExcalidrawSetAppState();
+
+    const singleElement =
+      selectedElements.length === 1 ? selectedElements[0] : null;
+
+    const multipleElements =
+      selectedElements.length > 1 ? selectedElements : null;
+
+    const [sceneDimension, setSceneDimension] = useState<{
+      width: number;
+      height: number;
+    }>({
+      width: 0,
+      height: 0,
+    });
+
+    const throttledSetSceneDimension = useMemo(
+      () =>
+        throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
+          const boundingBox = getCommonBounds(elements);
+          setSceneDimension({
+            width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
+            height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
+          });
+        }, STATS_TIMEOUT),
+      [],
+    );
+
+    useEffect(() => {
+      throttledSetSceneDimension(elements);
+    }, [sceneNonce, elements, throttledSetSceneDimension]);
+
+    useEffect(
+      () => () => throttledSetSceneDimension.cancel(),
+      [throttledSetSceneDimension],
+    );
+
+    const atomicUnits = useMemo(() => {
+      const selectedGroupIds = getSelectedGroupIds(appState);
+      const _atomicUnits = selectedGroupIds.map((gid) => {
+        return getElementsInGroup(selectedElements, gid).reduce((acc, el) => {
+          acc[el.id] = true;
+          return acc;
+        }, {} as AtomicUnit);
+      });
+      selectedElements
+        .filter((el) => !isInGroup(el))
+        .forEach((el) => {
+          _atomicUnits.push({
+            [el.id]: true,
+          });
+        });
+      return _atomicUnits;
+    }, [selectedElements, appState]);
+
+    return (
+      <div className="Stats">
+        <Island padding={3}>
+          <div className="title">
+            <h2>{t("stats.title")}</h2>
+            <div className="close" onClick={onClose}>
+              {CloseIcon}
+            </div>
+          </div>
+
+          <Collapsible
+            label={<h3>{t("stats.generalStats")}</h3>}
+            open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
+            openTrigger={() =>
+              setAppState((state) => {
+                return {
+                  ...state,
+                  stats: {
+                    open: true,
+                    panels: state.stats.panels ^ STATS_PANELS.generalStats,
+                  },
+                };
+              })
+            }
+          >
+            <table>
+              <tbody>
+                <tr>
+                  <th colSpan={2}>{t("stats.scene")}</th>
+                </tr>
+                <tr>
+                  <td>{t("stats.elements")}</td>
+                  <td>{elements.length}</td>
+                </tr>
+                <tr>
+                  <td>{t("stats.width")}</td>
+                  <td>{sceneDimension.width}</td>
+                </tr>
+                <tr>
+                  <td>{t("stats.height")}</td>
+                  <td>{sceneDimension.height}</td>
+                </tr>
+                {renderCustomStats?.(elements, appState)}
+              </tbody>
+            </table>
+          </Collapsible>
+
+          {selectedElements.length > 0 && (
+            <div
+              id="elementStats"
+              style={{
+                marginTop: 12,
+              }}
+            >
+              <Collapsible
+                label={<h3>{t("stats.elementProperties")}</h3>}
+                open={
+                  !!(appState.stats.panels & STATS_PANELS.elementProperties)
+                }
+                openTrigger={() =>
+                  setAppState((state) => {
+                    return {
+                      ...state,
+                      stats: {
+                        open: true,
+                        panels:
+                          state.stats.panels ^ STATS_PANELS.elementProperties,
+                      },
+                    };
+                  })
+                }
+              >
+                {singleElement && (
+                  <div className="sectionContent">
+                    <div className="elementType">
+                      {t(`element.${singleElement.type}`)}
+                    </div>
+
+                    <div className="statsItem">
+                      <Position
+                        element={singleElement}
+                        property="x"
+                        elementsMap={elementsMap}
+                      />
+                      <Position
+                        element={singleElement}
+                        property="y"
+                        elementsMap={elementsMap}
+                      />
+                      <Dimension
+                        property="width"
+                        element={singleElement}
+                        elementsMap={elementsMap}
+                      />
+                      <Dimension
+                        property="height"
+                        element={singleElement}
+                        elementsMap={elementsMap}
+                      />
+                      <Angle
+                        element={singleElement}
+                        elementsMap={elementsMap}
+                      />
+                      {singleElement.type === "text" && (
+                        <FontSize
+                          element={singleElement}
+                          elementsMap={elementsMap}
+                        />
+                      )}
+                    </div>
+                  </div>
+                )}
+
+                {multipleElements && (
+                  <div className="sectionContent">
+                    {elementsAreInSameGroup(multipleElements) && (
+                      <div className="elementType">{t("element.group")}</div>
+                    )}
+
+                    <div className="elementsCount">
+                      <div>{t("stats.elements")}</div>
+                      <div>{selectedElements.length}</div>
+                    </div>
+
+                    <div className="statsItem">
+                      <MultiPosition
+                        property="x"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                      />
+                      <MultiPosition
+                        property="y"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                      />
+                      <MultiDimension
+                        property="width"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                      />
+                      <MultiDimension
+                        property="height"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                      />
+                      <MultiAngle
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        scene={scene}
+                      />
+                      <MultiFontSize
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        scene={scene}
+                      />
+                    </div>
+                  </div>
+                )}
+              </Collapsible>
+            </div>
+          )}
+        </Island>
+      </div>
+    );
+  },
+  (prev, next) => {
+    return (
+      prev.sceneNonce === next.sceneNonce &&
+      prev.selectedElements === next.selectedElements &&
+      prev.appState.stats.panels === next.appState.stats.panels
+    );
+  },
+);

+ 658 - 0
packages/excalidraw/components/Stats/stats.test.tsx

@@ -0,0 +1,658 @@
+import { fireEvent, queryByTestId } from "@testing-library/react";
+import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
+import { getStepSizedValue } from "./utils";
+import {
+  GlobalTestState,
+  mockBoundingClientRect,
+  render,
+  restoreOriginalGetBoundingClientRect,
+} from "../../tests/test-utils";
+import * as StaticScene from "../../renderer/staticScene";
+import { vi } from "vitest";
+import { reseed } from "../../random";
+import { setDateTimeForTests } from "../../utils";
+import { Excalidraw } from "../..";
+import { t } from "../../i18n";
+import type {
+  ExcalidrawElement,
+  ExcalidrawTextElement,
+} from "../../element/types";
+import { degreeToRadian, rotate } from "../../math";
+import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
+import { getCommonBounds, isTextElement } from "../../element";
+import { API } from "../../tests/helpers/api";
+import { actionGroup } from "../../actions";
+import { isInGroup } from "../../groups";
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
+let stats: HTMLElement | null = null;
+let elementStats: HTMLElement | null | undefined = null;
+
+const getStatsProperty = (label: string) => {
+  if (elementStats) {
+    const properties = elementStats?.querySelector(".statsItem");
+    return properties?.querySelector?.(
+      `.drag-input-container[data-testid="${label}"]`,
+    );
+  }
+
+  return null;
+};
+
+const testInputProperty = (
+  element: ExcalidrawElement,
+  property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
+  label: string,
+  initialValue: number,
+  nextValue: number,
+) => {
+  const input = getStatsProperty(label)?.querySelector(
+    ".drag-input",
+  ) as HTMLInputElement;
+  expect(input).not.toBeNull();
+  expect(input.value).toBe(initialValue.toString());
+  input?.focus();
+  input.value = nextValue.toString();
+  input?.blur();
+  if (property === "angle") {
+    expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
+  } else if (property === "fontSize" && isTextElement(element)) {
+    expect(element[property]).toBe(Number(nextValue));
+  } else if (property !== "fontSize") {
+    expect(element[property]).toBe(Number(nextValue));
+  }
+};
+
+describe("step sized value", () => {
+  it("should return edge values correctly", () => {
+    const steps = [10, 15, 20, 25, 30];
+    const values = [10, 15, 20, 25, 30];
+
+    steps.forEach((step, idx) => {
+      expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]);
+    });
+  });
+
+  it("step sized value lies in the middle", () => {
+    let stepSize = 15;
+    let values = [7.5, 9, 12, 14.99, 15, 22.49];
+
+    values.forEach((value) => {
+      expect(getStepSizedValue(value, stepSize)).toEqual(15);
+    });
+
+    stepSize = 10;
+    values = [-5, 4.99, 0, 1.23];
+    values.forEach((value) => {
+      expect(getStepSizedValue(value, stepSize)).toEqual(0);
+    });
+  });
+});
+
+// single element
+describe("stats for a generic element", () => {
+  beforeEach(async () => {
+    localStorage.clear();
+    renderStaticScene.mockClear();
+    reseed(7);
+    setDateTimeForTests("201933152653");
+
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+    h.elements = [];
+
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+      button: 2,
+      clientX: 1,
+      clientY: 1,
+    });
+    const contextMenu = UI.queryContextMenu();
+    fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+    stats = UI.queryStats();
+
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(200, 100);
+    elementStats = stats?.querySelector("#elementStats");
+  });
+
+  beforeAll(() => {
+    mockBoundingClientRect();
+  });
+
+  afterAll(() => {
+    restoreOriginalGetBoundingClientRect();
+  });
+
+  it("should open stats", () => {
+    expect(stats).not.toBeNull();
+    expect(elementStats).not.toBeNull();
+
+    // title
+    const title = elementStats?.querySelector("h3");
+    expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
+
+    // element type
+    const elementType = elementStats?.querySelector(".elementType");
+    expect(elementType).not.toBeNull();
+    expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
+
+    // properties
+    const properties = elementStats?.querySelector(".statsItem");
+    expect(properties?.childNodes).not.toBeNull();
+    ["X", "Y", "W", "H", "A"].forEach((label) => () => {
+      expect(
+        properties?.querySelector?.(
+          `.drag-input-container[data-testid="${label}"]`,
+        ),
+      ).not.toBeNull();
+    });
+  });
+
+  it("should be able to edit all properties for a general element", () => {
+    const rectangle = h.elements[0];
+    const initialX = rectangle.x;
+    const initialY = rectangle.y;
+
+    testInputProperty(rectangle, "width", "W", 200, 100);
+    testInputProperty(rectangle, "height", "H", 100, 200);
+    testInputProperty(rectangle, "x", "X", initialX, 230);
+    testInputProperty(rectangle, "y", "Y", initialY, 220);
+    testInputProperty(rectangle, "angle", "A", 0, 45);
+  });
+
+  it("should keep only two decimal places", () => {
+    const rectangle = h.elements[0];
+    const rectangleId = rectangle.id;
+
+    const input = getStatsProperty("W")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(input).not.toBeNull();
+    expect(input.value).toBe(rectangle.width.toString());
+    input?.focus();
+    input.value = "123.123";
+    input?.blur();
+    expect(h.elements.length).toBe(1);
+    expect(rectangle.id).toBe(rectangleId);
+    expect(input.value).toBe("123.12");
+    expect(rectangle.width).toBe(123.12);
+
+    input?.focus();
+    input.value = "88.98766";
+    input?.blur();
+    expect(input.value).toBe("88.99");
+    expect(rectangle.width).toBe(88.99);
+  });
+
+  it("should update input x and y when angle is changed", () => {
+    const rectangle = h.elements[0];
+    const [cx, cy] = [
+      rectangle.x + rectangle.width / 2,
+      rectangle.y + rectangle.height / 2,
+    ];
+    const [topLeftX, topLeftY] = rotate(
+      rectangle.x,
+      rectangle.y,
+      cx,
+      cy,
+      rectangle.angle,
+    );
+
+    const xInput = getStatsProperty("X")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    const yInput = getStatsProperty("Y")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(xInput.value).toBe(topLeftX.toString());
+    expect(yInput.value).toBe(topLeftY.toString());
+
+    testInputProperty(rectangle, "angle", "A", 0, 45);
+
+    let [newTopLeftX, newTopLeftY] = rotate(
+      rectangle.x,
+      rectangle.y,
+      cx,
+      cy,
+      rectangle.angle,
+    );
+
+    expect(newTopLeftX.toString()).not.toEqual(xInput.value);
+    expect(newTopLeftY.toString()).not.toEqual(yInput.value);
+
+    testInputProperty(rectangle, "angle", "A", 45, 66);
+
+    [newTopLeftX, newTopLeftY] = rotate(
+      rectangle.x,
+      rectangle.y,
+      cx,
+      cy,
+      rectangle.angle,
+    );
+    expect(newTopLeftX.toString()).not.toEqual(xInput.value);
+    expect(newTopLeftY.toString()).not.toEqual(yInput.value);
+  });
+
+  it("should fix top left corner when width or height is changed", () => {
+    const rectangle = h.elements[0];
+
+    testInputProperty(rectangle, "angle", "A", 0, 45);
+    let [cx, cy] = [
+      rectangle.x + rectangle.width / 2,
+      rectangle.y + rectangle.height / 2,
+    ];
+    const [topLeftX, topLeftY] = rotate(
+      rectangle.x,
+      rectangle.y,
+      cx,
+      cy,
+      rectangle.angle,
+    );
+    testInputProperty(rectangle, "width", "W", rectangle.width, 400);
+    [cx, cy] = [
+      rectangle.x + rectangle.width / 2,
+      rectangle.y + rectangle.height / 2,
+    ];
+    let [currentTopLeftX, currentTopLeftY] = rotate(
+      rectangle.x,
+      rectangle.y,
+      cx,
+      cy,
+      rectangle.angle,
+    );
+    expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
+    expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
+
+    testInputProperty(rectangle, "height", "H", rectangle.height, 400);
+    [cx, cy] = [
+      rectangle.x + rectangle.width / 2,
+      rectangle.y + rectangle.height / 2,
+    ];
+    [currentTopLeftX, currentTopLeftY] = rotate(
+      rectangle.x,
+      rectangle.y,
+      cx,
+      cy,
+      rectangle.angle,
+    );
+
+    expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
+    expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
+  });
+});
+
+describe("stats for a non-generic element", () => {
+  beforeEach(async () => {
+    localStorage.clear();
+    renderStaticScene.mockClear();
+    reseed(7);
+    setDateTimeForTests("201933152653");
+
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+    h.elements = [];
+
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+      button: 2,
+      clientX: 1,
+      clientY: 1,
+    });
+    const contextMenu = UI.queryContextMenu();
+    fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+    stats = UI.queryStats();
+  });
+
+  beforeAll(() => {
+    mockBoundingClientRect();
+  });
+
+  afterAll(() => {
+    restoreOriginalGetBoundingClientRect();
+  });
+
+  it("text element", async () => {
+    UI.clickTool("text");
+    mouse.clickAt(20, 30);
+    const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
+    const editor = await getTextEditor(textEditorSelector, true);
+    await new Promise((r) => setTimeout(r, 0));
+    updateTextEditor(editor, "Hello!");
+    editor.blur();
+
+    const text = h.elements[0] as ExcalidrawTextElement;
+    mouse.clickOn(text);
+
+    elementStats = stats?.querySelector("#elementStats");
+
+    // can change font size
+    const input = getStatsProperty("F")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(input).not.toBeNull();
+    expect(input.value).toBe(text.fontSize.toString());
+    input?.focus();
+    input.value = "36";
+    input?.blur();
+    expect(text.fontSize).toBe(36);
+
+    // cannot change width or height
+    const width = getStatsProperty("W")?.querySelector(".drag-input");
+    expect(width).toBeUndefined();
+    const height = getStatsProperty("H")?.querySelector(".drag-input");
+    expect(height).toBeUndefined();
+
+    // min font size is 4
+    input.focus();
+    input.value = "0";
+    input.blur();
+    expect(text.fontSize).not.toBe(0);
+    expect(text.fontSize).toBe(4);
+  });
+
+  it("frame element", () => {
+    const frame = API.createElement({
+      id: "id0",
+      type: "frame",
+      x: 150,
+      width: 150,
+    });
+    h.elements = [frame];
+    h.setState({
+      selectedElementIds: {
+        [frame.id]: true,
+      },
+    });
+
+    elementStats = stats?.querySelector("#elementStats");
+
+    expect(elementStats).not.toBeNull();
+
+    // cannot change angle
+    const angle = getStatsProperty("A")?.querySelector(".drag-input");
+    expect(angle).toBeUndefined();
+
+    // can change width or height
+    testInputProperty(frame, "width", "W", frame.width, 250);
+    testInputProperty(frame, "height", "H", frame.height, 500);
+  });
+
+  it("image element", () => {
+    const image = API.createElement({ type: "image", width: 200, height: 100 });
+    h.elements = [image];
+    mouse.clickOn(image);
+    h.setState({
+      selectedElementIds: {
+        [image.id]: true,
+      },
+    });
+    elementStats = stats?.querySelector("#elementStats");
+    expect(elementStats).not.toBeNull();
+    const widthToHeight = image.width / image.height;
+
+    // when width or height is changed, the aspect ratio is preserved
+    testInputProperty(image, "width", "W", image.width, 400);
+    expect(image.width).toBe(400);
+    expect(image.width / image.height).toBe(widthToHeight);
+
+    testInputProperty(image, "height", "H", image.height, 80);
+    expect(image.height).toBe(80);
+    expect(image.width / image.height).toBe(widthToHeight);
+  });
+});
+
+// multiple elements
+describe("stats for multiple elements", () => {
+  beforeEach(async () => {
+    mouse.reset();
+    localStorage.clear();
+    renderStaticScene.mockClear();
+    reseed(7);
+    setDateTimeForTests("201933152653");
+
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+    h.elements = [];
+
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+      button: 2,
+      clientX: 1,
+      clientY: 1,
+    });
+    const contextMenu = UI.queryContextMenu();
+    fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+    stats = UI.queryStats();
+  });
+
+  beforeAll(() => {
+    mockBoundingClientRect();
+  });
+
+  afterAll(() => {
+    restoreOriginalGetBoundingClientRect();
+  });
+
+  it("should display MIXED for elements with different values", () => {
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(200, 100);
+
+    UI.clickTool("ellipse");
+    mouse.down(50, 50);
+    mouse.up(100, 100);
+
+    UI.clickTool("diamond");
+    mouse.down(-100, -100);
+    mouse.up(125, 145);
+
+    h.setState({
+      selectedElementIds: h.elements.reduce((acc, el) => {
+        acc[el.id] = true;
+        return acc;
+      }, {} as Record<string, true>),
+    });
+
+    elementStats = stats?.querySelector("#elementStats");
+
+    const width = getStatsProperty("W")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(width?.value).toBe("Mixed");
+    const height = getStatsProperty("H")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(height?.value).toBe("Mixed");
+    const angle = getStatsProperty("A")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(angle.value).toBe("0");
+
+    width.focus();
+    width.value = "250";
+    width.blur();
+    h.elements.forEach((el) => {
+      expect(el.width).toBe(250);
+    });
+
+    height.focus();
+    height.value = "450";
+    height.blur();
+    h.elements.forEach((el) => {
+      expect(el.height).toBe(450);
+    });
+  });
+
+  it("should display a property when one of the elements is editable for that property", async () => {
+    // text, rectangle, frame
+    UI.clickTool("text");
+    mouse.clickAt(20, 30);
+    const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
+    const editor = await getTextEditor(textEditorSelector, true);
+    await new Promise((r) => setTimeout(r, 0));
+    updateTextEditor(editor, "Hello!");
+    editor.blur();
+
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(200, 100);
+
+    const frame = API.createElement({
+      id: "id0",
+      type: "frame",
+      x: 150,
+      width: 150,
+    });
+
+    h.elements = [...h.elements, frame];
+
+    const text = h.elements.find((el) => el.type === "text");
+    const rectangle = h.elements.find((el) => el.type === "rectangle");
+
+    h.setState({
+      selectedElementIds: h.elements.reduce((acc, el) => {
+        acc[el.id] = true;
+        return acc;
+      }, {} as Record<string, true>),
+    });
+
+    elementStats = stats?.querySelector("#elementStats");
+
+    const width = getStatsProperty("W")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(width).not.toBeNull();
+    expect(width.value).toBe("Mixed");
+
+    const height = getStatsProperty("H")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(height).not.toBeNull();
+    expect(height.value).toBe("Mixed");
+
+    const angle = getStatsProperty("A")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(angle).not.toBeNull();
+    expect(angle.value).toBe("0");
+
+    const fontSize = getStatsProperty("F")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(fontSize).not.toBeNull();
+
+    // changing width does not affect text
+    width.focus();
+    width.value = "200";
+    width.blur();
+
+    expect(rectangle?.width).toBe(200);
+    expect(frame.width).toBe(200);
+    expect(text?.width).not.toBe(200);
+
+    angle.focus();
+    angle.value = "40";
+    angle.blur();
+
+    const angleInRadian = degreeToRadian(40);
+    expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
+    expect(text?.angle).toBeCloseTo(angleInRadian, 4);
+    expect(frame.angle).toBe(0);
+  });
+
+  it("should treat groups as single units", () => {
+    const createAndSelectGroup = () => {
+      UI.clickTool("rectangle");
+      mouse.down();
+      mouse.up(100, 100);
+
+      UI.clickTool("rectangle");
+      mouse.down(0, 0);
+      mouse.up(100, 100);
+
+      mouse.reset();
+      Keyboard.withModifierKeys({ shift: true }, () => {
+        mouse.click();
+      });
+
+      h.app.actionManager.executeAction(actionGroup);
+    };
+
+    createAndSelectGroup();
+
+    const elementsInGroup = h.elements.filter((el) => isInGroup(el));
+    let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+
+    elementStats = stats?.querySelector("#elementStats");
+
+    const x = getStatsProperty("X")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(x).not.toBeNull();
+    expect(Number(x.value)).toBe(x1);
+
+    x.focus();
+    x.value = "300";
+    x.blur();
+
+    expect(h.elements[0].x).toBe(300);
+    expect(h.elements[1].x).toBe(400);
+    expect(x.value).toBe("300");
+
+    const y = getStatsProperty("Y")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(y).not.toBeNull();
+    expect(Number(y.value)).toBe(y1);
+
+    y.focus();
+    y.value = "200";
+    y.blur();
+
+    expect(h.elements[0].y).toBe(200);
+    expect(h.elements[1].y).toBe(300);
+    expect(y.value).toBe("200");
+
+    const width = getStatsProperty("W")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(width).not.toBeNull();
+    expect(Number(width.value)).toBe(200);
+
+    const height = getStatsProperty("H")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(height).not.toBeNull();
+    expect(Number(height.value)).toBe(200);
+
+    width.focus();
+    width.value = "400";
+    width.blur();
+
+    [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+    let newGroupWidth = x2 - x1;
+
+    expect(newGroupWidth).toBeCloseTo(400, 4);
+
+    width.focus();
+    width.value = "300";
+    width.blur();
+
+    [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+    newGroupWidth = x2 - x1;
+    expect(newGroupWidth).toBeCloseTo(300, 4);
+
+    height.focus();
+    height.value = "500";
+    height.blur();
+
+    [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+    const newGroupHeight = y2 - y1;
+    expect(newGroupHeight).toBeCloseTo(500, 4);
+  });
+});

+ 238 - 0
packages/excalidraw/components/Stats/utils.ts

@@ -0,0 +1,238 @@
+import { updateBoundElements } from "../../element/binding";
+import { mutateElement } from "../../element/mutateElement";
+import {
+  measureFontSizeFromWidth,
+  rescalePointsInElement,
+} from "../../element/resizeElements";
+import {
+  getApproxMinLineHeight,
+  getApproxMinLineWidth,
+  getBoundTextElement,
+  getBoundTextMaxWidth,
+  handleBindTextResize,
+} from "../../element/textElement";
+import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
+import type {
+  ElementsMap,
+  ExcalidrawElement,
+  NonDeletedExcalidrawElement,
+} from "../../element/types";
+import { rotate } from "../../math";
+import { getFontString } from "../../utils";
+
+export const SMALLEST_DELTA = 0.01;
+
+export const isPropertyEditable = (
+  element: ExcalidrawElement,
+  property: keyof ExcalidrawElement,
+) => {
+  if (property === "height" && isTextElement(element)) {
+    return false;
+  }
+  if (property === "width" && isTextElement(element)) {
+    return false;
+  }
+  if (property === "angle" && isFrameLikeElement(element)) {
+    return false;
+  }
+  return true;
+};
+
+export const getStepSizedValue = (value: number, stepSize: number) => {
+  const v = value + stepSize / 2;
+  return v - (v % stepSize);
+};
+
+export type AtomicUnit = Record<string, true>;
+export const getElementsInAtomicUnit = (
+  atomicUnit: AtomicUnit,
+  elementsMap: ElementsMap,
+  originalElementsMap?: ElementsMap,
+) => {
+  return Object.keys(atomicUnit)
+    .map((id) => ({
+      original: (originalElementsMap ?? elementsMap).get(id),
+      latest: elementsMap.get(id),
+    }))
+    .filter((el) => el.original !== undefined && el.latest !== undefined) as {
+    original: NonDeletedExcalidrawElement;
+    latest: NonDeletedExcalidrawElement;
+  }[];
+};
+
+export const newOrigin = (
+  x1: number,
+  y1: number,
+  w1: number,
+  h1: number,
+  w2: number,
+  h2: number,
+  angle: number,
+) => {
+  /**
+   * The formula below is the result of solving
+   *   rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
+   * where rotate is the function defined in math.ts
+   *
+   * This is so that the new origin (x2, y2),
+   * when rotated against the new center (cx2, cy2),
+   * coincides with (x1, y1) rotated against (cx1, cy1)
+   *
+   * The reason for doing this computation is so the element's top left corner
+   * on the canvas remains fixed after any changes in its dimension.
+   */
+
+  return {
+    x:
+      x1 +
+      (w1 - w2) / 2 +
+      ((w2 - w1) / 2) * Math.cos(angle) +
+      ((h1 - h2) / 2) * Math.sin(angle),
+    y:
+      y1 +
+      (h1 - h2) / 2 +
+      ((w2 - w1) / 2) * Math.sin(angle) +
+      ((h2 - h1) / 2) * Math.cos(angle),
+  };
+};
+
+export const resizeElement = (
+  nextWidth: number,
+  nextHeight: number,
+  keepAspectRatio: boolean,
+  latestElement: ExcalidrawElement,
+  origElement: ExcalidrawElement,
+  elementsMap: ElementsMap,
+  originalElementsMap: Map<string, ExcalidrawElement>,
+  shouldInformMutation = true,
+) => {
+  let boundTextFont: { fontSize?: number } = {};
+  const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+
+  if (boundTextElement) {
+    const minWidth = getApproxMinLineWidth(
+      getFontString(boundTextElement),
+      boundTextElement.lineHeight,
+    );
+    const minHeight = getApproxMinLineHeight(
+      boundTextElement.fontSize,
+      boundTextElement.lineHeight,
+    );
+    nextWidth = Math.max(nextWidth, minWidth);
+    nextHeight = Math.max(nextHeight, minHeight);
+  }
+
+  mutateElement(
+    latestElement,
+    {
+      ...newOrigin(
+        latestElement.x,
+        latestElement.y,
+        latestElement.width,
+        latestElement.height,
+        nextWidth,
+        nextHeight,
+        latestElement.angle,
+      ),
+      width: nextWidth,
+      height: nextHeight,
+      ...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
+    },
+    shouldInformMutation,
+  );
+
+  if (boundTextElement) {
+    boundTextFont = {
+      fontSize: boundTextElement.fontSize,
+    };
+    if (keepAspectRatio) {
+      const updatedElement = {
+        ...latestElement,
+        width: nextWidth,
+        height: nextHeight,
+      };
+
+      const nextFont = measureFontSizeFromWidth(
+        boundTextElement,
+        elementsMap,
+        getBoundTextMaxWidth(updatedElement, boundTextElement),
+      );
+      boundTextFont = {
+        fontSize: nextFont?.size ?? boundTextElement.fontSize,
+      };
+    }
+  }
+
+  updateBoundElements(latestElement, elementsMap, {
+    newSize: {
+      width: nextWidth,
+      height: nextHeight,
+    },
+  });
+
+  if (boundTextElement && boundTextFont) {
+    mutateElement(boundTextElement, {
+      fontSize: boundTextFont.fontSize,
+    });
+  }
+  handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
+};
+
+export const moveElement = (
+  newTopLeftX: number,
+  newTopLeftY: number,
+  latestElement: ExcalidrawElement,
+  originalElement: ExcalidrawElement,
+  elementsMap: ElementsMap,
+  originalElementsMap: ElementsMap,
+  shouldInformMutation = true,
+) => {
+  const [cx, cy] = [
+    originalElement.x + originalElement.width / 2,
+    originalElement.y + originalElement.height / 2,
+  ];
+  const [topLeftX, topLeftY] = rotate(
+    originalElement.x,
+    originalElement.y,
+    cx,
+    cy,
+    originalElement.angle,
+  );
+
+  const changeInX = newTopLeftX - topLeftX;
+  const changeInY = newTopLeftY - topLeftY;
+
+  const [x, y] = rotate(
+    newTopLeftX,
+    newTopLeftY,
+    cx + changeInX,
+    cy + changeInY,
+    -originalElement.angle,
+  );
+
+  mutateElement(
+    latestElement,
+    {
+      x,
+      y,
+    },
+    shouldInformMutation,
+  );
+
+  const boundTextElement = getBoundTextElement(
+    originalElement,
+    originalElementsMap,
+  );
+  if (boundTextElement) {
+    const latestBoundTextElement = elementsMap.get(boundTextElement.id);
+    latestBoundTextElement &&
+      mutateElement(
+        latestBoundTextElement,
+        {
+          x: boundTextElement.x + changeInX,
+          y: boundTextElement.y + changeInY,
+        },
+        shouldInformMutation,
+      );
+  }
+};

+ 28 - 0
packages/excalidraw/components/icons.tsx

@@ -1573,6 +1573,18 @@ export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
   ),
 );
 
+export const angleIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M21 19h-18l9 -15" />
+    <path d="M20.615 15.171h.015" />
+    <path d="M19.515 11.771h.015" />
+    <path d="M17.715 8.671h.015" />
+    <path d="M15.415 5.971h.015" />
+  </g>,
+  tablerIconProps,
+);
+
 export const publishIcon = createIcon(
   <path
     d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
@@ -2061,3 +2073,19 @@ export const lineEditorIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const collapseDownIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M6 9l6 6l6 -6" />
+  </g>,
+  tablerIconProps,
+);
+
+export const collapseUpIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M6 15l6 -6l6 6" />
+  </g>,
+  tablerIconProps,
+);

+ 4 - 0
packages/excalidraw/constants.ts

@@ -405,3 +405,7 @@ export const EDITOR_LS_KEYS = {
  * where filename is optional and we can't retrieve name from app state
  */
 export const DEFAULT_FILENAME = "Untitled";
+
+export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
+
+export const MIN_WIDTH_OR_HEIGHT = 1;

+ 6 - 0
packages/excalidraw/css/styles.scss

@@ -22,6 +22,12 @@
   --sat: env(safe-area-inset-top);
 }
 
+body.excalidraw-cursor-resize,
+body.excalidraw-cursor-resize a:hover,
+body.excalidraw-cursor-resize * {
+  cursor: ew-resize;
+}
+
 .excalidraw {
   --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
     Roboto, Helvetica, Arial, sans-serif;

+ 2 - 2
packages/excalidraw/element/resizeElements.ts

@@ -178,7 +178,7 @@ const rotateSingleElement = (
   }
 };
 
-const rescalePointsInElement = (
+export const rescalePointsInElement = (
   element: NonDeletedExcalidrawElement,
   width: number,
   height: number,
@@ -195,7 +195,7 @@ const rescalePointsInElement = (
       }
     : {};
 
-const measureFontSizeFromWidth = (
+export const measureFontSizeFromWidth = (
   element: NonDeleted<ExcalidrawTextElement>,
   elementsMap: ElementsMap,
   nextWidth: number,

+ 3 - 1
packages/excalidraw/groups.ts

@@ -373,7 +373,9 @@ export const getNonDeletedGroupIds = (elements: ElementsMap) => {
   return nonDeletedGroupIds;
 };
 
-export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
+export const elementsAreInSameGroup = (
+  elements: readonly ExcalidrawElement[],
+) => {
   const allGroups = elements.flatMap((element) => element.groupIds);
   const groupCount = new Map<string, number>();
   let maxGroup = 0;

+ 20 - 1
packages/excalidraw/locales/en.json

@@ -270,6 +270,22 @@
     "mermaidToExcalidraw": "Mermaid to Excalidraw",
     "magicSettings": "AI settings"
   },
+  "element": {
+    "rectangle": "Rectangle",
+    "diamond": "Diamond",
+    "ellipse": "Ellipse",
+    "arrow": "Arrow",
+    "line": "Line",
+    "freedraw": "Freedraw",
+    "text": "Text",
+    "image": "Image",
+    "group": "Group",
+    "frame": "Frame",
+    "magicframe": "Wireframe to code",
+    "embeddable": "Web Embed",
+    "selection": "Selection",
+    "iframe": "IFrame"
+  },
   "headings": {
     "canvasActions": "Canvas actions",
     "selectedShapeActions": "Selected shape actions",
@@ -443,7 +459,10 @@
     "scene": "Scene",
     "selected": "Selected",
     "storage": "Storage",
-    "title": "Stats for nerds",
+    "fullTitle": "Stats & Element properties",
+    "title": "Stats",
+    "generalStats": "General stats",
+    "elementProperties": "Element properties",
     "total": "Total",
     "version": "Version",
     "versionCopy": "Click to copy",

+ 8 - 0
packages/excalidraw/math.ts

@@ -475,6 +475,14 @@ export const isRightAngle = (angle: number) => {
   return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
 };
 
+export const radianToDegree = (r: number) => {
+  return (r * 180) / Math.PI;
+};
+
+export const degreeToRadian = (d: number) => {
+  return (d / 180) * Math.PI;
+};
+
 // Given two ranges, return if the two ranges overlap with each other
 // e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
 export const rangesOverlap = (

+ 3 - 0
packages/excalidraw/scene/Scene.ts

@@ -105,6 +105,9 @@ class Scene {
     }
   }
 
+  /**
+   * @deprecated pass down `app.scene` and use it directly
+   */
   static getScene(elementKey: ElementKey): Scene | null {
     if (isIdKey(elementKey)) {
       return this.sceneMapById.get(elementKey) || null;

+ 74 - 18
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -874,10 +874,13 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1066,10 +1069,13 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": {
@@ -1274,10 +1280,13 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1597,10 +1606,13 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1920,10 +1932,13 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": {
@@ -2126,10 +2141,13 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2360,10 +2378,13 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2658,10 +2679,13 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3014,10 +3038,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": {
@@ -3481,10 +3508,13 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3796,10 +3826,13 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4114,10 +4147,13 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5292,10 +5328,13 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6413,10 +6452,13 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7243,7 +7285,12 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
           </g>
         </svg>,
         "keyTest": [Function],
-        "label": "stats.title",
+        "keywords": [
+          "edit",
+          "attributes",
+          "customize",
+        ],
+        "label": "stats.fullTitle",
         "name": "stats",
         "paletteName": "Toggle stats",
         "perform": [Function],
@@ -7331,10 +7378,13 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8234,10 +8284,13 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9123,10 +9176,13 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,

+ 228 - 57
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -84,10 +84,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -662,10 +665,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1155,10 +1161,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1498,10 +1507,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1841,10 +1853,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2099,10 +2114,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2526,10 +2544,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2817,10 +2838,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3093,10 +3117,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3379,10 +3406,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3657,10 +3687,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3884,10 +3917,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4135,10 +4171,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4400,10 +4439,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4623,10 +4665,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4846,10 +4891,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5067,10 +5115,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5288,10 +5339,13 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5538,10 +5592,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5861,10 +5918,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6281,10 +6341,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6657,10 +6720,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6957,10 +7023,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7245,10 +7314,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7466,10 +7538,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7813,10 +7888,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8166,10 +8244,13 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8556,10 +8637,13 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8837,10 +8921,13 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9094,10 +9181,13 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9350,10 +9440,13 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9574,10 +9667,13 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9866,10 +9962,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10194,10 +10293,13 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10423,10 +10525,13 @@ exports[`history > multiplayer undo/redo > should update history entries after r
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10667,10 +10772,13 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": false,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10900,10 +11008,13 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11131,10 +11242,13 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11527,10 +11641,13 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": false,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11765,10 +11882,13 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": false,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11998,10 +12118,13 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": false,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12231,10 +12354,13 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12470,10 +12596,13 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12795,10 +12924,13 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12961,10 +13093,13 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13239,10 +13374,13 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13499,10 +13637,13 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13765,10 +13906,13 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13919,10 +14063,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14601,10 +14748,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15207,10 +15357,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -15811,10 +15964,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -16511,10 +16667,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -17245,10 +17404,13 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -17713,10 +17875,13 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -18222,10 +18387,13 @@ exports[`history > singleplayer undo/redo > should support element creation, del
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -18672,10 +18840,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,

+ 208 - 52
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -92,10 +92,13 @@ exports[`given element A and group of elements B and given both are selected whe
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -498,10 +501,13 @@ exports[`given element A and group of elements B and given both are selected whe
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -884,10 +890,13 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1418,10 +1427,13 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1616,10 +1628,13 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -1977,10 +1992,13 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2204,10 +2222,13 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2375,10 +2396,13 @@ exports[`regression tests > can drag element that covers another element, while
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2682,10 +2706,13 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -2919,10 +2946,13 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3151,10 +3181,13 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3370,10 +3403,13 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3616,10 +3652,13 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -3915,10 +3954,13 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4377,10 +4419,13 @@ exports[`regression tests > deselects group of selected elements on pointer down
   },
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4649,10 +4694,13 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -4950,10 +4998,13 @@ exports[`regression tests > deselects selected element on pointer down when poin
   },
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5119,10 +5170,13 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5307,10 +5361,13 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5682,10 +5739,13 @@ exports[`regression tests > drags selected elements from point inside common bou
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -5955,10 +6015,13 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -6755,10 +6818,13 @@ exports[`regression tests > given a group of selected elements with an element t
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7074,10 +7140,13 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7338,10 +7407,13 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7561,10 +7633,13 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7785,10 +7860,13 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -7954,10 +8032,13 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8123,10 +8204,13 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8315,10 +8399,13 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8524,10 +8611,13 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8708,10 +8798,13 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -8916,10 +9009,13 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9102,10 +9198,13 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9294,10 +9393,13 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9480,10 +9582,13 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9647,10 +9752,13 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -9832,10 +9940,13 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10009,10 +10120,13 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10506,10 +10620,13 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10768,10 +10885,13 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "selectionElement": null,
   "shouldCacheIgnoreZoom": true,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -10885,10 +11005,13 @@ exports[`regression tests > shift click on selected element should deselect it o
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11077,10 +11200,13 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11379,10 +11505,13 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -11784,10 +11913,13 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12377,10 +12509,13 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -12496,10 +12631,13 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13130,10 +13268,13 @@ exports[`regression tests > switches from group of selected elements to another
   },
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13486,10 +13627,13 @@ exports[`regression tests > switches selected element on pointer down > [end of
   },
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13706,10 +13850,13 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "selectionElement": null,
   "shouldCacheIgnoreZoom": true,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -13823,10 +13970,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14188,10 +14338,13 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
@@ -14306,10 +14459,13 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": true,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,

+ 6 - 0
packages/excalidraw/tests/helpers/ui.ts

@@ -559,4 +559,10 @@ export class UI {
       ".context-menu",
     ) as HTMLElement | null;
   };
+
+  static queryStats = () => {
+    return GlobalTestState.renderResult.container.querySelector(
+      ".Stats",
+    ) as HTMLElement | null;
+  };
 }

+ 6 - 1
packages/excalidraw/types.ts

@@ -336,7 +336,11 @@ export interface AppState {
 
   fileHandle: FileSystemHandle | null;
   collaborators: Map<SocketId, Collaborator>;
-  showStats: boolean;
+  stats: {
+    open: boolean;
+    /** bitmap. Use `STATS_PANELS` bit values */
+    panels: number;
+  };
   currentChartType: ChartType;
   pasteDialog:
     | {
@@ -593,6 +597,7 @@ export type AppClassProperties = {
   files: BinaryFiles;
   device: App["device"];
   scene: App["scene"];
+  syncActionResult: App["syncActionResult"];
   pasteFromClipboard: App["pasteFromClipboard"];
   id: App["id"];
   onInsertElements: App["onInsertElements"];

+ 4 - 1
packages/utils/__snapshots__/export.test.ts.snap

@@ -84,10 +84,13 @@ exports[`exportToSvg > with default arguments 1`] = `
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHyperlinkPopup": false,
-  "showStats": false,
   "showWelcomeScreen": false,
   "snapLines": [],
   "startBoundElement": null,
+  "stats": {
+    "open": false,
+    "panels": 3,
+  },
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,