瀏覽代碼

Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

Daniel J. Geiger 1 年之前
父節點
當前提交
629cd307fd
共有 100 個文件被更改,包括 5388 次插入908 次删除
  1. 1 1
      .env.development
  2. 1 1
      .env.production
  3. 6 3
      .github/workflows/test.yml
  4. 1 1
      dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx
  5. 1 1
      dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx
  6. 1 1
      dev-docs/src/css/custom.scss
  7. 2 2
      examples/excalidraw/components/App.tsx
  8. 1 1
      examples/excalidraw/initialData.tsx
  9. 3 0
      examples/excalidraw/with-nextjs/.gitignore
  10. 2 1
      examples/excalidraw/with-nextjs/package.json
  11. 4 1
      examples/excalidraw/with-nextjs/src/app/page.tsx
  12. 1 1
      examples/excalidraw/with-nextjs/src/common.scss
  13. 2 0
      examples/excalidraw/with-script-in-browser/.gitignore
  14. 1 0
      examples/excalidraw/with-script-in-browser/index.html
  15. 4 2
      examples/excalidraw/with-script-in-browser/package.json
  16. 53 23
      excalidraw-app/App.tsx
  17. 2 3
      excalidraw-app/app-language/LanguageList.tsx
  18. 25 0
      excalidraw-app/app-language/language-detector.ts
  19. 15 0
      excalidraw-app/app-language/language-state.ts
  20. 2 2
      excalidraw-app/components/AppMainMenu.tsx
  21. 66 7
      excalidraw-app/index.html
  22. 1 0
      excalidraw-app/index.scss
  23. 4 3
      excalidraw-app/package.json
  24. 2 2
      excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
  25. 11 1
      excalidraw-app/vite.config.mts
  26. 1 0
      package.json
  27. 4 0
      packages/excalidraw/CHANGELOG.md
  28. 3 1
      packages/excalidraw/actions/actionBoundText.tsx
  29. 1 1
      packages/excalidraw/actions/actionCanvas.tsx
  30. 6 1
      packages/excalidraw/actions/actionFinalize.tsx
  31. 1 1
      packages/excalidraw/actions/actionFlip.ts
  32. 10 2
      packages/excalidraw/actions/actionHistory.tsx
  33. 4 2
      packages/excalidraw/actions/actionProperties.test.tsx
  34. 369 84
      packages/excalidraw/actions/actionProperties.tsx
  35. 3 5
      packages/excalidraw/actions/actionStyles.ts
  36. 48 0
      packages/excalidraw/actions/actionTextAutoResize.ts
  37. 4 3
      packages/excalidraw/actions/actionToggleStats.tsx
  38. 3 1
      packages/excalidraw/actions/types.ts
  39. 10 7
      packages/excalidraw/analytics.ts
  40. 8 2
      packages/excalidraw/appState.ts
  41. 17 8
      packages/excalidraw/change.ts
  42. 2 3
      packages/excalidraw/components/Actions.tsx
  43. 323 231
      packages/excalidraw/components/App.tsx
  44. 12 0
      packages/excalidraw/components/ButtonIcon.scss
  45. 36 0
      packages/excalidraw/components/ButtonIcon.tsx
  46. 8 10
      packages/excalidraw/components/ButtonIconSelect.tsx
  47. 10 0
      packages/excalidraw/components/ButtonSeparator.tsx
  48. 6 1
      packages/excalidraw/components/CheckboxItem.tsx
  49. 1 1
      packages/excalidraw/components/ColorPicker/ColorPicker.scss
  50. 70 123
      packages/excalidraw/components/ColorPicker/ColorPicker.tsx
  51. 1 1
      packages/excalidraw/components/ColorPicker/Picker.tsx
  52. 4 2
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  53. 1 0
      packages/excalidraw/components/ContextMenu.tsx
  54. 1 0
      packages/excalidraw/components/Dialog.tsx
  55. 5 1
      packages/excalidraw/components/FollowMode/FollowMode.tsx
  56. 15 0
      packages/excalidraw/components/FontPicker/FontPicker.scss
  57. 110 0
      packages/excalidraw/components/FontPicker/FontPicker.tsx
  58. 268 0
      packages/excalidraw/components/FontPicker/FontPickerList.tsx
  59. 38 0
      packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx
  60. 66 0
      packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts
  61. 2 2
      packages/excalidraw/components/HelpDialog.scss
  62. 5 1
      packages/excalidraw/components/HelpDialog.tsx
  63. 2 0
      packages/excalidraw/components/IconPicker.tsx
  64. 93 0
      packages/excalidraw/components/LayerUI.scss
  65. 18 14
      packages/excalidraw/components/LayerUI.tsx
  66. 1 1
      packages/excalidraw/components/LibraryMenu.scss
  67. 2 2
      packages/excalidraw/components/LibraryMenuItems.scss
  68. 1 13
      packages/excalidraw/components/MobileMenu.tsx
  69. 1 0
      packages/excalidraw/components/PasteChartDialog.tsx
  70. 96 0
      packages/excalidraw/components/PropertiesPopover.tsx
  71. 1 1
      packages/excalidraw/components/PublishLibrary.scss
  72. 48 0
      packages/excalidraw/components/QuickSearch.scss
  73. 28 0
      packages/excalidraw/components/QuickSearch.tsx
  74. 21 0
      packages/excalidraw/components/ScrollableList.scss
  75. 24 0
      packages/excalidraw/components/ScrollableList.tsx
  76. 0 54
      packages/excalidraw/components/Stats.scss
  77. 0 108
      packages/excalidraw/components/Stats.tsx
  78. 93 0
      packages/excalidraw/components/Stats/Angle.tsx
  79. 39 0
      packages/excalidraw/components/Stats/Collapsible.tsx
  80. 134 0
      packages/excalidraw/components/Stats/Dimension.tsx
  81. 75 0
      packages/excalidraw/components/Stats/DragInput.scss
  82. 311 0
      packages/excalidraw/components/Stats/DragInput.tsx
  83. 99 0
      packages/excalidraw/components/Stats/FontSize.tsx
  84. 135 0
      packages/excalidraw/components/Stats/MultiAngle.tsx
  85. 382 0
      packages/excalidraw/components/Stats/MultiDimension.tsx
  86. 164 0
      packages/excalidraw/components/Stats/MultiFontSize.tsx
  87. 259 0
      packages/excalidraw/components/Stats/MultiPosition.tsx
  88. 115 0
      packages/excalidraw/components/Stats/Position.tsx
  89. 302 0
      packages/excalidraw/components/Stats/index.tsx
  90. 756 0
      packages/excalidraw/components/Stats/stats.test.tsx
  91. 301 0
      packages/excalidraw/components/Stats/utils.ts
  92. 1 1
      packages/excalidraw/components/TTDDialog/TTDDialog.scss
  93. 4 13
      packages/excalidraw/components/TTDDialog/common.ts
  94. 2 64
      packages/excalidraw/components/UserList.scss
  95. 43 50
      packages/excalidraw/components/UserList.tsx
  96. 10 4
      packages/excalidraw/components/canvases/InteractiveCanvas.tsx
  97. 4 3
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  98. 57 9
      packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
  99. 72 18
      packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx
  100. 6 2
      packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx

+ 1 - 1
.env.development

@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
 # whether to disable live reload / HMR. Usuaully what you want to do when
 # debugging Service Workers.
 VITE_APP_DEV_DISABLE_LIVE_RELOAD=
-VITE_APP_DISABLE_TRACKING=true
+VITE_APP_ENABLE_TRACKING=true
 
 FAST_REFRESH=false
 

+ 1 - 1
.env.production

@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
 
 VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 
-VITE_APP_DISABLE_TRACKING=
+VITE_APP_ENABLE_TRACKING=false

+ 6 - 3
.github/workflows/test.yml

@@ -1,14 +1,17 @@
 name: Tests
 
-on: pull_request
+on:
+  pull_request:
+  push:
+    branches: master
 
 jobs:
   test:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
       - name: Setup Node.js 18.x
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v4
         with:
           node-version: 18.x
       - name: Install and test

+ 1 - 1
dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx

@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
 ```jsx showLineNumbers
 export default function App() {
   const [excalidrawAPI, setExcalidrawAPI] = useState(null);
-  return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
+  return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
 }
 ```
 

+ 1 - 1
dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx

@@ -90,7 +90,7 @@ function App() {
         <img src={canvasUrl} alt="" />
       </div>
       <div style={{ height: "400px" }}>
-        <Excalidraw ref={(api) => setExcalidrawAPI(api)}
+        <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
 />
       </div>
     </>

+ 1 - 1
dev-docs/src/css/custom.scss

@@ -59,7 +59,7 @@ pre a {
   padding: 5px;
   background: #70b1ec;
   color: white;
-  font-weight: bold;
+  font-weight: 700;
   border: none;
 }
 

+ 2 - 2
examples/excalidraw/components/App.tsx

@@ -872,7 +872,7 @@ export default function App({
                 files: excalidrawAPI.getFiles(),
               });
               const ctx = canvas.getContext("2d")!;
-              ctx.font = "30px Virgil";
+              ctx.font = "30px Excalifont";
               ctx.strokeText("My custom text", 50, 60);
               setCanvasUrl(canvas.toDataURL());
             }}
@@ -893,7 +893,7 @@ export default function App({
                 files: excalidrawAPI.getFiles(),
               });
               const ctx = canvas.getContext("2d")!;
-              ctx.font = "30px Virgil";
+              ctx.font = "30px Excalifont";
               ctx.strokeText("My custom text", 50, 60);
               setCanvasUrl(canvas.toDataURL());
             }}

+ 1 - 1
examples/excalidraw/initialData.tsx

@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
 ];
 export default {
   elements,
-  appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
+  appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
   scrollToContent: true,
   libraryItems: [
     [

+ 3 - 0
examples/excalidraw/with-nextjs/.gitignore

@@ -34,3 +34,6 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+
+# copied assets
+public/*.woff2

+ 2 - 1
examples/excalidraw/with-nextjs/package.json

@@ -3,7 +3,8 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
+    "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
+    "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
     "dev": "yarn build:workspace && next dev -p 3005",
     "build": "yarn build:workspace && next build",
     "start": "next start -p 3006",

+ 4 - 1
examples/excalidraw/with-nextjs/src/app/page.tsx

@@ -1,4 +1,5 @@
 import dynamic from "next/dynamic";
+import Script from "next/script";
 import "../common.scss";
 
 // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -15,7 +16,9 @@ export default function Page() {
     <>
       <a href="/excalidraw-in-pages">Switch to Pages router</a>
       <h1 className="page-title">App Router</h1>
-
+      <Script id="load-env-variables" strategy="beforeInteractive">
+        {`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
+      </Script>
       {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
       <ExcalidrawWithClientOnly />
     </>

+ 1 - 1
examples/excalidraw/with-nextjs/src/common.scss

@@ -7,7 +7,7 @@ a {
   color: #1c7ed6;
   font-size: 20px;
   text-decoration: none;
-  font-weight: 550;
+  font-weight: 500;
 }
 
 .page-title {

+ 2 - 0
examples/excalidraw/with-script-in-browser/.gitignore

@@ -0,0 +1,2 @@
+# copied assets
+public/*.woff2

+ 1 - 0
examples/excalidraw/with-script-in-browser/index.html

@@ -11,6 +11,7 @@
     <title>React App</title>
     <script>
       window.name = "codesandbox";
+      window.EXCALIDRAW_ASSET_PATH = window.origin;
     </script>
     <link rel="stylesheet" href="/dist/browser/dev/index.css" />
   </head>

+ 4 - 2
examples/excalidraw/with-script-in-browser/package.json

@@ -12,8 +12,10 @@
     "typescript": "^5"
   },
   "scripts": {
-    "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
-    "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
+    "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
+    "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
+    "start": "yarn build:workspace && vite",
+    "build": "yarn build:workspace && vite build",
     "build:preview": "yarn build && vite preview --port 5002"
   }
 }

+ 53 - 23
excalidraw-app/App.tsx

@@ -1,5 +1,4 @@
 import polyfill from "../packages/excalidraw/polyfill";
-import LanguageDetector from "i18next-browser-languagedetector";
 import { useCallback, useEffect, useRef, useState } from "react";
 import { trackEvent } from "../packages/excalidraw/analytics";
 import { getDefaultAppState } from "../packages/excalidraw/appState";
@@ -23,7 +22,6 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
 import { t } from "../packages/excalidraw/i18n";
 import {
   Excalidraw,
-  defaultLang,
   LiveCollaborationTrigger,
   TTDDialog,
   TTDDialogTrigger,
@@ -94,7 +92,7 @@ import {
 import { AppMainMenu } from "./components/AppMainMenu";
 import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
 import { AppFooter } from "./components/AppFooter";
-import { atom, Provider, useAtom, useAtomValue } from "jotai";
+import { Provider, useAtom, useAtomValue } from "jotai";
 import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
 import { appJotaiStore } from "./app-jotai";
 
@@ -122,11 +120,45 @@ import {
   youtubeIcon,
 } from "../packages/excalidraw/components/icons";
 import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
+import { getPreferredLanguage } from "./app-language/language-detector";
+import { useAppLangCode } from "./app-language/language-state";
 
 polyfill();
 
 window.EXCALIDRAW_THROTTLE_RENDER = true;
 
+declare global {
+  interface BeforeInstallPromptEventChoiceResult {
+    outcome: "accepted" | "dismissed";
+  }
+
+  interface BeforeInstallPromptEvent extends Event {
+    prompt(): Promise<void>;
+    userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
+  }
+
+  interface WindowEventMap {
+    beforeinstallprompt: BeforeInstallPromptEvent;
+  }
+}
+
+let pwaEvent: BeforeInstallPromptEvent | null = null;
+
+// Adding a listener outside of the component as it may (?) need to be
+// subscribed early to catch the event.
+//
+// Also note that it will fire only if certain heuristics are met (user has
+// used the app for some time, etc.)
+window.addEventListener(
+  "beforeinstallprompt",
+  (event: BeforeInstallPromptEvent) => {
+    // prevent Chrome <= 67 from automatically showing the prompt
+    event.preventDefault();
+    // cache for later use
+    pwaEvent = event;
+  },
+);
+
 let isSelfEmbedding = false;
 
 if (window.self !== window.top) {
@@ -141,11 +173,6 @@ if (window.self !== window.top) {
   }
 }
 
-const languageDetector = new LanguageDetector();
-languageDetector.init({
-  languageUtils: {},
-});
-
 const shareableLinkConfirmDialog = {
   title: t("overwriteConfirm.modal.shareableLink.title"),
   description: (
@@ -291,19 +318,15 @@ const initializeScene = async (opts: {
   return { scene: null, isExternalScene: false };
 };
 
-const detectedLangCode = languageDetector.detect() || defaultLang.code;
-export const appLangCodeAtom = atom(
-  Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
-);
-
 const ExcalidrawWrapper = () => {
   const [errorMessage, setErrorMessage] = useState("");
-  const [langCode, setLangCode] = useAtom(appLangCodeAtom);
   const isCollabDisabled = isRunningInIframe();
 
   const [appTheme, setAppTheme] = useAtom(appThemeAtom);
   const { editorTheme } = useHandleAppTheme();
 
+  const [langCode, setLangCode] = useAppLangCode();
+
   // initial state
   // ---------------------------------------------------------------------------
 
@@ -461,11 +484,7 @@ const ExcalidrawWrapper = () => {
         if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
           const localDataState = importFromLocalStorage();
           const username = importUsernameFromLocalStorage();
-          let langCode = languageDetector.detect() || defaultLang.code;
-          if (Array.isArray(langCode)) {
-            langCode = langCode[0];
-          }
-          setLangCode(langCode);
+          setLangCode(getPreferredLanguage());
           excalidrawAPI.updateScene({
             ...localDataState,
             storeAction: StoreAction.UPDATE,
@@ -566,10 +585,6 @@ const ExcalidrawWrapper = () => {
     };
   }, [excalidrawAPI]);
 
-  useEffect(() => {
-    languageDetector.cacheUserLanguage(langCode);
-  }, [langCode]);
-
   const onChange = (
     elements: readonly OrderedExcalidrawElement[],
     appState: AppState,
@@ -1103,6 +1118,21 @@ const ExcalidrawWrapper = () => {
                 );
               },
             },
+            {
+              label: t("labels.installPWA"),
+              category: DEFAULT_CATEGORIES.app,
+              predicate: () => !!pwaEvent,
+              perform: () => {
+                if (pwaEvent) {
+                  pwaEvent.prompt();
+                  pwaEvent.userChoice.then(() => {
+                    // event cannot be reused, but we'll hopefully
+                    // grab new one as the event should be fired again
+                    pwaEvent = null;
+                  });
+                }
+              },
+            },
           ]}
         />
       </Excalidraw>

+ 2 - 3
excalidraw-app/components/LanguageList.tsx → excalidraw-app/app-language/LanguageList.tsx

@@ -1,8 +1,7 @@
 import { useSetAtom } from "jotai";
 import React from "react";
-import { appLangCodeAtom } from "../App";
-import { useI18n } from "../../packages/excalidraw/i18n";
-import { languages } from "../../packages/excalidraw/i18n";
+import { useI18n, languages } from "../../packages/excalidraw/i18n";
+import { appLangCodeAtom } from "./language-state";
 
 export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
   const { t, langCode } = useI18n();

+ 25 - 0
excalidraw-app/app-language/language-detector.ts

@@ -0,0 +1,25 @@
+import LanguageDetector from "i18next-browser-languagedetector";
+import { defaultLang, languages } from "../../packages/excalidraw";
+
+export const languageDetector = new LanguageDetector();
+
+languageDetector.init({
+  languageUtils: {},
+});
+
+export const getPreferredLanguage = () => {
+  const detectedLanguages = languageDetector.detect();
+
+  const detectedLanguage = Array.isArray(detectedLanguages)
+    ? detectedLanguages[0]
+    : detectedLanguages;
+
+  const initialLanguage =
+    (detectedLanguage
+      ? // region code may not be defined if user uses generic preferred language
+        // (e.g. chinese vs instead of chinese-simplified)
+        languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
+      : null) || defaultLang.code;
+
+  return initialLanguage;
+};

+ 15 - 0
excalidraw-app/app-language/language-state.ts

@@ -0,0 +1,15 @@
+import { atom, useAtom } from "jotai";
+import { useEffect } from "react";
+import { getPreferredLanguage, languageDetector } from "./language-detector";
+
+export const appLangCodeAtom = atom(getPreferredLanguage());
+
+export const useAppLangCode = () => {
+  const [langCode, setLangCode] = useAtom(appLangCodeAtom);
+
+  useEffect(() => {
+    languageDetector.cacheUserLanguage(langCode);
+  }, [langCode]);
+
+  return [langCode, setLangCode] as const;
+};

+ 2 - 2
excalidraw-app/components/AppMainMenu.tsx

@@ -6,7 +6,7 @@ import {
 import type { Theme } from "../../packages/excalidraw/element/types";
 import { MainMenu } from "../../packages/excalidraw/index";
 import { isExcalidrawPlusSignedUser } from "../app_constants";
-import { LanguageList } from "./LanguageList";
+import { LanguageList } from "../app-language/LanguageList";
 
 export const AppMainMenu: React.FC<{
   onCollabDialogOpen: () => any;
@@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
       <MainMenu.ItemLink
         icon={ExcalLogo}
         href={`${
-          import.meta.env.VITE_APP_PLUS_APP
+          import.meta.env.VITE_APP_PLUS_LP
         }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
         className=""
       >

+ 66 - 7
excalidraw-app/index.html

@@ -20,7 +20,7 @@
       name="description"
       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
     />
-    <meta name="image" content="https://excalidraw.com/og-image-2.png" />
+    <meta name="image" content="https://excalidraw.com/og-image-3.png" />
 
     <!-- Open Graph / Facebook -->
     <meta property="og:site_name" content="Excalidraw" />
@@ -35,7 +35,7 @@
       property="og:description"
       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
     />
-    <meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
+    <meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
 
     <!-- Twitter -->
     <meta property="twitter:card" content="summary_large_image" />
@@ -51,7 +51,7 @@
     />
     <meta
       property="twitter:image"
-      content="https://excalidraw.com/og-twitter-v2.png"
+      content="https://excalidraw.com/og-image-3.png"
     />
 
     <!-- General tags -->
@@ -114,6 +114,14 @@
       ) {
         window.location.href = "https://app.excalidraw.com";
       }
+
+      // point into our CDN in prod
+      window.EXCALIDRAW_ASSET_PATH =
+        "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
+    </script>
+    <% } else { %>
+    <script>
+      window.EXCALIDRAW_ASSET_PATH = window.origin;
     </script>
     <% } %>
 
@@ -124,22 +132,74 @@
     <!-- Excalidraw version -->
     <meta name="version" content="{version}" />
 
+    <!-- Warmup the connection for Google fonts -->
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+
+    <!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
+    <% if (typeof PROD != 'undefined' && PROD == true) { %>
+    <link
+      rel="preload"
+      href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
+    <link
+      rel="preload"
+      href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Virgil-Regular-hO16qHwV.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
+    <link
+      rel="preload"
+      href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/ComicShanns-Regular-D0c8wzsC.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
+    <% } else { %>
+    <!-- in DEV we need to preload from the local server and without the hash -->
+    <link
+      rel="preload"
+      href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
+    <link
+      rel="preload"
+      href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
+      as="font"
+      type="font/woff2"
+      crossorigin="anonymous"
+    />
     <link
       rel="preload"
-      href="/Virgil.woff2"
+      href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
       as="font"
       type="font/woff2"
       crossorigin="anonymous"
     />
+    <% } %>
+
+    <!-- For Nunito only preload the latin range, which should be enough for now -->
     <link
       rel="preload"
-      href="/Cascadia.woff2"
+      href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
       as="font"
       type="font/woff2"
       crossorigin="anonymous"
     />
 
-    <link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
+    <!-- Register Assistant as the UI font, before the scene inits -->
+    <link
+      rel="stylesheet"
+      href="../packages/excalidraw/fonts/assets/fonts.css"
+      type="text/css"
+    />
+
     <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
     VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
     <script>
@@ -158,7 +218,6 @@
     </script>
     <% } %>
     <script>
-      window.EXCALIDRAW_ASSET_PATH = "/";
       // setting this so that libraries installation reuses this window tab.
       window.name = "_excalidraw";
     </script>

+ 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;

+ 4 - 3
excalidraw-app/package.json

@@ -31,12 +31,13 @@
   "prettier": "@excalidraw/prettier-config",
   "scripts": {
     "build-node": "node ./scripts/build-node.js",
-    "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
-    "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
+    "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
+    "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
     "build:version": "node ../scripts/build-version.js",
     "build": "yarn build:app && yarn build:version",
     "start": "yarn && vite",
-    "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
+    "start:production": "yarn build && yarn serve",
+    "serve": "npx http-server build -a localhost -p 5001 -o",
     "build:preview": "yarn build && vite preview --port 5000"
   }
 }

+ 2 - 2
excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap

@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
   class="welcome-screen-center"
 >
   <div
-    class="welcome-screen-center__logo virgil welcome-screen-decor"
+    class="welcome-screen-center__logo excalifont welcome-screen-decor"
   >
     <div
       class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
     </div>
   </div>
   <div
-    class="welcome-screen-center__heading welcome-screen-decor virgil"
+    class="welcome-screen-center__heading welcome-screen-decor excalifont"
   >
     All your data is saved locally in your browser.
   </div>

+ 11 - 1
excalidraw-app/vite.config.mts

@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
 import { VitePWA } from "vite-plugin-pwa";
 import checker from "vite-plugin-checker";
 import { createHtmlPlugin } from "vite-plugin-html";
+import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
 
 // To load .env.local variables
 const envVars = loadEnv("", `../`);
@@ -22,6 +23,14 @@ export default defineConfig({
     outDir: "build",
     rollupOptions: {
       output: {
+        assetFileNames(chunkInfo) {
+          if (chunkInfo?.name?.endsWith(".woff2")) {
+            // put on root so we are flexible about the CDN path
+            return '[name]-[hash][extname]';
+          }
+
+          return 'assets/[name]-[hash][extname]';
+        },
         // Creating separate chunk for locales except for en and percentages.json so they
         // can be cached at runtime and not merged with
         // app precache. en.json and percentages.json are needed for first load
@@ -35,12 +44,13 @@ export default defineConfig({
             // Taking the substring after "locales/"
             return `locales/${id.substring(index + 8)}`;
           }
-        },
+        }
       },
     },
     sourcemap: true,
   },
   plugins: [
+    woff2BrowserPlugin(),
     react(),
     checker({
       typescript: true,

+ 1 - 0
package.json

@@ -1,6 +1,7 @@
 {
   "private": true,
   "name": "excalidraw-monorepo",
+  "packageManager": "[email protected]",
   "workspaces": [
     "excalidraw-app",
     "packages/excalidraw",

+ 4 - 0
packages/excalidraw/CHANGELOG.md

@@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
+
 - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
 
+- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
+
 - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
 
 - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)

+ 3 - 1
packages/excalidraw/actions/actionBoundText.tsx

@@ -1,8 +1,8 @@
 import {
   BOUND_TEXT_PADDING,
   ROUNDNESS,
-  VERTICAL_ALIGN,
   TEXT_ALIGN,
+  VERTICAL_ALIGN,
 } from "../constants";
 import { isTextElement, newElement } from "../element";
 import { mutateElement } from "../element/mutateElement";
@@ -140,6 +140,7 @@ export const actionBindText = register({
       containerId: container.id,
       verticalAlign: VERTICAL_ALIGN.MIDDLE,
       textAlign: TEXT_ALIGN.CENTER,
+      autoResize: true,
     });
     mutateElement(container, {
       boundElements: (container.boundElements || []).concat({
@@ -294,6 +295,7 @@ export const actionWrapTextInContainer = register({
             verticalAlign: VERTICAL_ALIGN.MIDDLE,
             boundElements: null,
             textAlign: TEXT_ALIGN.CENTER,
+            autoResize: true,
           },
           false,
         );

+ 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"

+ 6 - 1
packages/excalidraw/actions/actionFinalize.tsx

@@ -131,7 +131,12 @@ export const actionFinalize = register({
           -1,
           arrayToMap(elements),
         );
-        maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
+        maybeBindLinearElement(
+          multiPointElement,
+          appState,
+          { x, y },
+          elementsMap,
+        );
       }
     }
 

+ 1 - 1
packages/excalidraw/actions/actionFlip.ts

@@ -124,7 +124,7 @@ const flipElements = (
 
   bindOrUnbindLinearElements(
     selectedElements.filter(isLinearElement),
-    app,
+    elementsMap,
     isBindingEnabled(appState),
     [],
   );

+ 10 - 2
packages/excalidraw/actions/actionHistory.tsx

@@ -65,7 +65,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
   PanelComponent: ({ updateData, data }) => {
     const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
       history.onHistoryChangedEmitter,
-      new HistoryChangedEvent(),
+      new HistoryChangedEvent(
+        history.isUndoStackEmpty,
+        history.isRedoStackEmpty,
+      ),
     );
 
     return (
@@ -76,6 +79,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
         onClick={updateData}
         size={data?.size || "medium"}
         disabled={isUndoStackEmpty}
+        data-testid="button-undo"
       />
     );
   },
@@ -103,7 +107,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
   PanelComponent: ({ updateData, data }) => {
     const { isRedoStackEmpty } = useEmitter(
       history.onHistoryChangedEmitter,
-      new HistoryChangedEvent(),
+      new HistoryChangedEvent(
+        history.isUndoStackEmpty,
+        history.isRedoStackEmpty,
+      ),
     );
 
     return (
@@ -114,6 +121,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
         onClick={updateData}
         size={data?.size || "medium"}
         disabled={isRedoStackEmpty}
+        data-testid="button-redo"
       />
     );
   },

+ 4 - 2
packages/excalidraw/actions/actionProperties.test.tsx

@@ -155,13 +155,15 @@ describe("element locking", () => {
       });
       const text = API.createElement({
         type: "text",
-        fontFamily: FONT_FAMILY.Cascadia,
+        fontFamily: FONT_FAMILY["Comic Shanns"],
       });
       h.elements = [rect, text];
       API.setSelectedElements([rect, text]);
 
       expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
-      expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
+      expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
+        "active",
+      );
     });
   });
 });

+ 369 - 84
packages/excalidraw/actions/actionProperties.tsx

@@ -1,4 +1,6 @@
+import { useEffect, useMemo, useRef, useState } from "react";
 import type { AppClassProperties, AppState, Primitive } from "../types";
+import type { StoreActionType } from "../store";
 import {
   DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
   DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
 import { ButtonIconSelect } from "../components/ButtonIconSelect";
 import { ColorPicker } from "../components/ColorPicker/ColorPicker";
 import { IconPicker } from "../components/IconPicker";
+import { FontPicker } from "../components/FontPicker/FontPicker";
 // TODO barnabasmolnar/editor-redesign
 // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
 // ArrowHead icons
@@ -38,9 +41,6 @@ import {
   FontSizeExtraLargeIcon,
   EdgeSharpIcon,
   EdgeRoundIcon,
-  FreedrawIcon,
-  FontFamilyNormalIcon,
-  FontFamilyCodeIcon,
   TextAlignLeftIcon,
   TextAlignCenterIcon,
   TextAlignRightIcon,
@@ -65,10 +65,7 @@ import {
   redrawTextBoundingBox,
 } from "../element";
 import { mutateElement, newElementWith } from "../element/mutateElement";
-import {
-  getBoundTextElement,
-  getDefaultLineHeight,
-} from "../element/textElement";
+import { getBoundTextElement } from "../element/textElement";
 import {
   isBoundToContainer,
   isLinearElement,
@@ -94,9 +91,10 @@ import {
   isSomeElementSelected,
 } from "../scene";
 import { hasStrokeColor } from "../scene/comparisons";
-import { arrayToMap, getShortcutKey } from "../utils";
+import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
 import { register } from "./register";
 import { StoreAction } from "../store";
+import { Fonts, getLineHeight } from "../fonts";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 
@@ -167,7 +165,7 @@ const offsetElementAfterFontResize = (
   prevElement: ExcalidrawTextElement,
   nextElement: ExcalidrawTextElement,
 ) => {
-  if (isBoundToContainer(nextElement)) {
+  if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
     return nextElement;
   }
   return mutateElement(
@@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({
   },
 });
 
+type ChangeFontFamilyData = Partial<
+  Pick<
+    AppState,
+    "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
+  >
+> & {
+  /** cache of selected & editing elements populated on opened popup */
+  cachedElements?: Map<string, ExcalidrawElement>;
+  /** flag to reset all elements to their cached versions  */
+  resetAll?: true;
+  /** flag to reset all containers to their cached versions */
+  resetContainers?: true;
+};
+
 export const actionChangeFontFamily = register({
   name: "changeFontFamily",
   label: "labels.fontFamily",
   trackEvent: false,
   perform: (elements, appState, value, app) => {
-    return {
-      elements: changeProperty(
+    const { cachedElements, resetAll, resetContainers, ...nextAppState } =
+      value as ChangeFontFamilyData;
+
+    if (resetAll) {
+      const nextElements = changeProperty(
         elements,
         appState,
-        (oldElement) => {
-          if (isTextElement(oldElement)) {
-            const newElement: ExcalidrawTextElement = newElementWith(
-              oldElement,
-              {
-                fontFamily: value,
-                lineHeight: getDefaultLineHeight(value),
-              },
-            );
-            redrawTextBoundingBox(
-              newElement,
-              app.scene.getContainerElement(oldElement),
-              app.scene.getNonDeletedElementsMap(),
-            );
+        (element) => {
+          const cachedElement = cachedElements?.get(element.id);
+          if (cachedElement) {
+            const newElement = newElementWith(element, {
+              ...cachedElement,
+            });
+
             return newElement;
           }
 
-          return oldElement;
+          return element;
         },
         true,
-      ),
+      );
+
+      return {
+        elements: nextElements,
+        appState: {
+          ...appState,
+          ...nextAppState,
+        },
+        storeAction: StoreAction.UPDATE,
+      };
+    }
+
+    const { currentItemFontFamily, currentHoveredFontFamily } = value;
+
+    let nexStoreAction: StoreActionType = StoreAction.NONE;
+    let nextFontFamily: FontFamilyValues | undefined;
+    let skipOnHoverRender = false;
+
+    if (currentItemFontFamily) {
+      nextFontFamily = currentItemFontFamily;
+      nexStoreAction = StoreAction.CAPTURE;
+    } else if (currentHoveredFontFamily) {
+      nextFontFamily = currentHoveredFontFamily;
+      nexStoreAction = StoreAction.NONE;
+
+      const selectedTextElements = getSelectedElements(elements, appState, {
+        includeBoundTextElement: true,
+      }).filter((element) => isTextElement(element));
+
+      // skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
+      if (selectedTextElements.length > 200) {
+        skipOnHoverRender = true;
+      } else {
+        let i = 0;
+        let textLengthAccumulator = 0;
+
+        while (
+          i < selectedTextElements.length &&
+          textLengthAccumulator < 5000
+        ) {
+          const textElement = selectedTextElements[i] as ExcalidrawTextElement;
+          textLengthAccumulator += textElement?.originalText.length || 0;
+          i++;
+        }
+
+        if (textLengthAccumulator > 5000) {
+          skipOnHoverRender = true;
+        }
+      }
+    }
+
+    const result = {
       appState: {
         ...appState,
-        currentItemFontFamily: value,
+        ...nextAppState,
       },
-      storeAction: StoreAction.CAPTURE,
+      storeAction: nexStoreAction,
     };
+
+    if (nextFontFamily && !skipOnHoverRender) {
+      const elementContainerMapping = new Map<
+        ExcalidrawTextElement,
+        ExcalidrawElement | null
+      >();
+      let uniqueGlyphs = new Set<string>();
+      let skipFontFaceCheck = false;
+
+      const fontsCache = Array.from(Fonts.loadedFontsCache.values());
+      const fontFamily = Object.entries(FONT_FAMILY).find(
+        ([_, value]) => value === nextFontFamily,
+      )?.[0];
+
+      // skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
+      if (
+        currentHoveredFontFamily &&
+        fontFamily &&
+        fontsCache.some((sig) => sig.startsWith(fontFamily))
+      ) {
+        skipFontFaceCheck = true;
+      }
+
+      // following causes re-render so make sure we changed the family
+      // otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
+      Object.assign(result, {
+        elements: changeProperty(
+          elements,
+          appState,
+          (oldElement) => {
+            if (
+              isTextElement(oldElement) &&
+              (oldElement.fontFamily !== nextFontFamily ||
+                currentItemFontFamily) // force update on selection
+            ) {
+              const newElement: ExcalidrawTextElement = newElementWith(
+                oldElement,
+                {
+                  fontFamily: nextFontFamily,
+                  lineHeight: getLineHeight(nextFontFamily!),
+                },
+              );
+
+              const cachedContainer =
+                cachedElements?.get(oldElement.containerId || "") || {};
+
+              const container = app.scene.getContainerElement(oldElement);
+
+              if (resetContainers && container && cachedContainer) {
+                // reset the container back to it's cached version
+                mutateElement(container, { ...cachedContainer }, false);
+              }
+
+              if (!skipFontFaceCheck) {
+                uniqueGlyphs = new Set([
+                  ...uniqueGlyphs,
+                  ...Array.from(newElement.originalText),
+                ]);
+              }
+
+              elementContainerMapping.set(newElement, container);
+
+              return newElement;
+            }
+
+            return oldElement;
+          },
+          true,
+        ),
+      });
+
+      // size is irrelevant, but necessary
+      const fontString = `10px ${getFontFamilyString({
+        fontFamily: nextFontFamily,
+      })}`;
+      const glyphs = Array.from(uniqueGlyphs.values()).join();
+
+      if (
+        skipFontFaceCheck ||
+        window.document.fonts.check(fontString, glyphs)
+      ) {
+        // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
+        for (const [element, container] of elementContainerMapping) {
+          // trigger synchronous redraw
+          redrawTextBoundingBox(
+            element,
+            container,
+            app.scene.getNonDeletedElementsMap(),
+            false,
+          );
+        }
+      } else {
+        // otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
+        window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
+          for (const [element, container] of elementContainerMapping) {
+            // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
+            const latestElement = app.scene.getElement(element.id);
+            const latestContainer = container
+              ? app.scene.getElement(container.id)
+              : null;
+
+            if (latestElement) {
+              // trigger async redraw
+              redrawTextBoundingBox(
+                latestElement as ExcalidrawTextElement,
+                latestContainer,
+                app.scene.getNonDeletedElementsMap(),
+                false,
+              );
+            }
+          }
+
+          // trigger update once we've mutated all the elements, which also updates our cache
+          app.fonts.onLoaded(fontFaces);
+        });
+      }
+    }
+
+    return result;
   },
-  PanelComponent: ({ elements, appState, updateData, app }) => {
-    const options: {
-      value: FontFamilyValues;
-      text: string;
-      icon: JSX.Element;
-      testId: string;
-    }[] = [
-      {
-        value: FONT_FAMILY.Virgil,
-        text: t("labels.handDrawn"),
-        icon: FreedrawIcon,
-        testId: "font-family-virgil",
-      },
-      {
-        value: FONT_FAMILY.Helvetica,
-        text: t("labels.normal"),
-        icon: FontFamilyNormalIcon,
-        testId: "font-family-normal",
-      },
-      {
-        value: FONT_FAMILY.Cascadia,
-        text: t("labels.code"),
-        icon: FontFamilyCodeIcon,
-        testId: "font-family-code",
-      },
-    ];
+  PanelComponent: ({ elements, appState, app, updateData }) => {
+    const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
+    const prevSelectedFontFamilyRef = useRef<number | null>(null);
+    // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
+    const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
+    const isUnmounted = useRef(true);
+
+    const selectedFontFamily = useMemo(() => {
+      const getFontFamily = (
+        elementsArray: readonly ExcalidrawElement[],
+        elementsMap: Map<string, ExcalidrawElement>,
+      ) =>
+        getFormValue(
+          elementsArray,
+          appState,
+          (element) => {
+            if (isTextElement(element)) {
+              return element.fontFamily;
+            }
+            const boundTextElement = getBoundTextElement(element, elementsMap);
+            if (boundTextElement) {
+              return boundTextElement.fontFamily;
+            }
+            return null;
+          },
+          (element) =>
+            isTextElement(element) ||
+            getBoundTextElement(element, elementsMap) !== null,
+          (hasSelection) =>
+            hasSelection
+              ? null
+              : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
+        );
+
+      // popup opened, use cached elements
+      if (
+        batchedData.openPopup === "fontFamily" &&
+        appState.openPopup === "fontFamily"
+      ) {
+        return getFontFamily(
+          Array.from(cachedElementsRef.current?.values() ?? []),
+          cachedElementsRef.current,
+        );
+      }
+
+      // popup closed, use all elements
+      if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
+        return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
+      }
+
+      // popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
+      return prevSelectedFontFamilyRef.current;
+    }, [batchedData.openPopup, appState, elements, app.scene]);
+
+    useEffect(() => {
+      prevSelectedFontFamilyRef.current = selectedFontFamily;
+    }, [selectedFontFamily]);
+
+    useEffect(() => {
+      if (Object.keys(batchedData).length) {
+        updateData(batchedData);
+        // reset the data after we've used the data
+        setBatchedData({});
+      }
+      // call update only on internal state changes
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [batchedData]);
+
+    useEffect(() => {
+      isUnmounted.current = false;
+
+      return () => {
+        isUnmounted.current = true;
+      };
+    }, []);
 
     return (
       <fieldset>
         <legend>{t("labels.fontFamily")}</legend>
-        <ButtonIconSelect<FontFamilyValues | false>
-          group="font-family"
-          options={options}
-          value={getFormValue(
-            elements,
-            appState,
-            (element) => {
-              if (isTextElement(element)) {
-                return element.fontFamily;
+        <FontPicker
+          isOpened={appState.openPopup === "fontFamily"}
+          selectedFontFamily={selectedFontFamily}
+          hoveredFontFamily={appState.currentHoveredFontFamily}
+          onSelect={(fontFamily) => {
+            setBatchedData({
+              openPopup: null,
+              currentHoveredFontFamily: null,
+              currentItemFontFamily: fontFamily,
+            });
+
+            // defensive clear so immediate close won't abuse the cached elements
+            cachedElementsRef.current.clear();
+          }}
+          onHover={(fontFamily) => {
+            setBatchedData({
+              currentHoveredFontFamily: fontFamily,
+              cachedElements: new Map(cachedElementsRef.current),
+              resetContainers: true,
+            });
+          }}
+          onLeave={() => {
+            setBatchedData({
+              currentHoveredFontFamily: null,
+              cachedElements: new Map(cachedElementsRef.current),
+              resetAll: true,
+            });
+          }}
+          onPopupChange={(open) => {
+            if (open) {
+              // open, populate the cache from scratch
+              cachedElementsRef.current.clear();
+
+              const { editingElement } = appState;
+
+              if (editingElement?.type === "text") {
+                // retrieve the latest version from the scene, as `editingElement` isn't mutated
+                const latestEditingElement = app.scene.getElement(
+                  editingElement.id,
+                );
+
+                // inside the wysiwyg editor
+                cachedElementsRef.current.set(
+                  editingElement.id,
+                  newElementWith(
+                    latestEditingElement || editingElement,
+                    {},
+                    true,
+                  ),
+                );
+              } else {
+                const selectedElements = getSelectedElements(
+                  elements,
+                  appState,
+                  {
+                    includeBoundTextElement: true,
+                  },
+                );
+
+                for (const element of selectedElements) {
+                  cachedElementsRef.current.set(
+                    element.id,
+                    newElementWith(element, {}, true),
+                  );
+                }
               }
-              const boundTextElement = getBoundTextElement(
-                element,
-                app.scene.getNonDeletedElementsMap(),
-              );
-              if (boundTextElement) {
-                return boundTextElement.fontFamily;
+
+              setBatchedData({
+                openPopup: "fontFamily",
+              });
+            } else {
+              // close, use the cache and clear it afterwards
+              const data = {
+                openPopup: null,
+                currentHoveredFontFamily: null,
+                cachedElements: new Map(cachedElementsRef.current),
+                resetAll: true,
+              } as ChangeFontFamilyData;
+
+              if (isUnmounted.current) {
+                // in case the component was unmounted by the parent, trigger the update directly
+                updateData({ ...batchedData, ...data });
+              } else {
+                setBatchedData(data);
               }
-              return null;
-            },
-            (element) =>
-              isTextElement(element) ||
-              getBoundTextElement(
-                element,
-                app.scene.getNonDeletedElementsMap(),
-              ) !== null,
-            (hasSelection) =>
-              hasSelection
-                ? null
-                : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
-          )}
-          onChange={(value) => updateData(value)}
+
+              cachedElementsRef.current.clear();
+            }
+          }}
         />
       </fieldset>
     );

+ 3 - 5
packages/excalidraw/actions/actionStyles.ts

@@ -12,10 +12,7 @@ import {
   DEFAULT_FONT_FAMILY,
   DEFAULT_TEXT_ALIGN,
 } from "../constants";
-import {
-  getBoundTextElement,
-  getDefaultLineHeight,
-} from "../element/textElement";
+import { getBoundTextElement } from "../element/textElement";
 import {
   hasBoundTextElement,
   canApplyRoundnessTypeToElement,
@@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
 import type { ExcalidrawTextElement } from "../element/types";
 import { paintIcon } from "../components/icons";
 import { StoreAction } from "../store";
+import { getLineHeight } from "../fonts";
 
 // `copiedStyles` is exported only for tests.
 export let copiedStyles: string = "{}";
@@ -122,7 +120,7 @@ export const actionPasteStyles = register({
                 DEFAULT_TEXT_ALIGN,
               lineHeight:
                 (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
-                getDefaultLineHeight(fontFamily),
+                getLineHeight(fontFamily),
             });
             let container = null;
             if (newElement.containerId) {

+ 48 - 0
packages/excalidraw/actions/actionTextAutoResize.ts

@@ -0,0 +1,48 @@
+import { isTextElement } from "../element";
+import { newElementWith } from "../element/mutateElement";
+import { measureText } from "../element/textElement";
+import { getSelectedElements } from "../scene";
+import { StoreAction } from "../store";
+import type { AppClassProperties } from "../types";
+import { getFontString } from "../utils";
+import { register } from "./register";
+
+export const actionTextAutoResize = register({
+  name: "autoResize",
+  label: "labels.autoResize",
+  icon: null,
+  trackEvent: { category: "element" },
+  predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return (
+      selectedElements.length === 1 &&
+      isTextElement(selectedElements[0]) &&
+      !selectedElements[0].autoResize
+    );
+  },
+  perform: (elements, appState, _, app) => {
+    const selectedElements = getSelectedElements(elements, appState);
+
+    return {
+      appState,
+      elements: elements.map((element) => {
+        if (element.id === selectedElements[0].id && isTextElement(element)) {
+          const metrics = measureText(
+            element.originalText,
+            getFontString(element),
+            element.lineHeight,
+          );
+
+          return newElementWith(element, {
+            autoResize: true,
+            width: metrics.width,
+            height: metrics.height,
+            text: element.originalText,
+          });
+        }
+        return element;
+      }),
+      storeAction: StoreAction.CAPTURE,
+    };
+  },
+});

+ 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,
 });

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

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

+ 10 - 7
packages/excalidraw/analytics.ts

@@ -1,6 +1,6 @@
 // place here categories that you want to track. We want to track just a
 // small subset of categories at a given time.
-const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
+const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
 
 export const trackEvent = (
   category: string,
@@ -9,17 +9,20 @@ export const trackEvent = (
   value?: number,
 ) => {
   try {
-    // prettier-ignore
     if (
-      typeof window === "undefined"
-      || import.meta.env.VITE_WORKER_ID
-      // comment out to debug locally
-      || import.meta.env.PROD
+      typeof window === "undefined" ||
+      import.meta.env.VITE_WORKER_ID ||
+      import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
     ) {
       return;
     }
 
-    if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
+    if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
+      return;
+    }
+
+    if (import.meta.env.DEV) {
+      // comment out to debug in dev
       return;
     }
 

+ 8 - 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";
@@ -35,6 +36,7 @@ export const getDefaultAppState = (): Omit<
     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemTextAlign: DEFAULT_TEXT_ALIGN,
+    currentHoveredFontFamily: null,
     cursorButton: "up",
     activeEmbeddable: null,
     draggingElement: null,
@@ -80,7 +82,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 },
@@ -145,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (<
   currentItemStrokeStyle: { browser: true, export: false, server: false },
   currentItemStrokeWidth: { browser: true, export: false, server: false },
   currentItemTextAlign: { browser: true, export: false, server: false },
+  currentHoveredFontFamily: { browser: false, export: false, server: false },
   cursorButton: { browser: true, export: false, server: false },
   activeEmbeddable: { browser: false, export: false, server: false },
   draggingElement: { browser: false, export: false, server: false },
@@ -198,7 +204,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 },

+ 17 - 8
packages/excalidraw/change.ts

@@ -1477,19 +1477,28 @@ export class ElementsChange implements Change<SceneElementsMap> {
       return elements;
     }
 
-    const previous = Array.from(elements.values());
-    const reordered = orderByFractionalIndex([...previous]);
+    const unordered = Array.from(elements.values());
+    const ordered = orderByFractionalIndex([...unordered]);
+    const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
+      (acc, arrayIndex) => {
+        const candidate = unordered[Number(arrayIndex)];
+        if (candidate && changed.has(candidate.id)) {
+          acc.set(candidate.id, candidate);
+        }
 
-    if (
-      !flags.containsVisibleDifference &&
-      Delta.isRightDifferent(previous, reordered, true)
-    ) {
+        return acc;
+      },
+      new Map(),
+    );
+
+    if (!flags.containsVisibleDifference && moved.size) {
       // we found a difference in order!
       flags.containsVisibleDifference = true;
     }
 
-    // let's synchronize all invalid indices of moved elements
-    return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
+    // synchronize all elements that were actually moved
+    // could fallback to synchronizing all invalid indices
+    return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
   }
 
   /**

+ 2 - 3
packages/excalidraw/components/Actions.tsx

@@ -160,10 +160,8 @@ export const SelectedShapeActions = ({
       {(appState.activeTool.type === "text" ||
         targetElements.some(isTextElement)) && (
         <>
-          {renderAction("changeFontSize")}
-
           {renderAction("changeFontFamily")}
-
+          {renderAction("changeFontSize")}
           {(appState.activeTool.type === "text" ||
             suppportsHorizontalAlign(targetElements, elementsMap)) &&
             renderAction("changeTextAlign")}
@@ -470,6 +468,7 @@ export const ExitZenModeAction = ({
   showExitZenModeBtn: boolean;
 }) => (
   <button
+    type="button"
     className={clsx("disable-zen-mode", {
       "disable-zen-mode--visible": showExitZenModeBtn,
     })}

File diff suppressed because it is too large
+ 323 - 231
packages/excalidraw/components/App.tsx


+ 12 - 0
packages/excalidraw/components/ButtonIcon.scss

@@ -0,0 +1,12 @@
+@import "../css/theme";
+
+.excalidraw {
+  button.standalone {
+    @include outlineButtonIconStyles;
+
+    & > * {
+      // dissalow pointer events on children, so we always have event.target on the button itself
+      pointer-events: none;
+    }
+  }
+}

+ 36 - 0
packages/excalidraw/components/ButtonIcon.tsx

@@ -0,0 +1,36 @@
+import { forwardRef } from "react";
+import clsx from "clsx";
+
+import "./ButtonIcon.scss";
+
+interface ButtonIconProps {
+  icon: JSX.Element;
+  title: string;
+  className?: string;
+  testId?: string;
+  /** if not supplied, defaults to value identity check */
+  active?: boolean;
+  /** include standalone style (could interfere with parent styles) */
+  standalone?: boolean;
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
+}
+
+export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
+  (props, ref) => {
+    const { title, className, testId, active, standalone, icon, onClick } =
+      props;
+    return (
+      <button
+        type="button"
+        ref={ref}
+        key={title}
+        title={title}
+        data-testid={testId}
+        className={clsx(className, { standalone, active })}
+        onClick={onClick}
+      >
+        {icon}
+      </button>
+    );
+  },
+);

+ 8 - 10
packages/excalidraw/components/ButtonIconSelect.tsx

@@ -1,4 +1,5 @@
 import clsx from "clsx";
+import { ButtonIcon } from "./ButtonIcon";
 
 // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
 export const ButtonIconSelect = <T extends Object>(
@@ -24,20 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
       }
   ),
 ) => (
-  <div className="buttonList buttonListIcon">
+  <div className="buttonList">
     {props.options.map((option) =>
       props.type === "button" ? (
-        <button
+        <ButtonIcon
           key={option.text}
-          onClick={(event) => props.onClick(option.value, event)}
-          className={clsx({
-            active: option.active ?? props.value === option.value,
-          })}
-          data-testid={option.testId}
+          icon={option.icon}
           title={option.text}
-        >
-          {option.icon}
-        </button>
+          testId={option.testId}
+          active={option.active ?? props.value === option.value}
+          onClick={(event) => props.onClick(option.value, event)}
+        />
       ) : (
         <label
           key={option.text}

+ 10 - 0
packages/excalidraw/components/ButtonSeparator.tsx

@@ -0,0 +1,10 @@
+export const ButtonSeparator = () => (
+  <div
+    style={{
+      width: 1,
+      height: "1rem",
+      backgroundColor: "var(--default-border-color)",
+      margin: "0 auto",
+    }}
+  />
+);

+ 6 - 1
packages/excalidraw/components/CheckboxItem.tsx

@@ -22,7 +22,12 @@ export const CheckboxItem: React.FC<{
         ).focus();
       }}
     >
-      <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
+      <button
+        type="button"
+        className="Checkbox-box"
+        role="checkbox"
+        aria-checked={checked}
+      >
         {checkIcon}
       </button>
       <div className="Checkbox-label">{children}</div>

+ 1 - 1
packages/excalidraw/components/ColorPicker/ColorPicker.scss

@@ -20,7 +20,7 @@
     align-items: center;
 
     @include isMobile {
-      max-width: 175px;
+      max-width: 11rem;
     }
   }
 

+ 70 - 123
packages/excalidraw/components/ColorPicker/ColorPicker.tsx

@@ -1,22 +1,24 @@
-import { isInteractive, isTransparent, isWritableElement } from "../../utils";
+import { isTransparent } from "../../utils";
 import type { ExcalidrawElement } from "../../element/types";
 import type { AppState } from "../../types";
 import { TopPicks } from "./TopPicks";
+import { ButtonSeparator } from "../ButtonSeparator";
 import { Picker } from "./Picker";
 import * as Popover from "@radix-ui/react-popover";
 import { useAtom } from "jotai";
 import type { ColorPickerType } from "./colorPickerUtils";
 import { activeColorPickerSectionAtom } from "./colorPickerUtils";
-import { useDevice, useExcalidrawContainer } from "../App";
+import { useExcalidrawContainer } from "../App";
 import type { ColorTuple, ColorPaletteCustom } from "../../colors";
 import { COLOR_PALETTE } from "../../colors";
 import PickerHeading from "./PickerHeading";
 import { t } from "../../i18n";
 import clsx from "clsx";
+import { useRef } from "react";
 import { jotaiScope } from "../../jotai";
 import { ColorInput } from "./ColorInput";
-import { useRef } from "react";
 import { activeEyeDropperAtom } from "../EyeDropper";
+import { PropertiesPopover } from "../PropertiesPopover";
 
 import "./ColorPicker.scss";
 
@@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
   | "palette"
   | "updateData"
 >) => {
+  const { container } = useExcalidrawContainer();
   const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
 
   const [eyeDropperState, setEyeDropperState] = useAtom(
@@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
     jotaiScope,
   );
 
-  const { container } = useExcalidrawContainer();
-  const device = useDevice();
-
   const colorInputJSX = (
     <div>
       <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
       />
     </div>
   );
+
   const popoverRef = useRef<HTMLDivElement>(null);
 
   const focusPickerContent = () => {
@@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
   };
 
   return (
-    <Popover.Portal container={container}>
-      <Popover.Content
-        ref={popoverRef}
-        className="focus-visible-none"
-        data-prevent-outside-click
-        onFocusOutside={(event) => {
-          focusPickerContent();
+    <PropertiesPopover
+      container={container}
+      style={{ maxWidth: "208px" }}
+      onFocusOutside={(event) => {
+        // refocus due to eye dropper
+        focusPickerContent();
+        event.preventDefault();
+      }}
+      onPointerDownOutside={(event) => {
+        if (eyeDropperState) {
+          // prevent from closing if we click outside the popover
+          // while eyedropping (e.g. click when clicking the sidebar;
+          // the eye-dropper-backdrop is prevented downstream)
           event.preventDefault();
-        }}
-        onPointerDownOutside={(event) => {
-          if (eyeDropperState) {
-            // prevent from closing if we click outside the popover
-            // while eyedropping (e.g. click when clicking the sidebar;
-            // the eye-dropper-backdrop is prevented downstream)
-            event.preventDefault();
-          }
-        }}
-        onCloseAutoFocus={(e) => {
-          e.stopPropagation();
-          // prevents focusing the trigger
-          e.preventDefault();
-
-          // return focus to excalidraw container unless
-          // user focuses an interactive element, such as a button, or
-          // enters the text editor by clicking on canvas with the text tool
-          if (container && !isInteractive(document.activeElement)) {
-            container.focus();
-          }
-
-          updateData({ openPopup: null });
-          setActiveColorPickerSection(null);
-        }}
-        side={
-          device.editor.isMobile && !device.viewport.isLandscape
-            ? "bottom"
-            : "right"
-        }
-        align={
-          device.editor.isMobile && !device.viewport.isLandscape
-            ? "center"
-            : "start"
         }
-        alignOffset={-16}
-        sideOffset={20}
-        style={{
-          zIndex: "var(--zIndex-layerUI)",
-          backgroundColor: "var(--popup-bg-color)",
-          maxWidth: "208px",
-          maxHeight: window.innerHeight,
-          padding: "12px",
-          borderRadius: "8px",
-          boxSizing: "border-box",
-          overflowY: "auto",
-          boxShadow:
-            "0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
-        }}
-      >
-        {palette ? (
-          <Picker
-            palette={palette}
-            color={color}
-            onChange={(changedColor) => {
-              onChange(changedColor);
-            }}
-            onEyeDropperToggle={(force) => {
-              setEyeDropperState((state) => {
-                if (force) {
-                  state = state || {
-                    keepOpenOnAlt: true,
+      }}
+      onClose={() => {
+        updateData({ openPopup: null });
+        setActiveColorPickerSection(null);
+      }}
+    >
+      {palette ? (
+        <Picker
+          palette={palette}
+          color={color}
+          onChange={(changedColor) => {
+            onChange(changedColor);
+          }}
+          onEyeDropperToggle={(force) => {
+            setEyeDropperState((state) => {
+              if (force) {
+                state = state || {
+                  keepOpenOnAlt: true,
+                  onSelect: onChange,
+                  colorPickerType: type,
+                };
+                state.keepOpenOnAlt = true;
+                return state;
+              }
+
+              return force === false || state
+                ? null
+                : {
+                    keepOpenOnAlt: false,
                     onSelect: onChange,
                     colorPickerType: type,
                   };
-                  state.keepOpenOnAlt = true;
-                  return state;
-                }
-
-                return force === false || state
-                  ? null
-                  : {
-                      keepOpenOnAlt: false,
-                      onSelect: onChange,
-                      colorPickerType: type,
-                    };
-              });
-            }}
-            onEscape={(event) => {
-              if (eyeDropperState) {
-                setEyeDropperState(null);
-              } else if (isWritableElement(event.target)) {
-                focusPickerContent();
-              } else {
-                updateData({ openPopup: null });
-              }
-            }}
-            label={label}
-            type={type}
-            elements={elements}
-            updateData={updateData}
-          >
-            {colorInputJSX}
-          </Picker>
-        ) : (
-          colorInputJSX
-        )}
-        <Popover.Arrow
-          width={20}
-          height={10}
-          style={{
-            fill: "var(--popup-bg-color)",
-            filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
+            });
           }}
-        />
-      </Popover.Content>
-    </Popover.Portal>
+          onEscape={(event) => {
+            if (eyeDropperState) {
+              setEyeDropperState(null);
+            } else {
+              updateData({ openPopup: null });
+            }
+          }}
+          label={label}
+          type={type}
+          elements={elements}
+          updateData={updateData}
+        >
+          {colorInputJSX}
+        </Picker>
+      ) : (
+        colorInputJSX
+      )}
+    </PropertiesPopover>
   );
 };
 
@@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
   return (
     <Popover.Trigger
       type="button"
-      className={clsx("color-picker__button active-color", {
+      className={clsx("color-picker__button active-color properties-trigger", {
         "is-transparent": color === "transparent" || !color,
       })}
       aria-label={label}
@@ -268,14 +222,7 @@ export const ColorPicker = ({
           type={type}
           topPicks={topPicks}
         />
-        <div
-          style={{
-            width: 1,
-            height: "100%",
-            backgroundColor: "var(--default-border-color)",
-            margin: "0 auto",
-          }}
-        />
+        <ButtonSeparator />
         <Popover.Root
           open={appState.openPopup === type}
           onOpenChange={(open) => {

+ 1 - 1
packages/excalidraw/components/ColorPicker/Picker.tsx

@@ -138,7 +138,7 @@ export const Picker = ({
             event.stopPropagation();
           }
         }}
-        className="color-picker-content"
+        className="color-picker-content properties-content"
         // to allow focusing by clicking but not by tabbing
         tabIndex={-1}
       >

+ 4 - 2
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -540,7 +540,7 @@ function CommandPaletteInner({
           ...command,
           icon: command.icon || boltIcon,
           order: command.order ?? getCategoryOrder(command.category),
-          haystack: `${deburr(command.label)} ${
+          haystack: `${deburr(command.label.toLocaleLowerCase())} ${
             command.keywords?.join(" ") || ""
           }`,
         };
@@ -777,7 +777,9 @@ function CommandPaletteInner({
       return;
     }
 
-    const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
+    const _query = deburr(
+      commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""),
+    );
     matchingCommands = fuzzy
       .filter(_query, matchingCommands, {
         extract: (command) => command.haystack,

+ 1 - 0
packages/excalidraw/components/ContextMenu.tsx

@@ -105,6 +105,7 @@ export const ContextMenu = React.memo(
                 }}
               >
                 <button
+                  type="button"
                   className={clsx("context-menu-item", {
                     dangerous: actionName === "deleteSelectedElements",
                     checkmark: item.checked?.(appState),

+ 1 - 0
packages/excalidraw/components/Dialog.tsx

@@ -123,6 +123,7 @@ export const Dialog = (props: DialogProps) => {
             onClick={onClose}
             title={t("buttons.close")}
             aria-label={t("buttons.close")}
+            type="button"
           >
             {CloseIcon}
           </button>

+ 5 - 1
packages/excalidraw/components/FollowMode/FollowMode.tsx

@@ -27,7 +27,11 @@ const FollowMode = ({
             {userToFollow.username}
           </span>
         </div>
-        <button onClick={onDisconnect} className="follow-mode__disconnect-btn">
+        <button
+          type="button"
+          onClick={onDisconnect}
+          className="follow-mode__disconnect-btn"
+        >
           {CloseIcon}
         </button>
       </div>

+ 15 - 0
packages/excalidraw/components/FontPicker/FontPicker.scss

@@ -0,0 +1,15 @@
+@import "../../css/variables.module.scss";
+
+.excalidraw {
+  .FontPicker__container {
+    display: grid;
+    grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
+    align-items: center;
+
+    @include isMobile {
+      max-width: calc(
+        2rem + 4 * var(--default-button-size)
+      ); // 4 gaps + 4 buttons
+    }
+  }
+}

+ 110 - 0
packages/excalidraw/components/FontPicker/FontPicker.tsx

@@ -0,0 +1,110 @@
+import React, { useCallback, useMemo } from "react";
+import * as Popover from "@radix-ui/react-popover";
+
+import { FontPickerList } from "./FontPickerList";
+import { FontPickerTrigger } from "./FontPickerTrigger";
+import { ButtonIconSelect } from "../ButtonIconSelect";
+import {
+  FontFamilyCodeIcon,
+  FontFamilyNormalIcon,
+  FreedrawIcon,
+} from "../icons";
+import { ButtonSeparator } from "../ButtonSeparator";
+import type { FontFamilyValues } from "../../element/types";
+import { FONT_FAMILY } from "../../constants";
+import { t } from "../../i18n";
+
+import "./FontPicker.scss";
+
+export const DEFAULT_FONTS = [
+  {
+    value: FONT_FAMILY.Excalifont,
+    icon: FreedrawIcon,
+    text: t("labels.handDrawn"),
+    testId: "font-family-handrawn",
+  },
+  {
+    value: FONT_FAMILY.Nunito,
+    icon: FontFamilyNormalIcon,
+    text: t("labels.normal"),
+    testId: "font-family-normal",
+  },
+  {
+    value: FONT_FAMILY["Comic Shanns"],
+    icon: FontFamilyCodeIcon,
+    text: t("labels.code"),
+    testId: "font-family-code",
+  },
+];
+
+const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
+
+export const isDefaultFont = (fontFamily: number | null) => {
+  if (!fontFamily) {
+    return false;
+  }
+
+  return defaultFontFamilies.has(fontFamily);
+};
+
+interface FontPickerProps {
+  isOpened: boolean;
+  selectedFontFamily: FontFamilyValues | null;
+  hoveredFontFamily: FontFamilyValues | null;
+  onSelect: (fontFamily: FontFamilyValues) => void;
+  onHover: (fontFamily: FontFamilyValues) => void;
+  onLeave: () => void;
+  onPopupChange: (open: boolean) => void;
+}
+
+export const FontPicker = React.memo(
+  ({
+    isOpened,
+    selectedFontFamily,
+    hoveredFontFamily,
+    onSelect,
+    onHover,
+    onLeave,
+    onPopupChange,
+  }: FontPickerProps) => {
+    const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
+    const onSelectCallback = useCallback(
+      (value: number | false) => {
+        if (value) {
+          onSelect(value);
+        }
+      },
+      [onSelect],
+    );
+
+    return (
+      <div role="dialog" aria-modal="true" className="FontPicker__container">
+        <ButtonIconSelect<FontFamilyValues | false>
+          type="button"
+          options={defaultFonts}
+          value={selectedFontFamily}
+          onClick={onSelectCallback}
+        />
+        <ButtonSeparator />
+        <Popover.Root open={isOpened} onOpenChange={onPopupChange}>
+          <FontPickerTrigger selectedFontFamily={selectedFontFamily} />
+          {isOpened && (
+            <FontPickerList
+              selectedFontFamily={selectedFontFamily}
+              hoveredFontFamily={hoveredFontFamily}
+              onSelect={onSelectCallback}
+              onHover={onHover}
+              onLeave={onLeave}
+              onOpen={() => onPopupChange(true)}
+              onClose={() => onPopupChange(false)}
+            />
+          )}
+        </Popover.Root>
+      </div>
+    );
+  },
+  (prev, next) =>
+    prev.isOpened === next.isOpened &&
+    prev.selectedFontFamily === next.selectedFontFamily &&
+    prev.hoveredFontFamily === next.hoveredFontFamily,
+);

+ 268 - 0
packages/excalidraw/components/FontPicker/FontPickerList.tsx

@@ -0,0 +1,268 @@
+import React, {
+  useMemo,
+  useState,
+  useRef,
+  useEffect,
+  useCallback,
+  type KeyboardEventHandler,
+} from "react";
+import { useApp, useAppProps, useExcalidrawContainer } from "../App";
+import { PropertiesPopover } from "../PropertiesPopover";
+import { QuickSearch } from "../QuickSearch";
+import { ScrollableList } from "../ScrollableList";
+import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
+import DropdownMenuItem, {
+  DropDownMenuItemBadgeType,
+  DropDownMenuItemBadge,
+} from "../dropdownMenu/DropdownMenuItem";
+import { type FontFamilyValues } from "../../element/types";
+import { arrayToList, debounce, getFontFamilyString } from "../../utils";
+import { t } from "../../i18n";
+import { fontPickerKeyHandler } from "./keyboardNavHandlers";
+import { Fonts } from "../../fonts";
+import type { ValueOf } from "../../utility-types";
+
+export interface FontDescriptor {
+  value: number;
+  icon: JSX.Element;
+  text: string;
+  deprecated?: true;
+  badge?: {
+    type: ValueOf<typeof DropDownMenuItemBadgeType>;
+    placeholder: string;
+  };
+}
+
+interface FontPickerListProps {
+  selectedFontFamily: FontFamilyValues | null;
+  hoveredFontFamily: FontFamilyValues | null;
+  onSelect: (value: number) => void;
+  onHover: (value: number) => void;
+  onLeave: () => void;
+  onOpen: () => void;
+  onClose: () => void;
+}
+
+export const FontPickerList = React.memo(
+  ({
+    selectedFontFamily,
+    hoveredFontFamily,
+    onSelect,
+    onHover,
+    onLeave,
+    onOpen,
+    onClose,
+  }: FontPickerListProps) => {
+    const { container } = useExcalidrawContainer();
+    const { fonts } = useApp();
+    const { showDeprecatedFonts } = useAppProps();
+
+    const [searchTerm, setSearchTerm] = useState("");
+    const inputRef = useRef<HTMLInputElement>(null);
+    const allFonts = useMemo(
+      () =>
+        Array.from(Fonts.registered.entries())
+          .filter(([_, { metadata }]) => !metadata.serverSide)
+          .map(([familyId, { metadata, fontFaces }]) => {
+            const font = {
+              value: familyId,
+              icon: metadata.icon,
+              text: fontFaces[0].fontFace.family,
+            };
+
+            if (metadata.deprecated) {
+              Object.assign(font, {
+                deprecated: metadata.deprecated,
+                badge: {
+                  type: DropDownMenuItemBadgeType.RED,
+                  placeholder: t("fontList.badge.old"),
+                },
+              });
+            }
+
+            return font as FontDescriptor;
+          })
+          .sort((a, b) =>
+            a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
+          ),
+      [],
+    );
+
+    const sceneFamilies = useMemo(
+      () => new Set(fonts.sceneFamilies),
+      // cache per selected font family, so hover re-render won't mess it up
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+      [selectedFontFamily],
+    );
+
+    const sceneFonts = useMemo(
+      () => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
+      [allFonts, sceneFamilies],
+    );
+
+    const availableFonts = useMemo(
+      () =>
+        allFonts.filter(
+          (font) =>
+            !sceneFamilies.has(font.value) &&
+            (showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
+        ),
+      [allFonts, sceneFamilies, showDeprecatedFonts],
+    );
+
+    const filteredFonts = useMemo(
+      () =>
+        arrayToList(
+          [...sceneFonts, ...availableFonts].filter((font) =>
+            font.text?.toLowerCase().includes(searchTerm),
+          ),
+        ),
+      [sceneFonts, availableFonts, searchTerm],
+    );
+
+    const hoveredFont = useMemo(() => {
+      let font;
+
+      if (hoveredFontFamily) {
+        font = filteredFonts.find((font) => font.value === hoveredFontFamily);
+      } else if (selectedFontFamily) {
+        font = filteredFonts.find((font) => font.value === selectedFontFamily);
+      }
+
+      if (!font && searchTerm) {
+        if (filteredFonts[0]?.value) {
+          // hover first element on search
+          onHover(filteredFonts[0].value);
+        } else {
+          // re-render cache on no results
+          onLeave();
+        }
+      }
+
+      return font;
+    }, [
+      hoveredFontFamily,
+      selectedFontFamily,
+      searchTerm,
+      filteredFonts,
+      onHover,
+      onLeave,
+    ]);
+
+    const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
+      (event) => {
+        const handled = fontPickerKeyHandler({
+          event,
+          inputRef,
+          hoveredFont,
+          filteredFonts,
+          onSelect,
+          onHover,
+          onClose,
+        });
+
+        if (handled) {
+          event.preventDefault();
+          event.stopPropagation();
+        }
+      },
+      [hoveredFont, filteredFonts, onSelect, onHover, onClose],
+    );
+
+    useEffect(() => {
+      onOpen();
+
+      return () => {
+        onClose();
+      };
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, []);
+
+    const sceneFilteredFonts = useMemo(
+      () => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
+      [filteredFonts, sceneFamilies],
+    );
+
+    const availableFilteredFonts = useMemo(
+      () => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
+      [filteredFonts, sceneFamilies],
+    );
+
+    const renderFont = (font: FontDescriptor, index: number) => (
+      <DropdownMenuItem
+        key={font.value}
+        icon={font.icon}
+        value={font.value}
+        order={index}
+        textStyle={{
+          fontFamily: getFontFamilyString({ fontFamily: font.value }),
+        }}
+        hovered={font.value === hoveredFont?.value}
+        selected={font.value === selectedFontFamily}
+        // allow to tab between search and selected font
+        tabIndex={font.value === selectedFontFamily ? 0 : -1}
+        onClick={(e) => {
+          onSelect(Number(e.currentTarget.value));
+        }}
+        onMouseMove={() => {
+          if (hoveredFont?.value !== font.value) {
+            onHover(font.value);
+          }
+        }}
+      >
+        {font.text}
+        {font.badge && (
+          <DropDownMenuItemBadge type={font.badge.type}>
+            {font.badge.placeholder}
+          </DropDownMenuItemBadge>
+        )}
+      </DropdownMenuItem>
+    );
+
+    const groups = [];
+
+    if (sceneFilteredFonts.length) {
+      groups.push(
+        <DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
+          {sceneFilteredFonts.map(renderFont)}
+        </DropdownMenuGroup>,
+      );
+    }
+
+    if (availableFilteredFonts.length) {
+      groups.push(
+        <DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
+          {availableFilteredFonts.map((font, index) =>
+            renderFont(font, index + sceneFilteredFonts.length),
+          )}
+        </DropdownMenuGroup>,
+      );
+    }
+
+    return (
+      <PropertiesPopover
+        className="properties-content"
+        container={container}
+        style={{ width: "15rem" }}
+        onClose={onClose}
+        onPointerLeave={onLeave}
+        onKeyDown={onKeyDown}
+      >
+        <QuickSearch
+          ref={inputRef}
+          placeholder={t("quickSearch.placeholder")}
+          onChange={debounce(setSearchTerm, 20)}
+        />
+        <ScrollableList
+          className="dropdown-menu fonts manual-hover"
+          placeholder={t("fontList.empty")}
+        >
+          {groups.length ? groups : null}
+        </ScrollableList>
+      </PropertiesPopover>
+    );
+  },
+  (prev, next) =>
+    prev.selectedFontFamily === next.selectedFontFamily &&
+    prev.hoveredFontFamily === next.hoveredFontFamily,
+);

+ 38 - 0
packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx

@@ -0,0 +1,38 @@
+import * as Popover from "@radix-ui/react-popover";
+import { useMemo } from "react";
+import { ButtonIcon } from "../ButtonIcon";
+import { TextIcon } from "../icons";
+import type { FontFamilyValues } from "../../element/types";
+import { t } from "../../i18n";
+import { isDefaultFont } from "./FontPicker";
+
+interface FontPickerTriggerProps {
+  selectedFontFamily: FontFamilyValues | null;
+}
+
+export const FontPickerTrigger = ({
+  selectedFontFamily,
+}: FontPickerTriggerProps) => {
+  const isTriggerActive = useMemo(
+    () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
+    [selectedFontFamily],
+  );
+
+  return (
+    <Popover.Trigger asChild>
+      {/* Empty div as trigger so it's stretched 100% due to different button sizes */}
+      <div>
+        <ButtonIcon
+          standalone
+          icon={TextIcon}
+          title={t("labels.showFonts")}
+          className="properties-trigger"
+          testId={"font-family-show-fonts"}
+          active={isTriggerActive}
+          // no-op
+          onClick={() => {}}
+        />
+      </div>
+    </Popover.Trigger>
+  );
+};

+ 66 - 0
packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts

@@ -0,0 +1,66 @@
+import type { Node } from "../../utils";
+import { KEYS } from "../../keys";
+import { type FontDescriptor } from "./FontPickerList";
+
+interface FontPickerKeyNavHandlerProps {
+  event: React.KeyboardEvent<HTMLDivElement>;
+  inputRef: React.RefObject<HTMLInputElement>;
+  hoveredFont: Node<FontDescriptor> | undefined;
+  filteredFonts: Node<FontDescriptor>[];
+  onClose: () => void;
+  onSelect: (value: number) => void;
+  onHover: (value: number) => void;
+}
+
+export const fontPickerKeyHandler = ({
+  event,
+  inputRef,
+  hoveredFont,
+  filteredFonts,
+  onClose,
+  onSelect,
+  onHover,
+}: FontPickerKeyNavHandlerProps) => {
+  if (
+    !event[KEYS.CTRL_OR_CMD] &&
+    event.shiftKey &&
+    event.key.toLowerCase() === KEYS.F
+  ) {
+    // refocus input on the popup trigger shortcut
+    inputRef.current?.focus();
+    return true;
+  }
+
+  if (event.key === KEYS.ESCAPE) {
+    onClose();
+    return true;
+  }
+
+  if (event.key === KEYS.ENTER) {
+    if (hoveredFont?.value) {
+      onSelect(hoveredFont.value);
+    }
+
+    return true;
+  }
+
+  if (event.key === KEYS.ARROW_DOWN) {
+    if (hoveredFont?.next) {
+      onHover(hoveredFont.next.value);
+    } else if (filteredFonts[0]?.value) {
+      onHover(filteredFonts[0].value);
+    }
+
+    return true;
+  }
+
+  if (event.key === KEYS.ARROW_UP) {
+    if (hoveredFont?.prev) {
+      onHover(hoveredFont.prev.value);
+    } else if (filteredFonts[filteredFonts.length - 1]?.value) {
+      onHover(filteredFonts[filteredFonts.length - 1].value);
+    }
+
+    return true;
+  }
+};

+ 2 - 2
packages/excalidraw/components/HelpDialog.scss

@@ -8,7 +8,7 @@
 
     h3 {
       margin: 1.5rem 0;
-      font-weight: bold;
+      font-weight: 700;
       font-size: 1.125rem;
     }
 
@@ -82,7 +82,7 @@
     &__island {
       h4 {
         font-size: 1rem;
-        font-weight: bold;
+        font-weight: 700;
         margin: 0;
         margin-bottom: 0.625rem;
       }

+ 5 - 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
@@ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("labels.showBackground")}
               shortcuts={[getShortcutKey("G")]}
             />
+            <Shortcut
+              label={t("labels.showFonts")}
+              shortcuts={[getShortcutKey("Shift+F")]}
+            />
             <Shortcut
               label={t("labels.decreaseFontSize")}
               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}

+ 2 - 0
packages/excalidraw/components/IconPicker.tsx

@@ -108,6 +108,7 @@ function Picker<T>({
       <div className="picker-content" ref={rGallery}>
         {options.map((option, i) => (
           <button
+            type="button"
             className={clsx("picker-option", {
               active: value === option.value,
             })}
@@ -171,6 +172,7 @@ export function IconPicker<T>({
     <div>
       <button
         name={group}
+        type="button"
         className={isActive ? "active" : ""}
         aria-label={label}
         onClick={() => setActive(!isActive)}

+ 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 {

+ 18 - 14
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";
@@ -65,6 +63,8 @@ import { SubtypeToggles } from "./Subtypes";
 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;
@@ -242,6 +242,11 @@ const LayerUI = ({
       elements,
     );
 
+    const shouldShowStats =
+      appState.stats.open &&
+      !appState.zenModeEnabled &&
+      !appState.viewModeEnabled;
+
     return (
       <FixedSideContainer side="top">
         <div className="App-menu App-menu_top">
@@ -355,6 +360,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>
@@ -446,7 +460,7 @@ const LayerUI = ({
                 );
                 ShapeCache.delete(element);
               }
-              Scene.getScene(selectedElements[0])?.informMutation();
+              Scene.getScene(selectedElements[0])?.triggerUpdate();
             } else if (colorPickerType === "elementBackground") {
               setAppState({
                 currentItemBackgroundColor: color,
@@ -544,19 +558,9 @@ 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"
                 className="scroll-back-to-content"
                 onClick={() => {
                   setAppState((appState) => ({

+ 1 - 1
packages/excalidraw/components/LibraryMenu.scss

@@ -11,7 +11,7 @@
   .library-actions-counter {
     background-color: var(--color-primary);
     color: var(--color-primary-light);
-    font-weight: bold;
+    font-weight: 700;
     display: flex;
     align-items: center;
     justify-content: center;

+ 2 - 2
packages/excalidraw/components/LibraryMenuItems.scss

@@ -13,7 +13,7 @@
 
     &__label {
       color: var(--color-primary);
-      font-weight: bold;
+      font-weight: 700;
       font-size: 1.125rem;
       margin-bottom: 0.75rem;
     }
@@ -62,7 +62,7 @@
     &__header {
       color: var(--color-primary);
       font-size: 1.125rem;
-      font-weight: bold;
+      font-weight: 700;
       margin-bottom: 0.75rem;
       width: 100%;
       padding-right: 4rem; // due to dropdown button

+ 1 - 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";
@@ -159,17 +157,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={{
@@ -196,6 +183,7 @@ export const MobileMenu = ({
               !appState.openMenu &&
               !appState.openSidebar && (
                 <button
+                  type="button"
                   className="scroll-back-to-content"
                   onClick={() => {
                     setAppState((appState) => ({

+ 1 - 0
packages/excalidraw/components/PasteChartDialog.tsx

@@ -94,6 +94,7 @@ const ChartPreviewBtn = (props: {
 
   return (
     <button
+      type="button"
       className="ChartPreview"
       onClick={() => {
         if (chartElements) {

+ 96 - 0
packages/excalidraw/components/PropertiesPopover.tsx

@@ -0,0 +1,96 @@
+import React, { type ReactNode } from "react";
+import clsx from "clsx";
+import * as Popover from "@radix-ui/react-popover";
+
+import { useDevice } from "./App";
+import { Island } from "./Island";
+import { isInteractive } from "../utils";
+
+interface PropertiesPopoverProps {
+  className?: string;
+  container: HTMLDivElement | null;
+  children: ReactNode;
+  style?: object;
+  onClose: () => void;
+  onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
+  onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
+  onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
+  onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
+}
+
+export const PropertiesPopover = React.forwardRef<
+  HTMLDivElement,
+  PropertiesPopoverProps
+>(
+  (
+    {
+      className,
+      container,
+      children,
+      style,
+      onClose,
+      onKeyDown,
+      onFocusOutside,
+      onPointerLeave,
+      onPointerDownOutside,
+    },
+    ref,
+  ) => {
+    const device = useDevice();
+
+    return (
+      <Popover.Portal container={container}>
+        <Popover.Content
+          ref={ref}
+          className={clsx("focus-visible-none", className)}
+          data-prevent-outside-click
+          side={
+            device.editor.isMobile && !device.viewport.isLandscape
+              ? "bottom"
+              : "right"
+          }
+          align={
+            device.editor.isMobile && !device.viewport.isLandscape
+              ? "center"
+              : "start"
+          }
+          alignOffset={-16}
+          sideOffset={20}
+          style={{
+            zIndex: "var(--zIndex-popup)",
+          }}
+          onPointerLeave={onPointerLeave}
+          onKeyDown={onKeyDown}
+          onFocusOutside={onFocusOutside}
+          onPointerDownOutside={onPointerDownOutside}
+          onCloseAutoFocus={(e) => {
+            e.stopPropagation();
+            // prevents focusing the trigger
+            e.preventDefault();
+
+            // return focus to excalidraw container unless
+            // user focuses an interactive element, such as a button, or
+            // enters the text editor by clicking on canvas with the text tool
+            if (container && !isInteractive(document.activeElement)) {
+              container.focus();
+            }
+
+            onClose();
+          }}
+        >
+          <Island padding={3} style={style}>
+            {children}
+          </Island>
+          <Popover.Arrow
+            width={20}
+            height={10}
+            style={{
+              fill: "var(--popup-bg-color)",
+              filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
+            }}
+          />
+        </Popover.Content>
+      </Popover.Portal>
+    );
+  },
+);

+ 1 - 1
packages/excalidraw/components/PublishLibrary.scss

@@ -133,7 +133,7 @@
     .required,
     .error {
       color: $oc-red-8;
-      font-weight: bold;
+      font-weight: 700;
       font-size: 1rem;
       margin: 0.2rem;
     }

+ 48 - 0
packages/excalidraw/components/QuickSearch.scss

@@ -0,0 +1,48 @@
+.excalidraw {
+  --list-border-color: var(--color-gray-20);
+
+  .QuickSearch__wrapper {
+    position: relative;
+    height: 2.6rem; // added +0.1 due to Safari
+    border-bottom: 1px solid var(--list-border-color);
+
+    svg {
+      position: absolute;
+      top: 47.5%; // 50% is not exactly in the center of the input
+      transform: translateY(-50%);
+      left: 0.75rem;
+      width: 1.25rem;
+      height: 1.25rem;
+      color: var(--color-gray-40);
+      z-index: 1;
+    }
+  }
+
+  &.theme--dark {
+    --list-border-color: var(--color-gray-80);
+
+    .QuickSearch__wrapper {
+      border-bottom: none;
+    }
+  }
+
+  .QuickSearch__input {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    box-sizing: border-box;
+    border: 0 !important;
+    font-size: 0.875rem;
+    padding-left: 2.5rem !important;
+    padding-right: 0.75rem !important;
+
+    &::placeholder {
+      color: var(--color-gray-40);
+    }
+
+    &:focus {
+      box-shadow: none !important;
+    }
+  }
+}

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

@@ -0,0 +1,28 @@
+import clsx from "clsx";
+import React from "react";
+import { searchIcon } from "./icons";
+
+import "./QuickSearch.scss";
+
+interface QuickSearchProps {
+  className?: string;
+  placeholder: string;
+  onChange: (term: string) => void;
+}
+
+export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
+  ({ className, placeholder, onChange }, ref) => {
+    return (
+      <div className={clsx("QuickSearch__wrapper", className)}>
+        {searchIcon}
+        <input
+          ref={ref}
+          className="QuickSearch__input"
+          type="text"
+          placeholder={placeholder}
+          onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
+        />
+      </div>
+    );
+  },
+);

+ 21 - 0
packages/excalidraw/components/ScrollableList.scss

@@ -0,0 +1,21 @@
+.excalidraw {
+  .ScrollableList__wrapper {
+    position: static !important;
+    border: none;
+    font-size: 0.875rem;
+    overflow-y: auto;
+
+    & > .empty,
+    & > .hint {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      padding: 0.5rem;
+      font-size: 0.75rem;
+      color: var(--color-gray-60);
+      overflow: hidden;
+      text-align: center;
+      line-height: 150%;
+    }
+  }
+}

+ 24 - 0
packages/excalidraw/components/ScrollableList.tsx

@@ -0,0 +1,24 @@
+import clsx from "clsx";
+import { Children } from "react";
+
+import "./ScrollableList.scss";
+
+interface ScrollableListProps {
+  className?: string;
+  placeholder: string;
+  children: React.ReactNode;
+}
+
+export const ScrollableList = ({
+  className,
+  placeholder,
+  children,
+}: ScrollableListProps) => {
+  const isEmpty = !Children.count(children);
+
+  return (
+    <div className={clsx("ScrollableList__wrapper", className)} role="menu">
+      {isEmpty ? <div className="empty">{placeholder}</div> : children}
+    </div>
+  );
+};

+ 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>
-  );
-};

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

@@ -0,0 +1,93 @@
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import { isArrowElement } from "../../element/typeChecks";
+import type { 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, updateBindings } from "./utils";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+
+interface AngleProps {
+  element: ExcalidrawElement;
+  scene: Scene;
+  appState: AppState;
+  property: "angle";
+}
+
+const STEP_SIZE = 15;
+
+const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const origElement = originalElements[0];
+  if (origElement) {
+    const latestElement = elementsMap.get(origElement.id);
+    if (!latestElement) {
+      return;
+    }
+
+    if (nextValue !== undefined) {
+      const nextAngle = degreeToRadian(nextValue);
+      mutateElement(latestElement, {
+        angle: nextAngle,
+      });
+      updateBindings(latestElement, elementsMap);
+
+      const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+      if (boundTextElement && !isArrowElement(latestElement)) {
+        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(latestElement, {
+      angle: nextAngle,
+    });
+    updateBindings(latestElement, elementsMap);
+
+    const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+    if (boundTextElement && !isArrowElement(latestElement)) {
+      mutateElement(boundTextElement, { angle: nextAngle });
+    }
+  }
+};
+
+const Angle = ({ element, scene, appState, property }: AngleProps) => {
+  return (
+    <DragInput
+      label="A"
+      icon={angleIcon}
+      value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
+      elements={[element]}
+      dragInputCallback={handleDegreeChange}
+      editable={isPropertyEditable(element, "angle")}
+      scene={scene}
+      appState={appState}
+      property={property}
+    />
+  );
+};
+
+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;

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

@@ -0,0 +1,134 @@
+import type { 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";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+
+interface DimensionDragInputProps {
+  property: "width" | "height";
+  element: ExcalidrawElement;
+  scene: Scene;
+  appState: AppState;
+}
+
+const STEP_SIZE = 10;
+const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
+  return element.type === "image";
+};
+
+const handleDimensionChange: DragInputCallbackType<
+  DimensionDragInputProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  shouldKeepAspectRatio,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const origElement = originalElements[0];
+  if (origElement) {
+    const keepAspectRatio =
+      shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
+    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,
+        origElement,
+        elementsMap,
+      );
+
+      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,
+      origElement,
+      elementsMap,
+    );
+  }
+};
+
+const DimensionDragInput = ({
+  property,
+  element,
+  scene,
+  appState,
+}: DimensionDragInputProps) => {
+  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)}
+      scene={scene}
+      appState={appState}
+      property={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;
+    }
+  }
+}

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

@@ -0,0 +1,311 @@
+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 clsx from "clsx";
+import { useApp } from "../App";
+import { InlineIcon } from "../InlineIcon";
+import type { StatsInputProperty } from "./utils";
+import { SMALLEST_DELTA } from "./utils";
+import { StoreAction } from "../../store";
+import type Scene from "../../scene/Scene";
+
+import "./DragInput.scss";
+import type { AppState } from "../../types";
+import { cloneJSON } from "../../utils";
+
+export type DragInputCallbackType<
+  P extends StatsInputProperty,
+  E = ExcalidrawElement,
+> = (props: {
+  accumulatedChange: number;
+  instantChange: number;
+  originalElements: readonly E[];
+  originalElementsMap: ElementsMap;
+  shouldKeepAspectRatio: boolean;
+  shouldChangeByStepSize: boolean;
+  nextValue?: number;
+  property: P;
+  scene: Scene;
+  originalAppState: AppState;
+}) => void;
+
+interface StatsDragInputProps<
+  T extends StatsInputProperty,
+  E = ExcalidrawElement,
+> {
+  label: string | React.ReactNode;
+  icon?: React.ReactNode;
+  value: number | "Mixed";
+  elements: readonly E[];
+  editable?: boolean;
+  shouldKeepAspectRatio?: boolean;
+  dragInputCallback: DragInputCallbackType<T, E>;
+  property: T;
+  scene: Scene;
+  appState: AppState;
+}
+
+const StatsDragInput = <
+  T extends StatsInputProperty,
+  E extends ExcalidrawElement = ExcalidrawElement,
+>({
+  label,
+  icon,
+  dragInputCallback,
+  value,
+  elements,
+  editable = true,
+  shouldKeepAspectRatio,
+  property,
+  scene,
+  appState,
+}: StatsDragInputProps<T, E>) => {
+  const app = useApp();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const labelRef = useRef<HTMLDivElement>(null);
+
+  const [inputValue, setInputValue] = useState(value.toString());
+
+  const stateRef = useRef<{
+    originalAppState: AppState;
+    originalElements: readonly E[];
+    lastUpdatedValue: string;
+    updatePending: boolean;
+  }>(null!);
+  if (!stateRef.current) {
+    stateRef.current = {
+      originalAppState: cloneJSON(appState),
+      originalElements: elements,
+      lastUpdatedValue: inputValue,
+      updatePending: false,
+    };
+  }
+
+  useEffect(() => {
+    const inputValue = value.toString();
+    setInputValue(inputValue);
+    stateRef.current.lastUpdatedValue = inputValue;
+  }, [value]);
+
+  const handleInputValue = (
+    updatedValue: string,
+    elements: readonly E[],
+    appState: AppState,
+  ) => {
+    if (!stateRef.current.updatePending) {
+      return false;
+    }
+    stateRef.current.updatePending = false;
+
+    const parsed = Number(updatedValue);
+    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) {
+      stateRef.current.lastUpdatedValue = updatedValue;
+      dragInputCallback({
+        accumulatedChange: 0,
+        instantChange: 0,
+        originalElements: elements,
+        originalElementsMap: app.scene.getNonDeletedElementsMap(),
+        shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+        shouldChangeByStepSize: false,
+        nextValue: rounded,
+        property,
+        scene,
+        originalAppState: appState,
+      });
+      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,
+          stateRef.current.originalElements,
+          stateRef.current.originalAppState,
+        );
+      }
+    };
+  }, [
+    // we need to track change of `editable` state as mount/unmount
+    // because react doesn't trigger `blur` when a an input is blurred due
+    // to being disabled (https://github.com/facebook/react/issues/9142).
+    // As such, if we keep rendering disabled inputs, then change in selection
+    // to an element that has a given property as non-editable would not trigger
+    // blur/unmount and wouldn't update the value.
+    editable,
+  ]);
+
+  if (!editable) {
+    return null;
+  }
+
+  return (
+    <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 originalElementsMap: Map<string, ExcalidrawElement> | null =
+              app.scene
+                .getNonDeletedElements()
+                .reduce((acc: ElementsMap, element) => {
+                  acc.set(element.id, deepCopyElement(element));
+                  return acc;
+                }, new Map());
+
+            let originalElements: readonly E[] | null = elements.map(
+              (element) => originalElementsMap!.get(element.id) as E,
+            );
+
+            const originalAppState: AppState = cloneJSON(appState);
+
+            let accumulatedChange: number | null = null;
+
+            document.body.classList.add("excalidraw-cursor-resize");
+
+            const onPointerMove = (event: PointerEvent) => {
+              if (!accumulatedChange) {
+                accumulatedChange = 0;
+              }
+
+              if (
+                lastPointer &&
+                originalElementsMap !== null &&
+                originalElements !== null &&
+                accumulatedChange !== null
+              ) {
+                const instantChange = event.clientX - lastPointer.x;
+                accumulatedChange += instantChange;
+
+                dragInputCallback({
+                  accumulatedChange,
+                  instantChange,
+                  originalElements,
+                  originalElementsMap,
+                  shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+                  shouldChangeByStepSize: event.shiftKey,
+                  property,
+                  scene,
+                  originalAppState,
+                });
+              }
+
+              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, elements, appState);
+              app.focusContainer();
+            }
+          }
+        }}
+        ref={inputRef}
+        value={inputValue}
+        onChange={(event) => {
+          stateRef.current.updatePending = true;
+          setInputValue(event.target.value);
+        }}
+        onFocus={(event) => {
+          event.target.select();
+          stateRef.current.originalElements = elements;
+          stateRef.current.originalAppState = cloneJSON(appState);
+        }}
+        onBlur={(event) => {
+          if (!inputValue) {
+            setInputValue(value.toString());
+          } else if (editable) {
+            handleInputValue(
+              event.target.value,
+              stateRef.current.originalElements,
+              stateRef.current.originalAppState,
+            );
+          }
+        }}
+        disabled={!editable}
+      />
+    </div>
+  );
+};
+
+export default StatsDragInput;

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

@@ -0,0 +1,99 @@
+import type {
+  ExcalidrawElement,
+  ExcalidrawTextElement,
+} from "../../element/types";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { mutateElement } from "../../element/mutateElement";
+import { getStepSizedValue } from "./utils";
+import { fontSizeIcon } from "../icons";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { isTextElement, redrawTextBoundingBox } from "../../element";
+import { hasBoundTextElement } from "../../element/typeChecks";
+import { getBoundTextElement } from "../../element/textElement";
+
+interface FontSizeProps {
+  element: ExcalidrawElement;
+  scene: Scene;
+  appState: AppState;
+  property: "fontSize";
+}
+
+const MIN_FONT_SIZE = 4;
+const STEP_SIZE = 4;
+
+const handleFontSizeChange: DragInputCallbackType<
+  FontSizeProps["property"],
+  ExcalidrawTextElement
+> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+
+  const origElement = originalElements[0];
+  if (origElement) {
+    const latestElement = elementsMap.get(origElement.id);
+    if (!latestElement || !isTextElement(latestElement)) {
+      return;
+    }
+
+    let nextFontSize;
+
+    if (nextValue !== undefined) {
+      nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+    } else if (origElement.type === "text") {
+      const originalFontSize = Math.round(origElement.fontSize);
+      const changeInFontSize = Math.round(accumulatedChange);
+      nextFontSize = Math.max(
+        originalFontSize + changeInFontSize,
+        MIN_FONT_SIZE,
+      );
+      if (shouldChangeByStepSize) {
+        nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
+      }
+    }
+
+    if (nextFontSize) {
+      mutateElement(latestElement, {
+        fontSize: nextFontSize,
+      });
+      redrawTextBoundingBox(
+        latestElement,
+        scene.getContainerElement(latestElement),
+        scene.getNonDeletedElementsMap(),
+      );
+    }
+  }
+};
+
+const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
+  const _element = isTextElement(element)
+    ? element
+    : hasBoundTextElement(element)
+    ? getBoundTextElement(element, scene.getNonDeletedElementsMap())
+    : null;
+
+  if (!_element) {
+    return null;
+  }
+
+  return (
+    <StatsDragInput
+      label="F"
+      value={Math.round(_element.fontSize * 10) / 10}
+      elements={[_element]}
+      dragInputCallback={handleFontSizeChange}
+      icon={fontSizeIcon}
+      appState={appState}
+      scene={scene}
+      property={property}
+    />
+  );
+};
+
+export default FontSize;

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

@@ -0,0 +1,135 @@
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import { isArrowElement } from "../../element/typeChecks";
+import type { 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";
+import type { AppState } from "../../types";
+
+interface MultiAngleProps {
+  elements: readonly ExcalidrawElement[];
+  scene: Scene;
+  appState: AppState;
+  property: "angle";
+}
+
+const STEP_SIZE = 15;
+
+const handleDegreeChange: DragInputCallbackType<
+  MultiAngleProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const editableLatestIndividualElements = originalElements
+    .map((el) => elementsMap.get(el.id))
+    .filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
+  const editableOriginalIndividualElements = originalElements.filter(
+    (el) => !isInGroup(el) && isPropertyEditable(el, property),
+  );
+
+  if (nextValue !== undefined) {
+    const nextAngle = degreeToRadian(nextValue);
+
+    for (const element of editableLatestIndividualElements) {
+      if (!element) {
+        continue;
+      }
+      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];
+    if (!latestElement) {
+      continue;
+    }
+    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 MultiAngle = ({
+  elements,
+  scene,
+  appState,
+  property,
+}: MultiAngleProps) => {
+  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}
+      appState={appState}
+      scene={scene}
+      property={property}
+    />
+  );
+};
+
+export default MultiAngle;

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

@@ -0,0 +1,382 @@
+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,
+  NonDeletedSceneElementsMap,
+} from "../../element/types";
+import type Scene from "../../scene/Scene";
+import type { AppState, Point } from "../../types";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getAtomicUnits, 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: NonDeletedSceneElementsMap;
+  atomicUnits: AtomicUnit[];
+  scene: Scene;
+  appState: AppState;
+}
+
+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: NonDeletedSceneElementsMap,
+  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: NonDeletedSceneElementsMap,
+  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 handleDimensionChange: DragInputCallbackType<
+  MultiDimensionProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  originalAppState,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+  property,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const atomicUnits = getAtomicUnits(originalElements, originalAppState);
+  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,
+            origElement,
+            elementsMap,
+            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, origElement, elementsMap);
+      }
+    }
+  }
+
+  scene.triggerUpdate();
+};
+
+const MultiDimension = ({
+  property,
+  elements,
+  elementsMap,
+  atomicUnits,
+  scene,
+  appState,
+}: 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;
+
+  return (
+    <DragInput
+      label={property === "width" ? "W" : "H"}
+      elements={elements}
+      dragInputCallback={handleDimensionChange}
+      value={value}
+      editable={editable}
+      appState={appState}
+      property={property}
+      scene={scene}
+    />
+  );
+};
+
+export default MultiDimension;

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

@@ -0,0 +1,164 @@
+import { isTextElement, redrawTextBoundingBox } from "../../element";
+import { mutateElement } from "../../element/mutateElement";
+import { hasBoundTextElement } from "../../element/typeChecks";
+import type {
+  ExcalidrawElement,
+  ExcalidrawTextElement,
+  NonDeletedSceneElementsMap,
+} 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";
+import type { AppState } from "../../types";
+import { getBoundTextElement } from "../../element/textElement";
+
+interface MultiFontSizeProps {
+  elements: readonly ExcalidrawElement[];
+  scene: Scene;
+  elementsMap: NonDeletedSceneElementsMap;
+  appState: AppState;
+  property: "fontSize";
+}
+
+const MIN_FONT_SIZE = 4;
+const STEP_SIZE = 4;
+
+const getApplicableTextElements = (
+  elements: readonly (ExcalidrawElement | undefined)[],
+  elementsMap: NonDeletedSceneElementsMap,
+) =>
+  elements.reduce(
+    (acc: ExcalidrawTextElement[], el) => {
+      if (!el || isInGroup(el)) {
+        return acc;
+      }
+      if (isTextElement(el)) {
+        acc.push(el);
+        return acc;
+      }
+      if (hasBoundTextElement(el)) {
+        const boundTextElement = getBoundTextElement(el, elementsMap);
+        if (boundTextElement) {
+          acc.push(boundTextElement);
+          return acc;
+        }
+      }
+
+      return acc;
+    },
+
+    [],
+  );
+
+const handleFontSizeChange: DragInputCallbackType<
+  MultiFontSizeProps["property"],
+  ExcalidrawTextElement
+> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const latestTextElements = originalElements.map((el) =>
+    elementsMap.get(el.id),
+  ) as ExcalidrawTextElement[];
+
+  let nextFontSize;
+
+  if (nextValue) {
+    nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+
+    for (const textElement of latestTextElements) {
+      mutateElement(
+        textElement,
+        {
+          fontSize: nextFontSize,
+        },
+        false,
+      );
+
+      redrawTextBoundingBox(
+        textElement,
+        scene.getContainerElement(textElement),
+        elementsMap,
+        false,
+      );
+    }
+
+    scene.triggerUpdate();
+  } else {
+    const originalTextElements = originalElements 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);
+      }
+      mutateElement(
+        latestElement,
+        {
+          fontSize: nextFontSize,
+        },
+        false,
+      );
+
+      redrawTextBoundingBox(
+        latestElement,
+        scene.getContainerElement(latestElement),
+        elementsMap,
+        false,
+      );
+    }
+
+    scene.triggerUpdate();
+  }
+};
+
+const MultiFontSize = ({
+  elements,
+  scene,
+  appState,
+  property,
+  elementsMap,
+}: MultiFontSizeProps) => {
+  const latestTextElements = getApplicableTextElements(elements, elementsMap);
+
+  if (!latestTextElements.length) {
+    return null;
+  }
+
+  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;
+
+  return (
+    <StatsDragInput
+      label="F"
+      icon={fontSizeIcon}
+      elements={latestTextElements}
+      dragInputCallback={handleFontSizeChange}
+      value={value}
+      editable={editable}
+      scene={scene}
+      property={property}
+      appState={appState}
+    />
+  );
+};
+
+export default MultiFontSize;

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

@@ -0,0 +1,259 @@
+import type {
+  ElementsMap,
+  ExcalidrawElement,
+  NonDeletedSceneElementsMap,
+} from "../../element/types";
+import { rotate } from "../../math";
+import type Scene from "../../scene/Scene";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
+import { getCommonBounds, isTextElement } from "../../element";
+import { useMemo } from "react";
+import { getElementsInAtomicUnit, moveElement } from "./utils";
+import type { AtomicUnit } from "./utils";
+import type { AppState } from "../../types";
+
+interface MultiPositionProps {
+  property: "x" | "y";
+  elements: readonly ExcalidrawElement[];
+  elementsMap: ElementsMap;
+  atomicUnits: AtomicUnit[];
+  scene: Scene;
+  appState: AppState;
+}
+
+const STEP_SIZE = 10;
+
+const moveElements = (
+  property: MultiPositionProps["property"],
+  changeInTopX: number,
+  changeInTopY: number,
+  elements: readonly ExcalidrawElement[],
+  originalElements: readonly ExcalidrawElement[],
+  elementsMap: NonDeletedSceneElementsMap,
+  originalElementsMap: ElementsMap,
+) => {
+  for (let i = 0; i < elements.length; i++) {
+    const origElement = originalElements[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,
+      origElement,
+      elementsMap,
+      originalElementsMap,
+      false,
+    );
+  }
+};
+
+const moveGroupTo = (
+  nextX: number,
+  nextY: number,
+  originalElements: ExcalidrawElement[],
+  elementsMap: NonDeletedSceneElementsMap,
+  originalElementsMap: ElementsMap,
+  scene: Scene,
+) => {
+  const [x1, y1, ,] = getCommonBounds(originalElements);
+  const offsetX = nextX - x1;
+  const offsetY = nextY - y1;
+
+  for (let i = 0; i < originalElements.length; i++) {
+    const origElement = originalElements[i];
+
+    const latestElement = elementsMap.get(origElement.id);
+    if (!latestElement) {
+      continue;
+    }
+
+    // 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,
+        origElement,
+        elementsMap,
+        originalElementsMap,
+        false,
+      );
+    }
+  }
+};
+
+const handlePositionChange: DragInputCallbackType<
+  MultiPositionProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+  originalAppState,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+
+  if (nextValue !== undefined) {
+    for (const atomicUnit of getAtomicUnits(
+      originalElements,
+      originalAppState,
+    )) {
+      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.original),
+          elementsMap,
+          originalElementsMap,
+          scene,
+        );
+      } 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,
+            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,
+    originalElements,
+    originalElements,
+    elementsMap,
+    originalElementsMap,
+  );
+
+  scene.triggerUpdate();
+};
+
+const MultiPosition = ({
+  property,
+  elements,
+  elementsMap,
+  atomicUnits,
+  scene,
+  appState,
+}: 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";
+
+  return (
+    <StatsDragInput
+      label={property === "x" ? "X" : "Y"}
+      elements={elements}
+      dragInputCallback={handlePositionChange}
+      value={value}
+      property={property}
+      scene={scene}
+      appState={appState}
+    />
+  );
+};
+
+export default MultiPosition;

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

@@ -0,0 +1,115 @@
+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";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+
+interface PositionProps {
+  property: "x" | "y";
+  element: ExcalidrawElement;
+  elementsMap: ElementsMap;
+  scene: Scene;
+  appState: AppState;
+}
+
+const STEP_SIZE = 10;
+
+const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  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,
+      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,
+    origElement,
+    elementsMap,
+    originalElementsMap,
+  );
+};
+
+const Position = ({
+  property,
+  element,
+  elementsMap,
+  scene,
+  appState,
+}: 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;
+
+  return (
+    <StatsDragInput
+      label={property === "x" ? "X" : "Y"}
+      elements={[element]}
+      dragInputCallback={handlePositionChange}
+      value={value}
+      property={property}
+      scene={scene}
+      appState={appState}
+    />
+  );
+};
+
+export default Position;

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

@@ -0,0 +1,302 @@
+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 } 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 { getAtomicUnits } 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(() => {
+      return getAtomicUnits(selectedElements, appState);
+    }, [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}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <Position
+                        element={singleElement}
+                        property="y"
+                        elementsMap={elementsMap}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <Dimension
+                        property="width"
+                        element={singleElement}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <Dimension
+                        property="height"
+                        element={singleElement}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <Angle
+                        property="angle"
+                        element={singleElement}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <FontSize
+                        property="fontSize"
+                        element={singleElement}
+                        scene={scene}
+                        appState={appState}
+                      />
+                    </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}
+                        appState={appState}
+                      />
+                      <MultiPosition
+                        property="y"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <MultiDimension
+                        property="width"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <MultiDimension
+                        property="height"
+                        elements={multipleElements}
+                        elementsMap={elementsMap}
+                        atomicUnits={atomicUnits}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <MultiAngle
+                        property="angle"
+                        elements={multipleElements}
+                        scene={scene}
+                        appState={appState}
+                      />
+                      <MultiFontSize
+                        property="fontSize"
+                        elements={multipleElements}
+                        scene={scene}
+                        appState={appState}
+                        elementsMap={elementsMap}
+                      />
+                    </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
+    );
+  },
+);

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

@@ -0,0 +1,756 @@
+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, mutateElement } from "../..";
+import { t } from "../../i18n";
+import type {
+  ExcalidrawElement,
+  ExcalidrawLinearElement,
+  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";
+import React from "react";
+
+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 editInput = (input: HTMLInputElement, value: string) => {
+  input.focus();
+  fireEvent.change(input, { target: { value } });
+  input.blur();
+};
+
+const getStatsProperty = (label: string) => {
+  const elementStats = UI.queryStats()?.querySelector("#elementStats");
+
+  if (elementStats) {
+    const properties = elementStats?.querySelector(".statsItem");
+    return (
+      properties?.querySelector?.(
+        `.drag-input-container[data-testid="${label}"]`,
+      ) || null
+    );
+  }
+
+  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).toBeDefined();
+  expect(input.value).toBe(initialValue.toString());
+  editInput(input, String(nextValue));
+  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);
+    });
+  });
+});
+
+describe("binding with linear elements", () => {
+  beforeEach(async () => {
+    localStorage.clear();
+    renderStaticScene.mockClear();
+    reseed(19);
+    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);
+
+    UI.clickTool("arrow");
+    mouse.down(5, 0);
+    mouse.up(300, 50);
+
+    elementStats = stats?.querySelector("#elementStats");
+  });
+
+  beforeAll(() => {
+    mockBoundingClientRect();
+  });
+
+  afterAll(() => {
+    restoreOriginalGetBoundingClientRect();
+  });
+
+  it("should remain bound to linear element on small position change", async () => {
+    const linear = h.elements[1] as ExcalidrawLinearElement;
+    const inputX = getStatsProperty("X")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(linear.startBinding).not.toBe(null);
+    expect(inputX).not.toBeNull();
+    editInput(inputX, String("204"));
+    expect(linear.startBinding).not.toBe(null);
+  });
+
+  it("should remain bound to linear element on small angle change", async () => {
+    const linear = h.elements[1] as ExcalidrawLinearElement;
+    const inputAngle = getStatsProperty("A")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(linear.startBinding).not.toBe(null);
+    editInput(inputAngle, String("1"));
+    expect(linear.startBinding).not.toBe(null);
+  });
+
+  it("should unbind linear element on large position change", async () => {
+    const linear = h.elements[1] as ExcalidrawLinearElement;
+    const inputX = getStatsProperty("X")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(linear.startBinding).not.toBe(null);
+    expect(inputX).not.toBeNull();
+    editInput(inputX, String("254"));
+    expect(linear.startBinding).toBe(null);
+  });
+
+  it("should remain bound to linear element on small angle change", async () => {
+    const linear = h.elements[1] as ExcalidrawLinearElement;
+    const inputAngle = getStatsProperty("A")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+
+    expect(linear.startBinding).not.toBe(null);
+    editInput(inputAngle, String("45"));
+    expect(linear.startBinding).toBe(null);
+  });
+});
+
+// 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).toBeDefined();
+    expect(elementStats).toBeDefined();
+
+    // title
+    const title = elementStats?.querySelector("h3");
+    expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
+
+    // element type
+    const elementType = elementStats?.querySelector(".elementType");
+    expect(elementType).toBeDefined();
+    expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
+
+    // properties
+    const properties = elementStats?.querySelector(".statsItem");
+    expect(properties?.childNodes).toBeDefined();
+    ["X", "Y", "W", "H", "A"].forEach((label) => () => {
+      expect(
+        properties?.querySelector?.(
+          `.drag-input-container[data-testid="${label}"]`,
+        ),
+      ).toBeDefined();
+    });
+  });
+
+  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).toBeDefined();
+    expect(input.value).toBe(rectangle.width.toString());
+    editInput(input, "123.123");
+    expect(h.elements.length).toBe(1);
+    expect(rectangle.id).toBe(rectangleId);
+    expect(input.value).toBe("123.12");
+    expect(rectangle.width).toBe(123.12);
+
+    editInput(input, "88.98766");
+    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).toBeDefined();
+    expect(input.value).toBe(text.fontSize.toString());
+    editInput(input, "36");
+    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
+    editInput(input, "0");
+    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).toBeDefined();
+
+    // 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).toBeDefined();
+    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);
+  });
+
+  it("should display fontSize for bound text", () => {
+    const container = API.createElement({
+      type: "rectangle",
+      width: 200,
+      height: 100,
+    });
+    const text = API.createElement({
+      type: "text",
+      width: 200,
+      height: 100,
+      containerId: container.id,
+      fontSize: 20,
+    });
+    mutateElement(container, {
+      boundElements: [{ type: "text", id: text.id }],
+    });
+    h.elements = [container, text];
+
+    API.setSelectedElements([container]);
+    const fontSize = getStatsProperty("F")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(fontSize).toBeDefined();
+
+    editInput(fontSize, "40");
+
+    expect(text.fontSize).toBe(40);
+  });
+});
+
+// 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");
+
+    editInput(width, "250");
+    h.elements.forEach((el) => {
+      expect(el.width).toBe(250);
+    });
+
+    editInput(height, "450");
+    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({
+      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).toBeDefined();
+    expect(width.value).toBe("Mixed");
+
+    const height = getStatsProperty("H")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(height).toBeDefined();
+    expect(height.value).toBe("Mixed");
+
+    const angle = getStatsProperty("A")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(angle).toBeDefined();
+    expect(angle.value).toBe("0");
+
+    const fontSize = getStatsProperty("F")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(fontSize).toBeDefined();
+
+    // changing width does not affect text
+    editInput(width, "200");
+
+    expect(rectangle?.width).toBe(200);
+    expect(frame.width).toBe(200);
+    expect(text?.width).not.toBe(200);
+
+    editInput(angle, "40");
+
+    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).toBeDefined();
+    expect(Number(x.value)).toBe(x1);
+
+    editInput(x, "300");
+
+    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).toBeDefined();
+    expect(Number(y.value)).toBe(y1);
+
+    editInput(y, "200");
+
+    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).toBeDefined();
+    expect(Number(width.value)).toBe(200);
+
+    const height = getStatsProperty("H")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    expect(height).toBeDefined();
+    expect(Number(height.value)).toBe(200);
+
+    editInput(width, "400");
+
+    [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+    let newGroupWidth = x2 - x1;
+
+    expect(newGroupWidth).toBeCloseTo(400, 4);
+
+    editInput(width, "300");
+
+    [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+    newGroupWidth = x2 - x1;
+    expect(newGroupWidth).toBeCloseTo(300, 4);
+
+    editInput(height, "500");
+
+    [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+    const newGroupHeight = y2 - y1;
+    expect(newGroupHeight).toBeCloseTo(500, 4);
+  });
+});

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

@@ -0,0 +1,301 @@
+import {
+  bindOrUnbindLinearElements,
+  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,
+  isLinearElement,
+  isTextElement,
+} from "../../element/typeChecks";
+import type {
+  ElementsMap,
+  ExcalidrawElement,
+  NonDeletedExcalidrawElement,
+  NonDeletedSceneElementsMap,
+} from "../../element/types";
+import {
+  getSelectedGroupIds,
+  getElementsInGroup,
+  isInGroup,
+} from "../../groups";
+import { rotate } from "../../math";
+import type { AppState } from "../../types";
+import { getFontString } from "../../utils";
+
+export type StatsInputProperty =
+  | "x"
+  | "y"
+  | "width"
+  | "height"
+  | "angle"
+  | "fontSize";
+
+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,
+  origElement: ExcalidrawElement,
+  elementsMap: NonDeletedSceneElementsMap,
+  shouldInformMutation = true,
+) => {
+  const latestElement = elementsMap.get(origElement.id);
+  if (!latestElement) {
+    return;
+  }
+  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,
+  );
+  updateBindings(latestElement, elementsMap, {
+    newSize: {
+      width: nextWidth,
+      height: nextHeight,
+    },
+  });
+
+  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,
+      };
+    }
+  }
+
+  if (boundTextElement && boundTextFont) {
+    mutateElement(boundTextElement, {
+      fontSize: boundTextFont.fontSize,
+    });
+  }
+  handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
+};
+
+export const moveElement = (
+  newTopLeftX: number,
+  newTopLeftY: number,
+  originalElement: ExcalidrawElement,
+  elementsMap: NonDeletedSceneElementsMap,
+  originalElementsMap: ElementsMap,
+  shouldInformMutation = true,
+) => {
+  const latestElement = elementsMap.get(originalElement.id);
+  if (!latestElement) {
+    return;
+  }
+  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,
+  );
+  updateBindings(latestElement, elementsMap);
+
+  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,
+      );
+  }
+};
+
+export const getAtomicUnits = (
+  targetElements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const selectedGroupIds = getSelectedGroupIds(appState);
+  const _atomicUnits = selectedGroupIds.map((gid) => {
+    return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
+      acc[el.id] = true;
+      return acc;
+    }, {} as AtomicUnit);
+  });
+  targetElements
+    .filter((el) => !isInGroup(el))
+    .forEach((el) => {
+      _atomicUnits.push({
+        [el.id]: true,
+      });
+    });
+  return _atomicUnits;
+};
+
+export const updateBindings = (
+  latestElement: ExcalidrawElement,
+  elementsMap: NonDeletedSceneElementsMap,
+  options?: {
+    simultaneouslyUpdated?: readonly ExcalidrawElement[];
+    newSize?: { width: number; height: number };
+  },
+) => {
+  if (isLinearElement(latestElement)) {
+    bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
+  } else {
+    updateBoundElements(latestElement, elementsMap, options);
+  }
+};

+ 1 - 1
packages/excalidraw/components/TTDDialog/TTDDialog.scss

@@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
 
   .ttd-dialog-output-error {
     color: red;
-    font-weight: 800;
+    font-weight: 700;
     font-size: 30px;
     word-break: break-word;
     overflow: auto;

+ 4 - 13
packages/excalidraw/components/TTDDialog/common.ts

@@ -1,10 +1,6 @@
-import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
+import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
 import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
-import {
-  DEFAULT_EXPORT_PADDING,
-  DEFAULT_FONT_SIZE,
-  EDITOR_LS_KEYS,
-} from "../../constants";
+import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
 import { convertToExcalidrawElements, exportToCanvas } from "../../index";
 import type { NonDeletedExcalidrawElement } from "../../element/types";
 import type { AppClassProperties, BinaryFiles } from "../../types";
@@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps {
   api: Promise<{
     parseMermaidToExcalidraw: (
       definition: string,
-      options: MermaidOptions,
+      config?: MermaidConfig,
     ) => Promise<MermaidToExcalidrawResult>;
   }>;
 }
@@ -78,15 +74,10 @@ export const convertMermaidToExcalidraw = async ({
 
     let ret;
     try {
-      ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
-        fontSize: DEFAULT_FONT_SIZE,
-      });
+      ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
     } catch (err: any) {
       ret = await api.parseMermaidToExcalidraw(
         mermaidDefinition.replace(/"/g, "'"),
-        {
-          fontSize: DEFAULT_FONT_SIZE,
-        },
       );
     }
     const { elements, files } = ret;

+ 2 - 64
packages/excalidraw/components/UserList.scss

@@ -5,10 +5,11 @@
   --avatarList-gap: 0.625rem;
   --userList-padding: var(--space-factor);
 
-  .UserList-wrapper {
+  .UserList__wrapper {
     display: flex;
     width: 100%;
     justify-content: flex-end;
+    align-items: center;
     pointer-events: none !important;
   }
 
@@ -21,10 +22,6 @@
     align-items: center;
     gap: var(--avatarList-gap);
 
-    &:empty {
-      display: none;
-    }
-
     box-sizing: border-box;
 
     --max-size: calc(
@@ -157,66 +154,7 @@
   }
 
   .UserList__collaborators {
-    position: static;
     top: auto;
-    margin-top: 0;
     max-height: 50vh;
-    overflow-y: auto;
-    padding: 0.25rem 0.5rem;
-    border-top: 1px solid var(--userlist-collaborators-border-color);
-    border-bottom: 1px solid var(--userlist-collaborators-border-color);
-
-    &__empty {
-      color: var(--color-gray-60);
-      font-size: 0.75rem;
-      line-height: 150%;
-      padding: 0.5rem 0;
-    }
-  }
-
-  .UserList__hint {
-    padding: 0.5rem 0.75rem;
-    overflow: hidden;
-    text-align: center;
-    color: var(--userlist-hint-text-color);
-    font-size: 0.75rem;
-    line-height: 150%;
-  }
-
-  .UserList__search-wrapper {
-    position: relative;
-    height: 2.5rem;
-
-    svg {
-      position: absolute;
-      top: 50%;
-      transform: translateY(-50%);
-      left: 0.75rem;
-      width: 1.25rem;
-      height: 1.25rem;
-      color: var(--color-gray-40);
-      z-index: 1;
-    }
-  }
-
-  .UserList__search {
-    position: absolute;
-    top: 0;
-    left: 0;
-    width: 100%;
-    box-sizing: border-box;
-    border: 0 !important;
-    border-radius: 0 !important;
-    font-size: 0.875rem;
-    padding-left: 2.5rem !important;
-    padding-right: 0.75rem !important;
-
-    &::placeholder {
-      color: var(--color-gray-40);
-    }
-
-    &:focus {
-      box-shadow: none !important;
-    }
   }
 }

+ 43 - 50
packages/excalidraw/components/UserList.tsx

@@ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager";
 
 import * as Popover from "@radix-ui/react-popover";
 import { Island } from "./Island";
-import { searchIcon } from "./icons";
+import { QuickSearch } from "./QuickSearch";
 import { t } from "../i18n";
 import { isShallowEqual } from "../utils";
 import { supportsResizeObserver } from "../constants";
 import type { MarkRequired } from "../utility-types";
+import { ScrollableList } from "./ScrollableList";
 
 export type GoToCollaboratorComponentProps = {
   socketId: SocketId;
@@ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({
   shouldWrap ? (
     <Tooltip label={username || "Unknown user"}>{children}</Tooltip>
   ) : (
-    <React.Fragment>{children}</React.Fragment>
+    <>{children}</>
   );
 
 const renderCollaborator = ({
@@ -128,6 +129,10 @@ export const UserList = React.memo(
     ).filter((collaborator) => collaborator.username?.trim());
 
     const [searchTerm, setSearchTerm] = React.useState("");
+    const filteredCollaborators = uniqueCollaboratorsArray.filter(
+      (collaborator) =>
+        collaborator.username?.toLowerCase().includes(searchTerm),
+    );
 
     const userListWrapper = React.useRef<HTMLDivElement | null>(null);
 
@@ -161,14 +166,6 @@ export const UserList = React.memo(
 
     const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
 
-    const searchTermNormalized = searchTerm.trim().toLowerCase();
-
-    const filteredCollaborators = searchTermNormalized
-      ? uniqueCollaboratorsArray.filter((collaborator) =>
-          collaborator.username?.toLowerCase().includes(searchTerm),
-        )
-      : uniqueCollaboratorsArray;
-
     const firstNCollaborators = uniqueCollaboratorsArray.slice(
       0,
       maxAvatars - 1,
@@ -197,7 +194,7 @@ export const UserList = React.memo(
         )}
       </div>
     ) : (
-      <div className="UserList-wrapper" ref={userListWrapper}>
+      <div className="UserList__wrapper" ref={userListWrapper}>
         <div
           className={clsx("UserList", className)}
           style={{ [`--max-avatars` as any]: maxAvatars }}
@@ -205,13 +202,7 @@ export const UserList = React.memo(
           {firstNAvatarsJSX}
 
           {uniqueCollaboratorsArray.length > maxAvatars - 1 && (
-            <Popover.Root
-              onOpenChange={(isOpen) => {
-                if (!isOpen) {
-                  setSearchTerm("");
-                }
-              }}
-            >
+            <Popover.Root>
               <Popover.Trigger className="UserList__more">
                 +{uniqueCollaboratorsArray.length - maxAvatars + 1}
               </Popover.Trigger>
@@ -224,41 +215,43 @@ export const UserList = React.memo(
                 align="end"
                 sideOffset={10}
               >
-                <Island style={{ overflow: "hidden" }}>
+                <Island padding={2}>
                   {uniqueCollaboratorsArray.length >=
                     SHOW_COLLABORATORS_FILTER_AT && (
-                    <div className="UserList__search-wrapper">
-                      {searchIcon}
-                      <input
-                        className="UserList__search"
-                        type="text"
-                        placeholder={t("userList.search.placeholder")}
-                        value={searchTerm}
-                        onChange={(e) => {
-                          setSearchTerm(e.target.value);
-                        }}
-                      />
-                    </div>
+                    <QuickSearch
+                      placeholder={t("quickSearch.placeholder")}
+                      onChange={setSearchTerm}
+                    />
                   )}
-                  <div className="dropdown-menu UserList__collaborators">
-                    {filteredCollaborators.length === 0 && (
-                      <div className="UserList__collaborators__empty">
-                        {t("userList.search.empty")}
-                      </div>
-                    )}
-                    <div className="UserList__hint">
-                      {t("userList.hint.text")}
-                    </div>
-                    {filteredCollaborators.map((collaborator) =>
-                      renderCollaborator({
-                        actionManager,
-                        collaborator,
-                        socketId: collaborator.socketId,
-                        withName: true,
-                        isBeingFollowed: collaborator.socketId === userToFollow,
-                      }),
-                    )}
-                  </div>
+                  <ScrollableList
+                    className={"dropdown-menu UserList__collaborators"}
+                    placeholder={t("userList.empty")}
+                  >
+                    {/* The list checks for `Children.count()`, hence defensively returning empty list */}
+                    {filteredCollaborators.length > 0
+                      ? [
+                          <div className="hint">{t("userList.hint.text")}</div>,
+                          filteredCollaborators.map((collaborator) =>
+                            renderCollaborator({
+                              actionManager,
+                              collaborator,
+                              socketId: collaborator.socketId,
+                              withName: true,
+                              isBeingFollowed:
+                                collaborator.socketId === userToFollow,
+                            }),
+                          ),
+                        ]
+                      : []}
+                  </ScrollableList>
+                  <Popover.Arrow
+                    width={20}
+                    height={10}
+                    style={{
+                      fill: "var(--popup-bg-color)",
+                      filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
+                    }}
+                  />
                 </Island>
               </Popover.Content>
             </Popover.Root>

+ 10 - 4
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -9,7 +9,10 @@ import type {
   RenderableElementsMap,
   RenderInteractiveSceneCallback,
 } from "../../scene/types";
-import type { NonDeletedExcalidrawElement } from "../../element/types";
+import type {
+  NonDeletedExcalidrawElement,
+  NonDeletedSceneElementsMap,
+} from "../../element/types";
 import { isRenderThrottlingEnabled } from "../../reactUtils";
 import { renderInteractiveScene } from "../../renderer/interactiveScene";
 
@@ -19,7 +22,8 @@ type InteractiveCanvasProps = {
   elementsMap: RenderableElementsMap;
   visibleElements: readonly NonDeletedExcalidrawElement[];
   selectedElements: readonly NonDeletedExcalidrawElement[];
-  versionNonce: number | undefined;
+  allElementsMap: NonDeletedSceneElementsMap;
+  sceneNonce: number | undefined;
   selectionNonce: number | undefined;
   scale: number;
   appState: InteractiveCanvasAppState;
@@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
         elementsMap: props.elementsMap,
         visibleElements: props.visibleElements,
         selectedElements: props.selectedElements,
+        allElementsMap: props.allElementsMap,
         scale: window.devicePixelRatio,
         appState: props.appState,
         renderConfig: {
@@ -197,6 +202,7 @@ const getRelevantAppStateProps = (
   activeEmbeddable: appState.activeEmbeddable,
   snapLines: appState.snapLines,
   zenModeEnabled: appState.zenModeEnabled,
+  editingElement: appState.editingElement,
 });
 
 const areEqual = (
@@ -206,10 +212,10 @@ const areEqual = (
   // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
   if (
     prevProps.selectionNonce !== nextProps.selectionNonce ||
-    prevProps.versionNonce !== nextProps.versionNonce ||
+    prevProps.sceneNonce !== nextProps.sceneNonce ||
     prevProps.scale !== nextProps.scale ||
     // we need to memoize on elementsMap because they may have renewed
-    // even if versionNonce didn't change (e.g. we filter elements out based
+    // even if sceneNonce didn't change (e.g. we filter elements out based
     // on appState)
     prevProps.elementsMap !== nextProps.elementsMap ||
     prevProps.visibleElements !== nextProps.visibleElements ||

+ 4 - 3
packages/excalidraw/components/canvases/StaticCanvas.tsx

@@ -19,7 +19,7 @@ type StaticCanvasProps = {
   elementsMap: RenderableElementsMap;
   allElementsMap: NonDeletedSceneElementsMap;
   visibleElements: readonly NonDeletedExcalidrawElement[];
-  versionNonce: number | undefined;
+  sceneNonce: number | undefined;
   selectionNonce: number | undefined;
   scale: number;
   appState: StaticCanvasAppState;
@@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
   selectedElementIds: appState.selectedElementIds,
   frameToHighlight: appState.frameToHighlight,
   editingGroupId: appState.editingGroupId,
+  currentHoveredFontFamily: appState.currentHoveredFontFamily,
 });
 
 const areEqual = (
@@ -112,10 +113,10 @@ const areEqual = (
   nextProps: StaticCanvasProps,
 ) => {
   if (
-    prevProps.versionNonce !== nextProps.versionNonce ||
+    prevProps.sceneNonce !== nextProps.sceneNonce ||
     prevProps.scale !== nextProps.scale ||
     // we need to memoize on elementsMap because they may have renewed
-    // even if versionNonce didn't change (e.g. we filter elements out based
+    // even if sceneNonce didn't change (e.g. we filter elements out based
     // on appState)
     prevProps.elementsMap !== nextProps.elementsMap ||
     prevProps.visibleElements !== nextProps.visibleElements

+ 57 - 9
packages/excalidraw/components/dropdownMenu/DropdownMenu.scss

@@ -4,7 +4,7 @@
   .dropdown-menu {
     position: absolute;
     top: 100%;
-    margin-top: 0.25rem;
+    margin-top: 0.5rem;
 
     &--mobile {
       left: 0;
@@ -35,21 +35,69 @@
 
     .dropdown-menu-item-base {
       display: flex;
-      padding: 0 0.625rem;
       column-gap: 0.625rem;
       font-size: 0.875rem;
       color: var(--color-on-surface);
       width: 100%;
       box-sizing: border-box;
-      font-weight: normal;
+      font-weight: 400;
       font-family: inherit;
     }
 
+    &.manual-hover {
+      // disable built-in hover due to keyboard navigation
+      .dropdown-menu-item {
+        &:hover {
+          background-color: transparent;
+        }
+
+        &--hovered {
+          background-color: var(--button-hover-bg) !important;
+        }
+
+        &--selected {
+          background-color: var(--color-primary-light) !important;
+        }
+      }
+    }
+
+    &.fonts {
+      margin-top: 1rem;
+      // display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
+      // count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
+      max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
+
+      @media screen and (min-width: 1921px) {
+        max-height: calc(
+          7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
+        );
+      }
+
+      .dropdown-menu-item-base {
+        display: inline-flex;
+      }
+
+      .dropdown-menu-group:not(:first-child) {
+        margin-top: 1rem;
+      }
+
+      .dropdown-menu-group-title {
+        font-size: 0.75rem;
+        text-align: left;
+        font-weight: 400;
+        margin: 0 0 0.5rem;
+        line-height: 1.3;
+      }
+    }
+
     .dropdown-menu-item {
+      height: 2rem;
+      margin: 1px;
+      padding: 0 0.5rem;
+      width: calc(100% - 2px);
       background-color: transparent;
       border: 1px solid transparent;
       align-items: center;
-      height: 2rem;
       cursor: pointer;
       border-radius: var(--border-radius-md);
 
@@ -57,11 +105,6 @@
         height: 2.25rem;
       }
 
-      &--selected {
-        background: var(--color-primary-light);
-        --icon-fill-color: var(--color-primary-darker);
-      }
-
       &__text {
         display: flex;
         align-items: center;
@@ -83,6 +126,11 @@
         }
       }
 
+      &--selected {
+        background: var(--color-primary-light);
+        --icon-fill-color: var(--color-primary-darker);
+      }
+
       &:hover {
         background-color: var(--button-hover-bg);
         text-decoration: none;

+ 72 - 18
packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx

@@ -1,37 +1,62 @@
-import React from "react";
+import React, { useEffect, useRef } from "react";
 import {
   getDropdownMenuItemClassName,
   useHandleDropdownMenuItemClick,
 } from "./common";
 import MenuItemContent from "./DropdownMenuItemContent";
+import { useExcalidrawAppState } from "../App";
+import { THEME } from "../../constants";
+import type { ValueOf } from "../../utility-types";
 
 const DropdownMenuItem = ({
   icon,
-  onSelect,
+  value,
+  order,
   children,
   shortcut,
   className,
+  hovered,
   selected,
+  textStyle,
+  onSelect,
+  onClick,
   ...rest
 }: {
   icon?: JSX.Element;
-  onSelect: (event: Event) => void;
+  value?: string | number | undefined;
+  order?: number;
+  onSelect?: (event: Event) => void;
   children: React.ReactNode;
   shortcut?: string;
+  hovered?: boolean;
   selected?: boolean;
+  textStyle?: React.CSSProperties;
   className?: string;
 } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
-  const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+  const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
+  const ref = useRef<HTMLButtonElement>(null);
+
+  useEffect(() => {
+    if (hovered) {
+      if (order === 0) {
+        // scroll into the first item differently, so it's visible what is above (i.e. group title)
+        ref.current?.scrollIntoView({ block: "end" });
+      } else {
+        ref.current?.scrollIntoView({ block: "nearest" });
+      }
+    }
+  }, [hovered, order]);
 
   return (
     <button
       {...rest}
+      ref={ref}
+      value={value}
       onClick={handleClick}
-      type="button"
-      className={getDropdownMenuItemClassName(className, selected)}
+      className={getDropdownMenuItemClassName(className, selected, hovered)}
       title={rest.title ?? rest["aria-label"]}
     >
-      <MenuItemContent icon={icon} shortcut={shortcut}>
+      <MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
         {children}
       </MenuItemContent>
     </button>
@@ -39,24 +64,53 @@ const DropdownMenuItem = ({
 };
 DropdownMenuItem.displayName = "DropdownMenuItem";
 
+export const DropDownMenuItemBadgeType = {
+  GREEN: "green",
+  RED: "red",
+  BLUE: "blue",
+} as const;
+
 export const DropDownMenuItemBadge = ({
+  type = DropDownMenuItemBadgeType.BLUE,
   children,
 }: {
+  type?: ValueOf<typeof DropDownMenuItemBadgeType>;
   children: React.ReactNode;
 }) => {
-  return (
-    <div
-      style={{
-        display: "inline-flex",
-        marginLeft: "auto",
-        padding: "2px 4px",
+  const { theme } = useExcalidrawAppState();
+  const style = {
+    display: "inline-flex",
+    marginLeft: "auto",
+    padding: "2px 4px",
+    borderRadius: 6,
+    fontSize: 9,
+    fontFamily: "Cascadia, monospace",
+    border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
+  };
+
+  switch (type) {
+    case DropDownMenuItemBadgeType.GREEN:
+      Object.assign(style, {
+        backgroundColor: "var(--background-color-badge)",
+        color: "var(--color-badge)",
+      });
+      break;
+    case DropDownMenuItemBadgeType.RED:
+      Object.assign(style, {
+        backgroundColor: "pink",
+        color: "darkred",
+      });
+      break;
+    case DropDownMenuItemBadgeType.BLUE:
+    default:
+      Object.assign(style, {
         background: "var(--color-promo)",
         color: "var(--color-surface-lowest)",
-        borderRadius: 6,
-        fontSize: 9,
-        fontFamily: "Cascadia, monospace",
-      }}
-    >
+      });
+  }
+
+  return (
+    <div className="DropDownMenuItemBadge" style={style}>
       {children}
     </div>
   );

+ 6 - 2
packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx

@@ -1,19 +1,23 @@
 import { useDevice } from "../App";
 
 const MenuItemContent = ({
+  textStyle,
   icon,
   shortcut,
   children,
 }: {
   icon?: JSX.Element;
   shortcut?: string;
+  textStyle?: React.CSSProperties;
   children: React.ReactNode;
 }) => {
   const device = useDevice();
   return (
     <>
-      <div className="dropdown-menu-item__icon">{icon}</div>
-      <div className="dropdown-menu-item__text">{children}</div>
+      {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
+      <div style={textStyle} className="dropdown-menu-item__text">
+        {children}
+      </div>
       {shortcut && !device.editor.isMobile && (
         <div className="dropdown-menu-item__shortcut">{shortcut}</div>
       )}

Some files were not shown because too many files changed in this diff