Selaa lähdekoodia

feat: introduce font picker (#8012)

Co-authored-by: dwelle <[email protected]>
Marcel Mraz 1 vuosi sitten
vanhempi
commit
62228e0bbb
100 muutettua tiedostoa jossa 2779 lisäystä ja 1027 poistoa
  1. 1 1
      dev-docs/src/css/custom.scss
  2. 2 2
      examples/excalidraw/components/App.tsx
  3. 1 1
      examples/excalidraw/initialData.tsx
  4. 3 0
      examples/excalidraw/with-nextjs/.gitignore
  5. 2 1
      examples/excalidraw/with-nextjs/package.json
  6. 4 1
      examples/excalidraw/with-nextjs/src/app/page.tsx
  7. 1 1
      examples/excalidraw/with-nextjs/src/common.scss
  8. 2 0
      examples/excalidraw/with-script-in-browser/.gitignore
  9. 1 0
      examples/excalidraw/with-script-in-browser/index.html
  10. 4 2
      examples/excalidraw/with-script-in-browser/package.json
  11. 63 4
      excalidraw-app/index.html
  12. 2 1
      excalidraw-app/package.json
  13. 2 2
      excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
  14. 11 1
      excalidraw-app/vite.config.mts
  15. 2 0
      packages/excalidraw/CHANGELOG.md
  16. 4 2
      packages/excalidraw/actions/actionProperties.test.tsx
  17. 368 83
      packages/excalidraw/actions/actionProperties.tsx
  18. 3 5
      packages/excalidraw/actions/actionStyles.ts
  19. 2 0
      packages/excalidraw/appState.ts
  20. 1 3
      packages/excalidraw/components/Actions.tsx
  21. 49 15
      packages/excalidraw/components/App.tsx
  22. 12 0
      packages/excalidraw/components/ButtonIcon.scss
  23. 36 0
      packages/excalidraw/components/ButtonIcon.tsx
  24. 8 11
      packages/excalidraw/components/ButtonIconSelect.tsx
  25. 10 0
      packages/excalidraw/components/ButtonSeparator.tsx
  26. 1 1
      packages/excalidraw/components/ColorPicker/ColorPicker.scss
  27. 70 123
      packages/excalidraw/components/ColorPicker/ColorPicker.tsx
  28. 1 1
      packages/excalidraw/components/ColorPicker/Picker.tsx
  29. 15 0
      packages/excalidraw/components/FontPicker/FontPicker.scss
  30. 110 0
      packages/excalidraw/components/FontPicker/FontPicker.tsx
  31. 268 0
      packages/excalidraw/components/FontPicker/FontPickerList.tsx
  32. 38 0
      packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx
  33. 66 0
      packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts
  34. 2 2
      packages/excalidraw/components/HelpDialog.scss
  35. 4 0
      packages/excalidraw/components/HelpDialog.tsx
  36. 1 1
      packages/excalidraw/components/LibraryMenu.scss
  37. 2 2
      packages/excalidraw/components/LibraryMenuItems.scss
  38. 96 0
      packages/excalidraw/components/PropertiesPopover.tsx
  39. 1 1
      packages/excalidraw/components/PublishLibrary.scss
  40. 48 0
      packages/excalidraw/components/QuickSearch.scss
  41. 28 0
      packages/excalidraw/components/QuickSearch.tsx
  42. 21 0
      packages/excalidraw/components/ScrollableList.scss
  43. 24 0
      packages/excalidraw/components/ScrollableList.tsx
  44. 1 1
      packages/excalidraw/components/TTDDialog/TTDDialog.scss
  45. 2 64
      packages/excalidraw/components/UserList.scss
  46. 43 50
      packages/excalidraw/components/UserList.tsx
  47. 1 0
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  48. 57 9
      packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
  49. 72 18
      packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx
  50. 6 2
      packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx
  51. 4 2
      packages/excalidraw/components/dropdownMenu/common.ts
  52. 21 0
      packages/excalidraw/components/icons.tsx
  53. 2 2
      packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx
  54. 3 3
      packages/excalidraw/components/welcome-screen/WelcomeScreen.Hints.tsx
  55. 2 2
      packages/excalidraw/components/welcome-screen/WelcomeScreen.scss
  56. 15 3
      packages/excalidraw/constants.ts
  57. 9 12
      packages/excalidraw/css/styles.scss
  58. 3 0
      packages/excalidraw/css/theme.scss
  59. 11 1
      packages/excalidraw/css/variables.module.scss
  60. 25 25
      packages/excalidraw/data/__snapshots__/transform.test.ts.snap
  61. 3 6
      packages/excalidraw/data/restore.ts
  62. 3 7
      packages/excalidraw/data/transform.ts
  63. 3 1
      packages/excalidraw/element/mutateElement.ts
  64. 3 3
      packages/excalidraw/element/newElement.ts
  65. 4 4
      packages/excalidraw/element/textElement.test.ts
  66. 83 141
      packages/excalidraw/element/textElement.ts
  67. 10 10
      packages/excalidraw/element/textWysiwyg.test.tsx
  68. 49 61
      packages/excalidraw/element/textWysiwyg.tsx
  69. 78 0
      packages/excalidraw/fonts/ExcalidrawFont.ts
  70. 0 0
      packages/excalidraw/fonts/assets/Assistant-Bold.woff2
  71. 0 0
      packages/excalidraw/fonts/assets/Assistant-Medium.woff2
  72. 0 0
      packages/excalidraw/fonts/assets/Assistant-Regular.woff2
  73. 0 0
      packages/excalidraw/fonts/assets/Assistant-SemiBold.woff2
  74. BIN
      packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2
  75. BIN
      packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2
  76. BIN
      packages/excalidraw/fonts/assets/Excalifont-Regular.woff2
  77. BIN
      packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2
  78. BIN
      packages/excalidraw/fonts/assets/Virgil-Regular.woff2
  79. 34 0
      packages/excalidraw/fonts/assets/fonts.css
  80. 308 0
      packages/excalidraw/fonts/index.ts
  81. 125 0
      packages/excalidraw/fonts/metadata.ts
  82. 3 1
      packages/excalidraw/index.tsx
  83. 13 4
      packages/excalidraw/locales/en.json
  84. 12 4
      packages/excalidraw/renderer/renderElement.ts
  85. 1 1
      packages/excalidraw/renderer/staticSvgScene.ts
  86. 0 90
      packages/excalidraw/scene/Fonts.ts
  87. 57 31
      packages/excalidraw/scene/export.ts
  88. 34 17
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  89. 2 16
      packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
  90. 1 12
      packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
  91. 139 82
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  92. 1 1
      packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap
  93. 104 52
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  94. 4 6
      packages/excalidraw/tests/clipboard.test.tsx
  95. 1 1
      packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap
  96. 15 0
      packages/excalidraw/tests/fixtures/elementFixture.ts
  97. 4 0
      packages/excalidraw/tests/helpers/polyfills.ts
  98. 2 2
      packages/excalidraw/tests/regressionTests.test.tsx
  99. 1 11
      packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap
  100. 15 2
      packages/excalidraw/tests/scene/export.test.ts

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

+ 63 - 4
excalidraw-app/index.html

@@ -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>

+ 2 - 1
excalidraw-app/package.json

@@ -36,7 +36,8 @@
     "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,

+ 2 - 0
packages/excalidraw/CHANGELOG.md

@@ -19,6 +19,8 @@ Please add the latest change on the top under the correct section.
 
 - 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)

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

+ 368 - 83
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;
 
@@ -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) {

+ 2 - 0
packages/excalidraw/appState.ts

@@ -36,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,
@@ -149,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 },

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

@@ -158,10 +158,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")}

+ 49 - 15
packages/excalidraw/components/App.tsx

@@ -321,7 +321,6 @@ import {
   getBoundTextElement,
   getContainerCenter,
   getContainerElement,
-  getDefaultLineHeight,
   getLineHeightInPx,
   getMinTextElementWidth,
   isMeasureTextSupported,
@@ -337,7 +336,7 @@ import {
 import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
 import { shouldShowBoundingBox } from "../element/transformHandles";
 import { actionUnlockAllElements } from "../actions/actionElementLock";
-import { Fonts } from "../scene/Fonts";
+import { Fonts, getLineHeight } from "../fonts";
 import {
   getFrameChildren,
   isCursorInFrame,
@@ -532,8 +531,8 @@ class App extends React.Component<AppProps, AppState> {
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
 
   public scene: Scene;
+  public fonts: Fonts;
   public renderer: Renderer;
-  private fonts: Fonts;
   private resizeObserver: ResizeObserver | undefined;
   private nearestScrollableContainer: HTMLElement | Document | undefined;
   public library: AppClassProperties["library"];
@@ -2335,11 +2334,6 @@ class App extends React.Component<AppProps, AppState> {
         }),
       };
     }
-    // FontFaceSet loadingdone event we listen on may not always fire
-    // (looking at you Safari), so on init we manually load fonts for current
-    // text elements on canvas, and rerender them once done. This also
-    // seems faster even in browsers that do fire the loadingdone event.
-    this.fonts.loadFontsForElements(scene.elements);
 
     this.resetStore();
     this.resetHistory();
@@ -2347,6 +2341,12 @@ class App extends React.Component<AppProps, AppState> {
       ...scene,
       storeAction: StoreAction.UPDATE,
     });
+
+    // FontFaceSet loadingdone event we listen on may not always
+    // fire (looking at you Safari), so on init we manually load all
+    // fonts and rerender scene text elements once done. This also
+    // seems faster even in browsers that do fire the loadingdone event.
+    this.fonts.load();
   };
 
   private isMobileBreakpoint = (width: number, height: number) => {
@@ -2439,6 +2439,10 @@ class App extends React.Component<AppProps, AppState> {
           configurable: true,
           value: this.store,
         },
+        fonts: {
+          configurable: true,
+          value: this.fonts,
+        },
       });
     }
 
@@ -2576,7 +2580,7 @@ class App extends React.Component<AppProps, AppState> {
       // rerender text elements on font load to fix #637 && #1553
       addEventListener(document.fonts, "loadingdone", (event) => {
         const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
-        this.fonts.onFontsLoaded(loadedFontFaces);
+        this.fonts.onLoaded(loadedFontFaces);
       }),
       // Safari-only desktop pinch zoom
       addEventListener(
@@ -3379,7 +3383,7 @@ class App extends React.Component<AppProps, AppState> {
       fontSize: textElementProps.fontSize,
       fontFamily: textElementProps.fontFamily,
     });
-    const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
+    const lineHeight = getLineHeight(textElementProps.fontFamily);
     const [x1, , x2] = getVisibleSceneBounds(this.state);
     // long texts should not go beyond 800 pixels in width nor should it go below 200 px
     const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
@@ -3397,13 +3401,13 @@ class App extends React.Component<AppProps, AppState> {
           });
 
           let metrics = measureText(originalText, fontString, lineHeight);
-          const isTextWrapped = metrics.width > maxTextWidth;
+          const isTextUnwrapped = metrics.width > maxTextWidth;
 
-          const text = isTextWrapped
+          const text = isTextUnwrapped
             ? wrapText(originalText, fontString, maxTextWidth)
             : originalText;
 
-          metrics = isTextWrapped
+          metrics = isTextUnwrapped
             ? measureText(text, fontString, lineHeight)
             : metrics;
 
@@ -3417,7 +3421,7 @@ class App extends React.Component<AppProps, AppState> {
             text,
             originalText,
             lineHeight,
-            autoResize: !isTextWrapped,
+            autoResize: !isTextUnwrapped,
             frameId: topLayerFrame ? topLayerFrame.id : null,
           });
           acc.push(element);
@@ -4107,6 +4111,36 @@ class App extends React.Component<AppProps, AppState> {
         }
       }
 
+      if (
+        !event[KEYS.CTRL_OR_CMD] &&
+        event.shiftKey &&
+        event.key.toLowerCase() === KEYS.F
+      ) {
+        const selectedElements = this.scene.getSelectedElements(this.state);
+
+        if (
+          this.state.activeTool.type === "selection" &&
+          !selectedElements.length
+        ) {
+          return;
+        }
+
+        if (
+          this.state.activeTool.type === "text" ||
+          selectedElements.find(
+            (element) =>
+              isTextElement(element) ||
+              getBoundTextElement(
+                element,
+                this.scene.getNonDeletedElementsMap(),
+              ),
+          )
+        ) {
+          event.preventDefault();
+          this.setState({ openPopup: "fontFamily" });
+        }
+      }
+
       if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
         if (this.state.activeTool.type === "laser") {
           this.setActiveTool({ type: "selection" });
@@ -4761,7 +4795,7 @@ class App extends React.Component<AppProps, AppState> {
       existingTextElement?.fontFamily || this.state.currentItemFontFamily;
 
     const lineHeight =
-      existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
+      existingTextElement?.lineHeight || getLineHeight(fontFamily);
     const fontSize = this.state.currentItemFontSize;
 
     if (

+ 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 - 11
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,21 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
       }
   ),
 ) => (
-  <div className="buttonList buttonListIcon">
+  <div className="buttonList">
     {props.options.map((option) =>
       props.type === "button" ? (
-        <button
-          type="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",
+    }}
+  />
+);

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

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

+ 4 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -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+<")]}

+ 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

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

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

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

+ 1 - 0
packages/excalidraw/components/canvases/StaticCanvas.tsx

@@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
   selectedElementIds: appState.selectedElementIds,
   frameToHighlight: appState.frameToHighlight,
   editingGroupId: appState.editingGroupId,
+  currentHoveredFontFamily: appState.currentHoveredFontFamily,
 });
 
 const areEqual = (

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

+ 4 - 2
packages/excalidraw/components/dropdownMenu/common.ts

@@ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{
 export const getDropdownMenuItemClassName = (
   className = "",
   selected = false,
+  hovered = false,
 ) => {
-  return `dropdown-menu-item dropdown-menu-item-base ${className} ${
-    selected ? "dropdown-menu-item--selected" : ""
+  return `dropdown-menu-item dropdown-menu-item-base ${className}
+  ${selected ? "dropdown-menu-item--selected" : ""} ${
+    hovered ? "dropdown-menu-item--hovered" : ""
   }`.trim();
 };
 

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

@@ -1438,6 +1438,27 @@ export const fontSizeIcon = createIcon(
   tablerIconProps,
 );
 
+export const FontFamilyHeadingIcon = createIcon(
+  <>
+    <g
+      stroke="currentColor"
+      strokeWidth="1.25"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+    >
+      <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+      <path d="M7 12h10" />
+      <path d="M7 5v14" />
+      <path d="M17 5v14" />
+      <path d="M15 19h4" />
+      <path d="M15 5h4" />
+      <path d="M5 19h4" />
+      <path d="M5 5h4" />
+    </g>
+  </>,
+  tablerIconProps,
+);
+
 export const FontFamilyNormalIcon = createIcon(
   <>
     <g

+ 2 - 2
packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -109,7 +109,7 @@ Center.displayName = "Center";
 
 const Logo = ({ children }: { children?: React.ReactNode }) => {
   return (
-    <div className="welcome-screen-center__logo virgil welcome-screen-decor">
+    <div className="welcome-screen-center__logo excalifont welcome-screen-decor">
       {children || <ExcalidrawLogo withText />}
     </div>
   );
@@ -118,7 +118,7 @@ Logo.displayName = "Logo";
 
 const Heading = ({ children }: { children: React.ReactNode }) => {
   return (
-    <div className="welcome-screen-center__heading welcome-screen-decor virgil">
+    <div className="welcome-screen-center__heading welcome-screen-decor excalifont">
       {children}
     </div>
   );

+ 3 - 3
packages/excalidraw/components/welcome-screen/WelcomeScreen.Hints.tsx

@@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
   const { WelcomeScreenMenuHintTunnel } = useTunnels();
   return (
     <WelcomeScreenMenuHintTunnel.In>
-      <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
+      <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
         {WelcomeScreenMenuArrow}
         <div className="welcome-screen-decor-hint__label">
           {children || t("welcomeScreen.defaults.menuHint")}
@@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
   const { WelcomeScreenToolbarHintTunnel } = useTunnels();
   return (
     <WelcomeScreenToolbarHintTunnel.In>
-      <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
+      <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
         <div className="welcome-screen-decor-hint__label">
           {children || t("welcomeScreen.defaults.toolbarHint")}
         </div>
@@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
   const { WelcomeScreenHelpHintTunnel } = useTunnels();
   return (
     <WelcomeScreenHelpHintTunnel.In>
-      <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
+      <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
         <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
         {WelcomeScreenHelpArrow}
       </div>

+ 2 - 2
packages/excalidraw/components/welcome-screen/WelcomeScreen.scss

@@ -1,6 +1,6 @@
 .excalidraw {
-  .virgil {
-    font-family: "Virgil";
+  .excalifont {
+    font-family: "Excalifont";
   }
 
   // WelcomeSreen common

+ 15 - 3
packages/excalidraw/constants.ts

@@ -114,12 +114,24 @@ export const CLASSES = {
   SHAPE_ACTIONS_MENU: "App-menu__left",
 };
 
-// 1-based in case we ever do `if(element.fontFamily)`
+/**
+ * // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
+ *
+ * Let's think this through and consider:
+ * - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
+ * - https://drafts.csswg.org/css-fonts-4/#font-family-prop
+ * - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
+ */
 export const FONT_FAMILY = {
   Virgil: 1,
   Helvetica: 2,
   Cascadia: 3,
-  Assistant: 4,
+  // leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
+  Excalifont: 5,
+  Nunito: 6,
+  "Lilita One": 7,
+  "Comic Shanns": 8,
+  "Liberation Sans": 9,
 };
 
 export const THEME = {
@@ -147,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 
 export const MIN_FONT_SIZE = 1;
 export const DEFAULT_FONT_SIZE = 20;
-export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
+export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
 export const DEFAULT_TEXT_ALIGN = "left";
 export const DEFAULT_VERTICAL_ALIGN = "top";
 export const DEFAULT_VERSION = "{version}";

+ 9 - 12
packages/excalidraw/css/styles.scss

@@ -152,7 +152,7 @@ body.excalidraw-cursor-resize * {
       margin-bottom: 0.25rem;
       font-size: 0.75rem;
       color: var(--text-primary-color);
-      font-weight: normal;
+      font-weight: 400;
       display: block;
     }
 
@@ -227,14 +227,7 @@ body.excalidraw-cursor-resize * {
     label,
     button,
     .zIndexButton {
-      @include outlineButtonStyles;
-
-      padding: 0;
-
-      svg {
-        width: var(--default-icon-size);
-        height: var(--default-icon-size);
-      }
+      @include outlineButtonIconStyles;
     }
   }
 
@@ -394,7 +387,7 @@ body.excalidraw-cursor-resize * {
   .App-menu__left {
     overflow-y: auto;
     padding: 0.75rem;
-    width: 202px;
+    width: 200px;
     box-sizing: border-box;
     position: absolute;
   }
@@ -585,7 +578,7 @@ body.excalidraw-cursor-resize * {
   // use custom, minimalistic scrollbar
   // (doesn't work in Firefox)
   ::-webkit-scrollbar {
-    width: 3px;
+    width: 4px;
     height: 3px;
   }
 
@@ -664,6 +657,10 @@ body.excalidraw-cursor-resize * {
       --button-hover-bg: #363541;
       --button-bg: var(--color-surface-high);
     }
+
+    .buttonList {
+      padding: 0.25rem 0;
+    }
   }
 
   .excalidraw__paragraph {
@@ -757,7 +754,7 @@ body.excalidraw-cursor-resize * {
     padding: 1rem 1.6rem;
     border-radius: 12px;
     color: #fff;
-    font-weight: bold;
+    font-weight: 700;
     letter-spacing: 0.6px;
     font-family: "Assistant";
   }

+ 3 - 0
packages/excalidraw/css/theme.scss

@@ -151,6 +151,9 @@
   --color-border-outline-variant: #c5c5d0;
   --color-surface-primary-container: #e0dfff;
 
+  --color-badge: #0b6513;
+  --background-color-badge: #d3ffd2;
+
   &.theme--dark {
     &.theme--dark-background-none {
       background: none;

+ 11 - 1
packages/excalidraw/css/variables.module.scss

@@ -124,6 +124,16 @@
   }
 }
 
+@mixin outlineButtonIconStyles {
+  @include outlineButtonStyles;
+  padding: 0;
+
+  svg {
+    width: var(--default-icon-size);
+    height: var(--default-icon-size);
+  }
+}
+
 @mixin avatarStyles {
   width: var(--avatar-size, 1.5rem);
   height: var(--avatar-size, 1.5rem);
@@ -135,7 +145,7 @@
   align-items: center;
   cursor: pointer;
   font-size: 0.75rem;
-  font-weight: 800;
+  font-weight: 700;
   line-height: 1;
   color: var(--color-gray-90);
   flex: 0 0 auto;

+ 25 - 25
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -239,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -285,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -386,7 +386,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "containerId": "id48",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -487,7 +487,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "containerId": "id37",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -662,7 +662,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "containerId": "id41",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -708,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -754,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -1207,7 +1207,7 @@ exports[`Test Transform > should transform text element 1`] = `
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -1248,7 +1248,7 @@ exports[`Test Transform > should transform text element 2`] = `
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -1581,7 +1581,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "containerId": "B",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [
@@ -1624,7 +1624,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "containerId": "A",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [
@@ -1667,7 +1667,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "containerId": "Alice",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [
@@ -1710,7 +1710,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "containerId": "Bob",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [
@@ -1753,7 +1753,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "containerId": "Bob_Alice",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "containerId": "Bob_B",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2043,7 +2043,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "containerId": "id25",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2084,7 +2084,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "containerId": "id26",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2125,7 +2125,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "containerId": "id27",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2167,7 +2167,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
   "containerId": "id28",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2431,7 +2431,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "containerId": "id13",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2472,7 +2472,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "containerId": "id14",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2514,7 +2514,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "containerId": "id15",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2558,7 +2558,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "containerId": "id16",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2600,7 +2600,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "containerId": "id17",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2643,7 +2643,7 @@ exports[`Test Transform > should transform to text containers when label provide
   "containerId": "id18",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],

+ 3 - 6
packages/excalidraw/data/restore.ts

@@ -44,14 +44,11 @@ import { bumpVersion } from "../element/mutateElement";
 import { getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { arrayToMap } from "../utils";
 import type { MarkOptional, Mutable } from "../utility-types";
-import {
-  detectLineHeight,
-  getContainerElement,
-  getDefaultLineHeight,
-} from "../element/textElement";
+import { detectLineHeight, getContainerElement } from "../element/textElement";
 import { normalizeLink } from "./url";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { getSizeFromPoints } from "../points";
+import { getLineHeight } from "../fonts";
 
 type RestoredAppState = Omit<
   AppState,
@@ -206,7 +203,7 @@ const restoreElement = (
             detectLineHeight(element)
           : // no element height likely means programmatic use, so default
             // to a fixed line height
-            getDefaultLineHeight(element.fontFamily));
+            getLineHeight(element.fontFamily));
       element = restoreElementWithProperties(element, {
         fontSize,
         fontFamily,

+ 3 - 7
packages/excalidraw/data/transform.ts

@@ -18,11 +18,7 @@ import {
   newMagicFrameElement,
   newTextElement,
 } from "../element/newElement";
-import {
-  getDefaultLineHeight,
-  measureText,
-  normalizeText,
-} from "../element/textElement";
+import { measureText, normalizeText } from "../element/textElement";
 import type {
   ElementsMap,
   ExcalidrawArrowElement,
@@ -54,6 +50,7 @@ import {
 import { getSizeFromPoints } from "../points";
 import { randomId } from "../random";
 import { syncInvalidIndices } from "../fractionalIndex";
+import { getLineHeight } from "../fonts";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";
@@ -568,8 +565,7 @@ export const convertToExcalidrawElements = (
       case "text": {
         const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
         const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
-        const lineHeight =
-          element?.lineHeight || getDefaultLineHeight(fontFamily);
+        const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
         const text = element.text ?? "";
         const normalizedText = normalizeText(text);
         const metrics = measureText(

+ 3 - 1
packages/excalidraw/element/mutateElement.ts

@@ -107,6 +107,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
 export const newElementWith = <TElement extends ExcalidrawElement>(
   element: TElement,
   updates: ElementUpdate<TElement>,
+  /** pass `true` to always regenerate */
+  force = false,
 ): TElement => {
   let didChange = false;
   for (const key in updates) {
@@ -123,7 +125,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
     }
   }
 
-  if (!didChange) {
+  if (!didChange && !force) {
     return element;
   }
 

+ 3 - 3
packages/excalidraw/element/newElement.ts

@@ -36,7 +36,6 @@ import {
   normalizeText,
   wrapText,
   getBoundTextMaxWidth,
-  getDefaultLineHeight,
 } from "./textElement";
 import {
   DEFAULT_ELEMENT_PROPS,
@@ -47,6 +46,7 @@ import {
   VERTICAL_ALIGN,
 } from "../constants";
 import type { MarkOptional, Merge, Mutable } from "../utility-types";
+import { getLineHeight } from "../fonts";
 
 export type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -228,7 +228,7 @@ export const newTextElement = (
 ): NonDeleted<ExcalidrawTextElement> => {
   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
   const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
-  const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
+  const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
   const text = normalizeText(opts.text);
   const metrics = measureText(
     text,
@@ -514,7 +514,7 @@ export const regenerateId = (
     if (
       window.h?.app
         ?.getSceneElementsIncludingDeleted()
-        .find((el) => el.id === nextId)
+        .find((el: ExcalidrawElement) => el.id === nextId)
     ) {
       nextId += "_copy";
     }

+ 4 - 4
packages/excalidraw/element/textElement.test.ts

@@ -1,4 +1,5 @@
 import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
+import { getLineHeight } from "../fonts";
 import { API } from "../tests/helpers/api";
 import {
   computeContainerDimensionForBoundText,
@@ -8,7 +9,6 @@ import {
   wrapText,
   detectLineHeight,
   getLineHeightInPx,
-  getDefaultLineHeight,
   parseTokens,
 } from "./textElement";
 import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
@@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => {
 describe("Test getDefaultLineHeight", () => {
   it("should return line height using default font family when not passed", () => {
     //@ts-ignore
-    expect(getDefaultLineHeight()).toBe(1.25);
+    expect(getLineHeight()).toBe(1.25);
   });
 
   it("should return line height using default font family for unknown font", () => {
     const UNKNOWN_FONT = 5;
-    expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
+    expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
   });
 
   it("should return correct line height", () => {
-    expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
+    expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
   });
 });

+ 83 - 141
packages/excalidraw/element/textElement.ts

@@ -6,7 +6,6 @@ import type {
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
   ExcalidrawTextElementWithContainer,
-  FontFamilyValues,
   FontString,
   NonDeletedExcalidrawElement,
 } from "./types";
@@ -17,7 +16,6 @@ import {
   BOUND_TEXT_PADDING,
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
-  FONT_FAMILY,
   TEXT_ALIGN,
   VERTICAL_ALIGN,
 } from "../constants";
@@ -30,7 +28,7 @@ import {
   resetOriginalContainerCache,
   updateOriginalContainerCache,
 } from "./containerCache";
-import type { ExtractSetType, MakeBrand } from "../utility-types";
+import type { ExtractSetType } from "../utility-types";
 
 export const normalizeText = (text: string) => {
   return (
@@ -321,24 +319,6 @@ export const getLineHeightInPx = (
   return fontSize * lineHeight;
 };
 
-/**
- * Calculates vertical offset for a text with alphabetic baseline.
- */
-export const getVerticalOffset = (
-  fontFamily: ExcalidrawTextElement["fontFamily"],
-  fontSize: ExcalidrawTextElement["fontSize"],
-  lineHeightPx: number,
-) => {
-  const { unitsPerEm, ascender, descender } =
-    FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
-
-  const fontSizeEm = fontSize / unitsPerEm;
-  const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
-
-  const verticalOffset = fontSizeEm * ascender + lineGap;
-  return verticalOffset;
-};
-
 // FIXME rename to getApproxMinContainerHeight
 export const getApproxMinLineHeight = (
   fontSize: ExcalidrawTextElement["fontSize"],
@@ -349,29 +329,72 @@ export const getApproxMinLineHeight = (
 
 let canvas: HTMLCanvasElement | undefined;
 
-const getLineWidth = (text: string, font: FontString) => {
+/**
+ * @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
+ *
+ * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
+ *
+ * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
+ * - text wrapping
+ * - wysiwyg editor (+padding)
+ *
+ * Everything else should be based on the actual bounding box width.
+ *
+ * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
+ */
+const getLineWidth = (
+  text: string,
+  font: FontString,
+  forceAdvanceWidth?: true,
+) => {
   if (!canvas) {
     canvas = document.createElement("canvas");
   }
   const canvas2dContext = canvas.getContext("2d")!;
   canvas2dContext.font = font;
-  const width = canvas2dContext.measureText(text).width;
+  const metrics = canvas2dContext.measureText(text);
+
+  const advanceWidth = metrics.width;
+
+  // retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
+  if (
+    !forceAdvanceWidth &&
+    window.TextMetrics &&
+    "actualBoundingBoxLeft" in window.TextMetrics.prototype &&
+    "actualBoundingBoxRight" in window.TextMetrics.prototype
+  ) {
+    // could be negative, therefore getting the absolute value
+    const actualWidth =
+      Math.abs(metrics.actualBoundingBoxLeft) +
+      Math.abs(metrics.actualBoundingBoxRight);
+
+    // fallback to advance width if the actual width is zero, i.e. on text editing start
+    // or when actual width does not respect whitespace chars, i.e. spaces
+    // otherwise actual width should always be bigger
+    return Math.max(actualWidth, advanceWidth);
+  }
 
   // since in test env the canvas measureText algo
   // doesn't measure text and instead just returns number of
   // characters hence we assume that each letteris 10px
   if (isTestEnv()) {
-    return width * 10;
+    return advanceWidth * 10;
   }
-  return width;
+
+  return advanceWidth;
 };
 
-export const getTextWidth = (text: string, font: FontString) => {
+export const getTextWidth = (
+  text: string,
+  font: FontString,
+  forceAdvanceWidth?: true,
+) => {
   const lines = splitIntoLines(text);
   let width = 0;
   lines.forEach((line) => {
-    width = Math.max(width, getLineWidth(line, font));
+    width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
   });
+
   return width;
 };
 
@@ -402,7 +425,11 @@ export const parseTokens = (text: string) => {
   return words.join(" ").split(" ");
 };
 
-export const wrapText = (text: string, font: FontString, maxWidth: number) => {
+export const wrapText = (
+  text: string,
+  font: FontString,
+  maxWidth: number,
+): string => {
   // if maxWidth is not finite or NaN which can happen in case of bugs in
   // computation, we need to make sure we don't continue as we'll end up
   // in an infinite loop
@@ -412,7 +439,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
 
   const lines: Array<string> = [];
   const originalLines = text.split("\n");
-  const spaceWidth = getLineWidth(" ", font);
+  const spaceAdvanceWidth = getLineWidth(" ", font, true);
 
   let currentLine = "";
   let currentLineWidthTillNow = 0;
@@ -427,13 +454,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
     currentLine = "";
     currentLineWidthTillNow = 0;
   };
-  originalLines.forEach((originalLine) => {
-    const currentLineWidth = getTextWidth(originalLine, font);
+
+  for (const originalLine of originalLines) {
+    const currentLineWidth = getLineWidth(originalLine, font, true);
 
     // Push the line if its <= maxWidth
     if (currentLineWidth <= maxWidth) {
       lines.push(originalLine);
-      return; // continue
+      continue;
     }
 
     const words = parseTokens(originalLine);
@@ -442,7 +470,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
     let index = 0;
 
     while (index < words.length) {
-      const currentWordWidth = getLineWidth(words[index], font);
+      const currentWordWidth = getLineWidth(words[index], font, true);
 
       // This will only happen when single word takes entire width
       if (currentWordWidth === maxWidth) {
@@ -454,7 +482,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
       else if (currentWordWidth > maxWidth) {
         // push current line since the current word exceeds the max width
         // so will be appended in next line
-
         push(currentLine);
 
         resetParams();
@@ -463,20 +490,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
           const currentChar = String.fromCodePoint(
             words[index].codePointAt(0)!,
           );
-          const width = charWidth.calculate(currentChar, font);
-          currentLineWidthTillNow += width;
+
+          const line = currentLine + currentChar;
+          // use advance width instead of the actual width as it's closest to the browser wapping algo
+          // use width of the whole line instead of calculating individual chars to accomodate for kerning
+          const lineAdvanceWidth = getLineWidth(line, font, true);
+          const charAdvanceWidth = charWidth.calculate(currentChar, font);
+
+          currentLineWidthTillNow = lineAdvanceWidth;
           words[index] = words[index].slice(currentChar.length);
 
           if (currentLineWidthTillNow >= maxWidth) {
             push(currentLine);
             currentLine = currentChar;
-            currentLineWidthTillNow = width;
+            currentLineWidthTillNow = charAdvanceWidth;
           } else {
-            currentLine += currentChar;
+            currentLine = line;
           }
         }
         // push current line if appending space exceeds max width
-        if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
+        if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
           push(currentLine);
           resetParams();
           // space needs to be appended before next word
@@ -485,14 +518,18 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
           // with css word-wrap
         } else if (!currentLine.endsWith("-")) {
           currentLine += " ";
-          currentLineWidthTillNow += spaceWidth;
+          currentLineWidthTillNow += spaceAdvanceWidth;
         }
         index++;
       } else {
         // Start appending words in a line till max width reached
         while (currentLineWidthTillNow < maxWidth && index < words.length) {
           const word = words[index];
-          currentLineWidthTillNow = getLineWidth(currentLine + word, font);
+          currentLineWidthTillNow = getLineWidth(
+            currentLine + word,
+            font,
+            true,
+          );
 
           if (currentLineWidthTillNow > maxWidth) {
             push(currentLine);
@@ -512,7 +549,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
           }
 
           // Push the word if appending space exceeds max width
-          if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
+          if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
             if (shouldAppendSpace) {
               lines.push(currentLine.slice(0, -1));
             } else {
@@ -524,12 +561,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
         }
       }
     }
+
     if (currentLine.slice(-1) === " ") {
       // only remove last trailing space which we have added when joining words
       currentLine = currentLine.slice(0, -1);
       push(currentLine);
     }
-  });
+  }
+
   return lines.join("\n");
 };
 
@@ -542,7 +581,7 @@ export const charWidth = (() => {
       cachedCharWidth[font] = [];
     }
     if (!cachedCharWidth[font][ascii]) {
-      const width = getLineWidth(char, font);
+      const width = getLineWidth(char, font, true);
       cachedCharWidth[font][ascii] = width;
     }
 
@@ -594,30 +633,6 @@ export const getMaxCharWidth = (font: FontString) => {
   return Math.max(...cacheWithOutEmpty);
 };
 
-export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
-  // Generally lower case is used so converting to lower case
-  const dummyText = DUMMY_TEXT.toLocaleLowerCase();
-  const batchLength = 6;
-  let index = 0;
-  let widthTillNow = 0;
-  let str = "";
-  while (widthTillNow <= width) {
-    const batch = dummyText.substr(index, index + batchLength);
-    str += batch;
-    widthTillNow += getLineWidth(str, font);
-    if (index === dummyText.length - 1) {
-      index = 0;
-    }
-    index = index + batchLength;
-  }
-
-  while (widthTillNow > width) {
-    str = str.substr(0, str.length - 1);
-    widthTillNow = getLineWidth(str, font);
-  }
-  return str.length;
-};
-
 export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
   return container?.boundElements?.length
     ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
@@ -866,79 +881,6 @@ export const isMeasureTextSupported = () => {
   return width > 0;
 };
 
-/**
- * Unitless line height
- *
- * In previous versions we used `normal` line height, which browsers interpret
- * differently, and based on font-family and font-size.
- *
- * To make line heights consistent across browsers we hardcode the values for
- * each of our fonts based on most common average line-heights.
- * See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
- * where the values come from.
- */
-const DEFAULT_LINE_HEIGHT = {
-  // ~1.25 is the average for Virgil in WebKit and Blink.
-  // Gecko (FF) uses ~1.28.
-  [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
-  // ~1.15 is the average for Helvetica in WebKit and Blink.
-  [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
-  // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
-  [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
-};
-
-/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
-type sTypoAscender = number & MakeBrand<"sTypoAscender">;
-
-/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
-type sTypoDescender = number & MakeBrand<"sTypoDescender">;
-
-/** head.unitsPerEm, usually either 1000 or 2048 */
-type unitsPerEm = number & MakeBrand<"unitsPerEm">;
-
-/**
- * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
- * For custom fonts, read these metrics from OS/2 table and extend this object.
- *
- * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
- */
-export const FONT_METRICS: Record<
-  number,
-  {
-    unitsPerEm: number;
-    ascender: sTypoAscender;
-    descender: sTypoDescender;
-  }
-> = {
-  [FONT_FAMILY.Virgil]: {
-    unitsPerEm: 1000 as unitsPerEm,
-    ascender: 886 as sTypoAscender,
-    descender: -374 as sTypoDescender,
-  },
-  [FONT_FAMILY.Helvetica]: {
-    unitsPerEm: 2048 as unitsPerEm,
-    ascender: 1577 as sTypoAscender,
-    descender: -471 as sTypoDescender,
-  },
-  [FONT_FAMILY.Cascadia]: {
-    unitsPerEm: 2048 as unitsPerEm,
-    ascender: 1977 as sTypoAscender,
-    descender: -480 as sTypoDescender,
-  },
-  [FONT_FAMILY.Assistant]: {
-    unitsPerEm: 1000 as unitsPerEm,
-    ascender: 1021 as sTypoAscender,
-    descender: -287 as sTypoDescender,
-  },
-};
-
-export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
-  if (fontFamily in DEFAULT_LINE_HEIGHT) {
-    return DEFAULT_LINE_HEIGHT[fontFamily];
-  }
-  return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
-};
-
 export const getMinTextElementWidth = (
   font: FontString,
   lineHeight: ExcalidrawTextElement["lineHeight"],

+ 10 - 10
packages/excalidraw/element/textWysiwyg.test.tsx

@@ -916,13 +916,13 @@ describe("textWysiwyg", () => {
       await new Promise((r) => setTimeout(r, 0));
       updateTextEditor(editor, "Hello World!");
       editor.blur();
-      expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
+      expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
 
       fireEvent.click(screen.getByTitle(/code/i));
 
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
-      ).toEqual(FONT_FAMILY.Cascadia);
+      ).toEqual(FONT_FAMILY["Comic Shanns"]);
 
       //undo
       Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -930,7 +930,7 @@ describe("textWysiwyg", () => {
       });
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
-      ).toEqual(FONT_FAMILY.Virgil);
+      ).toEqual(FONT_FAMILY.Excalifont);
 
       //redo
       Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
@@ -938,7 +938,7 @@ describe("textWysiwyg", () => {
       });
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
-      ).toEqual(FONT_FAMILY.Cascadia);
+      ).toEqual(FONT_FAMILY["Comic Shanns"]);
     });
 
     it("should wrap text and vertcially center align once text submitted", async () => {
@@ -1330,14 +1330,14 @@ describe("textWysiwyg", () => {
 
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
-      ).toEqual(FONT_FAMILY.Cascadia);
+      ).toEqual(FONT_FAMILY["Comic Shanns"]);
       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
 
       fireEvent.click(screen.getByTitle(/Very large/i));
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
       ).toEqual(36);
-      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
+      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
     });
 
     it("should update line height when font family updated", async () => {
@@ -1357,18 +1357,18 @@ describe("textWysiwyg", () => {
       fireEvent.click(screen.getByTitle(/code/i));
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
-      ).toEqual(FONT_FAMILY.Cascadia);
+      ).toEqual(FONT_FAMILY["Comic Shanns"]);
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
-      ).toEqual(1.2);
+      ).toEqual(1.25);
 
       fireEvent.click(screen.getByTitle(/normal/i));
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
-      ).toEqual(FONT_FAMILY.Helvetica);
+      ).toEqual(FONT_FAMILY.Nunito);
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
-      ).toEqual(1.15);
+      ).toEqual(1.35);
     });
 
     describe("should align correctly", () => {

+ 49 - 61
packages/excalidraw/element/textWysiwyg.tsx

@@ -11,7 +11,7 @@ import {
   isBoundToContainer,
   isTextElement,
 } from "./typeChecks";
-import { CLASSES } from "../constants";
+import { CLASSES, isSafari } from "../constants";
 import type {
   ExcalidrawElement,
   ExcalidrawLinearElement,
@@ -132,10 +132,15 @@ export const textWysiwyg = ({
         updatedTextElement,
         app.scene.getNonDeletedElementsMap(),
       );
+
+      let width = updatedTextElement.width;
+
+      // set to element height by default since that's
+      // what is going to be used for unbounded text
+      let height = updatedTextElement.height;
+
       let maxWidth = updatedTextElement.width;
       let maxHeight = updatedTextElement.height;
-      let textElementWidth = updatedTextElement.width;
-      const textElementHeight = updatedTextElement.height;
 
       if (container && updatedTextElement.containerId) {
         if (isArrowElement(container)) {
@@ -177,9 +182,9 @@ export const textWysiwyg = ({
         );
 
         // autogrow container height if text exceeds
-        if (!isArrowElement(container) && textElementHeight > maxHeight) {
+        if (!isArrowElement(container) && height > maxHeight) {
           const targetContainerHeight = computeContainerDimensionForBoundText(
-            textElementHeight,
+            height,
             container.type,
           );
 
@@ -190,10 +195,10 @@ export const textWysiwyg = ({
           // is reached when text is removed
           !isArrowElement(container) &&
           container.height > originalContainerData.height &&
-          textElementHeight < maxHeight
+          height < maxHeight
         ) {
           const targetContainerHeight = computeContainerDimensionForBoundText(
-            textElementHeight,
+            height,
             container.type,
           );
           mutateElement(container, { height: targetContainerHeight });
@@ -226,30 +231,41 @@ export const textWysiwyg = ({
 
       if (!container) {
         maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
-        textElementWidth = Math.min(textElementWidth, maxWidth);
+        width = Math.min(width, maxWidth);
       } else {
-        textElementWidth += 0.5;
+        width += 0.5;
       }
 
+      // add 5% buffer otherwise it causes wysiwyg to jump
+      height *= 1.05;
+
+      const font = getFontString(updatedTextElement);
+
+      // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
+      const padding = !isSafari
+        ? Math.ceil(updatedTextElement.fontSize / 2)
+        : 0;
+
       // Make sure text editor height doesn't go beyond viewport
       const editorMaxHeight =
         (appState.height - viewportY) / appState.zoom.value;
       Object.assign(editable.style, {
-        font: getFontString(updatedTextElement),
+        font,
         // must be defined *after* font ¯\_(ツ)_/¯
         lineHeight: updatedTextElement.lineHeight,
-        width: `${textElementWidth}px`,
-        height: `${textElementHeight}px`,
-        left: `${viewportX}px`,
+        width: `${width}px`,
+        height: `${height}px`,
+        left: `${viewportX - padding}px`,
         top: `${viewportY}px`,
         transform: getTransform(
-          textElementWidth,
-          textElementHeight,
+          width,
+          height,
           getTextElementAngle(updatedTextElement, container),
           appState,
           maxWidth,
           editorMaxHeight,
         ),
+        padding: `0 ${padding}px`,
         textAlign,
         verticalAlign,
         color: updatedTextElement.strokeColor,
@@ -290,7 +306,6 @@ export const textWysiwyg = ({
     minHeight: "1em",
     backfaceVisibility: "hidden",
     margin: 0,
-    padding: 0,
     border: 0,
     outline: 0,
     resize: "none",
@@ -336,7 +351,7 @@ export const textWysiwyg = ({
           font,
           getBoundTextMaxWidth(container, boundTextElement),
         );
-        const width = getTextWidth(wrappedText, font);
+        const width = getTextWidth(wrappedText, font, true);
         editable.style.width = `${width}px`;
       }
     };
@@ -485,8 +500,10 @@ export const textWysiwyg = ({
   };
 
   const stopEvent = (event: Event) => {
-    event.preventDefault();
-    event.stopPropagation();
+    if (event.target instanceof HTMLCanvasElement) {
+      event.preventDefault();
+      event.stopPropagation();
+    }
   };
 
   // using a state variable instead of passing it to the handleSubmit callback
@@ -579,46 +596,15 @@ export const textWysiwyg = ({
     // in that same tick.
     const target = event?.target;
 
-    const isTargetPickerTrigger =
+    const isPropertiesTrigger =
       target instanceof HTMLElement &&
-      target.classList.contains("active-color");
+      target.classList.contains("properties-trigger");
 
     setTimeout(() => {
       editable.onblur = handleSubmit;
 
-      if (isTargetPickerTrigger) {
-        const callback = (
-          mutationList: MutationRecord[],
-          observer: MutationObserver,
-        ) => {
-          const radixIsRemoved = mutationList.find(
-            (mutation) =>
-              mutation.removedNodes.length > 0 &&
-              (mutation.removedNodes[0] as HTMLElement).dataset
-                ?.radixPopperContentWrapper !== undefined,
-          );
-
-          if (radixIsRemoved) {
-            // should work without this in theory
-            // and i think it does actually but radix probably somewhere,
-            // somehow sets the focus elsewhere
-            setTimeout(() => {
-              editable.focus();
-            });
-
-            observer.disconnect();
-          }
-        };
-
-        const observer = new MutationObserver(callback);
-
-        observer.observe(document.querySelector(".excalidraw-container")!, {
-          childList: true,
-        });
-      }
-
       // case: clicking on the same property → no change → no update → no focus
-      if (!isTargetPickerTrigger) {
+      if (!isPropertiesTrigger) {
         editable.focus();
       }
     });
@@ -626,16 +612,18 @@ export const textWysiwyg = ({
 
   // prevent blur when changing properties from the menu
   const onPointerDown = (event: MouseEvent) => {
-    const isTargetPickerTrigger =
-      event.target instanceof HTMLElement &&
-      event.target.classList.contains("active-color");
+    const target = event?.target;
+
+    const isPropertiesTrigger =
+      target instanceof HTMLElement &&
+      target.classList.contains("properties-trigger");
 
     if (
       ((event.target instanceof HTMLElement ||
         event.target instanceof SVGElement) &&
         event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
         !isWritableElement(event.target)) ||
-      isTargetPickerTrigger
+      isPropertiesTrigger
     ) {
       editable.onblur = null;
       window.addEventListener("pointerup", bindBlurEvent);
@@ -644,7 +632,7 @@ export const textWysiwyg = ({
       window.addEventListener("blur", handleSubmit);
     } else if (
       event.target instanceof HTMLElement &&
-      !event.target.contains(editable) &&
+      event.target instanceof HTMLCanvasElement &&
       // Vitest simply ignores stopPropagation, capture-mode, or rAF
       // so without introducing crazier hacks, nothing we can do
       !isTestEnv()
@@ -664,10 +652,10 @@ export const textWysiwyg = ({
   // handle updates of textElement properties of editing element
   const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
     updateWysiwygStyle();
-    const isColorPickerActive = !!document.activeElement?.closest(
-      ".color-picker-content",
+    const isPopupOpened = !!document.activeElement?.closest(
+      ".properties-content",
     );
-    if (!isColorPickerActive) {
+    if (!isPopupOpened) {
       editable.focus();
     }
   });

+ 78 - 0
packages/excalidraw/fonts/ExcalidrawFont.ts

@@ -0,0 +1,78 @@
+import { stringToBase64, toByteString } from "../data/encode";
+
+export interface Font {
+  url: URL;
+  fontFace: FontFace;
+  getContent(): Promise<string>;
+}
+export const UNPKG_PROD_URL = `https://unpkg.com/${
+  import.meta.env.VITE_PKG_NAME
+}@${import.meta.env.PKG_VERSION}/dist/prod/`;
+
+export class ExcalidrawFont implements Font {
+  public readonly url: URL;
+  public readonly fontFace: FontFace;
+
+  constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
+    // absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
+    const assetUrl: string = uri.replace(/^\/+/, "");
+    let baseUrl: string | undefined = undefined;
+
+    // fallback to unpkg to form a valid URL in case of a passed relative assetUrl
+    let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
+
+    // in case user passed a root-relative url (~absolute path),
+    // like "/" or "/some/path", or relative (starts with "./"),
+    // prepend it with `location.origin`
+    if (/^\.?\//.test(baseUrlBuilder)) {
+      baseUrlBuilder = new URL(
+        baseUrlBuilder.replace(/^\.?\/+/, ""),
+        window?.location?.origin,
+      ).toString();
+    }
+
+    // ensure there is a trailing slash, otherwise url won't be correctly concatenated
+    baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
+
+    this.url = new URL(assetUrl, baseUrl);
+    this.fontFace = new FontFace(family, `url(${this.url})`, {
+      display: "swap",
+      style: "normal",
+      weight: "400",
+      ...descriptors,
+    });
+  }
+
+  /**
+   * Fetches woff2 content based on the registered url (browser).
+   *
+   * Use dataurl outside the browser environment.
+   */
+  public async getContent(): Promise<string> {
+    if (this.url.protocol === "data:") {
+      // it's dataurl, the font is inlined as base64, no need to fetch
+      return this.url.toString();
+    }
+
+    const response = await fetch(this.url, {
+      headers: {
+        Accept: "font/woff2",
+      },
+    });
+
+    if (!response.ok) {
+      console.error(
+        `Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
+        response,
+      );
+    }
+
+    const mimeType = await response.headers.get("Content-Type");
+    const buffer = await response.arrayBuffer();
+
+    return `data:${mimeType};base64,${await stringToBase64(
+      await toByteString(buffer),
+      true,
+    )}`;
+  }
+}

+ 0 - 0
public/fonts/Assistant-Bold.woff2 → packages/excalidraw/fonts/assets/Assistant-Bold.woff2


+ 0 - 0
public/fonts/Assistant-Medium.woff2 → packages/excalidraw/fonts/assets/Assistant-Medium.woff2


+ 0 - 0
public/fonts/Assistant-Regular.woff2 → packages/excalidraw/fonts/assets/Assistant-Regular.woff2


+ 0 - 0
public/fonts/Assistant-SemiBold.woff2 → packages/excalidraw/fonts/assets/Assistant-SemiBold.woff2


BIN
packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2


BIN
packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2


BIN
packages/excalidraw/fonts/assets/Excalifont-Regular.woff2


BIN
packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2


BIN
packages/excalidraw/fonts/assets/Virgil-Regular.woff2


+ 34 - 0
packages/excalidraw/fonts/assets/fonts.css

@@ -0,0 +1,34 @@
+/* Only UI fonts here, which are needed before the editor initializes. */
+/* These also cannot be preprended with `EXCALIDRAW_ASSET_PATH`. */
+
+@font-face {
+  font-family: "Assistant";
+  src: url(./Assistant-Regular.woff2) format("woff2");
+  font-weight: 400;
+  style: normal;
+  display: swap;
+}
+
+@font-face {
+  font-family: "Assistant";
+  src: url(./Assistant-Medium.woff2) format("woff2");
+  font-weight: 500;
+  style: normal;
+  display: swap;
+}
+
+@font-face {
+  font-family: "Assistant";
+  src: url(./Assistant-SemiBold.woff2) format("woff2");
+  font-weight: 600;
+  style: normal;
+  display: swap;
+}
+
+@font-face {
+  font-family: "Assistant";
+  src: url(./Assistant-Bold.woff2) format("woff2");
+  font-weight: 700;
+  style: normal;
+  display: swap;
+}

+ 308 - 0
packages/excalidraw/fonts/index.ts

@@ -0,0 +1,308 @@
+import type Scene from "../scene/Scene";
+import type { ValueOf } from "../utility-types";
+import type { ExcalidrawTextElement, FontFamilyValues } from "../element/types";
+import { ShapeCache } from "../scene/ShapeCache";
+import { isTextElement } from "../element";
+import { getFontString } from "../utils";
+import { FONT_FAMILY } from "../constants";
+import {
+  LOCAL_FONT_PROTOCOL,
+  FONT_METADATA,
+  RANGES,
+  type FontMetadata,
+} from "./metadata";
+import { ExcalidrawFont, type Font } from "./ExcalidrawFont";
+import { getContainerElement } from "../element/textElement";
+
+import Virgil from "./assets/Virgil-Regular.woff2";
+import Excalifont from "./assets/Excalifont-Regular.woff2";
+import Cascadia from "./assets/CascadiaMono-Regular.woff2";
+import ComicShanns from "./assets/ComicShanns-Regular.woff2";
+import LiberationSans from "./assets/LiberationSans-Regular.woff2";
+
+import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
+import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
+
+import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
+import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
+import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
+import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
+import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
+
+export class Fonts {
+  // it's ok to track fonts across multiple instances only once, so let's use
+  // a static member to reduce memory footprint
+  public static readonly loadedFontsCache = new Set<string>();
+
+  private static _registered:
+    | Map<
+        number,
+        {
+          metadata: FontMetadata;
+          fontFaces: Font[];
+        }
+      >
+    | undefined;
+
+  public static get registered() {
+    if (!Fonts._registered) {
+      // lazy load the fonts
+      Fonts._registered = Fonts.init();
+    }
+
+    return Fonts._registered;
+  }
+
+  public get registered() {
+    return Fonts.registered;
+  }
+
+  private readonly scene: Scene;
+
+  public get sceneFamilies() {
+    return Array.from(
+      this.scene.getNonDeletedElements().reduce((families, element) => {
+        if (isTextElement(element)) {
+          families.add(element.fontFamily);
+        }
+        return families;
+      }, new Set<number>()),
+    );
+  }
+
+  constructor({ scene }: { scene: Scene }) {
+    this.scene = scene;
+  }
+
+  /**
+   * if we load a (new) font, it's likely that text elements using it have
+   * already been rendered using a fallback font. Thus, we want invalidate
+   * their shapes and rerender. See #637.
+   *
+   * Invalidates text elements and rerenders scene, provided that at least one
+   * of the supplied fontFaces has not already been processed.
+   */
+  public onLoaded = (fontFaces: readonly FontFace[]) => {
+    if (
+      // bail if all fonts with have been processed. We're checking just a
+      // subset of the font properties (though it should be enough), so it
+      // can technically bail on a false positive.
+      fontFaces.every((fontFace) => {
+        const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`;
+        if (Fonts.loadedFontsCache.has(sig)) {
+          return true;
+        }
+        Fonts.loadedFontsCache.add(sig);
+        return false;
+      })
+    ) {
+      return false;
+    }
+
+    let didUpdate = false;
+
+    const elementsMap = this.scene.getNonDeletedElementsMap();
+
+    for (const element of this.scene.getNonDeletedElements()) {
+      if (isTextElement(element)) {
+        didUpdate = true;
+        ShapeCache.delete(element);
+        const container = getContainerElement(element, elementsMap);
+        if (container) {
+          ShapeCache.delete(container);
+        }
+      }
+    }
+
+    if (didUpdate) {
+      this.scene.triggerUpdate();
+    }
+  };
+
+  public load = async () => {
+    // Add all registered font faces into the `document.fonts` (if not added already)
+    for (const { fontFaces } of Fonts.registered.values()) {
+      for (const { fontFace, url } of fontFaces) {
+        if (
+          url.protocol !== LOCAL_FONT_PROTOCOL &&
+          !window.document.fonts.has(fontFace)
+        ) {
+          window.document.fonts.add(fontFace);
+        }
+      }
+    }
+
+    const loaded = await Promise.all(
+      this.sceneFamilies.map(async (fontFamily) => {
+        const fontString = getFontString({
+          fontFamily,
+          fontSize: 16,
+        });
+
+        // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
+        if (!window.document.fonts.check(fontString)) {
+          try {
+            // WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
+            // we might want to retry here, i.e.  in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
+            return await window.document.fonts.load(fontString);
+          } catch (e) {
+            // don't let it all fail if just one font fails to load
+            console.error(
+              `Failed to load font: "${fontString}" with error "${e}", given the following registered font:`,
+              JSON.stringify(Fonts.registered.get(fontFamily), undefined, 2),
+            );
+          }
+        }
+
+        return Promise.resolve();
+      }),
+    );
+
+    this.onLoaded(loaded.flat().filter(Boolean) as FontFace[]);
+  };
+
+  /**
+   * WARN: should be called just once on init, even across multiple instances.
+   */
+  private static init() {
+    const fonts = {
+      registered: new Map<
+        ValueOf<typeof FONT_FAMILY>,
+        { metadata: FontMetadata; fontFaces: Font[] }
+      >(),
+    };
+
+    const _register = register.bind(fonts);
+
+    _register("Virgil", FONT_METADATA[FONT_FAMILY.Virgil], {
+      uri: Virgil,
+    });
+
+    _register("Excalifont", FONT_METADATA[FONT_FAMILY.Excalifont], {
+      uri: Excalifont,
+    });
+
+    // keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
+    _register("Helvetica", FONT_METADATA[FONT_FAMILY.Helvetica], {
+      uri: LOCAL_FONT_PROTOCOL,
+    });
+
+    // used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
+    _register(
+      "Liberation Sans",
+      FONT_METADATA[FONT_FAMILY["Liberation Sans"]],
+      {
+        uri: LiberationSans,
+      },
+    );
+
+    _register("Cascadia", FONT_METADATA[FONT_FAMILY.Cascadia], {
+      uri: Cascadia,
+    });
+
+    _register("Comic Shanns", FONT_METADATA[FONT_FAMILY["Comic Shanns"]], {
+      uri: ComicShanns,
+    });
+
+    _register(
+      "Lilita One",
+      FONT_METADATA[FONT_FAMILY["Lilita One"]],
+      { uri: LilitaLatinExt, descriptors: { unicodeRange: RANGES.LATIN_EXT } },
+      { uri: LilitaLatin, descriptors: { unicodeRange: RANGES.LATIN } },
+    );
+
+    _register(
+      "Nunito",
+      FONT_METADATA[FONT_FAMILY.Nunito],
+      {
+        uri: NunitoCyrilicExt,
+        descriptors: { unicodeRange: RANGES.CYRILIC_EXT, weight: "500" },
+      },
+      {
+        uri: NunitoCyrilic,
+        descriptors: { unicodeRange: RANGES.CYRILIC, weight: "500" },
+      },
+      {
+        uri: NunitoVietnamese,
+        descriptors: { unicodeRange: RANGES.VIETNAMESE, weight: "500" },
+      },
+      {
+        uri: NunitoLatinExt,
+        descriptors: { unicodeRange: RANGES.LATIN_EXT, weight: "500" },
+      },
+      {
+        uri: NunitoLatin,
+        descriptors: { unicodeRange: RANGES.LATIN, weight: "500" },
+      },
+    );
+
+    return fonts.registered;
+  }
+}
+
+/**
+ * Register a new font.
+ *
+ * @param family font family
+ * @param metadata font metadata
+ * @param params array of the rest of the FontFace parameters [uri: string, descriptors: FontFaceDescriptors?] ,
+ */
+function register(
+  this:
+    | Fonts
+    | {
+        registered: Map<
+          ValueOf<typeof FONT_FAMILY>,
+          { metadata: FontMetadata; fontFaces: Font[] }
+        >;
+      },
+  family: string,
+  metadata: FontMetadata,
+  ...params: Array<{ uri: string; descriptors?: FontFaceDescriptors }>
+) {
+  // TODO: likely we will need to abandon number "id" in order to support custom fonts
+  const familyId = FONT_FAMILY[family as keyof typeof FONT_FAMILY];
+  const registeredFamily = this.registered.get(familyId);
+
+  if (!registeredFamily) {
+    this.registered.set(familyId, {
+      metadata,
+      fontFaces: params.map(
+        ({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
+      ),
+    });
+  }
+
+  return this.registered;
+}
+
+/**
+ * Calculates vertical offset for a text with alphabetic baseline.
+ */
+export const getVerticalOffset = (
+  fontFamily: ExcalidrawTextElement["fontFamily"],
+  fontSize: ExcalidrawTextElement["fontSize"],
+  lineHeightPx: number,
+) => {
+  const { unitsPerEm, ascender, descender } =
+    Fonts.registered.get(fontFamily)?.metadata.metrics ||
+    FONT_METADATA[FONT_FAMILY.Virgil].metrics;
+
+  const fontSizeEm = fontSize / unitsPerEm;
+  const lineGap =
+    (lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
+
+  const verticalOffset = fontSizeEm * ascender + lineGap;
+  return verticalOffset;
+};
+
+/**
+ * Gets line height forr a selected family.
+ */
+export const getLineHeight = (fontFamily: FontFamilyValues) => {
+  const { lineHeight } =
+    Fonts.registered.get(fontFamily)?.metadata.metrics ||
+    FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
+
+  return lineHeight as ExcalidrawTextElement["lineHeight"];
+};

+ 125 - 0
packages/excalidraw/fonts/metadata.ts

@@ -0,0 +1,125 @@
+import {
+  FontFamilyCodeIcon,
+  FontFamilyHeadingIcon,
+  FontFamilyNormalIcon,
+  FreedrawIcon,
+} from "../components/icons";
+import { FONT_FAMILY } from "../constants";
+
+/**
+ * Encapsulates font metrics with additional font metadata.
+ * */
+export interface FontMetadata {
+  /** for head & hhea metrics read the woff2 with https://fontdrop.info/ */
+  metrics: {
+    /** head.unitsPerEm metric */
+    unitsPerEm: 1000 | 1024 | 2048;
+    /** hhea.ascender metric */
+    ascender: number;
+    /** hhea.descender metric */
+    descender: number;
+    /** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
+    lineHeight: number;
+  };
+  /** element to be displayed as an icon  */
+  icon: JSX.Element;
+  /** flag to indicate a deprecated font */
+  deprecated?: true;
+  /** flag to indicate a server-side only font */
+  serverSide?: true;
+}
+
+export const FONT_METADATA: Record<number, FontMetadata> = {
+  [FONT_FAMILY.Excalifont]: {
+    metrics: {
+      unitsPerEm: 1000,
+      ascender: 886,
+      descender: -374,
+      lineHeight: 1.25,
+    },
+    icon: FreedrawIcon,
+  },
+  [FONT_FAMILY.Nunito]: {
+    metrics: {
+      unitsPerEm: 1000,
+      ascender: 1011,
+      descender: -353,
+      lineHeight: 1.35,
+    },
+    icon: FontFamilyNormalIcon,
+  },
+  [FONT_FAMILY["Lilita One"]]: {
+    metrics: {
+      unitsPerEm: 1000,
+      ascender: 923,
+      descender: -220,
+      lineHeight: 1.15,
+    },
+    icon: FontFamilyHeadingIcon,
+  },
+  [FONT_FAMILY["Comic Shanns"]]: {
+    metrics: {
+      unitsPerEm: 1000,
+      ascender: 750,
+      descender: -250,
+      lineHeight: 1.25,
+    },
+    icon: FontFamilyCodeIcon,
+  },
+  [FONT_FAMILY.Virgil]: {
+    metrics: {
+      unitsPerEm: 1000,
+      ascender: 886,
+      descender: -374,
+      lineHeight: 1.25,
+    },
+    icon: FreedrawIcon,
+    deprecated: true,
+  },
+  [FONT_FAMILY.Helvetica]: {
+    metrics: {
+      unitsPerEm: 2048,
+      ascender: 1577,
+      descender: -471,
+      lineHeight: 1.15,
+    },
+    icon: FontFamilyNormalIcon,
+    deprecated: true,
+  },
+  [FONT_FAMILY.Cascadia]: {
+    metrics: {
+      unitsPerEm: 2048,
+      ascender: 1900,
+      descender: -480,
+      lineHeight: 1.2,
+    },
+    icon: FontFamilyCodeIcon,
+    deprecated: true,
+  },
+  [FONT_FAMILY["Liberation Sans"]]: {
+    metrics: {
+      unitsPerEm: 2048,
+      ascender: 1854,
+      descender: -434,
+      lineHeight: 1.15,
+    },
+    icon: FontFamilyNormalIcon,
+    serverSide: true,
+  },
+};
+
+/** Unicode ranges */
+export const RANGES = {
+  LATIN:
+    "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
+  LATIN_EXT:
+    "U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF",
+  CYRILIC_EXT:
+    "U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F",
+  CYRILIC: "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116",
+  VIETNAMESE:
+    "U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB",
+};
+
+/** local protocol to skip the local font from registering or inlining */
+export const LOCAL_FONT_PROTOCOL = "local:";

+ 3 - 1
packages/excalidraw/index.tsx

@@ -5,7 +5,7 @@ import { isShallowEqual } from "./utils";
 
 import "./css/app.scss";
 import "./css/styles.scss";
-import "../../public/fonts/fonts.css";
+import "./fonts/assets/fonts.css";
 import polyfill from "./polyfill";
 
 import type { AppProps, ExcalidrawProps } from "./types";
@@ -50,6 +50,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
     validateEmbeddable,
     renderEmbeddable,
     aiEnabled,
+    showDeprecatedFonts,
   } = props;
 
   const canvasActions = props.UIOptions?.canvasActions;
@@ -137,6 +138,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
           validateEmbeddable={validateEmbeddable}
           renderEmbeddable={renderEmbeddable}
           aiEnabled={aiEnabled !== false}
+          showDeprecatedFonts={showDeprecatedFonts}
         >
           {children}
         </App>

+ 13 - 4
packages/excalidraw/locales/en.json

@@ -109,6 +109,7 @@
     "share": "Share",
     "showStroke": "Show stroke color picker",
     "showBackground": "Show background color picker",
+    "showFonts": "Show font picker",
     "toggleTheme": "Toggle light/dark theme",
     "theme": "Theme",
     "personalLib": "Personal Library",
@@ -557,11 +558,19 @@
     "syntax": "Mermaid Syntax",
     "preview": "Preview"
   },
-  "userList": {
-    "search": {
-      "placeholder": "Quick search",
-      "empty": "No users found"
+  "quickSearch": {
+    "placeholder": "Quick search"
+  },
+  "fontList": {
+    "badge": {
+      "old": "old"
     },
+    "sceneFonts": "In this scene",
+    "availableFonts": "Available fonts",
+    "empty": "No fonts found"
+  },
+  "userList": {
+    "empty": "No users found",
     "hint": {
       "text": "Click on user to follow",
       "followStatus": "You're currently following this user",

+ 12 - 4
packages/excalidraw/renderer/renderElement.ts

@@ -53,12 +53,12 @@ import {
   getLineHeightInPx,
   getBoundTextMaxHeight,
   getBoundTextMaxWidth,
-  getVerticalOffset,
 } from "../element/textElement";
 import { LinearElementEditor } from "../element/linearElementEditor";
 
 import { getContainingFrame } from "../frame";
 import { ShapeCache } from "../scene/ShapeCache";
+import { getVerticalOffset } from "../fonts";
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original
@@ -89,8 +89,16 @@ const shouldResetImageFilter = (
   );
 };
 
-const getCanvasPadding = (element: ExcalidrawElement) =>
-  element.type === "freedraw" ? element.strokeWidth * 12 : 20;
+const getCanvasPadding = (element: ExcalidrawElement) => {
+  switch (element.type) {
+    case "freedraw":
+      return element.strokeWidth * 12;
+    case "text":
+      return element.fontSize / 2;
+    default:
+      return 20;
+  }
+};
 
 export const getRenderOpacity = (
   element: ExcalidrawElement,
@@ -202,7 +210,7 @@ const generateElementCanvas = (
   canvas.width = width;
   canvas.height = height;
 
-  let canvasOffsetX = 0;
+  let canvasOffsetX = -100;
   let canvasOffsetY = 0;
 
   if (isLinearElement(element) || isFreeDrawElement(element)) {

+ 1 - 1
packages/excalidraw/renderer/staticSvgScene.ts

@@ -17,7 +17,6 @@ import {
   getBoundTextElement,
   getContainerElement,
   getLineHeightInPx,
-  getVerticalOffset,
 } from "../element/textElement";
 import {
   isArrowElement,
@@ -37,6 +36,7 @@ import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
 import type { AppState, BinaryFiles } from "../types";
 import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
 import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
+import { getVerticalOffset } from "../fonts";
 
 const roughSVGDrawWithPrecision = (
   rsvg: RoughSVG,

+ 0 - 90
packages/excalidraw/scene/Fonts.ts

@@ -1,90 +0,0 @@
-import { isTextElement } from "../element";
-import { getContainerElement } from "../element/textElement";
-import type {
-  ExcalidrawElement,
-  ExcalidrawTextElement,
-} from "../element/types";
-import { getFontString } from "../utils";
-import type Scene from "./Scene";
-import { ShapeCache } from "./ShapeCache";
-
-export class Fonts {
-  private scene: Scene;
-
-  constructor({ scene }: { scene: Scene }) {
-    this.scene = scene;
-  }
-
-  // it's ok to track fonts across multiple instances only once, so let's use
-  // a static member to reduce memory footprint
-  private static loadedFontFaces = new Set<string>();
-
-  /**
-   * if we load a (new) font, it's likely that text elements using it have
-   * already been rendered using a fallback font. Thus, we want invalidate
-   * their shapes and rerender. See #637.
-   *
-   * Invalidates text elements and rerenders scene, provided that at least one
-   * of the supplied fontFaces has not already been processed.
-   */
-  public onFontsLoaded = (fontFaces: readonly FontFace[]) => {
-    if (
-      // bail if all fonts with have been processed. We're checking just a
-      // subset of the font properties (though it should be enough), so it
-      // can technically bail on a false positive.
-      fontFaces.every((fontFace) => {
-        const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}`;
-        if (Fonts.loadedFontFaces.has(sig)) {
-          return true;
-        }
-        Fonts.loadedFontFaces.add(sig);
-        return false;
-      })
-    ) {
-      return false;
-    }
-
-    let didUpdate = false;
-
-    const elementsMap = this.scene.getNonDeletedElementsMap();
-
-    for (const element of this.scene.getNonDeletedElements()) {
-      if (isTextElement(element)) {
-        didUpdate = true;
-        ShapeCache.delete(element);
-        const container = getContainerElement(element, elementsMap);
-        if (container) {
-          ShapeCache.delete(container);
-        }
-      }
-    }
-
-    if (didUpdate) {
-      this.scene.triggerUpdate();
-    }
-  };
-
-  public loadFontsForElements = async (
-    elements: readonly ExcalidrawElement[],
-  ) => {
-    const fontFaces = await Promise.all(
-      [
-        ...new Set(
-          elements
-            .filter((element) => isTextElement(element))
-            .map((element) => (element as ExcalidrawTextElement).fontFamily),
-        ),
-      ].map((fontFamily) => {
-        const fontString = getFontString({
-          fontFamily,
-          fontSize: 16,
-        });
-        if (!document.fonts?.check?.(fontString)) {
-          return document.fonts?.load?.(fontString);
-        }
-        return undefined;
-      }),
-    );
-    this.onFontsLoaded(fontFaces.flat().filter(Boolean) as FontFace[]);
-  };
-}

+ 57 - 31
packages/excalidraw/scene/export.ts

@@ -13,8 +13,8 @@ import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
 import type { AppState, BinaryFiles } from "../types";
 import {
   DEFAULT_EXPORT_PADDING,
-  FONT_FAMILY,
   FRAME_STYLE,
+  FONT_FAMILY,
   SVG_NS,
   THEME,
   THEME_FILTER,
@@ -32,12 +32,18 @@ import {
   getRootElements,
 } from "../frame";
 import { newTextElement } from "../element";
-import type { Mutable } from "../utility-types";
+import { type Mutable } from "../utility-types";
 import { newElementWith } from "../element/mutateElement";
-import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
+import {
+  isFrameElement,
+  isFrameLikeElement,
+  isTextElement,
+} from "../element/typeChecks";
 import type { RenderableElementsMap } from "./types";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { renderStaticScene } from "../renderer/staticScene";
+import { Fonts } from "../fonts";
+import { LOCAL_FONT_PROTOCOL } from "../fonts/metadata";
 
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
@@ -95,7 +101,7 @@ const addFrameLabelsAsTextElements = (
       let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
         x: element.x,
         y: element.y - FRAME_STYLE.nameOffsetY,
-        fontFamily: FONT_FAMILY.Assistant,
+        fontFamily: FONT_FAMILY.Helvetica,
         fontSize: FRAME_STYLE.nameFontSize,
         lineHeight:
           FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
@@ -269,6 +275,7 @@ export const exportToSvg = async (
      */
     renderEmbeddables?: boolean;
     exportingFrame?: ExcalidrawFrameLikeElement | null;
+    skipInliningFonts?: true;
   },
 ): Promise<SVGSVGElement> => {
   const frameRendering = getFrameRenderingConfig(
@@ -333,21 +340,6 @@ export const exportToSvg = async (
     svgRoot.setAttribute("filter", THEME_FILTER);
   }
 
-  let assetPath = "https://excalidraw.com/";
-  // Asset path needs to be determined only when using package
-  if (import.meta.env.VITE_IS_EXCALIDRAW_NPM_PACKAGE) {
-    assetPath =
-      window.EXCALIDRAW_ASSET_PATH ||
-      `https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
-        import.meta.env.VITE_PKG_VERSION
-      }`;
-
-    if (assetPath?.startsWith("/")) {
-      assetPath = assetPath.replace("/", `${window.location.origin}/`);
-    }
-    assetPath = `${assetPath}/dist/excalidraw-assets/`;
-  }
-
   const offsetX = -minX + exportPadding;
   const offsetY = -minY + exportPadding;
 
@@ -371,23 +363,57 @@ export const exportToSvg = async (
         </clipPath>`;
   }
 
+  const fontFamilies = elements.reduce((acc, element) => {
+    if (isTextElement(element)) {
+      acc.add(element.fontFamily);
+    }
+
+    return acc;
+  }, new Set<number>());
+
+  const fontFaces = opts?.skipInliningFonts
+    ? []
+    : await Promise.all(
+        Array.from(fontFamilies).map(async (x) => {
+          const { fontFaces } = Fonts.registered.get(x) ?? {};
+
+          if (!Array.isArray(fontFaces)) {
+            console.error(
+              `Couldn't find registered font-faces for font-family "${x}"`,
+              Fonts.registered,
+            );
+            return;
+          }
+
+          return Promise.all(
+            fontFaces
+              .filter((font) => font.url.protocol !== LOCAL_FONT_PROTOCOL)
+              .map(async (font) => {
+                try {
+                  const content = await font.getContent();
+
+                  return `@font-face {
+        font-family: ${font.fontFace.family};
+        src: url(${content});
+          }`;
+                } catch (e) {
+                  console.error(
+                    `Skipped inlining font with URL "${font.url.toString()}"`,
+                    e,
+                  );
+                  return "";
+                }
+              }),
+          );
+        }),
+      );
+
   svgRoot.innerHTML = `
   ${SVG_EXPORT_TAG}
   ${metadata}
   <defs>
     <style class="style-fonts">
-      @font-face {
-        font-family: "Virgil";
-        src: url("${assetPath}Virgil.woff2");
-      }
-      @font-face {
-        font-family: "Cascadia";
-        src: url("${assetPath}Cascadia.woff2");
-      }
-      @font-face {
-        font-family: "Assistant";
-        src: url("${assetPath}Assistant-Regular.woff2");
-      }
+      ${fontFaces.flat().filter(Boolean).join("\n")}
     </style>
     ${exportingFrameClipPath}
   </defs>

+ 34 - 17
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -795,10 +795,11 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "top": 40,
   },
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -996,10 +997,11 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1207,10 +1209,11 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1533,10 +1536,11 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1859,10 +1863,11 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2070,10 +2075,11 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2305,10 +2311,11 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2601,10 +2608,11 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2965,10 +2973,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#a5d8ff",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "cross-hatch",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 60,
   "currentItemRoughness": 2,
@@ -3435,10 +3444,11 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3753,10 +3763,11 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4071,10 +4082,11 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5252,10 +5264,11 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
     "top": -7,
   },
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -6374,10 +6387,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
     "top": -7,
   },
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7304,10 +7318,11 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "top": -9,
   },
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8211,10 +8226,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "top": -7,
   },
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9100,10 +9116,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "top": 90,
   },
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,

+ 2 - 16
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap

@@ -11,11 +11,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
   >
     <button
       class="dropdown-menu-item dropdown-menu-item-base"
-      type="button"
     >
-      <div
-        class="dropdown-menu-item__icon"
-      />
       <div
         class="dropdown-menu-item__text"
       >
@@ -28,9 +24,6 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       rel="noreferrer"
       target="_blank"
     >
-      <div
-        class="dropdown-menu-item__icon"
-      />
       <div
         class="dropdown-menu-item__text"
       >
@@ -51,7 +44,6 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="help-menu-item"
       title="Help"
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -122,7 +114,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="load-button"
       title="Open"
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -160,7 +151,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="json-export-button"
       title="Save to..."
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -193,7 +183,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="image-export-button"
       title="Export image..."
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -255,7 +244,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="help-menu-item"
       title="Help"
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -313,7 +301,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="clear-canvas-button"
       title="Reset the canvas"
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -481,7 +468,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       class="dropdown-menu-item dropdown-menu-item-base"
       data-testid="toggle-dark-mode"
       title="Dark mode"
-      type="button"
     >
       <div
         class="dropdown-menu-item__icon"
@@ -593,14 +579,14 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
               </button>
             </div>
             <div
-              style="width: 1px; height: 100%; margin: 0px auto;"
+              style="width: 1px; height: 1rem; margin: 0px auto;"
             />
             <button
               aria-controls="radix-:r0:"
               aria-expanded="false"
               aria-haspopup="dialog"
               aria-label="Canvas background"
-              class="color-picker__button active-color"
+              class="color-picker__button active-color properties-trigger"
               data-state="closed"
               style="--swatch-color: #ffffff;"
               title="Show background color picker"

+ 1 - 12
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap

@@ -6,18 +6,7 @@ exports[`export > exporting svg containing transformed images > svg export outpu
   
   <defs>
     <style class="style-fonts">
-      @font-face {
-        font-family: "Virgil";
-        src: url("https://excalidraw.com/Virgil.woff2");
-      }
-      @font-face {
-        font-family: "Cascadia";
-        src: url("https://excalidraw.com/Cascadia.woff2");
-      }
-      @font-face {
-        font-family: "Assistant";
-        src: url("https://excalidraw.com/Assistant-Regular.woff2");
-      }
+      
     </style>
     
   </defs>

+ 139 - 82
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -12,10 +12,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -593,10 +594,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1090,10 +1092,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1436,10 +1439,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1782,10 +1786,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2044,10 +2049,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2473,10 +2479,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2611,7 +2618,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2652,7 +2659,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id142",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2767,10 +2774,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2905,7 +2913,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id145",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -2946,7 +2954,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id145",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -3046,10 +3054,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3184,7 +3193,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id132",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -3225,7 +3234,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -3335,10 +3344,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3505,7 +3515,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id137",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -3616,10 +3626,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3749,7 +3760,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -3846,10 +3857,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3984,7 +3996,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id138",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -4100,10 +4112,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4238,7 +4251,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id140",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -4299,7 +4312,7 @@ History {
               "containerId": "id140",
               "customData": undefined,
               "fillStyle": "solid",
-              "fontFamily": 1,
+              "fontFamily": 5,
               "fontSize": 20,
               "frameId": null,
               "groupIds": [],
@@ -4368,10 +4381,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4506,7 +4520,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id154",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -4594,10 +4608,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4732,7 +4747,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id152",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -4820,10 +4835,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4953,7 +4969,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": "id148",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -5044,10 +5060,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5182,7 +5199,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -5268,10 +5285,11 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5522,10 +5540,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5848,10 +5867,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -6268,10 +6288,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -6641,10 +6662,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -6955,10 +6977,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7246,10 +7269,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7470,10 +7494,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7820,10 +7845,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8170,10 +8196,11 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8569,10 +8596,11 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8851,10 +8879,11 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9111,10 +9140,11 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9370,10 +9400,11 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9596,10 +9627,11 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9892,10 +9924,11 @@ exports[`history > multiplayer undo/redo > should override remotely added points
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10225,10 +10258,11 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10455,10 +10489,11 @@ exports[`history > multiplayer undo/redo > should update history entries after r
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#a5d8ff",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10704,10 +10739,11 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10938,10 +10974,11 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -11174,10 +11211,11 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -11570,10 +11608,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -11812,10 +11851,11 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -12048,10 +12088,11 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -12284,10 +12325,11 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -12526,10 +12568,11 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -12853,10 +12896,11 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13020,10 +13064,11 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13303,10 +13348,11 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13565,10 +13611,11 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#a5d8ff",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13835,10 +13882,11 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13991,10 +14039,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -14134,7 +14183,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "containerId": "id50",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -14394,7 +14443,7 @@ History {
               "containerId": null,
               "customData": undefined,
               "fillStyle": "solid",
-              "fontFamily": 1,
+              "fontFamily": 5,
               "fontSize": 20,
               "frameId": null,
               "groupIds": [],
@@ -14676,10 +14725,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -14819,7 +14869,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "containerId": "id44",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -15003,7 +15053,7 @@ History {
               "containerId": null,
               "customData": undefined,
               "fillStyle": "solid",
-              "fontFamily": 1,
+              "fontFamily": 5,
               "fontSize": 20,
               "frameId": null,
               "groupIds": [],
@@ -15285,10 +15335,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -15428,7 +15479,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "containerId": "id56",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -15612,7 +15663,7 @@ History {
               "containerId": null,
               "customData": undefined,
               "fillStyle": "solid",
-              "fontFamily": 1,
+              "fontFamily": 5,
               "fontSize": 20,
               "frameId": null,
               "groupIds": [],
@@ -15894,10 +15945,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -16035,7 +16087,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "containerId": "id62",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -16289,7 +16341,7 @@ History {
               "containerId": null,
               "customData": undefined,
               "fillStyle": "solid",
-              "fontFamily": 1,
+              "fontFamily": 5,
               "fontSize": 20,
               "frameId": null,
               "groupIds": [],
@@ -16594,10 +16646,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -16738,7 +16791,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "containerId": "id69",
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 20,
   "frameId": null,
   "groupIds": [],
@@ -17007,7 +17060,7 @@ History {
               "containerId": null,
               "customData": undefined,
               "fillStyle": "solid",
-              "fontFamily": 1,
+              "fontFamily": 5,
               "fontSize": 20,
               "frameId": null,
               "groupIds": [],
@@ -17331,10 +17384,11 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -17800,10 +17854,11 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -18317,10 +18372,11 @@ exports[`history > singleplayer undo/redo > should support element creation, del
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -18768,10 +18824,11 @@ exports[`history > singleplayer undo/redo > should support linear element creati
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,

+ 1 - 1
packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap

@@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
   class="excalidraw-wysiwyg"
   data-type="wysiwyg"
   dir="auto"
-  style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
+  style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 25px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); padding: 0px 10px; text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Segoe UI Emoji;"
   tabindex="0"
   wrap="off"
 />

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

@@ -12,10 +12,11 @@ exports[`given element A and group of elements B and given both are selected whe
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -419,10 +420,11 @@ exports[`given element A and group of elements B and given both are selected whe
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -817,10 +819,11 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1354,10 +1357,11 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1550,10 +1554,11 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -1917,10 +1922,11 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2149,10 +2155,11 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2321,10 +2328,11 @@ exports[`regression tests > can drag element that covers another element, while
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2633,10 +2641,11 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -2871,10 +2880,11 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3106,10 +3116,11 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3328,10 +3339,11 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3576,10 +3588,11 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -3879,10 +3892,11 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4285,10 +4299,11 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4590,10 +4605,11 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -4865,10 +4881,11 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5097,10 +5114,11 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5288,10 +5306,11 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5662,10 +5681,11 @@ exports[`regression tests > drags selected elements from point inside common bou
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -5944,10 +5964,11 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -6742,10 +6763,11 @@ exports[`regression tests > given a group of selected elements with an element t
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7064,10 +7086,11 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7332,10 +7355,11 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7558,10 +7582,11 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7787,10 +7812,11 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -7959,10 +7985,11 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8131,10 +8158,11 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8303,10 +8331,11 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8515,10 +8544,11 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8727,10 +8757,11 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -8913,10 +8944,11 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9125,10 +9157,11 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9297,10 +9330,11 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9509,10 +9543,11 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9681,10 +9716,11 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -9867,10 +9903,11 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10039,10 +10076,11 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10545,10 +10583,11 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10814,10 +10853,11 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -10932,10 +10972,11 @@ exports[`regression tests > shift click on selected element should deselect it o
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -11123,10 +11164,11 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -11426,10 +11468,11 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -11830,10 +11873,11 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -12435,10 +12479,11 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -12556,10 +12601,11 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13132,10 +13178,11 @@ exports[`regression tests > switches from group of selected elements to another
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13492,10 +13539,11 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13779,10 +13827,11 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -13897,10 +13946,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -14267,10 +14317,11 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 3,
+  "currentItemFontFamily": 8,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
@@ -14385,10 +14436,11 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
+  "currentHoveredFontFamily": null,
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
-  "currentItemFontFamily": 1,
+  "currentItemFontFamily": 5,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,

+ 4 - 6
packages/excalidraw/tests/clipboard.test.tsx

@@ -4,16 +4,14 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
 import { Pointer, Keyboard } from "./helpers/ui";
 import { Excalidraw } from "../index";
 import { KEYS } from "../keys";
-import {
-  getDefaultLineHeight,
-  getLineHeightInPx,
-} from "../element/textElement";
+import { getLineHeightInPx } from "../element/textElement";
 import { getElementBounds } from "../element";
 import type { NormalizedZoomValue } from "../types";
 import { API } from "./helpers/api";
 import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
 import { arrayToMap } from "../utils";
 import { mockMermaidToExcalidraw } from "./helpers/mocks";
+import { getLineHeight } from "../fonts";
 
 const { h } = window;
 
@@ -146,7 +144,7 @@ describe("paste text as single lines", () => {
     const lineHeightPx =
       getLineHeightInPx(
         h.app.state.currentItemFontSize,
-        getDefaultLineHeight(h.state.currentItemFontFamily),
+        getLineHeight(h.state.currentItemFontFamily),
       ) +
       10 / h.app.state.zoom.value;
     mouse.moveTo(100, 100);
@@ -168,7 +166,7 @@ describe("paste text as single lines", () => {
     const lineHeightPx =
       getLineHeightInPx(
         h.app.state.currentItemFontSize,
-        getDefaultLineHeight(h.state.currentItemFontFamily),
+        getLineHeight(h.state.currentItemFontFamily),
       ) +
       10 / h.app.state.zoom.value;
     mouse.moveTo(100, 100);

+ 1 - 1
packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

@@ -351,7 +351,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
   "containerId": null,
   "customData": undefined,
   "fillStyle": "solid",
-  "fontFamily": 1,
+  "fontFamily": 5,
   "fontSize": 10,
   "frameId": null,
   "groupIds": [],

+ 15 - 0
packages/excalidraw/tests/fixtures/elementFixture.ts

@@ -1,3 +1,4 @@
+import { DEFAULT_FONT_FAMILY } from "../../constants";
 import type { ExcalidrawElement } from "../../element/types";
 
 const elementBase: Omit<ExcalidrawElement, "type"> = {
@@ -49,3 +50,17 @@ export const rectangleWithLinkFixture: ExcalidrawElement = {
   type: "rectangle",
   link: "excalidraw.com",
 };
+
+export const textFixture: ExcalidrawElement = {
+  ...elementBase,
+  type: "text",
+  fontSize: 20,
+  fontFamily: DEFAULT_FONT_FAMILY,
+  text: "original text",
+  originalText: "original text",
+  textAlign: "left",
+  verticalAlign: "top",
+  containerId: null,
+  lineHeight: 1.25 as any,
+  autoResize: false,
+};

+ 4 - 0
packages/excalidraw/tests/helpers/polyfills.ts

@@ -1,3 +1,5 @@
+import { URL } from "node:url";
+
 class ClipboardEvent {
   constructor(
     type: "paste" | "copy",
@@ -88,4 +90,6 @@ export const testPolyfills = {
   ClipboardEvent,
   DataTransfer,
   DataTransferItem,
+  // https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
+  URL,
 };

+ 2 - 2
packages/excalidraw/tests/regressionTests.test.tsx

@@ -644,9 +644,9 @@ describe("regression tests", () => {
 
   it("updates fontSize & fontFamily appState", () => {
     UI.clickTool("text");
-    expect(h.state.currentItemFontFamily).toEqual(FONT_FAMILY.Virgil);
+    expect(h.state.currentItemFontFamily).toEqual(FONT_FAMILY.Excalifont);
     fireEvent.click(screen.getByTitle(/code/i));
-    expect(h.state.currentItemFontFamily).toEqual(FONT_FAMILY.Cascadia);
+    expect(h.state.currentItemFontFamily).toEqual(FONT_FAMILY["Comic Shanns"]);
   });
 
   it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 11
packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap


+ 15 - 2
packages/excalidraw/tests/scene/export.test.ts

@@ -4,14 +4,14 @@ import {
   diamondFixture,
   ellipseFixture,
   rectangleWithLinkFixture,
+  textFixture,
 } from "../fixtures/elementFixture";
 import { API } from "../helpers/api";
 import { exportToCanvas, exportToSvg } from "../../../utils";
-import { FRAME_STYLE } from "../../constants";
+import { FONT_FAMILY, FRAME_STYLE } from "../../constants";
 import { prepareElementsForExport } from "../../data";
 
 describe("exportToSvg", () => {
-  window.EXCALIDRAW_ASSET_PATH = "/";
   const ELEMENT_HEIGHT = 100;
   const ELEMENT_WIDTH = 100;
   const ELEMENTS = [
@@ -27,6 +27,19 @@ describe("exportToSvg", () => {
       width: ELEMENT_WIDTH,
       index: "a1",
     },
+    {
+      ...textFixture,
+      height: ELEMENT_HEIGHT,
+      width: ELEMENT_WIDTH,
+      index: "a2",
+    },
+    {
+      ...textFixture,
+      fontFamily: FONT_FAMILY.Nunito, // test embedding external font
+      height: ELEMENT_HEIGHT,
+      width: ELEMENT_WIDTH,
+      index: "a3",
+    },
   ] as NonDeletedExcalidrawElement[];
 
   const DEFAULT_OPTIONS = {

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä