From 62228e0bbb780d1070a8cf206caa32132d22f19e Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Thu, 25 Jul 2024 18:55:55 +0200 Subject: [PATCH] feat: introduce font picker (#8012) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- dev-docs/src/css/custom.scss | 2 +- examples/excalidraw/components/App.tsx | 4 +- examples/excalidraw/initialData.tsx | 2 +- examples/excalidraw/with-nextjs/.gitignore | 3 + examples/excalidraw/with-nextjs/package.json | 3 +- .../excalidraw/with-nextjs/src/app/page.tsx | 5 +- .../excalidraw/with-nextjs/src/common.scss | 2 +- .../with-script-in-browser/.gitignore | 2 + .../with-script-in-browser/index.html | 1 + .../with-script-in-browser/package.json | 6 +- excalidraw-app/index.html | 67 ++- excalidraw-app/package.json | 3 +- .../__snapshots__/MobileMenu.test.tsx.snap | 4 +- excalidraw-app/vite.config.mts | 12 +- packages/excalidraw/CHANGELOG.md | 2 + .../actions/actionProperties.test.tsx | 6 +- .../excalidraw/actions/actionProperties.tsx | 451 ++++++++++++++---- packages/excalidraw/actions/actionStyles.ts | 8 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/Actions.tsx | 4 +- packages/excalidraw/components/App.tsx | 64 ++- .../excalidraw/components/ButtonIcon.scss | 12 + packages/excalidraw/components/ButtonIcon.tsx | 36 ++ .../components/ButtonIconSelect.tsx | 19 +- .../excalidraw/components/ButtonSeparator.tsx | 10 + .../components/ColorPicker/ColorPicker.scss | 2 +- .../components/ColorPicker/ColorPicker.tsx | 193 +++----- .../components/ColorPicker/Picker.tsx | 2 +- .../components/FontPicker/FontPicker.scss | 15 + .../components/FontPicker/FontPicker.tsx | 110 +++++ .../components/FontPicker/FontPickerList.tsx | 268 +++++++++++ .../FontPicker/FontPickerTrigger.tsx | 38 ++ .../FontPicker/keyboardNavHandlers.ts | 66 +++ .../excalidraw/components/HelpDialog.scss | 4 +- packages/excalidraw/components/HelpDialog.tsx | 4 + .../excalidraw/components/LibraryMenu.scss | 2 +- .../components/LibraryMenuItems.scss | 4 +- .../components/PropertiesPopover.tsx | 96 ++++ .../excalidraw/components/PublishLibrary.scss | 2 +- .../excalidraw/components/QuickSearch.scss | 48 ++ .../excalidraw/components/QuickSearch.tsx | 28 ++ .../excalidraw/components/ScrollableList.scss | 21 + .../excalidraw/components/ScrollableList.tsx | 24 + .../components/TTDDialog/TTDDialog.scss | 2 +- packages/excalidraw/components/UserList.scss | 66 +-- packages/excalidraw/components/UserList.tsx | 93 ++-- .../components/canvases/StaticCanvas.tsx | 1 + .../components/dropdownMenu/DropdownMenu.scss | 66 ++- .../dropdownMenu/DropdownMenuItem.tsx | 90 +++- .../dropdownMenu/DropdownMenuItemContent.tsx | 8 +- .../components/dropdownMenu/common.ts | 6 +- packages/excalidraw/components/icons.tsx | 21 + .../welcome-screen/WelcomeScreen.Center.tsx | 4 +- .../welcome-screen/WelcomeScreen.Hints.tsx | 6 +- .../welcome-screen/WelcomeScreen.scss | 4 +- packages/excalidraw/constants.ts | 18 +- packages/excalidraw/css/styles.scss | 21 +- packages/excalidraw/css/theme.scss | 3 + packages/excalidraw/css/variables.module.scss | 12 +- .../data/__snapshots__/transform.test.ts.snap | 50 +- packages/excalidraw/data/restore.ts | 9 +- packages/excalidraw/data/transform.ts | 10 +- packages/excalidraw/element/mutateElement.ts | 4 +- packages/excalidraw/element/newElement.ts | 6 +- .../excalidraw/element/textElement.test.ts | 8 +- packages/excalidraw/element/textElement.ts | 224 ++++----- .../excalidraw/element/textWysiwyg.test.tsx | 20 +- packages/excalidraw/element/textWysiwyg.tsx | 110 ++--- packages/excalidraw/fonts/ExcalidrawFont.ts | 78 +++ .../fonts/assets}/Assistant-Bold.woff2 | Bin .../fonts/assets}/Assistant-Medium.woff2 | Bin .../fonts/assets}/Assistant-Regular.woff2 | Bin .../fonts/assets}/Assistant-SemiBold.woff2 | Bin .../fonts/assets/CascadiaMono-Regular.woff2 | Bin 0 -> 74128 bytes .../fonts/assets/ComicShanns-Regular.woff2 | Bin 0 -> 16856 bytes .../fonts/assets/Excalifont-Regular.woff2 | Bin 0 -> 52296 bytes .../fonts/assets/LiberationSans-Regular.woff2 | Bin 0 -> 70668 bytes .../fonts/assets/Virgil-Regular.woff2 | Bin 0 -> 56156 bytes packages/excalidraw/fonts/assets/fonts.css | 34 ++ packages/excalidraw/fonts/index.ts | 308 ++++++++++++ packages/excalidraw/fonts/metadata.ts | 125 +++++ packages/excalidraw/index.tsx | 4 +- packages/excalidraw/locales/en.json | 17 +- packages/excalidraw/renderer/renderElement.ts | 16 +- .../excalidraw/renderer/staticSvgScene.ts | 2 +- packages/excalidraw/scene/Fonts.ts | 90 ---- packages/excalidraw/scene/export.ts | 88 ++-- .../__snapshots__/contextmenu.test.tsx.snap | 51 +- .../__snapshots__/excalidraw.test.tsx.snap | 18 +- .../tests/__snapshots__/export.test.tsx.snap | 13 +- .../tests/__snapshots__/history.test.tsx.snap | 221 +++++---- .../linearElementEditor.test.tsx.snap | 2 +- .../regressionTests.test.tsx.snap | 156 ++++-- packages/excalidraw/tests/clipboard.test.tsx | 10 +- .../data/__snapshots__/restore.test.ts.snap | 2 +- .../tests/fixtures/elementFixture.ts | 15 + .../excalidraw/tests/helpers/polyfills.ts | 4 + .../excalidraw/tests/regressionTests.test.tsx | 4 +- .../scene/__snapshots__/export.test.ts.snap | 121 +++-- .../excalidraw/tests/scene/export.test.ts | 17 +- packages/excalidraw/types.ts | 11 +- packages/excalidraw/utils.ts | 35 +- .../utils/__snapshots__/export.test.ts.snap | 3 +- packages/utils/export.ts | 3 + packages/utils/index.ts | 1 + packages/utils/package.json | 6 +- public/fonts/Cascadia.ttf | Bin 213476 -> 0 bytes public/fonts/Cascadia.woff2 | Bin 86812 -> 0 bytes public/fonts/FG_Virgil.ttf | Bin 236876 -> 0 bytes public/fonts/FG_Virgil.woff2 | Bin 119508 -> 0 bytes public/fonts/Virgil.woff2 | Bin 61248 -> 0 bytes public/fonts/fonts.css | 38 -- scripts/buildPackage.js | 14 +- scripts/buildUtils.js | 16 +- scripts/woff2/assets/NotoEmoji-Regular.ttf | Bin 0 -> 836652 bytes scripts/woff2/woff2-esbuild-plugins.js | 269 +++++++++++ scripts/woff2/woff2-vite-plugins.js | 46 ++ setupTests.ts | 60 +++ vitest.config.mts | 4 + yarn.lock | 75 +++ 120 files changed, 3390 insertions(+), 1106 deletions(-) create mode 100644 examples/excalidraw/with-script-in-browser/.gitignore create mode 100644 packages/excalidraw/components/ButtonIcon.scss create mode 100644 packages/excalidraw/components/ButtonIcon.tsx create mode 100644 packages/excalidraw/components/ButtonSeparator.tsx create mode 100644 packages/excalidraw/components/FontPicker/FontPicker.scss create mode 100644 packages/excalidraw/components/FontPicker/FontPicker.tsx create mode 100644 packages/excalidraw/components/FontPicker/FontPickerList.tsx create mode 100644 packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx create mode 100644 packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts create mode 100644 packages/excalidraw/components/PropertiesPopover.tsx create mode 100644 packages/excalidraw/components/QuickSearch.scss create mode 100644 packages/excalidraw/components/QuickSearch.tsx create mode 100644 packages/excalidraw/components/ScrollableList.scss create mode 100644 packages/excalidraw/components/ScrollableList.tsx create mode 100644 packages/excalidraw/fonts/ExcalidrawFont.ts rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-Bold.woff2 (100%) rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-Medium.woff2 (100%) rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-Regular.woff2 (100%) rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-SemiBold.woff2 (100%) create mode 100644 packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/Virgil-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/fonts.css create mode 100644 packages/excalidraw/fonts/index.ts create mode 100644 packages/excalidraw/fonts/metadata.ts delete mode 100644 packages/excalidraw/scene/Fonts.ts delete mode 100644 public/fonts/Cascadia.ttf delete mode 100644 public/fonts/Cascadia.woff2 delete mode 100644 public/fonts/FG_Virgil.ttf delete mode 100644 public/fonts/FG_Virgil.woff2 delete mode 100644 public/fonts/Virgil.woff2 delete mode 100644 public/fonts/fonts.css create mode 100644 scripts/woff2/assets/NotoEmoji-Regular.ttf create mode 100644 scripts/woff2/woff2-esbuild-plugins.js create mode 100644 scripts/woff2/woff2-vite-plugins.js diff --git a/dev-docs/src/css/custom.scss b/dev-docs/src/css/custom.scss index 93c7f90ab..0ab28c9bd 100644 --- a/dev-docs/src/css/custom.scss +++ b/dev-docs/src/css/custom.scss @@ -59,7 +59,7 @@ pre a { padding: 5px; background: #70b1ec; color: white; - font-weight: bold; + font-weight: 700; border: none; } diff --git a/examples/excalidraw/components/App.tsx b/examples/excalidraw/components/App.tsx index 3b553a453..7cfd8a05a 100644 --- a/examples/excalidraw/components/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -872,7 +872,7 @@ export default function App({ files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; - ctx.font = "30px Virgil"; + ctx.font = "30px Excalifont"; ctx.strokeText("My custom text", 50, 60); setCanvasUrl(canvas.toDataURL()); }} @@ -893,7 +893,7 @@ export default function App({ files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; - ctx.font = "30px Virgil"; + ctx.font = "30px Excalifont"; ctx.strokeText("My custom text", 50, 60); setCanvasUrl(canvas.toDataURL()); }} diff --git a/examples/excalidraw/initialData.tsx b/examples/excalidraw/initialData.tsx index 3cb5e7af4..0db23d5f2 100644 --- a/examples/excalidraw/initialData.tsx +++ b/examples/excalidraw/initialData.tsx @@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [ ]; export default { elements, - appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, + appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 }, scrollToContent: true, libraryItems: [ [ diff --git a/examples/excalidraw/with-nextjs/.gitignore b/examples/excalidraw/with-nextjs/.gitignore index fd3dbb571..2279431c5 100644 --- a/examples/excalidraw/with-nextjs/.gitignore +++ b/examples/excalidraw/with-nextjs/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# copied assets +public/*.woff2 \ No newline at end of file diff --git a/examples/excalidraw/with-nextjs/package.json b/examples/excalidraw/with-nextjs/package.json index 177952407..5b4590ac5 100644 --- a/examples/excalidraw/with-nextjs/package.json +++ b/examples/excalidraw/with-nextjs/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", "dev": "yarn build:workspace && next dev -p 3005", "build": "yarn build:workspace && next build", "start": "next start -p 3006", diff --git a/examples/excalidraw/with-nextjs/src/app/page.tsx b/examples/excalidraw/with-nextjs/src/app/page.tsx index bc8c98fcf..191aca120 100644 --- a/examples/excalidraw/with-nextjs/src/app/page.tsx +++ b/examples/excalidraw/with-nextjs/src/app/page.tsx @@ -1,4 +1,5 @@ import dynamic from "next/dynamic"; +import Script from "next/script"; import "../common.scss"; // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically @@ -15,7 +16,9 @@ export default function Page() { <> Switch to Pages router

App Router

- + {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} diff --git a/examples/excalidraw/with-nextjs/src/common.scss b/examples/excalidraw/with-nextjs/src/common.scss index 1a77600a9..456bc7635 100644 --- a/examples/excalidraw/with-nextjs/src/common.scss +++ b/examples/excalidraw/with-nextjs/src/common.scss @@ -7,7 +7,7 @@ a { color: #1c7ed6; font-size: 20px; text-decoration: none; - font-weight: 550; + font-weight: 500; } .page-title { diff --git a/examples/excalidraw/with-script-in-browser/.gitignore b/examples/excalidraw/with-script-in-browser/.gitignore new file mode 100644 index 000000000..215fc2008 --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/.gitignore @@ -0,0 +1,2 @@ +# copied assets +public/*.woff2 \ No newline at end of file diff --git a/examples/excalidraw/with-script-in-browser/index.html b/examples/excalidraw/with-script-in-browser/index.html index a56d7f421..8e29a1d8a 100644 --- a/examples/excalidraw/with-script-in-browser/index.html +++ b/examples/excalidraw/with-script-in-browser/index.html @@ -11,6 +11,7 @@ React App diff --git a/examples/excalidraw/with-script-in-browser/package.json b/examples/excalidraw/with-script-in-browser/package.json index d721ac162..e1c8ac37a 100644 --- a/examples/excalidraw/with-script-in-browser/package.json +++ b/examples/excalidraw/with-script-in-browser/package.json @@ -12,8 +12,10 @@ "typescript": "^5" }, "scripts": { - "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", - "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", + "start": "yarn build:workspace && vite", + "build": "yarn build:workspace && vite build", "build:preview": "yarn build && vite preview --port 5002" } } diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 2fd21f722..a2919e512 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -114,6 +114,14 @@ ) { window.location.href = "https://app.excalidraw.com"; } + + // point into our CDN in prod + window.EXCALIDRAW_ASSET_PATH = + "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/"; + + <% } else { %> + <% } %> @@ -124,22 +132,74 @@ + + + + + + <% if (typeof PROD != 'undefined' && PROD == true) { %> + + + + <% } else { %> + + + + <% } %> + + - + + + <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> <% } %> diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index f066cebc7..d0a30b6d9 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -36,7 +36,8 @@ "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", "start": "yarn && vite", - "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", + "start:production": "yarn build && yarn serve", + "serve": "npx http-server build -a localhost -p 5001 -o", "build:preview": "yarn build && vite preview --port 5000" } } diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap index 4e526a998..77fc14757 100644 --- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u class="welcome-screen-center" >
All your data is saved locally in your browser.
diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index 39417de36..ee1256263 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; import { createHtmlPlugin } from "vite-plugin-html"; +import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; // To load .env.local variables const envVars = loadEnv("", `../`); @@ -22,6 +23,14 @@ export default defineConfig({ outDir: "build", rollupOptions: { output: { + assetFileNames(chunkInfo) { + if (chunkInfo?.name?.endsWith(".woff2")) { + // put on root so we are flexible about the CDN path + return '[name]-[hash][extname]'; + } + + return 'assets/[name]-[hash][extname]'; + }, // Creating separate chunk for locales except for en and percentages.json so they // can be cached at runtime and not merged with // app precache. en.json and percentages.json are needed for first load @@ -35,12 +44,13 @@ export default defineConfig({ // Taking the substring after "locales/" return `locales/${id.substring(index + 8)}`; } - }, + } }, }, sourcemap: true, }, plugins: [ + woff2BrowserPlugin(), react(), checker({ typescript: true, diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index cb58d6ab6..c5e633ad6 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -19,6 +19,8 @@ Please add the latest change on the top under the correct section. - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) +- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`. + - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) diff --git a/packages/excalidraw/actions/actionProperties.test.tsx b/packages/excalidraw/actions/actionProperties.test.tsx index 2e1690107..a7c90e303 100644 --- a/packages/excalidraw/actions/actionProperties.test.tsx +++ b/packages/excalidraw/actions/actionProperties.test.tsx @@ -155,13 +155,15 @@ describe("element locking", () => { }); const text = API.createElement({ type: "text", - fontFamily: FONT_FAMILY.Cascadia, + fontFamily: FONT_FAMILY["Comic Shanns"], }); h.elements = [rect, text]; API.setSelectedElements([rect, text]); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); - expect(queryByTestId(document.body, `font-family-code`)).toBeChecked(); + expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( + "active", + ); }); }); }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index d48f78ba4..e0cc825c9 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,6 @@ +import { useEffect, useMemo, useRef, useState } from "react"; import type { AppClassProperties, AppState, Primitive } from "../types"; +import type { StoreActionType } from "../store"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, @@ -9,6 +11,7 @@ import { trackEvent } from "../analytics"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { IconPicker } from "../components/IconPicker"; +import { FontPicker } from "../components/FontPicker/FontPicker"; // TODO barnabasmolnar/editor-redesign // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, // ArrowHead icons @@ -38,9 +41,6 @@ import { FontSizeExtraLargeIcon, EdgeSharpIcon, EdgeRoundIcon, - FreedrawIcon, - FontFamilyNormalIcon, - FontFamilyCodeIcon, TextAlignLeftIcon, TextAlignCenterIcon, TextAlignRightIcon, @@ -65,10 +65,7 @@ import { redrawTextBoundingBox, } from "../element"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { - getBoundTextElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; import { isBoundToContainer, isLinearElement, @@ -94,9 +91,10 @@ import { isSomeElementSelected, } from "../scene"; import { hasStrokeColor } from "../scene/comparisons"; -import { arrayToMap, getShortcutKey } from "../utils"; +import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils"; import { register } from "./register"; import { StoreAction } from "../store"; +import { Fonts, getLineHeight } from "../fonts"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({ }, }); +type ChangeFontFamilyData = Partial< + Pick< + AppState, + "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily" + > +> & { + /** cache of selected & editing elements populated on opened popup */ + cachedElements?: Map; + /** flag to reset all elements to their cached versions */ + resetAll?: true; + /** flag to reset all containers to their cached versions */ + resetContainers?: true; +}; + export const actionChangeFontFamily = register({ name: "changeFontFamily", label: "labels.fontFamily", trackEvent: false, perform: (elements, appState, value, app) => { - return { - elements: changeProperty( + const { cachedElements, resetAll, resetContainers, ...nextAppState } = + value as ChangeFontFamilyData; + + if (resetAll) { + const nextElements = changeProperty( elements, appState, - (oldElement) => { - if (isTextElement(oldElement)) { - const newElement: ExcalidrawTextElement = newElementWith( - oldElement, - { - fontFamily: value, - lineHeight: getDefaultLineHeight(value), - }, - ); - redrawTextBoundingBox( - newElement, - app.scene.getContainerElement(oldElement), - app.scene.getNonDeletedElementsMap(), - ); + (element) => { + const cachedElement = cachedElements?.get(element.id); + if (cachedElement) { + const newElement = newElementWith(element, { + ...cachedElement, + }); + return newElement; } - return oldElement; + return element; }, true, - ), + ); + + return { + elements: nextElements, + appState: { + ...appState, + ...nextAppState, + }, + storeAction: StoreAction.UPDATE, + }; + } + + const { currentItemFontFamily, currentHoveredFontFamily } = value; + + let nexStoreAction: StoreActionType = StoreAction.NONE; + let nextFontFamily: FontFamilyValues | undefined; + let skipOnHoverRender = false; + + if (currentItemFontFamily) { + nextFontFamily = currentItemFontFamily; + nexStoreAction = StoreAction.CAPTURE; + } else if (currentHoveredFontFamily) { + nextFontFamily = currentHoveredFontFamily; + nexStoreAction = StoreAction.NONE; + + const selectedTextElements = getSelectedElements(elements, appState, { + includeBoundTextElement: true, + }).filter((element) => isTextElement(element)); + + // skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined + if (selectedTextElements.length > 200) { + skipOnHoverRender = true; + } else { + let i = 0; + let textLengthAccumulator = 0; + + while ( + i < selectedTextElements.length && + textLengthAccumulator < 5000 + ) { + const textElement = selectedTextElements[i] as ExcalidrawTextElement; + textLengthAccumulator += textElement?.originalText.length || 0; + i++; + } + + if (textLengthAccumulator > 5000) { + skipOnHoverRender = true; + } + } + } + + const result = { appState: { ...appState, - currentItemFontFamily: value, + ...nextAppState, }, - storeAction: StoreAction.CAPTURE, + storeAction: nexStoreAction, }; + + if (nextFontFamily && !skipOnHoverRender) { + const elementContainerMapping = new Map< + ExcalidrawTextElement, + ExcalidrawElement | null + >(); + let uniqueGlyphs = new Set(); + let skipFontFaceCheck = false; + + const fontsCache = Array.from(Fonts.loadedFontsCache.values()); + const fontFamily = Object.entries(FONT_FAMILY).find( + ([_, value]) => value === nextFontFamily, + )?.[0]; + + // skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine) + if ( + currentHoveredFontFamily && + fontFamily && + fontsCache.some((sig) => sig.startsWith(fontFamily)) + ) { + skipFontFaceCheck = true; + } + + // following causes re-render so make sure we changed the family + // otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg + Object.assign(result, { + elements: changeProperty( + elements, + appState, + (oldElement) => { + if ( + isTextElement(oldElement) && + (oldElement.fontFamily !== nextFontFamily || + currentItemFontFamily) // force update on selection + ) { + const newElement: ExcalidrawTextElement = newElementWith( + oldElement, + { + fontFamily: nextFontFamily, + lineHeight: getLineHeight(nextFontFamily!), + }, + ); + + const cachedContainer = + cachedElements?.get(oldElement.containerId || "") || {}; + + const container = app.scene.getContainerElement(oldElement); + + if (resetContainers && container && cachedContainer) { + // reset the container back to it's cached version + mutateElement(container, { ...cachedContainer }, false); + } + + if (!skipFontFaceCheck) { + uniqueGlyphs = new Set([ + ...uniqueGlyphs, + ...Array.from(newElement.originalText), + ]); + } + + elementContainerMapping.set(newElement, container); + + return newElement; + } + + return oldElement; + }, + true, + ), + }); + + // size is irrelevant, but necessary + const fontString = `10px ${getFontFamilyString({ + fontFamily: nextFontFamily, + })}`; + const glyphs = Array.from(uniqueGlyphs.values()).join(); + + if ( + skipFontFaceCheck || + window.document.fonts.check(fontString, glyphs) + ) { + // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded + for (const [element, container] of elementContainerMapping) { + // trigger synchronous redraw + redrawTextBoundingBox( + element, + container, + app.scene.getNonDeletedElementsMap(), + false, + ); + } + } else { + // otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded + window.document.fonts.load(fontString, glyphs).then((fontFaces) => { + for (const [element, container] of elementContainerMapping) { + // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts) + const latestElement = app.scene.getElement(element.id); + const latestContainer = container + ? app.scene.getElement(container.id) + : null; + + if (latestElement) { + // trigger async redraw + redrawTextBoundingBox( + latestElement as ExcalidrawTextElement, + latestContainer, + app.scene.getNonDeletedElementsMap(), + false, + ); + } + } + + // trigger update once we've mutated all the elements, which also updates our cache + app.fonts.onLoaded(fontFaces); + }); + } + } + + return result; }, - PanelComponent: ({ elements, appState, updateData, app }) => { - const options: { - value: FontFamilyValues; - text: string; - icon: JSX.Element; - testId: string; - }[] = [ - { - value: FONT_FAMILY.Virgil, - text: t("labels.handDrawn"), - icon: FreedrawIcon, - testId: "font-family-virgil", - }, - { - value: FONT_FAMILY.Helvetica, - text: t("labels.normal"), - icon: FontFamilyNormalIcon, - testId: "font-family-normal", - }, - { - value: FONT_FAMILY.Cascadia, - text: t("labels.code"), - icon: FontFamilyCodeIcon, - testId: "font-family-code", - }, - ]; + PanelComponent: ({ elements, appState, app, updateData }) => { + const cachedElementsRef = useRef>(new Map()); + const prevSelectedFontFamilyRef = useRef(null); + // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them + const [batchedData, setBatchedData] = useState({}); + const isUnmounted = useRef(true); + + const selectedFontFamily = useMemo(() => { + const getFontFamily = ( + elementsArray: readonly ExcalidrawElement[], + elementsMap: Map, + ) => + getFormValue( + elementsArray, + appState, + (element) => { + if (isTextElement(element)) { + return element.fontFamily; + } + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + return boundTextElement.fontFamily; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement(element, elementsMap) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, + ); + + // popup opened, use cached elements + if ( + batchedData.openPopup === "fontFamily" && + appState.openPopup === "fontFamily" + ) { + return getFontFamily( + Array.from(cachedElementsRef.current?.values() ?? []), + cachedElementsRef.current, + ); + } + + // popup closed, use all elements + if (!batchedData.openPopup && appState.openPopup !== "fontFamily") { + return getFontFamily(elements, app.scene.getNonDeletedElementsMap()); + } + + // popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had + return prevSelectedFontFamilyRef.current; + }, [batchedData.openPopup, appState, elements, app.scene]); + + useEffect(() => { + prevSelectedFontFamilyRef.current = selectedFontFamily; + }, [selectedFontFamily]); + + useEffect(() => { + if (Object.keys(batchedData).length) { + updateData(batchedData); + // reset the data after we've used the data + setBatchedData({}); + } + // call update only on internal state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [batchedData]); + + useEffect(() => { + isUnmounted.current = false; + + return () => { + isUnmounted.current = true; + }; + }, []); return (
{t("labels.fontFamily")} - - group="font-family" - options={options} - value={getFormValue( - elements, - appState, - (element) => { - if (isTextElement(element)) { - return element.fontFamily; + { + setBatchedData({ + openPopup: null, + currentHoveredFontFamily: null, + currentItemFontFamily: fontFamily, + }); + + // defensive clear so immediate close won't abuse the cached elements + cachedElementsRef.current.clear(); + }} + onHover={(fontFamily) => { + setBatchedData({ + currentHoveredFontFamily: fontFamily, + cachedElements: new Map(cachedElementsRef.current), + resetContainers: true, + }); + }} + onLeave={() => { + setBatchedData({ + currentHoveredFontFamily: null, + cachedElements: new Map(cachedElementsRef.current), + resetAll: true, + }); + }} + onPopupChange={(open) => { + if (open) { + // open, populate the cache from scratch + cachedElementsRef.current.clear(); + + const { editingElement } = appState; + + if (editingElement?.type === "text") { + // retrieve the latest version from the scene, as `editingElement` isn't mutated + const latestEditingElement = app.scene.getElement( + editingElement.id, + ); + + // inside the wysiwyg editor + cachedElementsRef.current.set( + editingElement.id, + newElementWith( + latestEditingElement || editingElement, + {}, + true, + ), + ); + } else { + const selectedElements = getSelectedElements( + elements, + appState, + { + includeBoundTextElement: true, + }, + ); + + for (const element of selectedElements) { + cachedElementsRef.current.set( + element.id, + newElementWith(element, {}, true), + ); + } } - const boundTextElement = getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ); - if (boundTextElement) { - return boundTextElement.fontFamily; + + setBatchedData({ + openPopup: "fontFamily", + }); + } else { + // close, use the cache and clear it afterwards + const data = { + openPopup: null, + currentHoveredFontFamily: null, + cachedElements: new Map(cachedElementsRef.current), + resetAll: true, + } as ChangeFontFamilyData; + + if (isUnmounted.current) { + // in case the component was unmounted by the parent, trigger the update directly + updateData({ ...batchedData, ...data }); + } else { + setBatchedData(data); } - return null; - }, - (element) => - isTextElement(element) || - getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ) !== null, - (hasSelection) => - hasSelection - ? null - : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, - )} - onChange={(value) => updateData(value)} + + cachedElementsRef.current.clear(); + } + }} />
); diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 9483476f8..1a17bf9de 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -12,10 +12,7 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, } from "../constants"; -import { - getBoundTextElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; import { hasBoundTextElement, canApplyRoundnessTypeToElement, @@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene"; import type { ExcalidrawTextElement } from "../element/types"; import { paintIcon } from "../components/icons"; import { StoreAction } from "../store"; +import { getLineHeight } from "../fonts"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -122,7 +120,7 @@ export const actionPasteStyles = register({ DEFAULT_TEXT_ALIGN, lineHeight: (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || - getDefaultLineHeight(fontFamily), + getLineHeight(fontFamily), }); let container = null; if (newElement.containerId) { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 677c0a077..2e490a908 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -36,6 +36,7 @@ export const getDefaultAppState = (): Omit< currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, + currentHoveredFontFamily: null, cursorButton: "up", activeEmbeddable: null, draggingElement: null, @@ -149,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false }, + currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false }, draggingElement: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index c49b4a5f0..2be642f79 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -158,10 +158,8 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontSize")} - {renderAction("changeFontFamily")} - + {renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d84a9febd..6f1ac7ffd 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -321,7 +321,6 @@ import { getBoundTextElement, getContainerCenter, getContainerElement, - getDefaultLineHeight, getLineHeightInPx, getMinTextElementWidth, isMeasureTextSupported, @@ -337,7 +336,7 @@ import { import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; -import { Fonts } from "../scene/Fonts"; +import { Fonts, getLineHeight } from "../fonts"; import { getFrameChildren, isCursorInFrame, @@ -532,8 +531,8 @@ class App extends React.Component { private excalidrawContainerRef = React.createRef(); public scene: Scene; + public fonts: Fonts; public renderer: Renderer; - private fonts: Fonts; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -2335,11 +2334,6 @@ class App extends React.Component { }), }; } - // FontFaceSet loadingdone event we listen on may not always fire - // (looking at you Safari), so on init we manually load fonts for current - // text elements on canvas, and rerender them once done. This also - // seems faster even in browsers that do fire the loadingdone event. - this.fonts.loadFontsForElements(scene.elements); this.resetStore(); this.resetHistory(); @@ -2347,6 +2341,12 @@ class App extends React.Component { ...scene, storeAction: StoreAction.UPDATE, }); + + // FontFaceSet loadingdone event we listen on may not always + // fire (looking at you Safari), so on init we manually load all + // fonts and rerender scene text elements once done. This also + // seems faster even in browsers that do fire the loadingdone event. + this.fonts.load(); }; private isMobileBreakpoint = (width: number, height: number) => { @@ -2439,6 +2439,10 @@ class App extends React.Component { configurable: true, value: this.store, }, + fonts: { + configurable: true, + value: this.fonts, + }, }); } @@ -2576,7 +2580,7 @@ class App extends React.Component { // rerender text elements on font load to fix #637 && #1553 addEventListener(document.fonts, "loadingdone", (event) => { const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onFontsLoaded(loadedFontFaces); + this.fonts.onLoaded(loadedFontFaces); }), // Safari-only desktop pinch zoom addEventListener( @@ -3379,7 +3383,7 @@ class App extends React.Component { fontSize: textElementProps.fontSize, fontFamily: textElementProps.fontFamily, }); - const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); + const lineHeight = getLineHeight(textElementProps.fontFamily); const [x1, , x2] = getVisibleSceneBounds(this.state); // long texts should not go beyond 800 pixels in width nor should it go below 200 px const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200); @@ -3397,13 +3401,13 @@ class App extends React.Component { }); let metrics = measureText(originalText, fontString, lineHeight); - const isTextWrapped = metrics.width > maxTextWidth; + const isTextUnwrapped = metrics.width > maxTextWidth; - const text = isTextWrapped + const text = isTextUnwrapped ? wrapText(originalText, fontString, maxTextWidth) : originalText; - metrics = isTextWrapped + metrics = isTextUnwrapped ? measureText(text, fontString, lineHeight) : metrics; @@ -3417,7 +3421,7 @@ class App extends React.Component { text, originalText, lineHeight, - autoResize: !isTextWrapped, + autoResize: !isTextUnwrapped, frameId: topLayerFrame ? topLayerFrame.id : null, }); acc.push(element); @@ -4107,6 +4111,36 @@ class App extends React.Component { } } + if ( + !event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + event.key.toLowerCase() === KEYS.F + ) { + const selectedElements = this.scene.getSelectedElements(this.state); + + if ( + this.state.activeTool.type === "selection" && + !selectedElements.length + ) { + return; + } + + if ( + this.state.activeTool.type === "text" || + selectedElements.find( + (element) => + isTextElement(element) || + getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ), + ) + ) { + event.preventDefault(); + this.setState({ openPopup: "fontFamily" }); + } + } + if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (this.state.activeTool.type === "laser") { this.setActiveTool({ type: "selection" }); @@ -4761,7 +4795,7 @@ class App extends React.Component { existingTextElement?.fontFamily || this.state.currentItemFontFamily; const lineHeight = - existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); + existingTextElement?.lineHeight || getLineHeight(fontFamily); const fontSize = this.state.currentItemFontSize; if ( diff --git a/packages/excalidraw/components/ButtonIcon.scss b/packages/excalidraw/components/ButtonIcon.scss new file mode 100644 index 000000000..e435b69e4 --- /dev/null +++ b/packages/excalidraw/components/ButtonIcon.scss @@ -0,0 +1,12 @@ +@import "../css/theme"; + +.excalidraw { + button.standalone { + @include outlineButtonIconStyles; + + & > * { + // dissalow pointer events on children, so we always have event.target on the button itself + pointer-events: none; + } + } +} diff --git a/packages/excalidraw/components/ButtonIcon.tsx b/packages/excalidraw/components/ButtonIcon.tsx new file mode 100644 index 000000000..5421f4c3a --- /dev/null +++ b/packages/excalidraw/components/ButtonIcon.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; +import clsx from "clsx"; + +import "./ButtonIcon.scss"; + +interface ButtonIconProps { + icon: JSX.Element; + title: string; + className?: string; + testId?: string; + /** if not supplied, defaults to value identity check */ + active?: boolean; + /** include standalone style (could interfere with parent styles) */ + standalone?: boolean; + onClick: (event: React.MouseEvent) => void; +} + +export const ButtonIcon = forwardRef( + (props, ref) => { + const { title, className, testId, active, standalone, icon, onClick } = + props; + return ( + + ); + }, +); diff --git a/packages/excalidraw/components/ButtonIconSelect.tsx b/packages/excalidraw/components/ButtonIconSelect.tsx index 6933f0304..c3a390257 100644 --- a/packages/excalidraw/components/ButtonIconSelect.tsx +++ b/packages/excalidraw/components/ButtonIconSelect.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import { ButtonIcon } from "./ButtonIcon"; // TODO: It might be "clever" to add option.icon to the existing component export const ButtonIconSelect = ( @@ -24,21 +25,17 @@ export const ButtonIconSelect = ( } ), ) => ( -
+
{props.options.map((option) => props.type === "button" ? ( - + testId={option.testId} + active={option.active ?? props.value === option.value} + onClick={(event) => props.onClick(option.value, event)} + /> ) : (