feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									4c5408263c
								
							
						
					
					
						commit
						62228e0bbb
					
				|  | @ -59,7 +59,7 @@ pre a { | ||||||
|   padding: 5px; |   padding: 5px; | ||||||
|   background: #70b1ec; |   background: #70b1ec; | ||||||
|   color: white; |   color: white; | ||||||
|   font-weight: bold; |   font-weight: 700; | ||||||
|   border: none; |   border: none; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -872,7 +872,7 @@ export default function App({ | ||||||
|                 files: excalidrawAPI.getFiles(), |                 files: excalidrawAPI.getFiles(), | ||||||
|               }); |               }); | ||||||
|               const ctx = canvas.getContext("2d")!; |               const ctx = canvas.getContext("2d")!; | ||||||
|               ctx.font = "30px Virgil"; |               ctx.font = "30px Excalifont"; | ||||||
|               ctx.strokeText("My custom text", 50, 60); |               ctx.strokeText("My custom text", 50, 60); | ||||||
|               setCanvasUrl(canvas.toDataURL()); |               setCanvasUrl(canvas.toDataURL()); | ||||||
|             }} |             }} | ||||||
|  | @ -893,7 +893,7 @@ export default function App({ | ||||||
|                 files: excalidrawAPI.getFiles(), |                 files: excalidrawAPI.getFiles(), | ||||||
|               }); |               }); | ||||||
|               const ctx = canvas.getContext("2d")!; |               const ctx = canvas.getContext("2d")!; | ||||||
|               ctx.font = "30px Virgil"; |               ctx.font = "30px Excalifont"; | ||||||
|               ctx.strokeText("My custom text", 50, 60); |               ctx.strokeText("My custom text", 50, 60); | ||||||
|               setCanvasUrl(canvas.toDataURL()); |               setCanvasUrl(canvas.toDataURL()); | ||||||
|             }} |             }} | ||||||
|  |  | ||||||
|  | @ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [ | ||||||
| ]; | ]; | ||||||
| export default { | export default { | ||||||
|   elements, |   elements, | ||||||
|   appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, |   appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 }, | ||||||
|   scrollToContent: true, |   scrollToContent: true, | ||||||
|   libraryItems: [ |   libraryItems: [ | ||||||
|     [ |     [ | ||||||
|  |  | ||||||
|  | @ -34,3 +34,6 @@ yarn-error.log* | ||||||
| # typescript | # typescript | ||||||
| *.tsbuildinfo | *.tsbuildinfo | ||||||
| next-env.d.ts | next-env.d.ts | ||||||
|  | 
 | ||||||
|  | # copied assets | ||||||
|  | public/*.woff2 | ||||||
|  | @ -3,7 +3,8 @@ | ||||||
|   "version": "0.1.0", |   "version": "0.1.0", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "scripts": { |   "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", |     "dev": "yarn build:workspace && next dev -p 3005", | ||||||
|     "build": "yarn build:workspace && next build", |     "build": "yarn build:workspace && next build", | ||||||
|     "start": "next start -p 3006", |     "start": "next start -p 3006", | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import dynamic from "next/dynamic"; | import dynamic from "next/dynamic"; | ||||||
|  | import Script from "next/script"; | ||||||
| import "../common.scss"; | import "../common.scss"; | ||||||
| 
 | 
 | ||||||
| // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
 | // 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> |       <a href="/excalidraw-in-pages">Switch to Pages router</a> | ||||||
|       <h1 className="page-title">App Router</h1> |       <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 */} |       {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} | ||||||
|       <ExcalidrawWithClientOnly /> |       <ExcalidrawWithClientOnly /> | ||||||
|     </> |     </> | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ a { | ||||||
|   color: #1c7ed6; |   color: #1c7ed6; | ||||||
|   font-size: 20px; |   font-size: 20px; | ||||||
|   text-decoration: none; |   text-decoration: none; | ||||||
|   font-weight: 550; |   font-weight: 500; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .page-title { | .page-title { | ||||||
|  |  | ||||||
|  | @ -0,0 +1,2 @@ | ||||||
|  | # copied assets | ||||||
|  | public/*.woff2 | ||||||
|  | @ -11,6 +11,7 @@ | ||||||
|     <title>React App</title> |     <title>React App</title> | ||||||
|     <script> |     <script> | ||||||
|       window.name = "codesandbox"; |       window.name = "codesandbox"; | ||||||
|  |       window.EXCALIDRAW_ASSET_PATH = window.origin; | ||||||
|     </script> |     </script> | ||||||
|     <link rel="stylesheet" href="/dist/browser/dev/index.css" /> |     <link rel="stylesheet" href="/dist/browser/dev/index.css" /> | ||||||
|   </head> |   </head> | ||||||
|  |  | ||||||
|  | @ -12,8 +12,10 @@ | ||||||
|     "typescript": "^5" |     "typescript": "^5" | ||||||
|   }, |   }, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", |     "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", | ||||||
|     "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", |     "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" |     "build:preview": "yarn build && vite preview --port 5002" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -114,6 +114,14 @@ | ||||||
|       ) { |       ) { | ||||||
|         window.location.href = "https://app.excalidraw.com"; |         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> |     </script> | ||||||
|     <% } %> |     <% } %> | ||||||
| 
 | 
 | ||||||
|  | @ -124,22 +132,74 @@ | ||||||
|     <!-- Excalidraw version --> |     <!-- Excalidraw version --> | ||||||
|     <meta name="version" content="{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 |     <link | ||||||
|       rel="preload" |       rel="preload" | ||||||
|       href="/Virgil.woff2" |       href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2" | ||||||
|       as="font" |       as="font" | ||||||
|       type="font/woff2" |       type="font/woff2" | ||||||
|       crossorigin="anonymous" |       crossorigin="anonymous" | ||||||
|     /> |     /> | ||||||
|     <link |     <link | ||||||
|       rel="preload" |       rel="preload" | ||||||
|       href="/Cascadia.woff2" |       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="../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="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2" | ||||||
|       as="font" |       as="font" | ||||||
|       type="font/woff2" |       type="font/woff2" | ||||||
|       crossorigin="anonymous" |       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' && |     <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && | ||||||
|     VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> |     VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> | ||||||
|     <script> |     <script> | ||||||
|  | @ -158,7 +218,6 @@ | ||||||
|     </script> |     </script> | ||||||
|     <% } %> |     <% } %> | ||||||
|     <script> |     <script> | ||||||
|       window.EXCALIDRAW_ASSET_PATH = "/"; |  | ||||||
|       // setting this so that libraries installation reuses this window tab. |       // setting this so that libraries installation reuses this window tab. | ||||||
|       window.name = "_excalidraw"; |       window.name = "_excalidraw"; | ||||||
|     </script> |     </script> | ||||||
|  |  | ||||||
|  | @ -36,7 +36,8 @@ | ||||||
|     "build:version": "node ../scripts/build-version.js", |     "build:version": "node ../scripts/build-version.js", | ||||||
|     "build": "yarn build:app && yarn build:version", |     "build": "yarn build:app && yarn build:version", | ||||||
|     "start": "yarn && vite", |     "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" |     "build:preview": "yarn build && vite preview --port 5000" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u | ||||||
|   class="welcome-screen-center" |   class="welcome-screen-center" | ||||||
| > | > | ||||||
|   <div |   <div | ||||||
|     class="welcome-screen-center__logo virgil welcome-screen-decor" |     class="welcome-screen-center__logo excalifont welcome-screen-decor" | ||||||
|   > |   > | ||||||
|     <div |     <div | ||||||
|       class="ExcalidrawLogo is-small" |       class="ExcalidrawLogo is-small" | ||||||
|  | @ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </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. |     All your data is saved locally in your browser. | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs"; | ||||||
| import { VitePWA } from "vite-plugin-pwa"; | import { VitePWA } from "vite-plugin-pwa"; | ||||||
| import checker from "vite-plugin-checker"; | import checker from "vite-plugin-checker"; | ||||||
| import { createHtmlPlugin } from "vite-plugin-html"; | import { createHtmlPlugin } from "vite-plugin-html"; | ||||||
|  | import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; | ||||||
| 
 | 
 | ||||||
| // To load .env.local variables
 | // To load .env.local variables
 | ||||||
| const envVars = loadEnv("", `../`); | const envVars = loadEnv("", `../`); | ||||||
|  | @ -22,6 +23,14 @@ export default defineConfig({ | ||||||
|     outDir: "build", |     outDir: "build", | ||||||
|     rollupOptions: { |     rollupOptions: { | ||||||
|       output: { |       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
 |         // Creating separate chunk for locales except for en and percentages.json so they
 | ||||||
|         // can be cached at runtime and not merged with
 |         // can be cached at runtime and not merged with
 | ||||||
|         // app precache. en.json and percentages.json are needed for first load
 |         // app precache. en.json and percentages.json are needed for first load
 | ||||||
|  | @ -35,12 +44,13 @@ export default defineConfig({ | ||||||
|             // Taking the substring after "locales/"
 |             // Taking the substring after "locales/"
 | ||||||
|             return `locales/${id.substring(index + 8)}`; |             return `locales/${id.substring(index + 8)}`; | ||||||
|           } |           } | ||||||
|         }, |         } | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|     sourcemap: true, |     sourcemap: true, | ||||||
|   }, |   }, | ||||||
|   plugins: [ |   plugins: [ | ||||||
|  |     woff2BrowserPlugin(), | ||||||
|     react(), |     react(), | ||||||
|     checker({ |     checker({ | ||||||
|       typescript: true, |       typescript: true, | ||||||
|  |  | ||||||
|  | @ -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 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) | - `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) | - 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) | ||||||
|  |  | ||||||
|  | @ -155,13 +155,15 @@ describe("element locking", () => { | ||||||
|       }); |       }); | ||||||
|       const text = API.createElement({ |       const text = API.createElement({ | ||||||
|         type: "text", |         type: "text", | ||||||
|         fontFamily: FONT_FAMILY.Cascadia, |         fontFamily: FONT_FAMILY["Comic Shanns"], | ||||||
|       }); |       }); | ||||||
|       h.elements = [rect, text]; |       h.elements = [rect, text]; | ||||||
|       API.setSelectedElements([rect, text]); |       API.setSelectedElements([rect, text]); | ||||||
| 
 | 
 | ||||||
|       expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); |       expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); | ||||||
|       expect(queryByTestId(document.body, `font-family-code`)).toBeChecked(); |       expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( | ||||||
|  |         "active", | ||||||
|  |       ); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -1,4 +1,6 @@ | ||||||
|  | import { useEffect, useMemo, useRef, useState } from "react"; | ||||||
| import type { AppClassProperties, AppState, Primitive } from "../types"; | import type { AppClassProperties, AppState, Primitive } from "../types"; | ||||||
|  | import type { StoreActionType } from "../store"; | ||||||
| import { | import { | ||||||
|   DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, |   DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, | ||||||
|   DEFAULT_ELEMENT_BACKGROUND_PICKS, |   DEFAULT_ELEMENT_BACKGROUND_PICKS, | ||||||
|  | @ -9,6 +11,7 @@ import { trackEvent } from "../analytics"; | ||||||
| import { ButtonIconSelect } from "../components/ButtonIconSelect"; | import { ButtonIconSelect } from "../components/ButtonIconSelect"; | ||||||
| import { ColorPicker } from "../components/ColorPicker/ColorPicker"; | import { ColorPicker } from "../components/ColorPicker/ColorPicker"; | ||||||
| import { IconPicker } from "../components/IconPicker"; | import { IconPicker } from "../components/IconPicker"; | ||||||
|  | import { FontPicker } from "../components/FontPicker/FontPicker"; | ||||||
| // TODO barnabasmolnar/editor-redesign
 | // TODO barnabasmolnar/editor-redesign
 | ||||||
| // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
 | // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
 | ||||||
| // ArrowHead icons
 | // ArrowHead icons
 | ||||||
|  | @ -38,9 +41,6 @@ import { | ||||||
|   FontSizeExtraLargeIcon, |   FontSizeExtraLargeIcon, | ||||||
|   EdgeSharpIcon, |   EdgeSharpIcon, | ||||||
|   EdgeRoundIcon, |   EdgeRoundIcon, | ||||||
|   FreedrawIcon, |  | ||||||
|   FontFamilyNormalIcon, |  | ||||||
|   FontFamilyCodeIcon, |  | ||||||
|   TextAlignLeftIcon, |   TextAlignLeftIcon, | ||||||
|   TextAlignCenterIcon, |   TextAlignCenterIcon, | ||||||
|   TextAlignRightIcon, |   TextAlignRightIcon, | ||||||
|  | @ -65,10 +65,7 @@ import { | ||||||
|   redrawTextBoundingBox, |   redrawTextBoundingBox, | ||||||
| } from "../element"; | } from "../element"; | ||||||
| import { mutateElement, newElementWith } from "../element/mutateElement"; | import { mutateElement, newElementWith } from "../element/mutateElement"; | ||||||
| import { | import { getBoundTextElement } from "../element/textElement"; | ||||||
|   getBoundTextElement, |  | ||||||
|   getDefaultLineHeight, |  | ||||||
| } from "../element/textElement"; |  | ||||||
| import { | import { | ||||||
|   isBoundToContainer, |   isBoundToContainer, | ||||||
|   isLinearElement, |   isLinearElement, | ||||||
|  | @ -94,9 +91,10 @@ import { | ||||||
|   isSomeElementSelected, |   isSomeElementSelected, | ||||||
| } from "../scene"; | } from "../scene"; | ||||||
| import { hasStrokeColor } from "../scene/comparisons"; | import { hasStrokeColor } from "../scene/comparisons"; | ||||||
| import { arrayToMap, getShortcutKey } from "../utils"; | import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils"; | ||||||
| import { register } from "./register"; | import { register } from "./register"; | ||||||
| import { StoreAction } from "../store"; | import { StoreAction } from "../store"; | ||||||
|  | import { Fonts, getLineHeight } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; | 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({ | export const actionChangeFontFamily = register({ | ||||||
|   name: "changeFontFamily", |   name: "changeFontFamily", | ||||||
|   label: "labels.fontFamily", |   label: "labels.fontFamily", | ||||||
|   trackEvent: false, |   trackEvent: false, | ||||||
|   perform: (elements, appState, value, app) => { |   perform: (elements, appState, value, app) => { | ||||||
|     return { |     const { cachedElements, resetAll, resetContainers, ...nextAppState } = | ||||||
|       elements: changeProperty( |       value as ChangeFontFamilyData; | ||||||
|  | 
 | ||||||
|  |     if (resetAll) { | ||||||
|  |       const nextElements = changeProperty( | ||||||
|         elements, |         elements, | ||||||
|         appState, |         appState, | ||||||
|         (oldElement) => { |         (element) => { | ||||||
|           if (isTextElement(oldElement)) { |           const cachedElement = cachedElements?.get(element.id); | ||||||
|             const newElement: ExcalidrawTextElement = newElementWith( |           if (cachedElement) { | ||||||
|               oldElement, |             const newElement = newElementWith(element, { | ||||||
|               { |               ...cachedElement, | ||||||
|                 fontFamily: value, |             }); | ||||||
|                 lineHeight: getDefaultLineHeight(value), | 
 | ||||||
|               }, |  | ||||||
|             ); |  | ||||||
|             redrawTextBoundingBox( |  | ||||||
|               newElement, |  | ||||||
|               app.scene.getContainerElement(oldElement), |  | ||||||
|               app.scene.getNonDeletedElementsMap(), |  | ||||||
|             ); |  | ||||||
|             return newElement; |             return newElement; | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           return oldElement; |           return element; | ||||||
|         }, |         }, | ||||||
|         true, |         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: { | ||||||
|         ...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 }) => { |   PanelComponent: ({ elements, appState, app, updateData }) => { | ||||||
|     const options: { |     const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map()); | ||||||
|       value: FontFamilyValues; |     const prevSelectedFontFamilyRef = useRef<number | null>(null); | ||||||
|       text: string; |     // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
 | ||||||
|       icon: JSX.Element; |     const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({}); | ||||||
|       testId: string; |     const isUnmounted = useRef(true); | ||||||
|     }[] = [ | 
 | ||||||
|       { |     const selectedFontFamily = useMemo(() => { | ||||||
|         value: FONT_FAMILY.Virgil, |       const getFontFamily = ( | ||||||
|         text: t("labels.handDrawn"), |         elementsArray: readonly ExcalidrawElement[], | ||||||
|         icon: FreedrawIcon, |         elementsMap: Map<string, ExcalidrawElement>, | ||||||
|         testId: "font-family-virgil", |       ) => | ||||||
|       }, |         getFormValue( | ||||||
|       { |           elementsArray, | ||||||
|         value: FONT_FAMILY.Helvetica, |           appState, | ||||||
|         text: t("labels.normal"), |           (element) => { | ||||||
|         icon: FontFamilyNormalIcon, |             if (isTextElement(element)) { | ||||||
|         testId: "font-family-normal", |               return element.fontFamily; | ||||||
|       }, |             } | ||||||
|       { |             const boundTextElement = getBoundTextElement(element, elementsMap); | ||||||
|         value: FONT_FAMILY.Cascadia, |             if (boundTextElement) { | ||||||
|         text: t("labels.code"), |               return boundTextElement.fontFamily; | ||||||
|         icon: FontFamilyCodeIcon, |             } | ||||||
|         testId: "font-family-code", |             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 ( |     return ( | ||||||
|       <fieldset> |       <fieldset> | ||||||
|         <legend>{t("labels.fontFamily")}</legend> |         <legend>{t("labels.fontFamily")}</legend> | ||||||
|         <ButtonIconSelect<FontFamilyValues | false> |         <FontPicker | ||||||
|           group="font-family" |           isOpened={appState.openPopup === "fontFamily"} | ||||||
|           options={options} |           selectedFontFamily={selectedFontFamily} | ||||||
|           value={getFormValue( |           hoveredFontFamily={appState.currentHoveredFontFamily} | ||||||
|             elements, |           onSelect={(fontFamily) => { | ||||||
|             appState, |             setBatchedData({ | ||||||
|             (element) => { |               openPopup: null, | ||||||
|               if (isTextElement(element)) { |               currentHoveredFontFamily: null, | ||||||
|                 return element.fontFamily; |               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, |               setBatchedData({ | ||||||
|                 app.scene.getNonDeletedElementsMap(), |                 openPopup: "fontFamily", | ||||||
|               ); |               }); | ||||||
|               if (boundTextElement) { |             } else { | ||||||
|                 return boundTextElement.fontFamily; |               // 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; | 
 | ||||||
|             }, |               cachedElementsRef.current.clear(); | ||||||
|             (element) => |             } | ||||||
|               isTextElement(element) || |           }} | ||||||
|               getBoundTextElement( |  | ||||||
|                 element, |  | ||||||
|                 app.scene.getNonDeletedElementsMap(), |  | ||||||
|               ) !== null, |  | ||||||
|             (hasSelection) => |  | ||||||
|               hasSelection |  | ||||||
|                 ? null |  | ||||||
|                 : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, |  | ||||||
|           )} |  | ||||||
|           onChange={(value) => updateData(value)} |  | ||||||
|         /> |         /> | ||||||
|       </fieldset> |       </fieldset> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -12,10 +12,7 @@ import { | ||||||
|   DEFAULT_FONT_FAMILY, |   DEFAULT_FONT_FAMILY, | ||||||
|   DEFAULT_TEXT_ALIGN, |   DEFAULT_TEXT_ALIGN, | ||||||
| } from "../constants"; | } from "../constants"; | ||||||
| import { | import { getBoundTextElement } from "../element/textElement"; | ||||||
|   getBoundTextElement, |  | ||||||
|   getDefaultLineHeight, |  | ||||||
| } from "../element/textElement"; |  | ||||||
| import { | import { | ||||||
|   hasBoundTextElement, |   hasBoundTextElement, | ||||||
|   canApplyRoundnessTypeToElement, |   canApplyRoundnessTypeToElement, | ||||||
|  | @ -27,6 +24,7 @@ import { getSelectedElements } from "../scene"; | ||||||
| import type { ExcalidrawTextElement } from "../element/types"; | import type { ExcalidrawTextElement } from "../element/types"; | ||||||
| import { paintIcon } from "../components/icons"; | import { paintIcon } from "../components/icons"; | ||||||
| import { StoreAction } from "../store"; | import { StoreAction } from "../store"; | ||||||
|  | import { getLineHeight } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| // `copiedStyles` is exported only for tests.
 | // `copiedStyles` is exported only for tests.
 | ||||||
| export let copiedStyles: string = "{}"; | export let copiedStyles: string = "{}"; | ||||||
|  | @ -122,7 +120,7 @@ export const actionPasteStyles = register({ | ||||||
|                 DEFAULT_TEXT_ALIGN, |                 DEFAULT_TEXT_ALIGN, | ||||||
|               lineHeight: |               lineHeight: | ||||||
|                 (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || |                 (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || | ||||||
|                 getDefaultLineHeight(fontFamily), |                 getLineHeight(fontFamily), | ||||||
|             }); |             }); | ||||||
|             let container = null; |             let container = null; | ||||||
|             if (newElement.containerId) { |             if (newElement.containerId) { | ||||||
|  |  | ||||||
|  | @ -36,6 +36,7 @@ export const getDefaultAppState = (): Omit< | ||||||
|     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, |     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, | ||||||
|     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, |     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, | ||||||
|     currentItemTextAlign: DEFAULT_TEXT_ALIGN, |     currentItemTextAlign: DEFAULT_TEXT_ALIGN, | ||||||
|  |     currentHoveredFontFamily: null, | ||||||
|     cursorButton: "up", |     cursorButton: "up", | ||||||
|     activeEmbeddable: null, |     activeEmbeddable: null, | ||||||
|     draggingElement: null, |     draggingElement: null, | ||||||
|  | @ -149,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (< | ||||||
|   currentItemStrokeStyle: { browser: true, export: false, server: false }, |   currentItemStrokeStyle: { browser: true, export: false, server: false }, | ||||||
|   currentItemStrokeWidth: { browser: true, export: false, server: false }, |   currentItemStrokeWidth: { browser: true, export: false, server: false }, | ||||||
|   currentItemTextAlign: { 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 }, |   cursorButton: { browser: true, export: false, server: false }, | ||||||
|   activeEmbeddable: { browser: false, export: false, server: false }, |   activeEmbeddable: { browser: false, export: false, server: false }, | ||||||
|   draggingElement: { browser: false, export: false, server: false }, |   draggingElement: { browser: false, export: false, server: false }, | ||||||
|  |  | ||||||
|  | @ -158,10 +158,8 @@ export const SelectedShapeActions = ({ | ||||||
|       {(appState.activeTool.type === "text" || |       {(appState.activeTool.type === "text" || | ||||||
|         targetElements.some(isTextElement)) && ( |         targetElements.some(isTextElement)) && ( | ||||||
|         <> |         <> | ||||||
|           {renderAction("changeFontSize")} |  | ||||||
| 
 |  | ||||||
|           {renderAction("changeFontFamily")} |           {renderAction("changeFontFamily")} | ||||||
| 
 |           {renderAction("changeFontSize")} | ||||||
|           {(appState.activeTool.type === "text" || |           {(appState.activeTool.type === "text" || | ||||||
|             suppportsHorizontalAlign(targetElements, elementsMap)) && |             suppportsHorizontalAlign(targetElements, elementsMap)) && | ||||||
|             renderAction("changeTextAlign")} |             renderAction("changeTextAlign")} | ||||||
|  |  | ||||||
|  | @ -321,7 +321,6 @@ import { | ||||||
|   getBoundTextElement, |   getBoundTextElement, | ||||||
|   getContainerCenter, |   getContainerCenter, | ||||||
|   getContainerElement, |   getContainerElement, | ||||||
|   getDefaultLineHeight, |  | ||||||
|   getLineHeightInPx, |   getLineHeightInPx, | ||||||
|   getMinTextElementWidth, |   getMinTextElementWidth, | ||||||
|   isMeasureTextSupported, |   isMeasureTextSupported, | ||||||
|  | @ -337,7 +336,7 @@ import { | ||||||
| import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; | import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; | ||||||
| import { shouldShowBoundingBox } from "../element/transformHandles"; | import { shouldShowBoundingBox } from "../element/transformHandles"; | ||||||
| import { actionUnlockAllElements } from "../actions/actionElementLock"; | import { actionUnlockAllElements } from "../actions/actionElementLock"; | ||||||
| import { Fonts } from "../scene/Fonts"; | import { Fonts, getLineHeight } from "../fonts"; | ||||||
| import { | import { | ||||||
|   getFrameChildren, |   getFrameChildren, | ||||||
|   isCursorInFrame, |   isCursorInFrame, | ||||||
|  | @ -532,8 +531,8 @@ class App extends React.Component<AppProps, AppState> { | ||||||
|   private excalidrawContainerRef = React.createRef<HTMLDivElement>(); |   private excalidrawContainerRef = React.createRef<HTMLDivElement>(); | ||||||
| 
 | 
 | ||||||
|   public scene: Scene; |   public scene: Scene; | ||||||
|  |   public fonts: Fonts; | ||||||
|   public renderer: Renderer; |   public renderer: Renderer; | ||||||
|   private fonts: Fonts; |  | ||||||
|   private resizeObserver: ResizeObserver | undefined; |   private resizeObserver: ResizeObserver | undefined; | ||||||
|   private nearestScrollableContainer: HTMLElement | Document | undefined; |   private nearestScrollableContainer: HTMLElement | Document | undefined; | ||||||
|   public library: AppClassProperties["library"]; |   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.resetStore(); | ||||||
|     this.resetHistory(); |     this.resetHistory(); | ||||||
|  | @ -2347,6 +2341,12 @@ class App extends React.Component<AppProps, AppState> { | ||||||
|       ...scene, |       ...scene, | ||||||
|       storeAction: StoreAction.UPDATE, |       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) => { |   private isMobileBreakpoint = (width: number, height: number) => { | ||||||
|  | @ -2439,6 +2439,10 @@ class App extends React.Component<AppProps, AppState> { | ||||||
|           configurable: true, |           configurable: true, | ||||||
|           value: this.store, |           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
 |       // rerender text elements on font load to fix #637 && #1553
 | ||||||
|       addEventListener(document.fonts, "loadingdone", (event) => { |       addEventListener(document.fonts, "loadingdone", (event) => { | ||||||
|         const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; |         const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; | ||||||
|         this.fonts.onFontsLoaded(loadedFontFaces); |         this.fonts.onLoaded(loadedFontFaces); | ||||||
|       }), |       }), | ||||||
|       // Safari-only desktop pinch zoom
 |       // Safari-only desktop pinch zoom
 | ||||||
|       addEventListener( |       addEventListener( | ||||||
|  | @ -3379,7 +3383,7 @@ class App extends React.Component<AppProps, AppState> { | ||||||
|       fontSize: textElementProps.fontSize, |       fontSize: textElementProps.fontSize, | ||||||
|       fontFamily: textElementProps.fontFamily, |       fontFamily: textElementProps.fontFamily, | ||||||
|     }); |     }); | ||||||
|     const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); |     const lineHeight = getLineHeight(textElementProps.fontFamily); | ||||||
|     const [x1, , x2] = getVisibleSceneBounds(this.state); |     const [x1, , x2] = getVisibleSceneBounds(this.state); | ||||||
|     // long texts should not go beyond 800 pixels in width nor should it go below 200 px
 |     // 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); |     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); |           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) |             ? wrapText(originalText, fontString, maxTextWidth) | ||||||
|             : originalText; |             : originalText; | ||||||
| 
 | 
 | ||||||
|           metrics = isTextWrapped |           metrics = isTextUnwrapped | ||||||
|             ? measureText(text, fontString, lineHeight) |             ? measureText(text, fontString, lineHeight) | ||||||
|             : metrics; |             : metrics; | ||||||
| 
 | 
 | ||||||
|  | @ -3417,7 +3421,7 @@ class App extends React.Component<AppProps, AppState> { | ||||||
|             text, |             text, | ||||||
|             originalText, |             originalText, | ||||||
|             lineHeight, |             lineHeight, | ||||||
|             autoResize: !isTextWrapped, |             autoResize: !isTextUnwrapped, | ||||||
|             frameId: topLayerFrame ? topLayerFrame.id : null, |             frameId: topLayerFrame ? topLayerFrame.id : null, | ||||||
|           }); |           }); | ||||||
|           acc.push(element); |           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 (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { | ||||||
|         if (this.state.activeTool.type === "laser") { |         if (this.state.activeTool.type === "laser") { | ||||||
|           this.setActiveTool({ type: "selection" }); |           this.setActiveTool({ type: "selection" }); | ||||||
|  | @ -4761,7 +4795,7 @@ class App extends React.Component<AppProps, AppState> { | ||||||
|       existingTextElement?.fontFamily || this.state.currentItemFontFamily; |       existingTextElement?.fontFamily || this.state.currentItemFontFamily; | ||||||
| 
 | 
 | ||||||
|     const lineHeight = |     const lineHeight = | ||||||
|       existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); |       existingTextElement?.lineHeight || getLineHeight(fontFamily); | ||||||
|     const fontSize = this.state.currentItemFontSize; |     const fontSize = this.state.currentItemFontSize; | ||||||
| 
 | 
 | ||||||
|     if ( |     if ( | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import clsx from "clsx"; | import clsx from "clsx"; | ||||||
|  | import { ButtonIcon } from "./ButtonIcon"; | ||||||
| 
 | 
 | ||||||
| // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
 | // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
 | ||||||
| export const ButtonIconSelect = <T extends Object>( | 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.options.map((option) => | ||||||
|       props.type === "button" ? ( |       props.type === "button" ? ( | ||||||
|         <button |         <ButtonIcon | ||||||
|           type="button" |  | ||||||
|           key={option.text} |           key={option.text} | ||||||
|           onClick={(event) => props.onClick(option.value, event)} |           icon={option.icon} | ||||||
|           className={clsx({ |  | ||||||
|             active: option.active ?? props.value === option.value, |  | ||||||
|           })} |  | ||||||
|           data-testid={option.testId} |  | ||||||
|           title={option.text} |           title={option.text} | ||||||
|         > |           testId={option.testId} | ||||||
|           {option.icon} |           active={option.active ?? props.value === option.value} | ||||||
|         </button> |           onClick={(event) => props.onClick(option.value, event)} | ||||||
|  |         /> | ||||||
|       ) : ( |       ) : ( | ||||||
|         <label |         <label | ||||||
|           key={option.text} |           key={option.text} | ||||||
|  |  | ||||||
|  | @ -0,0 +1,10 @@ | ||||||
|  | export const ButtonSeparator = () => ( | ||||||
|  |   <div | ||||||
|  |     style={{ | ||||||
|  |       width: 1, | ||||||
|  |       height: "1rem", | ||||||
|  |       backgroundColor: "var(--default-border-color)", | ||||||
|  |       margin: "0 auto", | ||||||
|  |     }} | ||||||
|  |   /> | ||||||
|  | ); | ||||||
|  | @ -20,7 +20,7 @@ | ||||||
|     align-items: center; |     align-items: center; | ||||||
| 
 | 
 | ||||||
|     @include isMobile { |     @include isMobile { | ||||||
|       max-width: 175px; |       max-width: 11rem; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,22 +1,24 @@ | ||||||
| import { isInteractive, isTransparent, isWritableElement } from "../../utils"; | import { isTransparent } from "../../utils"; | ||||||
| import type { ExcalidrawElement } from "../../element/types"; | import type { ExcalidrawElement } from "../../element/types"; | ||||||
| import type { AppState } from "../../types"; | import type { AppState } from "../../types"; | ||||||
| import { TopPicks } from "./TopPicks"; | import { TopPicks } from "./TopPicks"; | ||||||
|  | import { ButtonSeparator } from "../ButtonSeparator"; | ||||||
| import { Picker } from "./Picker"; | import { Picker } from "./Picker"; | ||||||
| import * as Popover from "@radix-ui/react-popover"; | import * as Popover from "@radix-ui/react-popover"; | ||||||
| import { useAtom } from "jotai"; | import { useAtom } from "jotai"; | ||||||
| import type { ColorPickerType } from "./colorPickerUtils"; | import type { ColorPickerType } from "./colorPickerUtils"; | ||||||
| import { activeColorPickerSectionAtom } from "./colorPickerUtils"; | import { activeColorPickerSectionAtom } from "./colorPickerUtils"; | ||||||
| import { useDevice, useExcalidrawContainer } from "../App"; | import { useExcalidrawContainer } from "../App"; | ||||||
| import type { ColorTuple, ColorPaletteCustom } from "../../colors"; | import type { ColorTuple, ColorPaletteCustom } from "../../colors"; | ||||||
| import { COLOR_PALETTE } from "../../colors"; | import { COLOR_PALETTE } from "../../colors"; | ||||||
| import PickerHeading from "./PickerHeading"; | import PickerHeading from "./PickerHeading"; | ||||||
| import { t } from "../../i18n"; | import { t } from "../../i18n"; | ||||||
| import clsx from "clsx"; | import clsx from "clsx"; | ||||||
|  | import { useRef } from "react"; | ||||||
| import { jotaiScope } from "../../jotai"; | import { jotaiScope } from "../../jotai"; | ||||||
| import { ColorInput } from "./ColorInput"; | import { ColorInput } from "./ColorInput"; | ||||||
| import { useRef } from "react"; |  | ||||||
| import { activeEyeDropperAtom } from "../EyeDropper"; | import { activeEyeDropperAtom } from "../EyeDropper"; | ||||||
|  | import { PropertiesPopover } from "../PropertiesPopover"; | ||||||
| 
 | 
 | ||||||
| import "./ColorPicker.scss"; | import "./ColorPicker.scss"; | ||||||
| 
 | 
 | ||||||
|  | @ -71,6 +73,7 @@ const ColorPickerPopupContent = ({ | ||||||
|   | "palette" |   | "palette" | ||||||
|   | "updateData" |   | "updateData" | ||||||
| >) => { | >) => { | ||||||
|  |   const { container } = useExcalidrawContainer(); | ||||||
|   const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); |   const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); | ||||||
| 
 | 
 | ||||||
|   const [eyeDropperState, setEyeDropperState] = useAtom( |   const [eyeDropperState, setEyeDropperState] = useAtom( | ||||||
|  | @ -78,9 +81,6 @@ const ColorPickerPopupContent = ({ | ||||||
|     jotaiScope, |     jotaiScope, | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const { container } = useExcalidrawContainer(); |  | ||||||
|   const device = useDevice(); |  | ||||||
| 
 |  | ||||||
|   const colorInputJSX = ( |   const colorInputJSX = ( | ||||||
|     <div> |     <div> | ||||||
|       <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> |       <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> | ||||||
|  | @ -94,6 +94,7 @@ const ColorPickerPopupContent = ({ | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
|  | 
 | ||||||
|   const popoverRef = useRef<HTMLDivElement>(null); |   const popoverRef = useRef<HTMLDivElement>(null); | ||||||
| 
 | 
 | ||||||
|   const focusPickerContent = () => { |   const focusPickerContent = () => { | ||||||
|  | @ -103,120 +104,73 @@ const ColorPickerPopupContent = ({ | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Popover.Portal container={container}> |     <PropertiesPopover | ||||||
|       <Popover.Content |       container={container} | ||||||
|         ref={popoverRef} |       style={{ maxWidth: "208px" }} | ||||||
|         className="focus-visible-none" |       onFocusOutside={(event) => { | ||||||
|         data-prevent-outside-click |         // refocus due to eye dropper
 | ||||||
|         onFocusOutside={(event) => { |         focusPickerContent(); | ||||||
|           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(); |           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 |       onClose={() => { | ||||||
|             ? "center" |         updateData({ openPopup: null }); | ||||||
|             : "start" |         setActiveColorPickerSection(null); | ||||||
|         } |       }} | ||||||
|         alignOffset={-16} |     > | ||||||
|         sideOffset={20} |       {palette ? ( | ||||||
|         style={{ |         <Picker | ||||||
|           zIndex: "var(--zIndex-layerUI)", |           palette={palette} | ||||||
|           backgroundColor: "var(--popup-bg-color)", |           color={color} | ||||||
|           maxWidth: "208px", |           onChange={(changedColor) => { | ||||||
|           maxHeight: window.innerHeight, |             onChange(changedColor); | ||||||
|           padding: "12px", |           }} | ||||||
|           borderRadius: "8px", |           onEyeDropperToggle={(force) => { | ||||||
|           boxSizing: "border-box", |             setEyeDropperState((state) => { | ||||||
|           overflowY: "auto", |               if (force) { | ||||||
|           boxShadow: |                 state = state || { | ||||||
|             "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)", |                   keepOpenOnAlt: true, | ||||||
|         }} |                   onSelect: onChange, | ||||||
|       > |                   colorPickerType: type, | ||||||
|         {palette ? ( |                 }; | ||||||
|           <Picker |                 state.keepOpenOnAlt = true; | ||||||
|             palette={palette} |                 return state; | ||||||
|             color={color} |               } | ||||||
|             onChange={(changedColor) => { | 
 | ||||||
|               onChange(changedColor); |               return force === false || state | ||||||
|             }} |                 ? null | ||||||
|             onEyeDropperToggle={(force) => { |                 : { | ||||||
|               setEyeDropperState((state) => { |                     keepOpenOnAlt: false, | ||||||
|                 if (force) { |  | ||||||
|                   state = state || { |  | ||||||
|                     keepOpenOnAlt: true, |  | ||||||
|                     onSelect: onChange, |                     onSelect: onChange, | ||||||
|                     colorPickerType: type, |                     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)", |  | ||||||
|           }} |           }} | ||||||
|         /> |           onEscape={(event) => { | ||||||
|       </Popover.Content> |             if (eyeDropperState) { | ||||||
|     </Popover.Portal> |               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 ( |   return ( | ||||||
|     <Popover.Trigger |     <Popover.Trigger | ||||||
|       type="button" |       type="button" | ||||||
|       className={clsx("color-picker__button active-color", { |       className={clsx("color-picker__button active-color properties-trigger", { | ||||||
|         "is-transparent": color === "transparent" || !color, |         "is-transparent": color === "transparent" || !color, | ||||||
|       })} |       })} | ||||||
|       aria-label={label} |       aria-label={label} | ||||||
|  | @ -268,14 +222,7 @@ export const ColorPicker = ({ | ||||||
|           type={type} |           type={type} | ||||||
|           topPicks={topPicks} |           topPicks={topPicks} | ||||||
|         /> |         /> | ||||||
|         <div |         <ButtonSeparator /> | ||||||
|           style={{ |  | ||||||
|             width: 1, |  | ||||||
|             height: "100%", |  | ||||||
|             backgroundColor: "var(--default-border-color)", |  | ||||||
|             margin: "0 auto", |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|         <Popover.Root |         <Popover.Root | ||||||
|           open={appState.openPopup === type} |           open={appState.openPopup === type} | ||||||
|           onOpenChange={(open) => { |           onOpenChange={(open) => { | ||||||
|  |  | ||||||
|  | @ -138,7 +138,7 @@ export const Picker = ({ | ||||||
|             event.stopPropagation(); |             event.stopPropagation(); | ||||||
|           } |           } | ||||||
|         }} |         }} | ||||||
|         className="color-picker-content" |         className="color-picker-content properties-content" | ||||||
|         // to allow focusing by clicking but not by tabbing
 |         // to allow focusing by clicking but not by tabbing
 | ||||||
|         tabIndex={-1} |         tabIndex={-1} | ||||||
|       > |       > | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -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, | ||||||
|  | ); | ||||||
|  | @ -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, | ||||||
|  | ); | ||||||
|  | @ -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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | @ -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; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | @ -8,7 +8,7 @@ | ||||||
| 
 | 
 | ||||||
|     h3 { |     h3 { | ||||||
|       margin: 1.5rem 0; |       margin: 1.5rem 0; | ||||||
|       font-weight: bold; |       font-weight: 700; | ||||||
|       font-size: 1.125rem; |       font-size: 1.125rem; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -82,7 +82,7 @@ | ||||||
|     &__island { |     &__island { | ||||||
|       h4 { |       h4 { | ||||||
|         font-size: 1rem; |         font-size: 1rem; | ||||||
|         font-weight: bold; |         font-weight: 700; | ||||||
|         margin: 0; |         margin: 0; | ||||||
|         margin-bottom: 0.625rem; |         margin-bottom: 0.625rem; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  | @ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||||
|               label={t("labels.showBackground")} |               label={t("labels.showBackground")} | ||||||
|               shortcuts={[getShortcutKey("G")]} |               shortcuts={[getShortcutKey("G")]} | ||||||
|             /> |             /> | ||||||
|  |             <Shortcut | ||||||
|  |               label={t("labels.showFonts")} | ||||||
|  |               shortcuts={[getShortcutKey("Shift+F")]} | ||||||
|  |             /> | ||||||
|             <Shortcut |             <Shortcut | ||||||
|               label={t("labels.decreaseFontSize")} |               label={t("labels.decreaseFontSize")} | ||||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} |               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ | ||||||
|   .library-actions-counter { |   .library-actions-counter { | ||||||
|     background-color: var(--color-primary); |     background-color: var(--color-primary); | ||||||
|     color: var(--color-primary-light); |     color: var(--color-primary-light); | ||||||
|     font-weight: bold; |     font-weight: 700; | ||||||
|     display: flex; |     display: flex; | ||||||
|     align-items: center; |     align-items: center; | ||||||
|     justify-content: center; |     justify-content: center; | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ | ||||||
| 
 | 
 | ||||||
|     &__label { |     &__label { | ||||||
|       color: var(--color-primary); |       color: var(--color-primary); | ||||||
|       font-weight: bold; |       font-weight: 700; | ||||||
|       font-size: 1.125rem; |       font-size: 1.125rem; | ||||||
|       margin-bottom: 0.75rem; |       margin-bottom: 0.75rem; | ||||||
|     } |     } | ||||||
|  | @ -62,7 +62,7 @@ | ||||||
|     &__header { |     &__header { | ||||||
|       color: var(--color-primary); |       color: var(--color-primary); | ||||||
|       font-size: 1.125rem; |       font-size: 1.125rem; | ||||||
|       font-weight: bold; |       font-weight: 700; | ||||||
|       margin-bottom: 0.75rem; |       margin-bottom: 0.75rem; | ||||||
|       width: 100%; |       width: 100%; | ||||||
|       padding-right: 4rem; // due to dropdown button |       padding-right: 4rem; // due to dropdown button | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | @ -133,7 +133,7 @@ | ||||||
|     .required, |     .required, | ||||||
|     .error { |     .error { | ||||||
|       color: $oc-red-8; |       color: $oc-red-8; | ||||||
|       font-weight: bold; |       font-weight: 700; | ||||||
|       font-size: 1rem; |       font-size: 1rem; | ||||||
|       margin: 0.2rem; |       margin: 0.2rem; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | @ -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%; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | @ -139,7 +139,7 @@ $verticalBreakpoint: 861px; | ||||||
| 
 | 
 | ||||||
|   .ttd-dialog-output-error { |   .ttd-dialog-output-error { | ||||||
|     color: red; |     color: red; | ||||||
|     font-weight: 800; |     font-weight: 700; | ||||||
|     font-size: 30px; |     font-size: 30px; | ||||||
|     word-break: break-word; |     word-break: break-word; | ||||||
|     overflow: auto; |     overflow: auto; | ||||||
|  |  | ||||||
|  | @ -5,10 +5,11 @@ | ||||||
|   --avatarList-gap: 0.625rem; |   --avatarList-gap: 0.625rem; | ||||||
|   --userList-padding: var(--space-factor); |   --userList-padding: var(--space-factor); | ||||||
| 
 | 
 | ||||||
|   .UserList-wrapper { |   .UserList__wrapper { | ||||||
|     display: flex; |     display: flex; | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     justify-content: flex-end; |     justify-content: flex-end; | ||||||
|  |     align-items: center; | ||||||
|     pointer-events: none !important; |     pointer-events: none !important; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -21,10 +22,6 @@ | ||||||
|     align-items: center; |     align-items: center; | ||||||
|     gap: var(--avatarList-gap); |     gap: var(--avatarList-gap); | ||||||
| 
 | 
 | ||||||
|     &:empty { |  | ||||||
|       display: none; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     box-sizing: border-box; |     box-sizing: border-box; | ||||||
| 
 | 
 | ||||||
|     --max-size: calc( |     --max-size: calc( | ||||||
|  | @ -157,66 +154,7 @@ | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   .UserList__collaborators { |   .UserList__collaborators { | ||||||
|     position: static; |  | ||||||
|     top: auto; |     top: auto; | ||||||
|     margin-top: 0; |  | ||||||
|     max-height: 50vh; |     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; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager"; | ||||||
| 
 | 
 | ||||||
| import * as Popover from "@radix-ui/react-popover"; | import * as Popover from "@radix-ui/react-popover"; | ||||||
| import { Island } from "./Island"; | import { Island } from "./Island"; | ||||||
| import { searchIcon } from "./icons"; | import { QuickSearch } from "./QuickSearch"; | ||||||
| import { t } from "../i18n"; | import { t } from "../i18n"; | ||||||
| import { isShallowEqual } from "../utils"; | import { isShallowEqual } from "../utils"; | ||||||
| import { supportsResizeObserver } from "../constants"; | import { supportsResizeObserver } from "../constants"; | ||||||
| import type { MarkRequired } from "../utility-types"; | import type { MarkRequired } from "../utility-types"; | ||||||
|  | import { ScrollableList } from "./ScrollableList"; | ||||||
| 
 | 
 | ||||||
| export type GoToCollaboratorComponentProps = { | export type GoToCollaboratorComponentProps = { | ||||||
|   socketId: SocketId; |   socketId: SocketId; | ||||||
|  | @ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({ | ||||||
|   shouldWrap ? ( |   shouldWrap ? ( | ||||||
|     <Tooltip label={username || "Unknown user"}>{children}</Tooltip> |     <Tooltip label={username || "Unknown user"}>{children}</Tooltip> | ||||||
|   ) : ( |   ) : ( | ||||||
|     <React.Fragment>{children}</React.Fragment> |     <>{children}</> | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
| const renderCollaborator = ({ | const renderCollaborator = ({ | ||||||
|  | @ -128,6 +129,10 @@ export const UserList = React.memo( | ||||||
|     ).filter((collaborator) => collaborator.username?.trim()); |     ).filter((collaborator) => collaborator.username?.trim()); | ||||||
| 
 | 
 | ||||||
|     const [searchTerm, setSearchTerm] = React.useState(""); |     const [searchTerm, setSearchTerm] = React.useState(""); | ||||||
|  |     const filteredCollaborators = uniqueCollaboratorsArray.filter( | ||||||
|  |       (collaborator) => | ||||||
|  |         collaborator.username?.toLowerCase().includes(searchTerm), | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     const userListWrapper = React.useRef<HTMLDivElement | null>(null); |     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 [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( |     const firstNCollaborators = uniqueCollaboratorsArray.slice( | ||||||
|       0, |       0, | ||||||
|       maxAvatars - 1, |       maxAvatars - 1, | ||||||
|  | @ -197,7 +194,7 @@ export const UserList = React.memo( | ||||||
|         )} |         )} | ||||||
|       </div> |       </div> | ||||||
|     ) : ( |     ) : ( | ||||||
|       <div className="UserList-wrapper" ref={userListWrapper}> |       <div className="UserList__wrapper" ref={userListWrapper}> | ||||||
|         <div |         <div | ||||||
|           className={clsx("UserList", className)} |           className={clsx("UserList", className)} | ||||||
|           style={{ [`--max-avatars` as any]: maxAvatars }} |           style={{ [`--max-avatars` as any]: maxAvatars }} | ||||||
|  | @ -205,13 +202,7 @@ export const UserList = React.memo( | ||||||
|           {firstNAvatarsJSX} |           {firstNAvatarsJSX} | ||||||
| 
 | 
 | ||||||
|           {uniqueCollaboratorsArray.length > maxAvatars - 1 && ( |           {uniqueCollaboratorsArray.length > maxAvatars - 1 && ( | ||||||
|             <Popover.Root |             <Popover.Root> | ||||||
|               onOpenChange={(isOpen) => { |  | ||||||
|                 if (!isOpen) { |  | ||||||
|                   setSearchTerm(""); |  | ||||||
|                 } |  | ||||||
|               }} |  | ||||||
|             > |  | ||||||
|               <Popover.Trigger className="UserList__more"> |               <Popover.Trigger className="UserList__more"> | ||||||
|                 +{uniqueCollaboratorsArray.length - maxAvatars + 1} |                 +{uniqueCollaboratorsArray.length - maxAvatars + 1} | ||||||
|               </Popover.Trigger> |               </Popover.Trigger> | ||||||
|  | @ -224,41 +215,43 @@ export const UserList = React.memo( | ||||||
|                 align="end" |                 align="end" | ||||||
|                 sideOffset={10} |                 sideOffset={10} | ||||||
|               > |               > | ||||||
|                 <Island style={{ overflow: "hidden" }}> |                 <Island padding={2}> | ||||||
|                   {uniqueCollaboratorsArray.length >= |                   {uniqueCollaboratorsArray.length >= | ||||||
|                     SHOW_COLLABORATORS_FILTER_AT && ( |                     SHOW_COLLABORATORS_FILTER_AT && ( | ||||||
|                     <div className="UserList__search-wrapper"> |                     <QuickSearch | ||||||
|                       {searchIcon} |                       placeholder={t("quickSearch.placeholder")} | ||||||
|                       <input |                       onChange={setSearchTerm} | ||||||
|                         className="UserList__search" |                     /> | ||||||
|                         type="text" |  | ||||||
|                         placeholder={t("userList.search.placeholder")} |  | ||||||
|                         value={searchTerm} |  | ||||||
|                         onChange={(e) => { |  | ||||||
|                           setSearchTerm(e.target.value); |  | ||||||
|                         }} |  | ||||||
|                       /> |  | ||||||
|                     </div> |  | ||||||
|                   )} |                   )} | ||||||
|                   <div className="dropdown-menu UserList__collaborators"> |                   <ScrollableList | ||||||
|                     {filteredCollaborators.length === 0 && ( |                     className={"dropdown-menu UserList__collaborators"} | ||||||
|                       <div className="UserList__collaborators__empty"> |                     placeholder={t("userList.empty")} | ||||||
|                         {t("userList.search.empty")} |                   > | ||||||
|                       </div> |                     {/* The list checks for `Children.count()`, hence defensively returning empty list */} | ||||||
|                     )} |                     {filteredCollaborators.length > 0 | ||||||
|                     <div className="UserList__hint"> |                       ? [ | ||||||
|                       {t("userList.hint.text")} |                           <div className="hint">{t("userList.hint.text")}</div>, | ||||||
|                     </div> |                           filteredCollaborators.map((collaborator) => | ||||||
|                     {filteredCollaborators.map((collaborator) => |                             renderCollaborator({ | ||||||
|                       renderCollaborator({ |                               actionManager, | ||||||
|                         actionManager, |                               collaborator, | ||||||
|                         collaborator, |                               socketId: collaborator.socketId, | ||||||
|                         socketId: collaborator.socketId, |                               withName: true, | ||||||
|                         withName: true, |                               isBeingFollowed: | ||||||
|                         isBeingFollowed: collaborator.socketId === userToFollow, |                                 collaborator.socketId === userToFollow, | ||||||
|                       }), |                             }), | ||||||
|                     )} |                           ), | ||||||
|                   </div> |                         ] | ||||||
|  |                       : []} | ||||||
|  |                   </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> |                 </Island> | ||||||
|               </Popover.Content> |               </Popover.Content> | ||||||
|             </Popover.Root> |             </Popover.Root> | ||||||
|  |  | ||||||
|  | @ -105,6 +105,7 @@ const getRelevantAppStateProps = ( | ||||||
|   selectedElementIds: appState.selectedElementIds, |   selectedElementIds: appState.selectedElementIds, | ||||||
|   frameToHighlight: appState.frameToHighlight, |   frameToHighlight: appState.frameToHighlight, | ||||||
|   editingGroupId: appState.editingGroupId, |   editingGroupId: appState.editingGroupId, | ||||||
|  |   currentHoveredFontFamily: appState.currentHoveredFontFamily, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const areEqual = ( | const areEqual = ( | ||||||
|  |  | ||||||
|  | @ -4,7 +4,7 @@ | ||||||
|   .dropdown-menu { |   .dropdown-menu { | ||||||
|     position: absolute; |     position: absolute; | ||||||
|     top: 100%; |     top: 100%; | ||||||
|     margin-top: 0.25rem; |     margin-top: 0.5rem; | ||||||
| 
 | 
 | ||||||
|     &--mobile { |     &--mobile { | ||||||
|       left: 0; |       left: 0; | ||||||
|  | @ -35,21 +35,69 @@ | ||||||
| 
 | 
 | ||||||
|     .dropdown-menu-item-base { |     .dropdown-menu-item-base { | ||||||
|       display: flex; |       display: flex; | ||||||
|       padding: 0 0.625rem; |  | ||||||
|       column-gap: 0.625rem; |       column-gap: 0.625rem; | ||||||
|       font-size: 0.875rem; |       font-size: 0.875rem; | ||||||
|       color: var(--color-on-surface); |       color: var(--color-on-surface); | ||||||
|       width: 100%; |       width: 100%; | ||||||
|       box-sizing: border-box; |       box-sizing: border-box; | ||||||
|       font-weight: normal; |       font-weight: 400; | ||||||
|       font-family: inherit; |       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 { |     .dropdown-menu-item { | ||||||
|  |       height: 2rem; | ||||||
|  |       margin: 1px; | ||||||
|  |       padding: 0 0.5rem; | ||||||
|  |       width: calc(100% - 2px); | ||||||
|       background-color: transparent; |       background-color: transparent; | ||||||
|       border: 1px solid transparent; |       border: 1px solid transparent; | ||||||
|       align-items: center; |       align-items: center; | ||||||
|       height: 2rem; |  | ||||||
|       cursor: pointer; |       cursor: pointer; | ||||||
|       border-radius: var(--border-radius-md); |       border-radius: var(--border-radius-md); | ||||||
| 
 | 
 | ||||||
|  | @ -57,11 +105,6 @@ | ||||||
|         height: 2.25rem; |         height: 2.25rem; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       &--selected { |  | ||||||
|         background: var(--color-primary-light); |  | ||||||
|         --icon-fill-color: var(--color-primary-darker); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       &__text { |       &__text { | ||||||
|         display: flex; |         display: flex; | ||||||
|         align-items: center; |         align-items: center; | ||||||
|  | @ -83,6 +126,11 @@ | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       &--selected { | ||||||
|  |         background: var(--color-primary-light); | ||||||
|  |         --icon-fill-color: var(--color-primary-darker); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       &:hover { |       &:hover { | ||||||
|         background-color: var(--button-hover-bg); |         background-color: var(--button-hover-bg); | ||||||
|         text-decoration: none; |         text-decoration: none; | ||||||
|  |  | ||||||
|  | @ -1,37 +1,62 @@ | ||||||
| import React from "react"; | import React, { useEffect, useRef } from "react"; | ||||||
| import { | import { | ||||||
|   getDropdownMenuItemClassName, |   getDropdownMenuItemClassName, | ||||||
|   useHandleDropdownMenuItemClick, |   useHandleDropdownMenuItemClick, | ||||||
| } from "./common"; | } from "./common"; | ||||||
| import MenuItemContent from "./DropdownMenuItemContent"; | import MenuItemContent from "./DropdownMenuItemContent"; | ||||||
|  | import { useExcalidrawAppState } from "../App"; | ||||||
|  | import { THEME } from "../../constants"; | ||||||
|  | import type { ValueOf } from "../../utility-types"; | ||||||
| 
 | 
 | ||||||
| const DropdownMenuItem = ({ | const DropdownMenuItem = ({ | ||||||
|   icon, |   icon, | ||||||
|   onSelect, |   value, | ||||||
|  |   order, | ||||||
|   children, |   children, | ||||||
|   shortcut, |   shortcut, | ||||||
|   className, |   className, | ||||||
|  |   hovered, | ||||||
|   selected, |   selected, | ||||||
|  |   textStyle, | ||||||
|  |   onSelect, | ||||||
|  |   onClick, | ||||||
|   ...rest |   ...rest | ||||||
| }: { | }: { | ||||||
|   icon?: JSX.Element; |   icon?: JSX.Element; | ||||||
|   onSelect: (event: Event) => void; |   value?: string | number | undefined; | ||||||
|  |   order?: number; | ||||||
|  |   onSelect?: (event: Event) => void; | ||||||
|   children: React.ReactNode; |   children: React.ReactNode; | ||||||
|   shortcut?: string; |   shortcut?: string; | ||||||
|  |   hovered?: boolean; | ||||||
|   selected?: boolean; |   selected?: boolean; | ||||||
|  |   textStyle?: React.CSSProperties; | ||||||
|   className?: string; |   className?: string; | ||||||
| } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { | } & 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 ( |   return ( | ||||||
|     <button |     <button | ||||||
|       {...rest} |       {...rest} | ||||||
|  |       ref={ref} | ||||||
|  |       value={value} | ||||||
|       onClick={handleClick} |       onClick={handleClick} | ||||||
|       type="button" |       className={getDropdownMenuItemClassName(className, selected, hovered)} | ||||||
|       className={getDropdownMenuItemClassName(className, selected)} |  | ||||||
|       title={rest.title ?? rest["aria-label"]} |       title={rest.title ?? rest["aria-label"]} | ||||||
|     > |     > | ||||||
|       <MenuItemContent icon={icon} shortcut={shortcut}> |       <MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}> | ||||||
|         {children} |         {children} | ||||||
|       </MenuItemContent> |       </MenuItemContent> | ||||||
|     </button> |     </button> | ||||||
|  | @ -39,24 +64,53 @@ const DropdownMenuItem = ({ | ||||||
| }; | }; | ||||||
| DropdownMenuItem.displayName = "DropdownMenuItem"; | DropdownMenuItem.displayName = "DropdownMenuItem"; | ||||||
| 
 | 
 | ||||||
|  | export const DropDownMenuItemBadgeType = { | ||||||
|  |   GREEN: "green", | ||||||
|  |   RED: "red", | ||||||
|  |   BLUE: "blue", | ||||||
|  | } as const; | ||||||
|  | 
 | ||||||
| export const DropDownMenuItemBadge = ({ | export const DropDownMenuItemBadge = ({ | ||||||
|  |   type = DropDownMenuItemBadgeType.BLUE, | ||||||
|   children, |   children, | ||||||
| }: { | }: { | ||||||
|  |   type?: ValueOf<typeof DropDownMenuItemBadgeType>; | ||||||
|   children: React.ReactNode; |   children: React.ReactNode; | ||||||
| }) => { | }) => { | ||||||
|   return ( |   const { theme } = useExcalidrawAppState(); | ||||||
|     <div |   const style = { | ||||||
|       style={{ |     display: "inline-flex", | ||||||
|         display: "inline-flex", |     marginLeft: "auto", | ||||||
|         marginLeft: "auto", |     padding: "2px 4px", | ||||||
|         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)", |         background: "var(--color-promo)", | ||||||
|         color: "var(--color-surface-lowest)", |         color: "var(--color-surface-lowest)", | ||||||
|         borderRadius: 6, |       }); | ||||||
|         fontSize: 9, |   } | ||||||
|         fontFamily: "Cascadia, monospace", | 
 | ||||||
|       }} |   return ( | ||||||
|     > |     <div className="DropDownMenuItemBadge" style={style}> | ||||||
|       {children} |       {children} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  | @ -1,19 +1,23 @@ | ||||||
| import { useDevice } from "../App"; | import { useDevice } from "../App"; | ||||||
| 
 | 
 | ||||||
| const MenuItemContent = ({ | const MenuItemContent = ({ | ||||||
|  |   textStyle, | ||||||
|   icon, |   icon, | ||||||
|   shortcut, |   shortcut, | ||||||
|   children, |   children, | ||||||
| }: { | }: { | ||||||
|   icon?: JSX.Element; |   icon?: JSX.Element; | ||||||
|   shortcut?: string; |   shortcut?: string; | ||||||
|  |   textStyle?: React.CSSProperties; | ||||||
|   children: React.ReactNode; |   children: React.ReactNode; | ||||||
| }) => { | }) => { | ||||||
|   const device = useDevice(); |   const device = useDevice(); | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <div className="dropdown-menu-item__icon">{icon}</div> |       {icon && <div className="dropdown-menu-item__icon">{icon}</div>} | ||||||
|       <div className="dropdown-menu-item__text">{children}</div> |       <div style={textStyle} className="dropdown-menu-item__text"> | ||||||
|  |         {children} | ||||||
|  |       </div> | ||||||
|       {shortcut && !device.editor.isMobile && ( |       {shortcut && !device.editor.isMobile && ( | ||||||
|         <div className="dropdown-menu-item__shortcut">{shortcut}</div> |         <div className="dropdown-menu-item__shortcut">{shortcut}</div> | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|  | @ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{ | ||||||
| export const getDropdownMenuItemClassName = ( | export const getDropdownMenuItemClassName = ( | ||||||
|   className = "", |   className = "", | ||||||
|   selected = false, |   selected = false, | ||||||
|  |   hovered = false, | ||||||
| ) => { | ) => { | ||||||
|   return `dropdown-menu-item dropdown-menu-item-base ${className} ${ |   return `dropdown-menu-item dropdown-menu-item-base ${className} | ||||||
|     selected ? "dropdown-menu-item--selected" : "" |   ${selected ? "dropdown-menu-item--selected" : ""} ${ | ||||||
|  |     hovered ? "dropdown-menu-item--hovered" : "" | ||||||
|   }`.trim();
 |   }`.trim();
 | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1438,6 +1438,27 @@ export const fontSizeIcon = createIcon( | ||||||
|   tablerIconProps, |   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( | export const FontFamilyNormalIcon = createIcon( | ||||||
|   <> |   <> | ||||||
|     <g |     <g | ||||||
|  |  | ||||||
|  | @ -109,7 +109,7 @@ Center.displayName = "Center"; | ||||||
| 
 | 
 | ||||||
| const Logo = ({ children }: { children?: React.ReactNode }) => { | const Logo = ({ children }: { children?: React.ReactNode }) => { | ||||||
|   return ( |   return ( | ||||||
|     <div className="welcome-screen-center__logo virgil welcome-screen-decor"> |     <div className="welcome-screen-center__logo excalifont welcome-screen-decor"> | ||||||
|       {children || <ExcalidrawLogo withText />} |       {children || <ExcalidrawLogo withText />} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
|  | @ -118,7 +118,7 @@ Logo.displayName = "Logo"; | ||||||
| 
 | 
 | ||||||
| const Heading = ({ children }: { children: React.ReactNode }) => { | const Heading = ({ children }: { children: React.ReactNode }) => { | ||||||
|   return ( |   return ( | ||||||
|     <div className="welcome-screen-center__heading welcome-screen-decor virgil"> |     <div className="welcome-screen-center__heading welcome-screen-decor excalifont"> | ||||||
|       {children} |       {children} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => { | ||||||
|   const { WelcomeScreenMenuHintTunnel } = useTunnels(); |   const { WelcomeScreenMenuHintTunnel } = useTunnels(); | ||||||
|   return ( |   return ( | ||||||
|     <WelcomeScreenMenuHintTunnel.In> |     <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} |         {WelcomeScreenMenuArrow} | ||||||
|         <div className="welcome-screen-decor-hint__label"> |         <div className="welcome-screen-decor-hint__label"> | ||||||
|           {children || t("welcomeScreen.defaults.menuHint")} |           {children || t("welcomeScreen.defaults.menuHint")} | ||||||
|  | @ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => { | ||||||
|   const { WelcomeScreenToolbarHintTunnel } = useTunnels(); |   const { WelcomeScreenToolbarHintTunnel } = useTunnels(); | ||||||
|   return ( |   return ( | ||||||
|     <WelcomeScreenToolbarHintTunnel.In> |     <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"> |         <div className="welcome-screen-decor-hint__label"> | ||||||
|           {children || t("welcomeScreen.defaults.toolbarHint")} |           {children || t("welcomeScreen.defaults.toolbarHint")} | ||||||
|         </div> |         </div> | ||||||
|  | @ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => { | ||||||
|   const { WelcomeScreenHelpHintTunnel } = useTunnels(); |   const { WelcomeScreenHelpHintTunnel } = useTunnels(); | ||||||
|   return ( |   return ( | ||||||
|     <WelcomeScreenHelpHintTunnel.In> |     <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> |         <div>{children || t("welcomeScreen.defaults.helpHint")}</div> | ||||||
|         {WelcomeScreenHelpArrow} |         {WelcomeScreenHelpArrow} | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| .excalidraw { | .excalidraw { | ||||||
|   .virgil { |   .excalifont { | ||||||
|     font-family: "Virgil"; |     font-family: "Excalifont"; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // WelcomeSreen common |   // WelcomeSreen common | ||||||
|  |  | ||||||
|  | @ -114,12 +114,24 @@ export const CLASSES = { | ||||||
|   SHAPE_ACTIONS_MENU: "App-menu__left", |   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 = { | export const FONT_FAMILY = { | ||||||
|   Virgil: 1, |   Virgil: 1, | ||||||
|   Helvetica: 2, |   Helvetica: 2, | ||||||
|   Cascadia: 3, |   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 = { | export const THEME = { | ||||||
|  | @ -147,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; | ||||||
| 
 | 
 | ||||||
| export const MIN_FONT_SIZE = 1; | export const MIN_FONT_SIZE = 1; | ||||||
| export const DEFAULT_FONT_SIZE = 20; | 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_TEXT_ALIGN = "left"; | ||||||
| export const DEFAULT_VERTICAL_ALIGN = "top"; | export const DEFAULT_VERTICAL_ALIGN = "top"; | ||||||
| export const DEFAULT_VERSION = "{version}"; | export const DEFAULT_VERSION = "{version}"; | ||||||
|  |  | ||||||
|  | @ -152,7 +152,7 @@ body.excalidraw-cursor-resize * { | ||||||
|       margin-bottom: 0.25rem; |       margin-bottom: 0.25rem; | ||||||
|       font-size: 0.75rem; |       font-size: 0.75rem; | ||||||
|       color: var(--text-primary-color); |       color: var(--text-primary-color); | ||||||
|       font-weight: normal; |       font-weight: 400; | ||||||
|       display: block; |       display: block; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -227,14 +227,7 @@ body.excalidraw-cursor-resize * { | ||||||
|     label, |     label, | ||||||
|     button, |     button, | ||||||
|     .zIndexButton { |     .zIndexButton { | ||||||
|       @include outlineButtonStyles; |       @include outlineButtonIconStyles; | ||||||
| 
 |  | ||||||
|       padding: 0; |  | ||||||
| 
 |  | ||||||
|       svg { |  | ||||||
|         width: var(--default-icon-size); |  | ||||||
|         height: var(--default-icon-size); |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -394,7 +387,7 @@ body.excalidraw-cursor-resize * { | ||||||
|   .App-menu__left { |   .App-menu__left { | ||||||
|     overflow-y: auto; |     overflow-y: auto; | ||||||
|     padding: 0.75rem; |     padding: 0.75rem; | ||||||
|     width: 202px; |     width: 200px; | ||||||
|     box-sizing: border-box; |     box-sizing: border-box; | ||||||
|     position: absolute; |     position: absolute; | ||||||
|   } |   } | ||||||
|  | @ -585,7 +578,7 @@ body.excalidraw-cursor-resize * { | ||||||
|   // use custom, minimalistic scrollbar |   // use custom, minimalistic scrollbar | ||||||
|   // (doesn't work in Firefox) |   // (doesn't work in Firefox) | ||||||
|   ::-webkit-scrollbar { |   ::-webkit-scrollbar { | ||||||
|     width: 3px; |     width: 4px; | ||||||
|     height: 3px; |     height: 3px; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -664,6 +657,10 @@ body.excalidraw-cursor-resize * { | ||||||
|       --button-hover-bg: #363541; |       --button-hover-bg: #363541; | ||||||
|       --button-bg: var(--color-surface-high); |       --button-bg: var(--color-surface-high); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     .buttonList { | ||||||
|  |       padding: 0.25rem 0; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   .excalidraw__paragraph { |   .excalidraw__paragraph { | ||||||
|  | @ -757,7 +754,7 @@ body.excalidraw-cursor-resize * { | ||||||
|     padding: 1rem 1.6rem; |     padding: 1rem 1.6rem; | ||||||
|     border-radius: 12px; |     border-radius: 12px; | ||||||
|     color: #fff; |     color: #fff; | ||||||
|     font-weight: bold; |     font-weight: 700; | ||||||
|     letter-spacing: 0.6px; |     letter-spacing: 0.6px; | ||||||
|     font-family: "Assistant"; |     font-family: "Assistant"; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -151,6 +151,9 @@ | ||||||
|   --color-border-outline-variant: #c5c5d0; |   --color-border-outline-variant: #c5c5d0; | ||||||
|   --color-surface-primary-container: #e0dfff; |   --color-surface-primary-container: #e0dfff; | ||||||
| 
 | 
 | ||||||
|  |   --color-badge: #0b6513; | ||||||
|  |   --background-color-badge: #d3ffd2; | ||||||
|  | 
 | ||||||
|   &.theme--dark { |   &.theme--dark { | ||||||
|     &.theme--dark-background-none { |     &.theme--dark-background-none { | ||||||
|       background: none; |       background: none; | ||||||
|  |  | ||||||
|  | @ -124,6 +124,16 @@ | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @mixin outlineButtonIconStyles { | ||||||
|  |   @include outlineButtonStyles; | ||||||
|  |   padding: 0; | ||||||
|  | 
 | ||||||
|  |   svg { | ||||||
|  |     width: var(--default-icon-size); | ||||||
|  |     height: var(--default-icon-size); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @mixin avatarStyles { | @mixin avatarStyles { | ||||||
|   width: var(--avatar-size, 1.5rem); |   width: var(--avatar-size, 1.5rem); | ||||||
|   height: var(--avatar-size, 1.5rem); |   height: var(--avatar-size, 1.5rem); | ||||||
|  | @ -135,7 +145,7 @@ | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   font-size: 0.75rem; |   font-size: 0.75rem; | ||||||
|   font-weight: 800; |   font-weight: 700; | ||||||
|   line-height: 1; |   line-height: 1; | ||||||
|   color: var(--color-gray-90); |   color: var(--color-gray-90); | ||||||
|   flex: 0 0 auto; |   flex: 0 0 auto; | ||||||
|  |  | ||||||
|  | @ -239,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -285,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -386,7 +386,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t | ||||||
|   "containerId": "id48", |   "containerId": "id48", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -487,7 +487,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe | ||||||
|   "containerId": "id37", |   "containerId": "id37", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -662,7 +662,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when | ||||||
|   "containerId": "id41", |   "containerId": "id41", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -708,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -754,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -1207,7 +1207,7 @@ exports[`Test Transform > should transform text element 1`] = ` | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -1248,7 +1248,7 @@ exports[`Test Transform > should transform text element 2`] = ` | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -1581,7 +1581,7 @@ exports[`Test Transform > should transform the elements correctly when linear el | ||||||
|   "containerId": "B", |   "containerId": "B", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [ |   "groupIds": [ | ||||||
|  | @ -1624,7 +1624,7 @@ exports[`Test Transform > should transform the elements correctly when linear el | ||||||
|   "containerId": "A", |   "containerId": "A", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [ |   "groupIds": [ | ||||||
|  | @ -1667,7 +1667,7 @@ exports[`Test Transform > should transform the elements correctly when linear el | ||||||
|   "containerId": "Alice", |   "containerId": "Alice", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [ |   "groupIds": [ | ||||||
|  | @ -1710,7 +1710,7 @@ exports[`Test Transform > should transform the elements correctly when linear el | ||||||
|   "containerId": "Bob", |   "containerId": "Bob", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [ |   "groupIds": [ | ||||||
|  | @ -1753,7 +1753,7 @@ exports[`Test Transform > should transform the elements correctly when linear el | ||||||
|   "containerId": "Bob_Alice", |   "containerId": "Bob_Alice", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -1794,7 +1794,7 @@ exports[`Test Transform > should transform the elements correctly when linear el | ||||||
|   "containerId": "Bob_B", |   "containerId": "Bob_B", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2043,7 +2043,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide | ||||||
|   "containerId": "id25", |   "containerId": "id25", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2084,7 +2084,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide | ||||||
|   "containerId": "id26", |   "containerId": "id26", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2125,7 +2125,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide | ||||||
|   "containerId": "id27", |   "containerId": "id27", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2167,7 +2167,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide | ||||||
|   "containerId": "id28", |   "containerId": "id28", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2431,7 +2431,7 @@ exports[`Test Transform > should transform to text containers when label provide | ||||||
|   "containerId": "id13", |   "containerId": "id13", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2472,7 +2472,7 @@ exports[`Test Transform > should transform to text containers when label provide | ||||||
|   "containerId": "id14", |   "containerId": "id14", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2514,7 +2514,7 @@ exports[`Test Transform > should transform to text containers when label provide | ||||||
|   "containerId": "id15", |   "containerId": "id15", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2558,7 +2558,7 @@ exports[`Test Transform > should transform to text containers when label provide | ||||||
|   "containerId": "id16", |   "containerId": "id16", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2600,7 +2600,7 @@ exports[`Test Transform > should transform to text containers when label provide | ||||||
|   "containerId": "id17", |   "containerId": "id17", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2643,7 +2643,7 @@ exports[`Test Transform > should transform to text containers when label provide | ||||||
|   "containerId": "id18", |   "containerId": "id18", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  |  | ||||||
|  | @ -44,14 +44,11 @@ import { bumpVersion } from "../element/mutateElement"; | ||||||
| import { getUpdatedTimestamp, updateActiveTool } from "../utils"; | import { getUpdatedTimestamp, updateActiveTool } from "../utils"; | ||||||
| import { arrayToMap } from "../utils"; | import { arrayToMap } from "../utils"; | ||||||
| import type { MarkOptional, Mutable } from "../utility-types"; | import type { MarkOptional, Mutable } from "../utility-types"; | ||||||
| import { | import { detectLineHeight, getContainerElement } from "../element/textElement"; | ||||||
|   detectLineHeight, |  | ||||||
|   getContainerElement, |  | ||||||
|   getDefaultLineHeight, |  | ||||||
| } from "../element/textElement"; |  | ||||||
| import { normalizeLink } from "./url"; | import { normalizeLink } from "./url"; | ||||||
| import { syncInvalidIndices } from "../fractionalIndex"; | import { syncInvalidIndices } from "../fractionalIndex"; | ||||||
| import { getSizeFromPoints } from "../points"; | import { getSizeFromPoints } from "../points"; | ||||||
|  | import { getLineHeight } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| type RestoredAppState = Omit< | type RestoredAppState = Omit< | ||||||
|   AppState, |   AppState, | ||||||
|  | @ -206,7 +203,7 @@ const restoreElement = ( | ||||||
|             detectLineHeight(element) |             detectLineHeight(element) | ||||||
|           : // no element height likely means programmatic use, so default
 |           : // no element height likely means programmatic use, so default
 | ||||||
|             // to a fixed line height
 |             // to a fixed line height
 | ||||||
|             getDefaultLineHeight(element.fontFamily)); |             getLineHeight(element.fontFamily)); | ||||||
|       element = restoreElementWithProperties(element, { |       element = restoreElementWithProperties(element, { | ||||||
|         fontSize, |         fontSize, | ||||||
|         fontFamily, |         fontFamily, | ||||||
|  |  | ||||||
|  | @ -18,11 +18,7 @@ import { | ||||||
|   newMagicFrameElement, |   newMagicFrameElement, | ||||||
|   newTextElement, |   newTextElement, | ||||||
| } from "../element/newElement"; | } from "../element/newElement"; | ||||||
| import { | import { measureText, normalizeText } from "../element/textElement"; | ||||||
|   getDefaultLineHeight, |  | ||||||
|   measureText, |  | ||||||
|   normalizeText, |  | ||||||
| } from "../element/textElement"; |  | ||||||
| import type { | import type { | ||||||
|   ElementsMap, |   ElementsMap, | ||||||
|   ExcalidrawArrowElement, |   ExcalidrawArrowElement, | ||||||
|  | @ -54,6 +50,7 @@ import { | ||||||
| import { getSizeFromPoints } from "../points"; | import { getSizeFromPoints } from "../points"; | ||||||
| import { randomId } from "../random"; | import { randomId } from "../random"; | ||||||
| import { syncInvalidIndices } from "../fractionalIndex"; | import { syncInvalidIndices } from "../fractionalIndex"; | ||||||
|  | import { getLineHeight } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| export type ValidLinearElement = { | export type ValidLinearElement = { | ||||||
|   type: "arrow" | "line"; |   type: "arrow" | "line"; | ||||||
|  | @ -568,8 +565,7 @@ export const convertToExcalidrawElements = ( | ||||||
|       case "text": { |       case "text": { | ||||||
|         const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; |         const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; | ||||||
|         const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; |         const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; | ||||||
|         const lineHeight = |         const lineHeight = element?.lineHeight || getLineHeight(fontFamily); | ||||||
|           element?.lineHeight || getDefaultLineHeight(fontFamily); |  | ||||||
|         const text = element.text ?? ""; |         const text = element.text ?? ""; | ||||||
|         const normalizedText = normalizeText(text); |         const normalizedText = normalizeText(text); | ||||||
|         const metrics = measureText( |         const metrics = measureText( | ||||||
|  |  | ||||||
|  | @ -107,6 +107,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>( | ||||||
| export const newElementWith = <TElement extends ExcalidrawElement>( | export const newElementWith = <TElement extends ExcalidrawElement>( | ||||||
|   element: TElement, |   element: TElement, | ||||||
|   updates: ElementUpdate<TElement>, |   updates: ElementUpdate<TElement>, | ||||||
|  |   /** pass `true` to always regenerate */ | ||||||
|  |   force = false, | ||||||
| ): TElement => { | ): TElement => { | ||||||
|   let didChange = false; |   let didChange = false; | ||||||
|   for (const key in updates) { |   for (const key in updates) { | ||||||
|  | @ -123,7 +125,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>( | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (!didChange) { |   if (!didChange && !force) { | ||||||
|     return element; |     return element; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -36,7 +36,6 @@ import { | ||||||
|   normalizeText, |   normalizeText, | ||||||
|   wrapText, |   wrapText, | ||||||
|   getBoundTextMaxWidth, |   getBoundTextMaxWidth, | ||||||
|   getDefaultLineHeight, |  | ||||||
| } from "./textElement"; | } from "./textElement"; | ||||||
| import { | import { | ||||||
|   DEFAULT_ELEMENT_PROPS, |   DEFAULT_ELEMENT_PROPS, | ||||||
|  | @ -47,6 +46,7 @@ import { | ||||||
|   VERTICAL_ALIGN, |   VERTICAL_ALIGN, | ||||||
| } from "../constants"; | } from "../constants"; | ||||||
| import type { MarkOptional, Merge, Mutable } from "../utility-types"; | import type { MarkOptional, Merge, Mutable } from "../utility-types"; | ||||||
|  | import { getLineHeight } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| export type ElementConstructorOpts = MarkOptional< | export type ElementConstructorOpts = MarkOptional< | ||||||
|   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, |   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, | ||||||
|  | @ -228,7 +228,7 @@ export const newTextElement = ( | ||||||
| ): NonDeleted<ExcalidrawTextElement> => { | ): NonDeleted<ExcalidrawTextElement> => { | ||||||
|   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; |   const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; | ||||||
|   const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; |   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 text = normalizeText(opts.text); | ||||||
|   const metrics = measureText( |   const metrics = measureText( | ||||||
|     text, |     text, | ||||||
|  | @ -514,7 +514,7 @@ export const regenerateId = ( | ||||||
|     if ( |     if ( | ||||||
|       window.h?.app |       window.h?.app | ||||||
|         ?.getSceneElementsIncludingDeleted() |         ?.getSceneElementsIncludingDeleted() | ||||||
|         .find((el) => el.id === nextId) |         .find((el: ExcalidrawElement) => el.id === nextId) | ||||||
|     ) { |     ) { | ||||||
|       nextId += "_copy"; |       nextId += "_copy"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; | import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; | ||||||
|  | import { getLineHeight } from "../fonts"; | ||||||
| import { API } from "../tests/helpers/api"; | import { API } from "../tests/helpers/api"; | ||||||
| import { | import { | ||||||
|   computeContainerDimensionForBoundText, |   computeContainerDimensionForBoundText, | ||||||
|  | @ -8,7 +9,6 @@ import { | ||||||
|   wrapText, |   wrapText, | ||||||
|   detectLineHeight, |   detectLineHeight, | ||||||
|   getLineHeightInPx, |   getLineHeightInPx, | ||||||
|   getDefaultLineHeight, |  | ||||||
|   parseTokens, |   parseTokens, | ||||||
| } from "./textElement"; | } from "./textElement"; | ||||||
| import type { ExcalidrawTextElementWithContainer, FontString } from "./types"; | import type { ExcalidrawTextElementWithContainer, FontString } from "./types"; | ||||||
|  | @ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => { | ||||||
| describe("Test getDefaultLineHeight", () => { | describe("Test getDefaultLineHeight", () => { | ||||||
|   it("should return line height using default font family when not passed", () => { |   it("should return line height using default font family when not passed", () => { | ||||||
|     //@ts-ignore
 |     //@ts-ignore
 | ||||||
|     expect(getDefaultLineHeight()).toBe(1.25); |     expect(getLineHeight()).toBe(1.25); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   it("should return line height using default font family for unknown font", () => { |   it("should return line height using default font family for unknown font", () => { | ||||||
|     const UNKNOWN_FONT = 5; |     const UNKNOWN_FONT = 5; | ||||||
|     expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25); |     expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   it("should return correct line height", () => { |   it("should return correct line height", () => { | ||||||
|     expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); |     expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -6,7 +6,6 @@ import type { | ||||||
|   ExcalidrawTextContainer, |   ExcalidrawTextContainer, | ||||||
|   ExcalidrawTextElement, |   ExcalidrawTextElement, | ||||||
|   ExcalidrawTextElementWithContainer, |   ExcalidrawTextElementWithContainer, | ||||||
|   FontFamilyValues, |  | ||||||
|   FontString, |   FontString, | ||||||
|   NonDeletedExcalidrawElement, |   NonDeletedExcalidrawElement, | ||||||
| } from "./types"; | } from "./types"; | ||||||
|  | @ -17,7 +16,6 @@ import { | ||||||
|   BOUND_TEXT_PADDING, |   BOUND_TEXT_PADDING, | ||||||
|   DEFAULT_FONT_FAMILY, |   DEFAULT_FONT_FAMILY, | ||||||
|   DEFAULT_FONT_SIZE, |   DEFAULT_FONT_SIZE, | ||||||
|   FONT_FAMILY, |  | ||||||
|   TEXT_ALIGN, |   TEXT_ALIGN, | ||||||
|   VERTICAL_ALIGN, |   VERTICAL_ALIGN, | ||||||
| } from "../constants"; | } from "../constants"; | ||||||
|  | @ -30,7 +28,7 @@ import { | ||||||
|   resetOriginalContainerCache, |   resetOriginalContainerCache, | ||||||
|   updateOriginalContainerCache, |   updateOriginalContainerCache, | ||||||
| } from "./containerCache"; | } from "./containerCache"; | ||||||
| import type { ExtractSetType, MakeBrand } from "../utility-types"; | import type { ExtractSetType } from "../utility-types"; | ||||||
| 
 | 
 | ||||||
| export const normalizeText = (text: string) => { | export const normalizeText = (text: string) => { | ||||||
|   return ( |   return ( | ||||||
|  | @ -321,24 +319,6 @@ export const getLineHeightInPx = ( | ||||||
|   return fontSize * lineHeight; |   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
 | // FIXME rename to getApproxMinContainerHeight
 | ||||||
| export const getApproxMinLineHeight = ( | export const getApproxMinLineHeight = ( | ||||||
|   fontSize: ExcalidrawTextElement["fontSize"], |   fontSize: ExcalidrawTextElement["fontSize"], | ||||||
|  | @ -349,29 +329,72 @@ export const getApproxMinLineHeight = ( | ||||||
| 
 | 
 | ||||||
| let canvas: HTMLCanvasElement | undefined; | 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) { |   if (!canvas) { | ||||||
|     canvas = document.createElement("canvas"); |     canvas = document.createElement("canvas"); | ||||||
|   } |   } | ||||||
|   const canvas2dContext = canvas.getContext("2d")!; |   const canvas2dContext = canvas.getContext("2d")!; | ||||||
|   canvas2dContext.font = font; |   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
 |   // since in test env the canvas measureText algo
 | ||||||
|   // doesn't measure text and instead just returns number of
 |   // doesn't measure text and instead just returns number of
 | ||||||
|   // characters hence we assume that each letteris 10px
 |   // characters hence we assume that each letteris 10px
 | ||||||
|   if (isTestEnv()) { |   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); |   const lines = splitIntoLines(text); | ||||||
|   let width = 0; |   let width = 0; | ||||||
|   lines.forEach((line) => { |   lines.forEach((line) => { | ||||||
|     width = Math.max(width, getLineWidth(line, font)); |     width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth)); | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|   return width; |   return width; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -402,7 +425,11 @@ export const parseTokens = (text: string) => { | ||||||
|   return words.join(" ").split(" "); |   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
 |   // 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
 |   // computation, we need to make sure we don't continue as we'll end up
 | ||||||
|   // in an infinite loop
 |   // in an infinite loop
 | ||||||
|  | @ -412,7 +439,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
| 
 | 
 | ||||||
|   const lines: Array<string> = []; |   const lines: Array<string> = []; | ||||||
|   const originalLines = text.split("\n"); |   const originalLines = text.split("\n"); | ||||||
|   const spaceWidth = getLineWidth(" ", font); |   const spaceAdvanceWidth = getLineWidth(" ", font, true); | ||||||
| 
 | 
 | ||||||
|   let currentLine = ""; |   let currentLine = ""; | ||||||
|   let currentLineWidthTillNow = 0; |   let currentLineWidthTillNow = 0; | ||||||
|  | @ -427,13 +454,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
|     currentLine = ""; |     currentLine = ""; | ||||||
|     currentLineWidthTillNow = 0; |     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
 |     // Push the line if its <= maxWidth
 | ||||||
|     if (currentLineWidth <= maxWidth) { |     if (currentLineWidth <= maxWidth) { | ||||||
|       lines.push(originalLine); |       lines.push(originalLine); | ||||||
|       return; // continue
 |       continue; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const words = parseTokens(originalLine); |     const words = parseTokens(originalLine); | ||||||
|  | @ -442,7 +470,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
|     let index = 0; |     let index = 0; | ||||||
| 
 | 
 | ||||||
|     while (index < words.length) { |     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
 |       // This will only happen when single word takes entire width
 | ||||||
|       if (currentWordWidth === maxWidth) { |       if (currentWordWidth === maxWidth) { | ||||||
|  | @ -454,7 +482,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
|       else if (currentWordWidth > maxWidth) { |       else if (currentWordWidth > maxWidth) { | ||||||
|         // push current line since the current word exceeds the max width
 |         // push current line since the current word exceeds the max width
 | ||||||
|         // so will be appended in next line
 |         // so will be appended in next line
 | ||||||
| 
 |  | ||||||
|         push(currentLine); |         push(currentLine); | ||||||
| 
 | 
 | ||||||
|         resetParams(); |         resetParams(); | ||||||
|  | @ -463,20 +490,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
|           const currentChar = String.fromCodePoint( |           const currentChar = String.fromCodePoint( | ||||||
|             words[index].codePointAt(0)!, |             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); |           words[index] = words[index].slice(currentChar.length); | ||||||
| 
 | 
 | ||||||
|           if (currentLineWidthTillNow >= maxWidth) { |           if (currentLineWidthTillNow >= maxWidth) { | ||||||
|             push(currentLine); |             push(currentLine); | ||||||
|             currentLine = currentChar; |             currentLine = currentChar; | ||||||
|             currentLineWidthTillNow = width; |             currentLineWidthTillNow = charAdvanceWidth; | ||||||
|           } else { |           } else { | ||||||
|             currentLine += currentChar; |             currentLine = line; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         // push current line if appending space exceeds max width
 |         // push current line if appending space exceeds max width
 | ||||||
|         if (currentLineWidthTillNow + spaceWidth >= maxWidth) { |         if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) { | ||||||
|           push(currentLine); |           push(currentLine); | ||||||
|           resetParams(); |           resetParams(); | ||||||
|           // space needs to be appended before next word
 |           // 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
 |           // with css word-wrap
 | ||||||
|         } else if (!currentLine.endsWith("-")) { |         } else if (!currentLine.endsWith("-")) { | ||||||
|           currentLine += " "; |           currentLine += " "; | ||||||
|           currentLineWidthTillNow += spaceWidth; |           currentLineWidthTillNow += spaceAdvanceWidth; | ||||||
|         } |         } | ||||||
|         index++; |         index++; | ||||||
|       } else { |       } else { | ||||||
|         // Start appending words in a line till max width reached
 |         // Start appending words in a line till max width reached
 | ||||||
|         while (currentLineWidthTillNow < maxWidth && index < words.length) { |         while (currentLineWidthTillNow < maxWidth && index < words.length) { | ||||||
|           const word = words[index]; |           const word = words[index]; | ||||||
|           currentLineWidthTillNow = getLineWidth(currentLine + word, font); |           currentLineWidthTillNow = getLineWidth( | ||||||
|  |             currentLine + word, | ||||||
|  |             font, | ||||||
|  |             true, | ||||||
|  |           ); | ||||||
| 
 | 
 | ||||||
|           if (currentLineWidthTillNow > maxWidth) { |           if (currentLineWidthTillNow > maxWidth) { | ||||||
|             push(currentLine); |             push(currentLine); | ||||||
|  | @ -512,7 +549,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           // Push the word if appending space exceeds max width
 |           // Push the word if appending space exceeds max width
 | ||||||
|           if (currentLineWidthTillNow + spaceWidth >= maxWidth) { |           if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) { | ||||||
|             if (shouldAppendSpace) { |             if (shouldAppendSpace) { | ||||||
|               lines.push(currentLine.slice(0, -1)); |               lines.push(currentLine.slice(0, -1)); | ||||||
|             } else { |             } else { | ||||||
|  | @ -524,12 +561,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     if (currentLine.slice(-1) === " ") { |     if (currentLine.slice(-1) === " ") { | ||||||
|       // only remove last trailing space which we have added when joining words
 |       // only remove last trailing space which we have added when joining words
 | ||||||
|       currentLine = currentLine.slice(0, -1); |       currentLine = currentLine.slice(0, -1); | ||||||
|       push(currentLine); |       push(currentLine); | ||||||
|     } |     } | ||||||
|   }); |   } | ||||||
|  | 
 | ||||||
|   return lines.join("\n"); |   return lines.join("\n"); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -542,7 +581,7 @@ export const charWidth = (() => { | ||||||
|       cachedCharWidth[font] = []; |       cachedCharWidth[font] = []; | ||||||
|     } |     } | ||||||
|     if (!cachedCharWidth[font][ascii]) { |     if (!cachedCharWidth[font][ascii]) { | ||||||
|       const width = getLineWidth(char, font); |       const width = getLineWidth(char, font, true); | ||||||
|       cachedCharWidth[font][ascii] = width; |       cachedCharWidth[font][ascii] = width; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -594,30 +633,6 @@ export const getMaxCharWidth = (font: FontString) => { | ||||||
|   return Math.max(...cacheWithOutEmpty); |   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) => { | export const getBoundTextElementId = (container: ExcalidrawElement | null) => { | ||||||
|   return container?.boundElements?.length |   return container?.boundElements?.length | ||||||
|     ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id || |     ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id || | ||||||
|  | @ -866,79 +881,6 @@ export const isMeasureTextSupported = () => { | ||||||
|   return width > 0; |   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 = ( | export const getMinTextElementWidth = ( | ||||||
|   font: FontString, |   font: FontString, | ||||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], |   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||||
|  |  | ||||||
|  | @ -916,13 +916,13 @@ describe("textWysiwyg", () => { | ||||||
|       await new Promise((r) => setTimeout(r, 0)); |       await new Promise((r) => setTimeout(r, 0)); | ||||||
|       updateTextEditor(editor, "Hello World!"); |       updateTextEditor(editor, "Hello World!"); | ||||||
|       editor.blur(); |       editor.blur(); | ||||||
|       expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); |       expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); | ||||||
| 
 | 
 | ||||||
|       fireEvent.click(screen.getByTitle(/code/i)); |       fireEvent.click(screen.getByTitle(/code/i)); | ||||||
| 
 | 
 | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, |         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, | ||||||
|       ).toEqual(FONT_FAMILY.Cascadia); |       ).toEqual(FONT_FAMILY["Comic Shanns"]); | ||||||
| 
 | 
 | ||||||
|       //undo
 |       //undo
 | ||||||
|       Keyboard.withModifierKeys({ ctrl: true }, () => { |       Keyboard.withModifierKeys({ ctrl: true }, () => { | ||||||
|  | @ -930,7 +930,7 @@ describe("textWysiwyg", () => { | ||||||
|       }); |       }); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, |         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, | ||||||
|       ).toEqual(FONT_FAMILY.Virgil); |       ).toEqual(FONT_FAMILY.Excalifont); | ||||||
| 
 | 
 | ||||||
|       //redo
 |       //redo
 | ||||||
|       Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { |       Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { | ||||||
|  | @ -938,7 +938,7 @@ describe("textWysiwyg", () => { | ||||||
|       }); |       }); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, |         (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 () => { |     it("should wrap text and vertcially center align once text submitted", async () => { | ||||||
|  | @ -1330,14 +1330,14 @@ describe("textWysiwyg", () => { | ||||||
| 
 | 
 | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, |         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, | ||||||
|       ).toEqual(FONT_FAMILY.Cascadia); |       ).toEqual(FONT_FAMILY["Comic Shanns"]); | ||||||
|       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); |       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); | ||||||
| 
 | 
 | ||||||
|       fireEvent.click(screen.getByTitle(/Very large/i)); |       fireEvent.click(screen.getByTitle(/Very large/i)); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, |         (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, | ||||||
|       ).toEqual(36); |       ).toEqual(36); | ||||||
|       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97); |       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it("should update line height when font family updated", async () => { |     it("should update line height when font family updated", async () => { | ||||||
|  | @ -1357,18 +1357,18 @@ describe("textWysiwyg", () => { | ||||||
|       fireEvent.click(screen.getByTitle(/code/i)); |       fireEvent.click(screen.getByTitle(/code/i)); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, |         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, | ||||||
|       ).toEqual(FONT_FAMILY.Cascadia); |       ).toEqual(FONT_FAMILY["Comic Shanns"]); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, |         (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, | ||||||
|       ).toEqual(1.2); |       ).toEqual(1.25); | ||||||
| 
 | 
 | ||||||
|       fireEvent.click(screen.getByTitle(/normal/i)); |       fireEvent.click(screen.getByTitle(/normal/i)); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, |         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, | ||||||
|       ).toEqual(FONT_FAMILY.Helvetica); |       ).toEqual(FONT_FAMILY.Nunito); | ||||||
|       expect( |       expect( | ||||||
|         (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, |         (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, | ||||||
|       ).toEqual(1.15); |       ).toEqual(1.35); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     describe("should align correctly", () => { |     describe("should align correctly", () => { | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ import { | ||||||
|   isBoundToContainer, |   isBoundToContainer, | ||||||
|   isTextElement, |   isTextElement, | ||||||
| } from "./typeChecks"; | } from "./typeChecks"; | ||||||
| import { CLASSES } from "../constants"; | import { CLASSES, isSafari } from "../constants"; | ||||||
| import type { | import type { | ||||||
|   ExcalidrawElement, |   ExcalidrawElement, | ||||||
|   ExcalidrawLinearElement, |   ExcalidrawLinearElement, | ||||||
|  | @ -132,10 +132,15 @@ export const textWysiwyg = ({ | ||||||
|         updatedTextElement, |         updatedTextElement, | ||||||
|         app.scene.getNonDeletedElementsMap(), |         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 maxWidth = updatedTextElement.width; | ||||||
|       let maxHeight = updatedTextElement.height; |       let maxHeight = updatedTextElement.height; | ||||||
|       let textElementWidth = updatedTextElement.width; |  | ||||||
|       const textElementHeight = updatedTextElement.height; |  | ||||||
| 
 | 
 | ||||||
|       if (container && updatedTextElement.containerId) { |       if (container && updatedTextElement.containerId) { | ||||||
|         if (isArrowElement(container)) { |         if (isArrowElement(container)) { | ||||||
|  | @ -177,9 +182,9 @@ export const textWysiwyg = ({ | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         // autogrow container height if text exceeds
 |         // autogrow container height if text exceeds
 | ||||||
|         if (!isArrowElement(container) && textElementHeight > maxHeight) { |         if (!isArrowElement(container) && height > maxHeight) { | ||||||
|           const targetContainerHeight = computeContainerDimensionForBoundText( |           const targetContainerHeight = computeContainerDimensionForBoundText( | ||||||
|             textElementHeight, |             height, | ||||||
|             container.type, |             container.type, | ||||||
|           ); |           ); | ||||||
| 
 | 
 | ||||||
|  | @ -190,10 +195,10 @@ export const textWysiwyg = ({ | ||||||
|           // is reached when text is removed
 |           // is reached when text is removed
 | ||||||
|           !isArrowElement(container) && |           !isArrowElement(container) && | ||||||
|           container.height > originalContainerData.height && |           container.height > originalContainerData.height && | ||||||
|           textElementHeight < maxHeight |           height < maxHeight | ||||||
|         ) { |         ) { | ||||||
|           const targetContainerHeight = computeContainerDimensionForBoundText( |           const targetContainerHeight = computeContainerDimensionForBoundText( | ||||||
|             textElementHeight, |             height, | ||||||
|             container.type, |             container.type, | ||||||
|           ); |           ); | ||||||
|           mutateElement(container, { height: targetContainerHeight }); |           mutateElement(container, { height: targetContainerHeight }); | ||||||
|  | @ -226,30 +231,41 @@ export const textWysiwyg = ({ | ||||||
| 
 | 
 | ||||||
|       if (!container) { |       if (!container) { | ||||||
|         maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; |         maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; | ||||||
|         textElementWidth = Math.min(textElementWidth, maxWidth); |         width = Math.min(width, maxWidth); | ||||||
|       } else { |       } 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
 |       // Make sure text editor height doesn't go beyond viewport
 | ||||||
|       const editorMaxHeight = |       const editorMaxHeight = | ||||||
|         (appState.height - viewportY) / appState.zoom.value; |         (appState.height - viewportY) / appState.zoom.value; | ||||||
|       Object.assign(editable.style, { |       Object.assign(editable.style, { | ||||||
|         font: getFontString(updatedTextElement), |         font, | ||||||
|         // must be defined *after* font ¯\_(ツ)_/¯
 |         // must be defined *after* font ¯\_(ツ)_/¯
 | ||||||
|         lineHeight: updatedTextElement.lineHeight, |         lineHeight: updatedTextElement.lineHeight, | ||||||
|         width: `${textElementWidth}px`, |         width: `${width}px`, | ||||||
|         height: `${textElementHeight}px`, |         height: `${height}px`, | ||||||
|         left: `${viewportX}px`, |         left: `${viewportX - padding}px`, | ||||||
|         top: `${viewportY}px`, |         top: `${viewportY}px`, | ||||||
|         transform: getTransform( |         transform: getTransform( | ||||||
|           textElementWidth, |           width, | ||||||
|           textElementHeight, |           height, | ||||||
|           getTextElementAngle(updatedTextElement, container), |           getTextElementAngle(updatedTextElement, container), | ||||||
|           appState, |           appState, | ||||||
|           maxWidth, |           maxWidth, | ||||||
|           editorMaxHeight, |           editorMaxHeight, | ||||||
|         ), |         ), | ||||||
|  |         padding: `0 ${padding}px`, | ||||||
|         textAlign, |         textAlign, | ||||||
|         verticalAlign, |         verticalAlign, | ||||||
|         color: updatedTextElement.strokeColor, |         color: updatedTextElement.strokeColor, | ||||||
|  | @ -290,7 +306,6 @@ export const textWysiwyg = ({ | ||||||
|     minHeight: "1em", |     minHeight: "1em", | ||||||
|     backfaceVisibility: "hidden", |     backfaceVisibility: "hidden", | ||||||
|     margin: 0, |     margin: 0, | ||||||
|     padding: 0, |  | ||||||
|     border: 0, |     border: 0, | ||||||
|     outline: 0, |     outline: 0, | ||||||
|     resize: "none", |     resize: "none", | ||||||
|  | @ -336,7 +351,7 @@ export const textWysiwyg = ({ | ||||||
|           font, |           font, | ||||||
|           getBoundTextMaxWidth(container, boundTextElement), |           getBoundTextMaxWidth(container, boundTextElement), | ||||||
|         ); |         ); | ||||||
|         const width = getTextWidth(wrappedText, font); |         const width = getTextWidth(wrappedText, font, true); | ||||||
|         editable.style.width = `${width}px`; |         editable.style.width = `${width}px`; | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|  | @ -485,8 +500,10 @@ export const textWysiwyg = ({ | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const stopEvent = (event: Event) => { |   const stopEvent = (event: Event) => { | ||||||
|     event.preventDefault(); |     if (event.target instanceof HTMLCanvasElement) { | ||||||
|     event.stopPropagation(); |       event.preventDefault(); | ||||||
|  |       event.stopPropagation(); | ||||||
|  |     } | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   // using a state variable instead of passing it to the handleSubmit callback
 |   // using a state variable instead of passing it to the handleSubmit callback
 | ||||||
|  | @ -579,46 +596,15 @@ export const textWysiwyg = ({ | ||||||
|     // in that same tick.
 |     // in that same tick.
 | ||||||
|     const target = event?.target; |     const target = event?.target; | ||||||
| 
 | 
 | ||||||
|     const isTargetPickerTrigger = |     const isPropertiesTrigger = | ||||||
|       target instanceof HTMLElement && |       target instanceof HTMLElement && | ||||||
|       target.classList.contains("active-color"); |       target.classList.contains("properties-trigger"); | ||||||
| 
 | 
 | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|       editable.onblur = handleSubmit; |       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
 |       // case: clicking on the same property → no change → no update → no focus
 | ||||||
|       if (!isTargetPickerTrigger) { |       if (!isPropertiesTrigger) { | ||||||
|         editable.focus(); |         editable.focus(); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  | @ -626,16 +612,18 @@ export const textWysiwyg = ({ | ||||||
| 
 | 
 | ||||||
|   // prevent blur when changing properties from the menu
 |   // prevent blur when changing properties from the menu
 | ||||||
|   const onPointerDown = (event: MouseEvent) => { |   const onPointerDown = (event: MouseEvent) => { | ||||||
|     const isTargetPickerTrigger = |     const target = event?.target; | ||||||
|       event.target instanceof HTMLElement && | 
 | ||||||
|       event.target.classList.contains("active-color"); |     const isPropertiesTrigger = | ||||||
|  |       target instanceof HTMLElement && | ||||||
|  |       target.classList.contains("properties-trigger"); | ||||||
| 
 | 
 | ||||||
|     if ( |     if ( | ||||||
|       ((event.target instanceof HTMLElement || |       ((event.target instanceof HTMLElement || | ||||||
|         event.target instanceof SVGElement) && |         event.target instanceof SVGElement) && | ||||||
|         event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) && |         event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) && | ||||||
|         !isWritableElement(event.target)) || |         !isWritableElement(event.target)) || | ||||||
|       isTargetPickerTrigger |       isPropertiesTrigger | ||||||
|     ) { |     ) { | ||||||
|       editable.onblur = null; |       editable.onblur = null; | ||||||
|       window.addEventListener("pointerup", bindBlurEvent); |       window.addEventListener("pointerup", bindBlurEvent); | ||||||
|  | @ -644,7 +632,7 @@ export const textWysiwyg = ({ | ||||||
|       window.addEventListener("blur", handleSubmit); |       window.addEventListener("blur", handleSubmit); | ||||||
|     } else if ( |     } else if ( | ||||||
|       event.target instanceof HTMLElement && |       event.target instanceof HTMLElement && | ||||||
|       !event.target.contains(editable) && |       event.target instanceof HTMLCanvasElement && | ||||||
|       // Vitest simply ignores stopPropagation, capture-mode, or rAF
 |       // Vitest simply ignores stopPropagation, capture-mode, or rAF
 | ||||||
|       // so without introducing crazier hacks, nothing we can do
 |       // so without introducing crazier hacks, nothing we can do
 | ||||||
|       !isTestEnv() |       !isTestEnv() | ||||||
|  | @ -664,10 +652,10 @@ export const textWysiwyg = ({ | ||||||
|   // handle updates of textElement properties of editing element
 |   // handle updates of textElement properties of editing element
 | ||||||
|   const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { |   const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { | ||||||
|     updateWysiwygStyle(); |     updateWysiwygStyle(); | ||||||
|     const isColorPickerActive = !!document.activeElement?.closest( |     const isPopupOpened = !!document.activeElement?.closest( | ||||||
|       ".color-picker-content", |       ".properties-content", | ||||||
|     ); |     ); | ||||||
|     if (!isColorPickerActive) { |     if (!isPopupOpened) { | ||||||
|       editable.focus(); |       editable.focus(); | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|  |     )}`;
 | ||||||
|  |   } | ||||||
|  | } | ||||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							|  | @ -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; | ||||||
|  | } | ||||||
|  | @ -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"]; | ||||||
|  | }; | ||||||
|  | @ -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:"; | ||||||
|  | @ -5,7 +5,7 @@ import { isShallowEqual } from "./utils"; | ||||||
| 
 | 
 | ||||||
| import "./css/app.scss"; | import "./css/app.scss"; | ||||||
| import "./css/styles.scss"; | import "./css/styles.scss"; | ||||||
| import "../../public/fonts/fonts.css"; | import "./fonts/assets/fonts.css"; | ||||||
| import polyfill from "./polyfill"; | import polyfill from "./polyfill"; | ||||||
| 
 | 
 | ||||||
| import type { AppProps, ExcalidrawProps } from "./types"; | import type { AppProps, ExcalidrawProps } from "./types"; | ||||||
|  | @ -50,6 +50,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { | ||||||
|     validateEmbeddable, |     validateEmbeddable, | ||||||
|     renderEmbeddable, |     renderEmbeddable, | ||||||
|     aiEnabled, |     aiEnabled, | ||||||
|  |     showDeprecatedFonts, | ||||||
|   } = props; |   } = props; | ||||||
| 
 | 
 | ||||||
|   const canvasActions = props.UIOptions?.canvasActions; |   const canvasActions = props.UIOptions?.canvasActions; | ||||||
|  | @ -137,6 +138,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { | ||||||
|           validateEmbeddable={validateEmbeddable} |           validateEmbeddable={validateEmbeddable} | ||||||
|           renderEmbeddable={renderEmbeddable} |           renderEmbeddable={renderEmbeddable} | ||||||
|           aiEnabled={aiEnabled !== false} |           aiEnabled={aiEnabled !== false} | ||||||
|  |           showDeprecatedFonts={showDeprecatedFonts} | ||||||
|         > |         > | ||||||
|           {children} |           {children} | ||||||
|         </App> |         </App> | ||||||
|  |  | ||||||
|  | @ -109,6 +109,7 @@ | ||||||
|     "share": "Share", |     "share": "Share", | ||||||
|     "showStroke": "Show stroke color picker", |     "showStroke": "Show stroke color picker", | ||||||
|     "showBackground": "Show background color picker", |     "showBackground": "Show background color picker", | ||||||
|  |     "showFonts": "Show font picker", | ||||||
|     "toggleTheme": "Toggle light/dark theme", |     "toggleTheme": "Toggle light/dark theme", | ||||||
|     "theme": "Theme", |     "theme": "Theme", | ||||||
|     "personalLib": "Personal Library", |     "personalLib": "Personal Library", | ||||||
|  | @ -557,11 +558,19 @@ | ||||||
|     "syntax": "Mermaid Syntax", |     "syntax": "Mermaid Syntax", | ||||||
|     "preview": "Preview" |     "preview": "Preview" | ||||||
|   }, |   }, | ||||||
|   "userList": { |   "quickSearch": { | ||||||
|     "search": { |     "placeholder": "Quick search" | ||||||
|       "placeholder": "Quick search", |   }, | ||||||
|       "empty": "No users found" |   "fontList": { | ||||||
|  |     "badge": { | ||||||
|  |       "old": "old" | ||||||
|     }, |     }, | ||||||
|  |     "sceneFonts": "In this scene", | ||||||
|  |     "availableFonts": "Available fonts", | ||||||
|  |     "empty": "No fonts found" | ||||||
|  |   }, | ||||||
|  |   "userList": { | ||||||
|  |     "empty": "No users found", | ||||||
|     "hint": { |     "hint": { | ||||||
|       "text": "Click on user to follow", |       "text": "Click on user to follow", | ||||||
|       "followStatus": "You're currently following this user", |       "followStatus": "You're currently following this user", | ||||||
|  |  | ||||||
|  | @ -53,12 +53,12 @@ import { | ||||||
|   getLineHeightInPx, |   getLineHeightInPx, | ||||||
|   getBoundTextMaxHeight, |   getBoundTextMaxHeight, | ||||||
|   getBoundTextMaxWidth, |   getBoundTextMaxWidth, | ||||||
|   getVerticalOffset, |  | ||||||
| } from "../element/textElement"; | } from "../element/textElement"; | ||||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | import { LinearElementEditor } from "../element/linearElementEditor"; | ||||||
| 
 | 
 | ||||||
| import { getContainingFrame } from "../frame"; | import { getContainingFrame } from "../frame"; | ||||||
| import { ShapeCache } from "../scene/ShapeCache"; | import { ShapeCache } from "../scene/ShapeCache"; | ||||||
|  | import { getVerticalOffset } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| // using a stronger invert (100% vs our regular 93%) and saturate
 | // using a stronger invert (100% vs our regular 93%) and saturate
 | ||||||
| // as a temp hack to make images in dark theme look closer to original
 | // as a temp hack to make images in dark theme look closer to original
 | ||||||
|  | @ -89,8 +89,16 @@ const shouldResetImageFilter = ( | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const getCanvasPadding = (element: ExcalidrawElement) => | const getCanvasPadding = (element: ExcalidrawElement) => { | ||||||
|   element.type === "freedraw" ? element.strokeWidth * 12 : 20; |   switch (element.type) { | ||||||
|  |     case "freedraw": | ||||||
|  |       return element.strokeWidth * 12; | ||||||
|  |     case "text": | ||||||
|  |       return element.fontSize / 2; | ||||||
|  |     default: | ||||||
|  |       return 20; | ||||||
|  |   } | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| export const getRenderOpacity = ( | export const getRenderOpacity = ( | ||||||
|   element: ExcalidrawElement, |   element: ExcalidrawElement, | ||||||
|  | @ -202,7 +210,7 @@ const generateElementCanvas = ( | ||||||
|   canvas.width = width; |   canvas.width = width; | ||||||
|   canvas.height = height; |   canvas.height = height; | ||||||
| 
 | 
 | ||||||
|   let canvasOffsetX = 0; |   let canvasOffsetX = -100; | ||||||
|   let canvasOffsetY = 0; |   let canvasOffsetY = 0; | ||||||
| 
 | 
 | ||||||
|   if (isLinearElement(element) || isFreeDrawElement(element)) { |   if (isLinearElement(element) || isFreeDrawElement(element)) { | ||||||
|  |  | ||||||
|  | @ -17,7 +17,6 @@ import { | ||||||
|   getBoundTextElement, |   getBoundTextElement, | ||||||
|   getContainerElement, |   getContainerElement, | ||||||
|   getLineHeightInPx, |   getLineHeightInPx, | ||||||
|   getVerticalOffset, |  | ||||||
| } from "../element/textElement"; | } from "../element/textElement"; | ||||||
| import { | import { | ||||||
|   isArrowElement, |   isArrowElement, | ||||||
|  | @ -37,6 +36,7 @@ import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; | ||||||
| import type { AppState, BinaryFiles } from "../types"; | import type { AppState, BinaryFiles } from "../types"; | ||||||
| import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; | import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; | ||||||
| import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; | import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; | ||||||
|  | import { getVerticalOffset } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| const roughSVGDrawWithPrecision = ( | const roughSVGDrawWithPrecision = ( | ||||||
|   rsvg: RoughSVG, |   rsvg: RoughSVG, | ||||||
|  |  | ||||||
|  | @ -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[]); |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
|  | @ -13,8 +13,8 @@ import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; | ||||||
| import type { AppState, BinaryFiles } from "../types"; | import type { AppState, BinaryFiles } from "../types"; | ||||||
| import { | import { | ||||||
|   DEFAULT_EXPORT_PADDING, |   DEFAULT_EXPORT_PADDING, | ||||||
|   FONT_FAMILY, |  | ||||||
|   FRAME_STYLE, |   FRAME_STYLE, | ||||||
|  |   FONT_FAMILY, | ||||||
|   SVG_NS, |   SVG_NS, | ||||||
|   THEME, |   THEME, | ||||||
|   THEME_FILTER, |   THEME_FILTER, | ||||||
|  | @ -32,12 +32,18 @@ import { | ||||||
|   getRootElements, |   getRootElements, | ||||||
| } from "../frame"; | } from "../frame"; | ||||||
| import { newTextElement } from "../element"; | import { newTextElement } from "../element"; | ||||||
| import type { Mutable } from "../utility-types"; | import { type Mutable } from "../utility-types"; | ||||||
| import { newElementWith } from "../element/mutateElement"; | import { newElementWith } from "../element/mutateElement"; | ||||||
| import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; | import { | ||||||
|  |   isFrameElement, | ||||||
|  |   isFrameLikeElement, | ||||||
|  |   isTextElement, | ||||||
|  | } from "../element/typeChecks"; | ||||||
| import type { RenderableElementsMap } from "./types"; | import type { RenderableElementsMap } from "./types"; | ||||||
| import { syncInvalidIndices } from "../fractionalIndex"; | import { syncInvalidIndices } from "../fractionalIndex"; | ||||||
| import { renderStaticScene } from "../renderer/staticScene"; | import { renderStaticScene } from "../renderer/staticScene"; | ||||||
|  | import { Fonts } from "../fonts"; | ||||||
|  | import { LOCAL_FONT_PROTOCOL } from "../fonts/metadata"; | ||||||
| 
 | 
 | ||||||
| const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; | const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; | ||||||
| 
 | 
 | ||||||
|  | @ -95,7 +101,7 @@ const addFrameLabelsAsTextElements = ( | ||||||
|       let textElement: Mutable<ExcalidrawTextElement> = newTextElement({ |       let textElement: Mutable<ExcalidrawTextElement> = newTextElement({ | ||||||
|         x: element.x, |         x: element.x, | ||||||
|         y: element.y - FRAME_STYLE.nameOffsetY, |         y: element.y - FRAME_STYLE.nameOffsetY, | ||||||
|         fontFamily: FONT_FAMILY.Assistant, |         fontFamily: FONT_FAMILY.Helvetica, | ||||||
|         fontSize: FRAME_STYLE.nameFontSize, |         fontSize: FRAME_STYLE.nameFontSize, | ||||||
|         lineHeight: |         lineHeight: | ||||||
|           FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"], |           FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"], | ||||||
|  | @ -269,6 +275,7 @@ export const exportToSvg = async ( | ||||||
|      */ |      */ | ||||||
|     renderEmbeddables?: boolean; |     renderEmbeddables?: boolean; | ||||||
|     exportingFrame?: ExcalidrawFrameLikeElement | null; |     exportingFrame?: ExcalidrawFrameLikeElement | null; | ||||||
|  |     skipInliningFonts?: true; | ||||||
|   }, |   }, | ||||||
| ): Promise<SVGSVGElement> => { | ): Promise<SVGSVGElement> => { | ||||||
|   const frameRendering = getFrameRenderingConfig( |   const frameRendering = getFrameRenderingConfig( | ||||||
|  | @ -333,21 +340,6 @@ export const exportToSvg = async ( | ||||||
|     svgRoot.setAttribute("filter", THEME_FILTER); |     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 offsetX = -minX + exportPadding; | ||||||
|   const offsetY = -minY + exportPadding; |   const offsetY = -minY + exportPadding; | ||||||
| 
 | 
 | ||||||
|  | @ -371,23 +363,57 @@ export const exportToSvg = async ( | ||||||
|         </clipPath>`;
 |         </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 = ` |   svgRoot.innerHTML = ` | ||||||
|   ${SVG_EXPORT_TAG} |   ${SVG_EXPORT_TAG} | ||||||
|   ${metadata} |   ${metadata} | ||||||
|   <defs> |   <defs> | ||||||
|     <style class="style-fonts"> |     <style class="style-fonts"> | ||||||
|       @font-face { |       ${fontFaces.flat().filter(Boolean).join("\n")} | ||||||
|         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"); |  | ||||||
|       } |  | ||||||
|     </style> |     </style> | ||||||
|     ${exportingFrameClipPath} |     ${exportingFrameClipPath} | ||||||
|   </defs> |   </defs> | ||||||
|  |  | ||||||
|  | @ -795,10 +795,11 @@ exports[`contextMenu element > right-clicking on a group should select whole gro | ||||||
|     "top": 40, |     "top": 40, | ||||||
|   }, |   }, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -996,10 +997,11 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1207,10 +1209,11 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1533,10 +1536,11 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1859,10 +1863,11 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2070,10 +2075,11 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2305,10 +2311,11 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2601,10 +2608,11 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2965,10 +2973,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#a5d8ff", |   "currentItemBackgroundColor": "#a5d8ff", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "cross-hatch", |   "currentItemFillStyle": "cross-hatch", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 60, |   "currentItemOpacity": 60, | ||||||
|   "currentItemRoughness": 2, |   "currentItemRoughness": 2, | ||||||
|  | @ -3435,10 +3444,11 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3753,10 +3763,11 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4071,10 +4082,11 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5252,10 +5264,11 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi | ||||||
|     "top": -7, |     "top": -7, | ||||||
|   }, |   }, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -6374,10 +6387,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro | ||||||
|     "top": -7, |     "top": -7, | ||||||
|   }, |   }, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7304,10 +7318,11 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app | ||||||
|     "top": -9, |     "top": -9, | ||||||
|   }, |   }, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8211,10 +8226,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap | ||||||
|     "top": -7, |     "top": -7, | ||||||
|   }, |   }, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9100,10 +9116,11 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap | ||||||
|     "top": 90, |     "top": 90, | ||||||
|   }, |   }, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  |  | ||||||
|  | @ -11,11 +11,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it | ||||||
|   > |   > | ||||||
|     <button |     <button | ||||||
|       class="dropdown-menu-item dropdown-menu-item-base" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |  | ||||||
|         class="dropdown-menu-item__icon" |  | ||||||
|       /> |  | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__text" |         class="dropdown-menu-item__text" | ||||||
|       > |       > | ||||||
|  | @ -28,9 +24,6 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it | ||||||
|       rel="noreferrer" |       rel="noreferrer" | ||||||
|       target="_blank" |       target="_blank" | ||||||
|     > |     > | ||||||
|       <div |  | ||||||
|         class="dropdown-menu-item__icon" |  | ||||||
|       /> |  | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__text" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="help-menu-item" |       data-testid="help-menu-item" | ||||||
|       title="Help" |       title="Help" | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="load-button" |       data-testid="load-button" | ||||||
|       title="Open" |       title="Open" | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="json-export-button" |       data-testid="json-export-button" | ||||||
|       title="Save to..." |       title="Save to..." | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="image-export-button" |       data-testid="image-export-button" | ||||||
|       title="Export image..." |       title="Export image..." | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="help-menu-item" |       data-testid="help-menu-item" | ||||||
|       title="Help" |       title="Help" | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="clear-canvas-button" |       data-testid="clear-canvas-button" | ||||||
|       title="Reset the canvas" |       title="Reset the canvas" | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         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" |       class="dropdown-menu-item dropdown-menu-item-base" | ||||||
|       data-testid="toggle-dark-mode" |       data-testid="toggle-dark-mode" | ||||||
|       title="Dark mode" |       title="Dark mode" | ||||||
|       type="button" |  | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         class="dropdown-menu-item__icon" |         class="dropdown-menu-item__icon" | ||||||
|  | @ -593,14 +579,14 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende | ||||||
|               </button> |               </button> | ||||||
|             </div> |             </div> | ||||||
|             <div |             <div | ||||||
|               style="width: 1px; height: 100%; margin: 0px auto;" |               style="width: 1px; height: 1rem; margin: 0px auto;" | ||||||
|             /> |             /> | ||||||
|             <button |             <button | ||||||
|               aria-controls="radix-:r0:" |               aria-controls="radix-:r0:" | ||||||
|               aria-expanded="false" |               aria-expanded="false" | ||||||
|               aria-haspopup="dialog" |               aria-haspopup="dialog" | ||||||
|               aria-label="Canvas background" |               aria-label="Canvas background" | ||||||
|               class="color-picker__button active-color" |               class="color-picker__button active-color properties-trigger" | ||||||
|               data-state="closed" |               data-state="closed" | ||||||
|               style="--swatch-color: #ffffff;" |               style="--swatch-color: #ffffff;" | ||||||
|               title="Show background color picker" |               title="Show background color picker" | ||||||
|  |  | ||||||
|  | @ -6,18 +6,7 @@ exports[`export > exporting svg containing transformed images > svg export outpu | ||||||
|    |    | ||||||
|   <defs> |   <defs> | ||||||
|     <style class="style-fonts"> |     <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> |     </style> | ||||||
|      |      | ||||||
|   </defs> |   </defs> | ||||||
|  |  | ||||||
|  | @ -12,10 +12,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -593,10 +594,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1090,10 +1092,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1436,10 +1439,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1782,10 +1786,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2044,10 +2049,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2473,10 +2479,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2611,7 +2618,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2652,7 +2659,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id142", |   "containerId": "id142", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2767,10 +2774,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2905,7 +2913,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id145", |   "containerId": "id145", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -2946,7 +2954,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id145", |   "containerId": "id145", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -3046,10 +3054,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3184,7 +3193,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id132", |   "containerId": "id132", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -3225,7 +3234,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -3335,10 +3344,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3505,7 +3515,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id137", |   "containerId": "id137", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -3616,10 +3626,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3749,7 +3760,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -3846,10 +3857,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3984,7 +3996,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id138", |   "containerId": "id138", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -4100,10 +4112,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4238,7 +4251,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id140", |   "containerId": "id140", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -4299,7 +4312,7 @@ History { | ||||||
|               "containerId": "id140", |               "containerId": "id140", | ||||||
|               "customData": undefined, |               "customData": undefined, | ||||||
|               "fillStyle": "solid", |               "fillStyle": "solid", | ||||||
|               "fontFamily": 1, |               "fontFamily": 5, | ||||||
|               "fontSize": 20, |               "fontSize": 20, | ||||||
|               "frameId": null, |               "frameId": null, | ||||||
|               "groupIds": [], |               "groupIds": [], | ||||||
|  | @ -4368,10 +4381,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4506,7 +4520,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id154", |   "containerId": "id154", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -4594,10 +4608,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4732,7 +4747,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id152", |   "containerId": "id152", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -4820,10 +4835,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4953,7 +4969,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": "id148", |   "containerId": "id148", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -5044,10 +5060,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5182,7 +5199,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -5268,10 +5285,11 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5522,10 +5540,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5848,10 +5867,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#ffc9c9", |   "currentItemBackgroundColor": "#ffc9c9", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -6268,10 +6288,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -6641,10 +6662,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -6955,10 +6977,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7246,10 +7269,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#ffc9c9", |   "currentItemBackgroundColor": "#ffc9c9", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7470,10 +7494,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7820,10 +7845,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8170,10 +8196,11 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8569,10 +8596,11 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8851,10 +8879,11 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9111,10 +9140,11 @@ exports[`history > multiplayer undo/redo > should not override remote changes on | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#ffc9c9", |   "currentItemBackgroundColor": "#ffc9c9", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9370,10 +9400,11 @@ exports[`history > multiplayer undo/redo > should not override remote changes on | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#ffc9c9", |   "currentItemBackgroundColor": "#ffc9c9", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9596,10 +9627,11 @@ exports[`history > multiplayer undo/redo > should override remotely added groups | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9892,10 +9924,11 @@ exports[`history > multiplayer undo/redo > should override remotely added points | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10225,10 +10258,11 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10455,10 +10489,11 @@ exports[`history > multiplayer undo/redo > should update history entries after r | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#a5d8ff", |   "currentItemBackgroundColor": "#a5d8ff", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10704,10 +10739,11 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10938,10 +10974,11 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -11174,10 +11211,11 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -11570,10 +11608,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on s | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -11812,10 +11851,11 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -12048,10 +12088,11 @@ exports[`history > singleplayer undo/redo > should end up with no history entry | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -12284,10 +12325,11 @@ exports[`history > singleplayer undo/redo > should iterate through the history w | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -12526,10 +12568,11 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -12853,10 +12896,11 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13020,10 +13064,11 @@ exports[`history > singleplayer undo/redo > should not end up with history entry | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13303,10 +13348,11 @@ exports[`history > singleplayer undo/redo > should not end up with history entry | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13565,10 +13611,11 @@ exports[`history > singleplayer undo/redo > should not override appstate changes | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#a5d8ff", |   "currentItemBackgroundColor": "#a5d8ff", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13835,10 +13882,11 @@ exports[`history > singleplayer undo/redo > should support appstate name or view | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13991,10 +14039,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -14134,7 +14183,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "containerId": "id50", |   "containerId": "id50", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -14394,7 +14443,7 @@ History { | ||||||
|               "containerId": null, |               "containerId": null, | ||||||
|               "customData": undefined, |               "customData": undefined, | ||||||
|               "fillStyle": "solid", |               "fillStyle": "solid", | ||||||
|               "fontFamily": 1, |               "fontFamily": 5, | ||||||
|               "fontSize": 20, |               "fontSize": 20, | ||||||
|               "frameId": null, |               "frameId": null, | ||||||
|               "groupIds": [], |               "groupIds": [], | ||||||
|  | @ -14676,10 +14725,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -14819,7 +14869,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "containerId": "id44", |   "containerId": "id44", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -15003,7 +15053,7 @@ History { | ||||||
|               "containerId": null, |               "containerId": null, | ||||||
|               "customData": undefined, |               "customData": undefined, | ||||||
|               "fillStyle": "solid", |               "fillStyle": "solid", | ||||||
|               "fontFamily": 1, |               "fontFamily": 5, | ||||||
|               "fontSize": 20, |               "fontSize": 20, | ||||||
|               "frameId": null, |               "frameId": null, | ||||||
|               "groupIds": [], |               "groupIds": [], | ||||||
|  | @ -15285,10 +15335,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -15428,7 +15479,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "containerId": "id56", |   "containerId": "id56", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -15612,7 +15663,7 @@ History { | ||||||
|               "containerId": null, |               "containerId": null, | ||||||
|               "customData": undefined, |               "customData": undefined, | ||||||
|               "fillStyle": "solid", |               "fillStyle": "solid", | ||||||
|               "fontFamily": 1, |               "fontFamily": 5, | ||||||
|               "fontSize": 20, |               "fontSize": 20, | ||||||
|               "frameId": null, |               "frameId": null, | ||||||
|               "groupIds": [], |               "groupIds": [], | ||||||
|  | @ -15894,10 +15945,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -16035,7 +16087,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "containerId": "id62", |   "containerId": "id62", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -16289,7 +16341,7 @@ History { | ||||||
|               "containerId": null, |               "containerId": null, | ||||||
|               "customData": undefined, |               "customData": undefined, | ||||||
|               "fillStyle": "solid", |               "fillStyle": "solid", | ||||||
|               "fontFamily": 1, |               "fontFamily": 5, | ||||||
|               "fontSize": 20, |               "fontSize": 20, | ||||||
|               "frameId": null, |               "frameId": null, | ||||||
|               "groupIds": [], |               "groupIds": [], | ||||||
|  | @ -16594,10 +16646,11 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -16738,7 +16791,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding | ||||||
|   "containerId": "id69", |   "containerId": "id69", | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 20, |   "fontSize": 20, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  | @ -17007,7 +17060,7 @@ History { | ||||||
|               "containerId": null, |               "containerId": null, | ||||||
|               "customData": undefined, |               "customData": undefined, | ||||||
|               "fillStyle": "solid", |               "fillStyle": "solid", | ||||||
|               "fontFamily": 1, |               "fontFamily": 5, | ||||||
|               "fontSize": 20, |               "fontSize": 20, | ||||||
|               "frameId": null, |               "frameId": null, | ||||||
|               "groupIds": [], |               "groupIds": [], | ||||||
|  | @ -17331,10 +17384,11 @@ exports[`history > singleplayer undo/redo > should support changes in elements' | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -17800,10 +17854,11 @@ exports[`history > singleplayer undo/redo > should support duplication of groups | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -18317,10 +18372,11 @@ exports[`history > singleplayer undo/redo > should support element creation, del | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -18768,10 +18824,11 @@ exports[`history > singleplayer undo/redo > should support linear element creati | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  |  | ||||||
|  | @ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo | ||||||
|   class="excalidraw-wysiwyg" |   class="excalidraw-wysiwyg" | ||||||
|   data-type="wysiwyg" |   data-type="wysiwyg" | ||||||
|   dir="auto" |   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" |   tabindex="0" | ||||||
|   wrap="off" |   wrap="off" | ||||||
| /> | /> | ||||||
|  |  | ||||||
|  | @ -12,10 +12,11 @@ exports[`given element A and group of elements B and given both are selected whe | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -419,10 +420,11 @@ exports[`given element A and group of elements B and given both are selected whe | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -817,10 +819,11 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1354,10 +1357,11 @@ exports[`regression tests > Drags selected element when hitting only bounding bo | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1550,10 +1554,11 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -1917,10 +1922,11 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2149,10 +2155,11 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2321,10 +2328,11 @@ exports[`regression tests > can drag element that covers another element, while | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2633,10 +2641,11 @@ exports[`regression tests > change the properties of a shape > [end of test] app | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#ffc9c9", |   "currentItemBackgroundColor": "#ffc9c9", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -2871,10 +2880,11 @@ exports[`regression tests > click on an element and drag it > [dragged] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3106,10 +3116,11 @@ exports[`regression tests > click on an element and drag it > [end of test] appS | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3328,10 +3339,11 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3576,10 +3588,11 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -3879,10 +3892,11 @@ exports[`regression tests > deleting last but one element in editing group shoul | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4285,10 +4299,11 @@ exports[`regression tests > deselects group of selected elements on pointer down | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4590,10 +4605,11 @@ exports[`regression tests > deselects group of selected elements on pointer up w | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -4865,10 +4881,11 @@ exports[`regression tests > deselects selected element on pointer down when poin | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5097,10 +5114,11 @@ exports[`regression tests > deselects selected element, on pointer up, when clic | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5288,10 +5306,11 @@ exports[`regression tests > double click to edit a group > [end of test] appStat | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5662,10 +5681,11 @@ exports[`regression tests > drags selected elements from point inside common bou | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -5944,10 +5964,11 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -6742,10 +6763,11 @@ exports[`regression tests > given a group of selected elements with an element t | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7064,10 +7086,11 @@ exports[`regression tests > given a selected element A and a not selected elemen | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "#ffc9c9", |   "currentItemBackgroundColor": "#ffc9c9", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7332,10 +7355,11 @@ exports[`regression tests > given selected element A with lower z-index than uns | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7558,10 +7582,11 @@ exports[`regression tests > given selected element A with lower z-index than uns | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7787,10 +7812,11 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -7959,10 +7985,11 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8131,10 +8158,11 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8303,10 +8331,11 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8515,10 +8544,11 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8727,10 +8757,11 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -8913,10 +8944,11 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9125,10 +9157,11 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9297,10 +9330,11 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9509,10 +9543,11 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9681,10 +9716,11 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -9867,10 +9903,11 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10039,10 +10076,11 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10545,10 +10583,11 @@ exports[`regression tests > noop interaction after undo shouldn't create history | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10814,10 +10853,11 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -10932,10 +10972,11 @@ exports[`regression tests > shift click on selected element should deselect it o | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -11123,10 +11164,11 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -11426,10 +11468,11 @@ exports[`regression tests > should group elements and ungroup them > [end of tes | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -11830,10 +11873,11 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -12435,10 +12479,11 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -12556,10 +12601,11 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13132,10 +13178,11 @@ exports[`regression tests > switches from group of selected elements to another | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13492,10 +13539,11 @@ exports[`regression tests > switches selected element on pointer down > [end of | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13779,10 +13827,11 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -13897,10 +13946,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -14267,10 +14317,11 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 3, |   "currentItemFontFamily": 8, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  | @ -14385,10 +14436,11 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` | ||||||
|   "collaborators": Map {}, |   "collaborators": Map {}, | ||||||
|   "contextMenu": null, |   "contextMenu": null, | ||||||
|   "currentChartType": "bar", |   "currentChartType": "bar", | ||||||
|  |   "currentHoveredFontFamily": null, | ||||||
|   "currentItemBackgroundColor": "transparent", |   "currentItemBackgroundColor": "transparent", | ||||||
|   "currentItemEndArrowhead": "arrow", |   "currentItemEndArrowhead": "arrow", | ||||||
|   "currentItemFillStyle": "solid", |   "currentItemFillStyle": "solid", | ||||||
|   "currentItemFontFamily": 1, |   "currentItemFontFamily": 5, | ||||||
|   "currentItemFontSize": 20, |   "currentItemFontSize": 20, | ||||||
|   "currentItemOpacity": 100, |   "currentItemOpacity": 100, | ||||||
|   "currentItemRoughness": 1, |   "currentItemRoughness": 1, | ||||||
|  |  | ||||||
|  | @ -4,16 +4,14 @@ import { render, waitFor, GlobalTestState } from "./test-utils"; | ||||||
| import { Pointer, Keyboard } from "./helpers/ui"; | import { Pointer, Keyboard } from "./helpers/ui"; | ||||||
| import { Excalidraw } from "../index"; | import { Excalidraw } from "../index"; | ||||||
| import { KEYS } from "../keys"; | import { KEYS } from "../keys"; | ||||||
| import { | import { getLineHeightInPx } from "../element/textElement"; | ||||||
|   getDefaultLineHeight, |  | ||||||
|   getLineHeightInPx, |  | ||||||
| } from "../element/textElement"; |  | ||||||
| import { getElementBounds } from "../element"; | import { getElementBounds } from "../element"; | ||||||
| import type { NormalizedZoomValue } from "../types"; | import type { NormalizedZoomValue } from "../types"; | ||||||
| import { API } from "./helpers/api"; | import { API } from "./helpers/api"; | ||||||
| import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard"; | import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard"; | ||||||
| import { arrayToMap } from "../utils"; | import { arrayToMap } from "../utils"; | ||||||
| import { mockMermaidToExcalidraw } from "./helpers/mocks"; | import { mockMermaidToExcalidraw } from "./helpers/mocks"; | ||||||
|  | import { getLineHeight } from "../fonts"; | ||||||
| 
 | 
 | ||||||
| const { h } = window; | const { h } = window; | ||||||
| 
 | 
 | ||||||
|  | @ -146,7 +144,7 @@ describe("paste text as single lines", () => { | ||||||
|     const lineHeightPx = |     const lineHeightPx = | ||||||
|       getLineHeightInPx( |       getLineHeightInPx( | ||||||
|         h.app.state.currentItemFontSize, |         h.app.state.currentItemFontSize, | ||||||
|         getDefaultLineHeight(h.state.currentItemFontFamily), |         getLineHeight(h.state.currentItemFontFamily), | ||||||
|       ) + |       ) + | ||||||
|       10 / h.app.state.zoom.value; |       10 / h.app.state.zoom.value; | ||||||
|     mouse.moveTo(100, 100); |     mouse.moveTo(100, 100); | ||||||
|  | @ -168,7 +166,7 @@ describe("paste text as single lines", () => { | ||||||
|     const lineHeightPx = |     const lineHeightPx = | ||||||
|       getLineHeightInPx( |       getLineHeightInPx( | ||||||
|         h.app.state.currentItemFontSize, |         h.app.state.currentItemFontSize, | ||||||
|         getDefaultLineHeight(h.state.currentItemFontFamily), |         getLineHeight(h.state.currentItemFontFamily), | ||||||
|       ) + |       ) + | ||||||
|       10 / h.app.state.zoom.value; |       10 / h.app.state.zoom.value; | ||||||
|     mouse.moveTo(100, 100); |     mouse.moveTo(100, 100); | ||||||
|  |  | ||||||
|  | @ -351,7 +351,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo | ||||||
|   "containerId": null, |   "containerId": null, | ||||||
|   "customData": undefined, |   "customData": undefined, | ||||||
|   "fillStyle": "solid", |   "fillStyle": "solid", | ||||||
|   "fontFamily": 1, |   "fontFamily": 5, | ||||||
|   "fontSize": 10, |   "fontSize": 10, | ||||||
|   "frameId": null, |   "frameId": null, | ||||||
|   "groupIds": [], |   "groupIds": [], | ||||||
|  |  | ||||||
|  | @ -1,3 +1,4 @@ | ||||||
|  | import { DEFAULT_FONT_FAMILY } from "../../constants"; | ||||||
| import type { ExcalidrawElement } from "../../element/types"; | import type { ExcalidrawElement } from "../../element/types"; | ||||||
| 
 | 
 | ||||||
| const elementBase: Omit<ExcalidrawElement, "type"> = { | const elementBase: Omit<ExcalidrawElement, "type"> = { | ||||||
|  | @ -49,3 +50,17 @@ export const rectangleWithLinkFixture: ExcalidrawElement = { | ||||||
|   type: "rectangle", |   type: "rectangle", | ||||||
|   link: "excalidraw.com", |   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, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -1,3 +1,5 @@ | ||||||
|  | import { URL } from "node:url"; | ||||||
|  | 
 | ||||||
| class ClipboardEvent { | class ClipboardEvent { | ||||||
|   constructor( |   constructor( | ||||||
|     type: "paste" | "copy", |     type: "paste" | "copy", | ||||||
|  | @ -88,4 +90,6 @@ export const testPolyfills = { | ||||||
|   ClipboardEvent, |   ClipboardEvent, | ||||||
|   DataTransfer, |   DataTransfer, | ||||||
|   DataTransferItem, |   DataTransferItem, | ||||||
|  |   // https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
 | ||||||
|  |   URL, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -644,9 +644,9 @@ describe("regression tests", () => { | ||||||
| 
 | 
 | ||||||
|   it("updates fontSize & fontFamily appState", () => { |   it("updates fontSize & fontFamily appState", () => { | ||||||
|     UI.clickTool("text"); |     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)); |     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", () => { |   it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => { | ||||||
|  |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -4,14 +4,14 @@ import { | ||||||
|   diamondFixture, |   diamondFixture, | ||||||
|   ellipseFixture, |   ellipseFixture, | ||||||
|   rectangleWithLinkFixture, |   rectangleWithLinkFixture, | ||||||
|  |   textFixture, | ||||||
| } from "../fixtures/elementFixture"; | } from "../fixtures/elementFixture"; | ||||||
| import { API } from "../helpers/api"; | import { API } from "../helpers/api"; | ||||||
| import { exportToCanvas, exportToSvg } from "../../../utils"; | import { exportToCanvas, exportToSvg } from "../../../utils"; | ||||||
| import { FRAME_STYLE } from "../../constants"; | import { FONT_FAMILY, FRAME_STYLE } from "../../constants"; | ||||||
| import { prepareElementsForExport } from "../../data"; | import { prepareElementsForExport } from "../../data"; | ||||||
| 
 | 
 | ||||||
| describe("exportToSvg", () => { | describe("exportToSvg", () => { | ||||||
|   window.EXCALIDRAW_ASSET_PATH = "/"; |  | ||||||
|   const ELEMENT_HEIGHT = 100; |   const ELEMENT_HEIGHT = 100; | ||||||
|   const ELEMENT_WIDTH = 100; |   const ELEMENT_WIDTH = 100; | ||||||
|   const ELEMENTS = [ |   const ELEMENTS = [ | ||||||
|  | @ -27,6 +27,19 @@ describe("exportToSvg", () => { | ||||||
|       width: ELEMENT_WIDTH, |       width: ELEMENT_WIDTH, | ||||||
|       index: "a1", |       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[]; |   ] as NonDeletedExcalidrawElement[]; | ||||||
| 
 | 
 | ||||||
|   const DEFAULT_OPTIONS = { |   const DEFAULT_OPTIONS = { | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue