diff --git a/.env.development b/.env.development index 95e21ff87..85eb32533 100644 --- a/.env.development +++ b/.env.development @@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW= # whether to disable live reload / HMR. Usuaully what you want to do when # debugging Service Workers. VITE_APP_DEV_DISABLE_LIVE_RELOAD= -VITE_APP_DISABLE_TRACKING=true +VITE_APP_ENABLE_TRACKING=true FAST_REFRESH=false diff --git a/.env.production b/.env.production index 0c715854a..64e696847 100644 --- a/.env.production +++ b/.env.production @@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' -VITE_APP_DISABLE_TRACKING= +VITE_APP_ENABLE_TRACKING=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c458a810..ac66fe1ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,17 @@ name: Tests -on: pull_request +on: + pull_request: + push: + branches: master jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 18.x - name: Install and test diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index f479ccaa1..f68b4bd11 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces ```jsx showLineNumbers export default function App() { const [excalidrawAPI, setExcalidrawAPI] = useState(null); - return setExcalidrawAPI(api)}} />; + return setExcalidrawAPI(api)} />; } ``` diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx index ef59054c4..5c075de86 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx @@ -90,7 +90,7 @@ function App() {
- setExcalidrawAPI(api)} + setExcalidrawAPI(api)} />
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/App.tsx b/excalidraw-app/App.tsx index b9ee083f3..becfc60e2 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -1,5 +1,4 @@ import polyfill from "../packages/excalidraw/polyfill"; -import LanguageDetector from "i18next-browser-languagedetector"; import { useCallback, useEffect, useRef, useState } from "react"; import { trackEvent } from "../packages/excalidraw/analytics"; import { getDefaultAppState } from "../packages/excalidraw/appState"; @@ -23,7 +22,6 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef import { t } from "../packages/excalidraw/i18n"; import { Excalidraw, - defaultLang, LiveCollaborationTrigger, TTDDialog, TTDDialogTrigger, @@ -94,7 +92,7 @@ import { import { AppMainMenu } from "./components/AppMainMenu"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppFooter } from "./components/AppFooter"; -import { atom, Provider, useAtom, useAtomValue } from "jotai"; +import { Provider, useAtom, useAtomValue } from "jotai"; import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; import { appJotaiStore } from "./app-jotai"; @@ -122,11 +120,45 @@ import { youtubeIcon, } from "../packages/excalidraw/components/icons"; import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; +import { getPreferredLanguage } from "./app-language/language-detector"; +import { useAppLangCode } from "./app-language/language-state"; polyfill(); window.EXCALIDRAW_THROTTLE_RENDER = true; +declare global { + interface BeforeInstallPromptEventChoiceResult { + outcome: "accepted" | "dismissed"; + } + + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise; + } + + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +} + +let pwaEvent: BeforeInstallPromptEvent | null = null; + +// Adding a listener outside of the component as it may (?) need to be +// subscribed early to catch the event. +// +// Also note that it will fire only if certain heuristics are met (user has +// used the app for some time, etc.) +window.addEventListener( + "beforeinstallprompt", + (event: BeforeInstallPromptEvent) => { + // prevent Chrome <= 67 from automatically showing the prompt + event.preventDefault(); + // cache for later use + pwaEvent = event; + }, +); + let isSelfEmbedding = false; if (window.self !== window.top) { @@ -141,11 +173,6 @@ if (window.self !== window.top) { } } -const languageDetector = new LanguageDetector(); -languageDetector.init({ - languageUtils: {}, -}); - const shareableLinkConfirmDialog = { title: t("overwriteConfirm.modal.shareableLink.title"), description: ( @@ -291,19 +318,15 @@ const initializeScene = async (opts: { return { scene: null, isExternalScene: false }; }; -const detectedLangCode = languageDetector.detect() || defaultLang.code; -export const appLangCodeAtom = atom( - Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode, -); - const ExcalidrawWrapper = () => { const [errorMessage, setErrorMessage] = useState(""); - const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); const [appTheme, setAppTheme] = useAtom(appThemeAtom); const { editorTheme } = useHandleAppTheme(); + const [langCode, setLangCode] = useAppLangCode(); + // initial state // --------------------------------------------------------------------------- @@ -461,11 +484,7 @@ const ExcalidrawWrapper = () => { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { const localDataState = importFromLocalStorage(); const username = importUsernameFromLocalStorage(); - let langCode = languageDetector.detect() || defaultLang.code; - if (Array.isArray(langCode)) { - langCode = langCode[0]; - } - setLangCode(langCode); + setLangCode(getPreferredLanguage()); excalidrawAPI.updateScene({ ...localDataState, storeAction: StoreAction.UPDATE, @@ -566,10 +585,6 @@ const ExcalidrawWrapper = () => { }; }, [excalidrawAPI]); - useEffect(() => { - languageDetector.cacheUserLanguage(langCode); - }, [langCode]); - const onChange = ( elements: readonly OrderedExcalidrawElement[], appState: AppState, @@ -1103,6 +1118,21 @@ const ExcalidrawWrapper = () => { ); }, }, + { + label: t("labels.installPWA"), + category: DEFAULT_CATEGORIES.app, + predicate: () => !!pwaEvent, + perform: () => { + if (pwaEvent) { + pwaEvent.prompt(); + pwaEvent.userChoice.then(() => { + // event cannot be reused, but we'll hopefully + // grab new one as the event should be fired again + pwaEvent = null; + }); + } + }, + }, ]} />
diff --git a/excalidraw-app/components/LanguageList.tsx b/excalidraw-app/app-language/LanguageList.tsx similarity index 79% rename from excalidraw-app/components/LanguageList.tsx rename to excalidraw-app/app-language/LanguageList.tsx index 8370d2f3e..08142b1f6 100644 --- a/excalidraw-app/components/LanguageList.tsx +++ b/excalidraw-app/app-language/LanguageList.tsx @@ -1,8 +1,7 @@ import { useSetAtom } from "jotai"; import React from "react"; -import { appLangCodeAtom } from "../App"; -import { useI18n } from "../../packages/excalidraw/i18n"; -import { languages } from "../../packages/excalidraw/i18n"; +import { useI18n, languages } from "../../packages/excalidraw/i18n"; +import { appLangCodeAtom } from "./language-state"; export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { const { t, langCode } = useI18n(); diff --git a/excalidraw-app/app-language/language-detector.ts b/excalidraw-app/app-language/language-detector.ts new file mode 100644 index 000000000..76dd1149c --- /dev/null +++ b/excalidraw-app/app-language/language-detector.ts @@ -0,0 +1,25 @@ +import LanguageDetector from "i18next-browser-languagedetector"; +import { defaultLang, languages } from "../../packages/excalidraw"; + +export const languageDetector = new LanguageDetector(); + +languageDetector.init({ + languageUtils: {}, +}); + +export const getPreferredLanguage = () => { + const detectedLanguages = languageDetector.detect(); + + const detectedLanguage = Array.isArray(detectedLanguages) + ? detectedLanguages[0] + : detectedLanguages; + + const initialLanguage = + (detectedLanguage + ? // region code may not be defined if user uses generic preferred language + // (e.g. chinese vs instead of chinese-simplified) + languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code + : null) || defaultLang.code; + + return initialLanguage; +}; diff --git a/excalidraw-app/app-language/language-state.ts b/excalidraw-app/app-language/language-state.ts new file mode 100644 index 000000000..5198a8ea8 --- /dev/null +++ b/excalidraw-app/app-language/language-state.ts @@ -0,0 +1,15 @@ +import { atom, useAtom } from "jotai"; +import { useEffect } from "react"; +import { getPreferredLanguage, languageDetector } from "./language-detector"; + +export const appLangCodeAtom = atom(getPreferredLanguage()); + +export const useAppLangCode = () => { + const [langCode, setLangCode] = useAtom(appLangCodeAtom); + + useEffect(() => { + languageDetector.cacheUserLanguage(langCode); + }, [langCode]); + + return [langCode, setLangCode] as const; +}; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 03c789b40..eb3f24caf 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -6,7 +6,7 @@ import { import type { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; -import { LanguageList } from "./LanguageList"; +import { LanguageList } from "../app-language/LanguageList"; export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; @@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{ diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index db5bd6457..a2919e512 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -20,7 +20,7 @@ name="description" content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." /> - + @@ -35,7 +35,7 @@ property="og:description" content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." /> - + @@ -51,7 +51,7 @@ /> @@ -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/index.scss b/excalidraw-app/index.scss index 3ca538870..cfaaf9cea 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -25,6 +25,7 @@ margin-bottom: auto; margin-inline-start: auto; margin-inline-end: 0.6em; + z-index: var(--zIndex-layerUI); svg { width: 1.2rem; diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index 8b82d01ad..d0a30b6d9 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -31,12 +31,13 @@ "prettier": "@excalidraw/prettier-config", "scripts": { "build-node": "node ./scripts/build-node.js", - "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", - "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", + "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build", + "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build", "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", "start": "yarn && vite", - "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", + "start:production": "yarn build && yarn serve", + "serve": "npx http-server build -a localhost -p 5001 -o", "build:preview": "yarn build && vite preview --port 5000" } } 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/package.json b/package.json index 31680edf5..ec326b4c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "private": true, "name": "excalidraw-monorepo", + "packageManager": "yarn@1.22.22", "workspaces": [ "excalidraw-app", "packages/excalidraw", diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 6577d04e0..8c61a9c7f 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section. ### Features +- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135) + - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) +- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`. + - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 1218e4df2..a0952abb7 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -1,8 +1,8 @@ import { BOUND_TEXT_PADDING, ROUNDNESS, - VERTICAL_ALIGN, TEXT_ALIGN, + VERTICAL_ALIGN, } from "../constants"; import { isTextElement, newElement } from "../element"; import { mutateElement } from "../element/mutateElement"; @@ -140,6 +140,7 @@ export const actionBindText = register({ containerId: container.id, verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, + autoResize: true, }); mutateElement(container, { boundElements: (container.boundElements || []).concat({ @@ -294,6 +295,7 @@ export const actionWrapTextInContainer = register({ verticalAlign: VERTICAL_ALIGN.MIDDLE, boundElements: null, textAlign: TEXT_ALIGN.CENTER, + autoResize: true, }, false, ); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index e78cdc708..32c2f728d 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -104,7 +104,7 @@ export const actionClearCanvas = register({ exportBackground: appState.exportBackground, exportEmbedScene: appState.exportEmbedScene, gridSize: appState.gridSize, - showStats: appState.showStats, + stats: appState.stats, pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9661154f7..15956b3a3 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -131,7 +131,12 @@ export const actionFinalize = register({ -1, arrayToMap(elements), ); - maybeBindLinearElement(multiPointElement, appState, { x, y }, app); + maybeBindLinearElement( + multiPointElement, + appState, + { x, y }, + elementsMap, + ); } } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 0aab7f903..3f521d27f 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -124,7 +124,7 @@ const flipElements = ( bindOrUnbindLinearElements( selectedElements.filter(isLinearElement), - app, + elementsMap, isBindingEnabled(appState), [], ); diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 7b4a67f28..8e2d10454 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -65,7 +65,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({ PanelComponent: ({ updateData, data }) => { const { isUndoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, - new HistoryChangedEvent(), + new HistoryChangedEvent( + history.isUndoStackEmpty, + history.isRedoStackEmpty, + ), ); return ( @@ -76,6 +79,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({ onClick={updateData} size={data?.size || "medium"} disabled={isUndoStackEmpty} + data-testid="button-undo" /> ); }, @@ -103,7 +107,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({ PanelComponent: ({ updateData, data }) => { const { isRedoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, - new HistoryChangedEvent(), + new HistoryChangedEvent( + history.isUndoStackEmpty, + history.isRedoStackEmpty, + ), ); return ( @@ -114,6 +121,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({ onClick={updateData} size={data?.size || "medium"} disabled={isRedoStackEmpty} + data-testid="button-redo" /> ); }, 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 b26e12de0..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; @@ -167,7 +165,7 @@ const offsetElementAfterFontResize = ( prevElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement, ) => { - if (isBoundToContainer(nextElement)) { + if (isBoundToContainer(nextElement) || !nextElement.autoResize) { return nextElement; } return mutateElement( @@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({ }, }); +type ChangeFontFamilyData = Partial< + Pick< + AppState, + "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily" + > +> & { + /** cache of selected & editing elements populated on opened popup */ + cachedElements?: Map; + /** 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/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts new file mode 100644 index 000000000..3093f3090 --- /dev/null +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -0,0 +1,48 @@ +import { isTextElement } from "../element"; +import { newElementWith } from "../element/mutateElement"; +import { measureText } from "../element/textElement"; +import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; +import type { AppClassProperties } from "../types"; +import { getFontString } from "../utils"; +import { register } from "./register"; + +export const actionTextAutoResize = register({ + name: "autoResize", + label: "labels.autoResize", + icon: null, + trackEvent: { category: "element" }, + predicate: (elements, appState, _: unknown, app: AppClassProperties) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + !selectedElements[0].autoResize + ); + }, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return { + appState, + elements: elements.map((element) => { + if (element.id === selectedElements[0].id && isTextElement(element)) { + const metrics = measureText( + element.originalText, + getFontString(element), + element.lineHeight, + ); + + return newElementWith(element, { + autoResize: true, + width: metrics.width, + height: metrics.height, + text: element.originalText, + }); + } + return element; + }), + storeAction: StoreAction.CAPTURE, + }; + }, +}); diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index fc1e70a47..45402e8ad 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -5,21 +5,22 @@ import { StoreAction } from "../store"; export const actionToggleStats = register({ name: "stats", - label: "stats.title", + label: "stats.fullTitle", icon: abacusIcon, paletteName: "Toggle stats", viewMode: true, trackEvent: { category: "menu" }, + keywords: ["edit", "attributes", "customize"], perform(elements, appState) { return { appState: { ...appState, - showStats: !this.checked!(appState), + stats: { ...appState.stats, open: !this.checked!(appState) }, }, storeAction: StoreAction.NONE, }; }, - checked: (appState) => appState.showStats, + checked: (appState) => appState.stats.open, keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH, }); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index b5e8f3d42..f5ba715ba 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -148,7 +148,9 @@ export type ActionName = | "setEmbeddableAsActiveTool" | "createContainerFromText" | "wrapTextInContainer" - | "commandPalette"; + | "commandPalette" + | "autoResize" + | "elementStats"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/analytics.ts b/packages/excalidraw/analytics.ts index bd4b6191e..600d962b4 100644 --- a/packages/excalidraw/analytics.ts +++ b/packages/excalidraw/analytics.ts @@ -1,6 +1,6 @@ // place here categories that you want to track. We want to track just a // small subset of categories at a given time. -const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[]; +const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]); export const trackEvent = ( category: string, @@ -9,17 +9,20 @@ export const trackEvent = ( value?: number, ) => { try { - // prettier-ignore if ( - typeof window === "undefined" - || import.meta.env.VITE_WORKER_ID - // comment out to debug locally - || import.meta.env.PROD + typeof window === "undefined" || + import.meta.env.VITE_WORKER_ID || + import.meta.env.VITE_APP_ENABLE_TRACKING !== "true" ) { return; } - if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) { + if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) { + return; + } + + if (import.meta.env.DEV) { + // comment out to debug in dev return; } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index ee84554ad..0d3eaff50 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -5,6 +5,7 @@ import { DEFAULT_FONT_SIZE, DEFAULT_TEXT_ALIGN, EXPORT_SCALES, + STATS_PANELS, THEME, } from "./constants"; import type { AppState, NormalizedZoomValue } from "./types"; @@ -35,6 +36,7 @@ export const getDefaultAppState = (): Omit< currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, + currentHoveredFontFamily: null, cursorButton: "up", activeEmbeddable: null, draggingElement: null, @@ -80,7 +82,10 @@ export const getDefaultAppState = (): Omit< selectedElementsAreBeingDragged: false, selectionElement: null, shouldCacheIgnoreZoom: false, - showStats: false, + stats: { + open: false, + panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, + }, startBoundElement: null, suggestedBindings: [], frameRendering: { enabled: true, clip: true, name: true, outline: true }, @@ -145,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false }, + currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false }, draggingElement: { browser: false, export: false, server: false }, @@ -198,7 +204,7 @@ const APP_STATE_STORAGE_CONF = (< }, selectionElement: { browser: false, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, - showStats: { browser: true, export: false, server: false }, + stats: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts index 8774cfdb9..884235e80 100644 --- a/packages/excalidraw/change.ts +++ b/packages/excalidraw/change.ts @@ -1477,19 +1477,28 @@ export class ElementsChange implements Change { return elements; } - const previous = Array.from(elements.values()); - const reordered = orderByFractionalIndex([...previous]); + const unordered = Array.from(elements.values()); + const ordered = orderByFractionalIndex([...unordered]); + const moved = Delta.getRightDifferences(unordered, ordered, true).reduce( + (acc, arrayIndex) => { + const candidate = unordered[Number(arrayIndex)]; + if (candidate && changed.has(candidate.id)) { + acc.set(candidate.id, candidate); + } - if ( - !flags.containsVisibleDifference && - Delta.isRightDifferent(previous, reordered, true) - ) { + return acc; + }, + new Map(), + ); + + if (!flags.containsVisibleDifference && moved.size) { // we found a difference in order! flags.containsVisibleDifference = true; } - // let's synchronize all invalid indices of moved elements - return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements; + // synchronize all elements that were actually moved + // could fallback to synchronizing all invalid indices + return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements; } /** diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 786668f1d..d8822afef 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -160,10 +160,8 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontSize")} - {renderAction("changeFontFamily")} - + {renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} @@ -470,6 +468,7 @@ export const ExitZenModeAction = ({ showExitZenModeBtn: boolean; }) => ( + ); + }, +); diff --git a/packages/excalidraw/components/ButtonIconSelect.tsx b/packages/excalidraw/components/ButtonIconSelect.tsx index eec8870a9..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,20 +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)} + /> ) : (
) : ( -
+
maxAvatars - 1 && ( - { - if (!isOpen) { - setSearchTerm(""); - } - }} - > + +{uniqueCollaboratorsArray.length - maxAvatars + 1} @@ -224,41 +215,43 @@ export const UserList = React.memo( align="end" sideOffset={10} > - + {uniqueCollaboratorsArray.length >= SHOW_COLLABORATORS_FILTER_AT && ( -
- {searchIcon} - { - setSearchTerm(e.target.value); - }} - /> -
+ )} -
- {filteredCollaborators.length === 0 && ( -
- {t("userList.search.empty")} -
- )} -
- {t("userList.hint.text")} -
- {filteredCollaborators.map((collaborator) => - renderCollaborator({ - actionManager, - collaborator, - socketId: collaborator.socketId, - withName: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), - )} -
+ + {/* The list checks for `Children.count()`, hence defensively returning empty list */} + {filteredCollaborators.length > 0 + ? [ +
{t("userList.hint.text")}
, + filteredCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + withName: true, + isBeingFollowed: + collaborator.socketId === userToFollow, + }), + ), + ] + : []} +
+
diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index c8eb799a5..2c14a6ab3 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -9,7 +9,10 @@ import type { RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; -import type { NonDeletedExcalidrawElement } from "../../element/types"; +import type { + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; import { renderInteractiveScene } from "../../renderer/interactiveScene"; @@ -19,7 +22,8 @@ type InteractiveCanvasProps = { elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; - versionNonce: number | undefined; + allElementsMap: NonDeletedSceneElementsMap; + sceneNonce: number | undefined; selectionNonce: number | undefined; scale: number; appState: InteractiveCanvasAppState; @@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { elementsMap: props.elementsMap, visibleElements: props.visibleElements, selectedElements: props.selectedElements, + allElementsMap: props.allElementsMap, scale: window.devicePixelRatio, appState: props.appState, renderConfig: { @@ -197,6 +202,7 @@ const getRelevantAppStateProps = ( activeEmbeddable: appState.activeEmbeddable, snapLines: appState.snapLines, zenModeEnabled: appState.zenModeEnabled, + editingElement: appState.editingElement, }); const areEqual = ( @@ -206,10 +212,10 @@ const areEqual = ( // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation if ( prevProps.selectionNonce !== nextProps.selectionNonce || - prevProps.versionNonce !== nextProps.versionNonce || + prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.scale !== nextProps.scale || // we need to memoize on elementsMap because they may have renewed - // even if versionNonce didn't change (e.g. we filter elements out based + // even if sceneNonce didn't change (e.g. we filter elements out based // on appState) prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements || diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index ef54bf33f..7577ee56a 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -19,7 +19,7 @@ type StaticCanvasProps = { elementsMap: RenderableElementsMap; allElementsMap: NonDeletedSceneElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; - versionNonce: number | undefined; + sceneNonce: number | undefined; selectionNonce: number | undefined; scale: number; appState: StaticCanvasAppState; @@ -105,6 +105,7 @@ const getRelevantAppStateProps = ( selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight, editingGroupId: appState.editingGroupId, + currentHoveredFontFamily: appState.currentHoveredFontFamily, }); const areEqual = ( @@ -112,10 +113,10 @@ const areEqual = ( nextProps: StaticCanvasProps, ) => { if ( - prevProps.versionNonce !== nextProps.versionNonce || + prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.scale !== nextProps.scale || // we need to memoize on elementsMap because they may have renewed - // even if versionNonce didn't change (e.g. we filter elements out based + // even if sceneNonce didn't change (e.g. we filter elements out based // on appState) prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index 1f39c7828..e48f6d71e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -4,7 +4,7 @@ .dropdown-menu { position: absolute; top: 100%; - margin-top: 0.25rem; + margin-top: 0.5rem; &--mobile { left: 0; @@ -35,21 +35,69 @@ .dropdown-menu-item-base { display: flex; - padding: 0 0.625rem; column-gap: 0.625rem; font-size: 0.875rem; color: var(--color-on-surface); width: 100%; box-sizing: border-box; - font-weight: normal; + font-weight: 400; font-family: inherit; } + &.manual-hover { + // disable built-in hover due to keyboard navigation + .dropdown-menu-item { + &:hover { + background-color: transparent; + } + + &--hovered { + background-color: var(--button-hover-bg) !important; + } + + &--selected { + background-color: var(--color-primary-light) !important; + } + } + } + + &.fonts { + margin-top: 1rem; + // display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom + // count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top + max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem); + + @media screen and (min-width: 1921px) { + max-height: calc( + 7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem + ); + } + + .dropdown-menu-item-base { + display: inline-flex; + } + + .dropdown-menu-group:not(:first-child) { + margin-top: 1rem; + } + + .dropdown-menu-group-title { + font-size: 0.75rem; + text-align: left; + font-weight: 400; + margin: 0 0 0.5rem; + line-height: 1.3; + } + } + .dropdown-menu-item { + height: 2rem; + margin: 1px; + padding: 0 0.5rem; + width: calc(100% - 2px); background-color: transparent; border: 1px solid transparent; align-items: center; - height: 2rem; cursor: pointer; border-radius: var(--border-radius-md); @@ -57,11 +105,6 @@ height: 2.25rem; } - &--selected { - background: var(--color-primary-light); - --icon-fill-color: var(--color-primary-darker); - } - &__text { display: flex; align-items: center; @@ -83,6 +126,11 @@ } } + &--selected { + background: var(--color-primary-light); + --icon-fill-color: var(--color-primary-darker); + } + &:hover { background-color: var(--button-hover-bg); text-decoration: none; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx index 65dba2287..e1f46b697 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx @@ -1,37 +1,62 @@ -import React from "react"; +import React, { useEffect, useRef } from "react"; import { getDropdownMenuItemClassName, useHandleDropdownMenuItemClick, } from "./common"; import MenuItemContent from "./DropdownMenuItemContent"; +import { useExcalidrawAppState } from "../App"; +import { THEME } from "../../constants"; +import type { ValueOf } from "../../utility-types"; const DropdownMenuItem = ({ icon, - onSelect, + value, + order, children, shortcut, className, + hovered, selected, + textStyle, + onSelect, + onClick, ...rest }: { icon?: JSX.Element; - onSelect: (event: Event) => void; + value?: string | number | undefined; + order?: number; + onSelect?: (event: Event) => void; children: React.ReactNode; shortcut?: string; + hovered?: boolean; selected?: boolean; + textStyle?: React.CSSProperties; className?: string; } & Omit, "onSelect">) => { - const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); + const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); + const ref = useRef(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 ( @@ -39,24 +64,53 @@ const DropdownMenuItem = ({ }; DropdownMenuItem.displayName = "DropdownMenuItem"; +export const DropDownMenuItemBadgeType = { + GREEN: "green", + RED: "red", + BLUE: "blue", +} as const; + export const DropDownMenuItemBadge = ({ + type = DropDownMenuItemBadgeType.BLUE, children, }: { + type?: ValueOf; children: React.ReactNode; }) => { - return ( -
+ }); + } + + return ( +
{children}
); diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx index 12247a2de..444b13707 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx @@ -1,19 +1,23 @@ import { useDevice } from "../App"; const MenuItemContent = ({ + textStyle, icon, shortcut, children, }: { icon?: JSX.Element; shortcut?: string; + textStyle?: React.CSSProperties; children: React.ReactNode; }) => { const device = useDevice(); return ( <> -
{icon}
-
{children}
+ {icon &&
{icon}
} +
+ {children} +
{shortcut && !device.editor.isMobile && (
{shortcut}
)} diff --git a/packages/excalidraw/components/dropdownMenu/common.ts b/packages/excalidraw/components/dropdownMenu/common.ts index c59584584..a2a46fc93 100644 --- a/packages/excalidraw/components/dropdownMenu/common.ts +++ b/packages/excalidraw/components/dropdownMenu/common.ts @@ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{ export const getDropdownMenuItemClassName = ( className = "", selected = false, + hovered = false, ) => { - return `dropdown-menu-item dropdown-menu-item-base ${className} ${ - selected ? "dropdown-menu-item--selected" : "" + return `dropdown-menu-item dropdown-menu-item-base ${className} + ${selected ? "dropdown-menu-item--selected" : ""} ${ + hovered ? "dropdown-menu-item--hovered" : "" }`.trim(); }; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 5b19a74a4..0cc0d3d5f 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -698,14 +698,18 @@ export const BringForwardIcon = createIcon(arrownNarrowUpJSX, tablerIconProps); export const SendBackwardIcon = createIcon(arrownNarrowUpJSX, { ...tablerIconProps, - transform: "rotate(180)", + style: { + transform: "rotate(180deg)", + }, }); export const BringToFrontIcon = createIcon(arrowBarToTopJSX, tablerIconProps); export const SendToBackIcon = createIcon(arrowBarToTopJSX, { ...tablerIconProps, - transform: "rotate(180)", + style: { + transform: "rotate(180deg)", + }, }); // @@ -1434,6 +1438,27 @@ export const fontSizeIcon = createIcon( tablerIconProps, ); +export const FontFamilyHeadingIcon = createIcon( + <> + + + + + + + + + + + , + tablerIconProps, +); + export const FontFamilyNormalIcon = createIcon( <> ), ); +export const angleIcon = createIcon( + + + + + + + + , + tablerIconProps, +); + export const publishIcon = createIcon( , tablerIconProps, ); + +export const collapseDownIcon = createIcon( + + + + , + tablerIconProps, +); + +export const collapseUpIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx b/packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx index 711cc30cc..edc6618e9 100644 --- a/packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx +++ b/packages/excalidraw/components/welcome-screen/WelcomeScreen.Center.tsx @@ -109,7 +109,7 @@ Center.displayName = "Center"; const Logo = ({ children }: { children?: React.ReactNode }) => { return ( -
+
{children || }
); @@ -118,7 +118,7 @@ Logo.displayName = "Logo"; const Heading = ({ children }: { children: React.ReactNode }) => { return ( -
+
{children}
); diff --git a/packages/excalidraw/components/welcome-screen/WelcomeScreen.Hints.tsx b/packages/excalidraw/components/welcome-screen/WelcomeScreen.Hints.tsx index ccd42ed27..896f4014e 100644 --- a/packages/excalidraw/components/welcome-screen/WelcomeScreen.Hints.tsx +++ b/packages/excalidraw/components/welcome-screen/WelcomeScreen.Hints.tsx @@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => { const { WelcomeScreenMenuHintTunnel } = useTunnels(); return ( -
+
{WelcomeScreenMenuArrow}
{children || t("welcomeScreen.defaults.menuHint")} @@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => { const { WelcomeScreenToolbarHintTunnel } = useTunnels(); return ( -
+
{children || t("welcomeScreen.defaults.toolbarHint")}
@@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => { const { WelcomeScreenHelpHintTunnel } = useTunnels(); return ( -
+
{children || t("welcomeScreen.defaults.helpHint")}
{WelcomeScreenHelpArrow}
diff --git a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss index 9b70cf53a..8472b19fe 100644 --- a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss +++ b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss @@ -1,6 +1,6 @@ .excalidraw { - .virgil { - font-family: "Virgil"; + .excalifont { + font-family: "Excalifont"; } // WelcomeSreen common diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 2af6ffa12..ce754e263 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -25,6 +25,11 @@ export const supportsResizeObserver = export const APP_NAME = "Excalidraw"; +// distance when creating text before it's considered `autoResize: false` +// we're using higher threshold so that clicks that end up being drags +// don't unintentionally create text elements that are wrapped to a few chars +// (happens a lot with fast clicks with the text tool) +export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const DRAGGING_THRESHOLD = 10; // px export const LINE_CONFIRM_THRESHOLD = 8; // px export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; @@ -109,12 +114,24 @@ export const CLASSES = { SHAPE_ACTIONS_MENU: "App-menu__left", }; -// 1-based in case we ever do `if(element.fontFamily)` +/** + * // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash. + * + * Let's think this through and consider: + * - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family + * - https://drafts.csswg.org/css-fonts-4/#font-family-prop + * - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc + */ export const FONT_FAMILY = { Virgil: 1, Helvetica: 2, Cascadia: 3, - Assistant: 4, + // leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian) + Excalifont: 5, + Nunito: 6, + "Lilita One": 7, + "Comic Shanns": 8, + "Liberation Sans": 9, }; export const THEME = { @@ -142,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const MIN_FONT_SIZE = 1; export const DEFAULT_FONT_SIZE = 20; -export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; +export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; @@ -269,7 +286,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; -export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; +export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; export const SVG_NS = "http://www.w3.org/2000/svg"; @@ -400,3 +417,7 @@ export const EDITOR_LS_KEYS = { * where filename is optional and we can't retrieve name from app state */ export const DEFAULT_FILENAME = "Untitled"; + +export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const; + +export const MIN_WIDTH_OR_HEIGHT = 1; diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index 3e053847d..f0c4c5d09 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -22,6 +22,12 @@ --sat: env(safe-area-inset-top); } +body.excalidraw-cursor-resize, +body.excalidraw-cursor-resize a:hover, +body.excalidraw-cursor-resize * { + cursor: ew-resize; +} + .excalidraw { --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; @@ -146,7 +152,7 @@ margin-bottom: 0.25rem; font-size: 0.75rem; color: var(--text-primary-color); - font-weight: normal; + font-weight: 400; display: block; } @@ -221,14 +227,7 @@ label, button, .zIndexButton { - @include outlineButtonStyles; - - padding: 0; - - svg { - width: var(--default-icon-size); - height: var(--default-icon-size); - } + @include outlineButtonIconStyles; } } @@ -388,7 +387,7 @@ .App-menu__left { overflow-y: auto; padding: 0.75rem; - width: 202px; + width: 200px; box-sizing: border-box; position: absolute; } @@ -579,7 +578,7 @@ // use custom, minimalistic scrollbar // (doesn't work in Firefox) ::-webkit-scrollbar { - width: 3px; + width: 4px; height: 3px; } @@ -658,6 +657,10 @@ --button-hover-bg: #363541; --button-bg: var(--color-surface-high); } + + .buttonList { + padding: 0.25rem 0; + } } .excalidraw__paragraph { @@ -751,7 +754,7 @@ padding: 1rem 1.6rem; border-radius: 12px; color: #fff; - font-weight: bold; + font-weight: 700; letter-spacing: 0.6px; font-family: "Assistant"; } diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index aa520a0c5..3b1202314 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -151,6 +151,9 @@ --color-border-outline-variant: #c5c5d0; --color-surface-primary-container: #e0dfff; + --color-badge: #0b6513; + --background-color-badge: #d3ffd2; + &.theme--dark { &.theme--dark-background-none { background: none; diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index cacce21e2..42e325a4c 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -124,6 +124,16 @@ } } +@mixin outlineButtonIconStyles { + @include outlineButtonStyles; + padding: 0; + + svg { + width: var(--default-icon-size); + height: var(--default-icon-size); + } +} + @mixin avatarStyles { width: var(--avatar-size, 1.5rem); height: var(--avatar-size, 1.5rem); @@ -135,7 +145,7 @@ align-items: center; cursor: pointer; font-size: 0.75rem; - font-weight: 800; + font-weight: 700; line-height: 1; color: var(--color-gray-90); flex: 0 0 auto; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index adb5b0372..29cb4c378 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -228,6 +228,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -238,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "containerId": null, "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -273,6 +274,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -283,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "containerId": null, "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -378,12 +380,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id48", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -478,12 +481,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id37", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -652,12 +656,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id41", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -692,6 +697,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -702,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "containerId": null, "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -737,6 +743,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -747,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "containerId": null, "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -1194,12 +1201,13 @@ exports[`Test Transform > should transform regular shapes 6`] = ` exports[`Test Transform > should transform text element 1`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": null, "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -1234,12 +1242,13 @@ exports[`Test Transform > should transform text element 1`] = ` exports[`Test Transform > should transform text element 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": null, "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -1566,12 +1575,13 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "B", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [ @@ -1608,12 +1618,13 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "A", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [ @@ -1650,12 +1661,13 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Alice", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [ @@ -1692,12 +1704,13 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [ @@ -1734,12 +1747,13 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob_Alice", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -1774,12 +1788,13 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob_B", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2022,12 +2037,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id25", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2062,12 +2078,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id26", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2102,12 +2119,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id27", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2143,12 +2161,13 @@ LABELLED ARROW", exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id28", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2406,12 +2425,13 @@ exports[`Test Transform > should transform to text containers when label provide exports[`Test Transform > should transform to text containers when label provided 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id13", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2446,12 +2466,13 @@ exports[`Test Transform > should transform to text containers when label provide exports[`Test Transform > should transform to text containers when label provided 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id14", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2487,12 +2508,13 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 9`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id15", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2530,12 +2552,13 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 10`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id16", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2571,12 +2594,13 @@ TEXT CONTAINER", exports[`Test Transform > should transform to text containers when label provided 11`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id17", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], @@ -2613,12 +2637,13 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 12`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id18", "customData": undefined, "fillStyle": "solid", - "fontFamily": 1, + "fontFamily": 5, "fontSize": 20, "frameId": null, "groupIds": [], diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index dc727dfde..db5b8cd0e 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -1,6 +1,7 @@ import type { ExcalidrawElement, ExcalidrawElementType, + ExcalidrawLinearElement, ExcalidrawSelectionElement, ExcalidrawTextElement, FontFamilyValues, @@ -21,7 +22,12 @@ import { isInvisiblySmallElement, refreshTextDimensions, } from "../element"; -import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks"; +import { + isArrowElement, + isLinearElement, + isTextElement, + isUsingAdaptiveRadius, +} from "../element/typeChecks"; import { randomId } from "../random"; import { DEFAULT_FONT_FAMILY, @@ -38,13 +44,11 @@ import { bumpVersion } from "../element/mutateElement"; import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import type { MarkOptional, Mutable } from "../utility-types"; -import { - detectLineHeight, - getContainerElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { detectLineHeight, getContainerElement } from "../element/textElement"; import { normalizeLink } from "./url"; import { syncInvalidIndices } from "../fractionalIndex"; +import { getSizeFromPoints } from "../points"; +import { getLineHeight } from "../fonts"; type RestoredAppState = Omit< AppState, @@ -203,7 +207,7 @@ const restoreElement = ( detectLineHeight(element) : // no element height likely means programmatic use, so default // to a fixed line height - getDefaultLineHeight(element.fontFamily)); + getLineHeight(element.fontFamily)); element = restoreElementWithProperties(element, { fontSize, fontFamily, @@ -212,7 +216,7 @@ const restoreElement = ( verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, originalText: element.originalText || text, - + autoResize: element.autoResize ?? true, lineHeight, }); @@ -274,6 +278,7 @@ const restoreElement = ( points, x, y, + ...getSizeFromPoints(points), }); } @@ -462,6 +467,23 @@ export const restoreElements = ( ), ); } + + if (isLinearElement(element)) { + if ( + element.startBinding && + (!restoredElementsMap.has(element.startBinding.elementId) || + !isArrowElement(element)) + ) { + (element as Mutable).startBinding = null; + } + if ( + element.endBinding && + (!restoredElementsMap.has(element.endBinding.elementId) || + !isArrowElement(element)) + ) { + (element as Mutable).endBinding = null; + } + } } return restoredElements; diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index e93f58502..73f00d63a 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -18,11 +18,7 @@ import { newMagicFrameElement, newTextElement, } from "../element/newElement"; -import { - getDefaultLineHeight, - measureText, - normalizeText, -} from "../element/textElement"; +import { measureText, normalizeText } from "../element/textElement"; import type { ElementsMap, ExcalidrawArrowElement, @@ -54,6 +50,7 @@ import { import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; import { syncInvalidIndices } from "../fractionalIndex"; +import { getLineHeight } from "../fonts"; export type ValidLinearElement = { type: "arrow" | "line"; @@ -568,8 +565,7 @@ export const convertToExcalidrawElements = ( case "text": { const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; - const lineHeight = - element?.lineHeight || getDefaultLineHeight(fontFamily); + const lineHeight = element?.lineHeight || getLineHeight(fontFamily); const text = element.text ?? ""; const normalizedText = normalizeText(text); const metrics = measureText( diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 6b1405129..1bec39239 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -25,7 +25,7 @@ import type { } from "./types"; import { getElementAbsoluteCoords } from "./bounds"; -import type { AppClassProperties, AppState, Point } from "../types"; +import type { AppState, Point } from "../types"; import { isPointOnShape } from "../../utils/collision"; import { getElementAtPosition } from "../scene"; import { @@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor"; import { arrayToMap, tupleToCoors } from "../utils"; import { KEYS } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { getElementShape } from "../shapes"; export type SuggestedBinding = | NonDeleted @@ -179,19 +180,19 @@ const bindOrUnbindLinearElementEdge = ( const getOriginalBindingIfStillCloseOfLinearElementEdge = ( linearElement: NonDeleted, edge: "start" | "end", - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { - const elementsMap = app.scene.getNonDeletedElementsMap(); const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); const elementId = edge === "start" ? linearElement.startBinding?.elementId : linearElement.endBinding?.elementId; if (elementId) { - const element = elementsMap.get( - elementId, - ) as NonDeleted; - if (bindingBorderTest(element, coors, app)) { + const element = elementsMap.get(elementId); + if ( + isBindableElement(element) && + bindingBorderTest(element, coors, elementsMap) + ) { return element; } } @@ -201,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = ( const getOriginalBindingsIfStillCloseToArrowEnds = ( linearElement: NonDeleted, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): (NonDeleted | null)[] => ["start", "end"].map((edge) => getOriginalBindingIfStillCloseOfLinearElementEdge( linearElement, edge as "start" | "end", - app, + elementsMap, ), ); @@ -215,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = ( selectedElement: NonDeleted, isBindingEnabled: boolean, draggingPoints: readonly number[], - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): (NonDeleted | null | "keep")[] => { const startIdx = 0; const endIdx = selectedElement.points.length - 1; @@ -223,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = ( const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const start = startDragged ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "start", app) + ? getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ) : null // If binding is disabled and start is dragged, break all binds : // We have to update the focus and gap of the binding, so let's rebind - getElligibleElementForBindingElement(selectedElement, "start", app); + getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ); const end = endDragged ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "end", app) + ? getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + ) : null // If binding is disabled and end is dragged, break all binds : // We have to update the focus and gap of the binding, so let's rebind - getElligibleElementForBindingElement(selectedElement, "end", app); + getElligibleElementForBindingElement(selectedElement, "end", elementsMap); return [start, end]; }; const getBindingStrategyForDraggingArrowOrJoints = ( selectedElement: NonDeleted, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, isBindingEnabled: boolean, ): (NonDeleted | null | "keep")[] => { const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( selectedElement, - app, + elementsMap, ); const start = startIsClose ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "start", app) + ? getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ) : null : null; const end = endIsClose ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "end", app) + ? getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + ) : null : null; @@ -262,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = ( export const bindOrUnbindLinearElements = ( selectedElements: NonDeleted[], - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, isBindingEnabled: boolean, draggingPoints: readonly number[] | null, ): void => { @@ -273,27 +294,22 @@ export const bindOrUnbindLinearElements = ( selectedElement, isBindingEnabled, draggingPoints ?? [], - app, + elementsMap, ) : // The arrow itself (the shaft) or the inner joins are dragged getBindingStrategyForDraggingArrowOrJoints( selectedElement, - app, + elementsMap, isBindingEnabled, ); - bindOrUnbindLinearElement( - selectedElement, - start, - end, - app.scene.getNonDeletedElementsMap(), - ); + bindOrUnbindLinearElement(selectedElement, start, end, elementsMap); }); }; export const getSuggestedBindingsForArrows = ( selectedElements: NonDeleted[], - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): SuggestedBinding[] => { // HOT PATH: Bail out if selected elements list is too large if (selectedElements.length > 50) { @@ -304,7 +320,7 @@ export const getSuggestedBindingsForArrows = ( selectedElements .filter(isLinearElement) .flatMap((element) => - getOriginalBindingsIfStillCloseToArrowEnds(element, app), + getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap), ) .filter( (element): element is NonDeleted => @@ -326,17 +342,20 @@ export const maybeBindLinearElement = ( linearElement: NonDeleted, appState: AppState, pointerCoords: { x: number; y: number }, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (appState.startBoundElement != null) { bindLinearElement( linearElement, appState.startBoundElement, "start", - app.scene.getNonDeletedElementsMap(), + elementsMap, ); } - const hoveredElement = getHoveredElementForBinding(pointerCoords, app); + const hoveredElement = getHoveredElementForBinding( + pointerCoords, + elementsMap, + ); if ( hoveredElement != null && !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( @@ -345,12 +364,7 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement( - linearElement, - hoveredElement, - "end", - app.scene.getNonDeletedElementsMap(), - ); + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); } }; @@ -360,6 +374,9 @@ export const bindLinearElement = ( startOrEnd: "start" | "end", elementsMap: NonDeletedSceneElementsMap, ): void => { + if (!isArrowElement(linearElement)) { + return; + } mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: { elementId: hoveredElement.id, @@ -431,13 +448,13 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - app.scene.getNonDeletedElements(), + [...elementsMap].map(([_, value]) => value), (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords, app), + bindingBorderTest(element, pointerCoords, elementsMap), ); return hoveredElement as NonDeleted | null; }; @@ -661,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { return getHoveredElementForBinding( - getLinearElementEdgeCoors( - linearElement, - startOrEnd, - app.scene.getNonDeletedElementsMap(), - ), - app, + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + elementsMap, ); }; @@ -706,6 +719,9 @@ export const fixBindingsAfterDuplication = ( const allBoundElementIds: Set = new Set(); const allBindableElementIds: Set = new Set(); const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld"; + const duplicateIdToOldId = new Map( + [...oldIdToDuplicatedId].map(([key, value]) => [value, key]), + ); oldElements.forEach((oldElement) => { const { boundElements } = oldElement; if (boundElements != null && boundElements.length > 0) { @@ -755,7 +771,11 @@ export const fixBindingsAfterDuplication = ( sceneElements .filter(({ id }) => allBindableElementIds.has(id)) .forEach((bindableElement) => { - const { boundElements } = bindableElement; + const oldElementId = duplicateIdToOldId.get(bindableElement.id); + const { boundElements } = sceneElements.find( + ({ id }) => id === oldElementId, + )!; + if (boundElements != null && boundElements.length > 0) { mutateElement(bindableElement, { boundElements: boundElements.map((boundElement) => @@ -826,10 +846,10 @@ const newBoundElements = ( const bindingBorderTest = ( element: NonDeleted, { x, y }: { x: number; y: number }, - app: AppClassProperties, + elementsMap: ElementsMap, ): boolean => { const threshold = maxBindingGap(element, element.width, element.height); - const shape = app.getElementShape(element); + const shape = getElementShape(element, elementsMap); return isPointOnShape([x, y], shape, threshold); }; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index b10952863..2c951148e 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -4,11 +4,17 @@ import { getCommonBounds } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getPerfectElementSize } from "./sizeHelpers"; import type { NonDeletedExcalidrawElement } from "./types"; -import type { AppState, PointerDownState } from "../types"; -import { getBoundTextElement } from "./textElement"; +import type { AppState, NormalizedZoomValue, PointerDownState } from "../types"; +import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getGridPoint } from "../math"; import type Scene from "../scene/Scene"; -import { isArrowElement, isFrameLikeElement } from "./typeChecks"; +import { + isArrowElement, + isFrameLikeElement, + isTextElement, +} from "./typeChecks"; +import { getFontString } from "../utils"; +import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; export const dragSelectedElements = ( pointerDownState: PointerDownState, @@ -140,6 +146,7 @@ export const dragNewElement = ( height: number, shouldMaintainAspectRatio: boolean, shouldResizeFromCenter: boolean, + zoom: NormalizedZoomValue, /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is true */ widthAspectRatio?: number | null, @@ -185,12 +192,41 @@ export const dragNewElement = ( newY = originY - height / 2; } + let textAutoResize = null; + + // NOTE this should apply only to creating text elements, not existing + // (once we rewrite appState.draggingElement to actually mean dragging + // elements) + if (isTextElement(draggingElement)) { + height = draggingElement.height; + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: draggingElement.fontSize, + fontFamily: draggingElement.fontFamily, + }), + draggingElement.lineHeight, + ); + width = Math.max(width, minWidth); + + if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) { + textAutoResize = { + autoResize: false, + }; + } + + newY = originY; + if (shouldResizeFromCenter) { + newX = originX - width / 2; + } + } + if (width !== 0 && height !== 0) { mutateElement(draggingElement, { x: newX + (originOffset?.x ?? 0), y: newY + (originOffset?.y ?? 0), width, height, + ...textAutoResize, }); } }; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index b9c203a36..35661608e 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks"; export { newElement, newTextElement, - updateTextElement, refreshTextDimensions, newLinearElement, newImageElement, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 48b33d150..971922762 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -381,7 +381,7 @@ export class LinearElementEditor { elementsMap, ), ), - app, + elementsMap, ) : null; @@ -715,7 +715,10 @@ export class LinearElementEditor { }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding(scenePointer, app), + endBindingElement: getHoveredElementForBinding( + scenePointer, + elementsMap, + ), }; ret.didAddPoint = true; @@ -1165,7 +1168,7 @@ export class LinearElementEditor { const nextPoints = points.map((point, idx) => { const selectedPointData = targetPoints.find((p) => p.index === idx); if (selectedPointData) { - if (selectedOriginPoint) { + if (selectedPointData.index === 0) { return point; } @@ -1174,7 +1177,10 @@ export class LinearElementEditor { const deltaY = selectedPointData.point[1] - points[selectedPointData.index][1]; - return [point[0] + deltaX, point[1] + deltaY] as const; + return [ + point[0] + deltaX - offsetX, + point[1] + deltaY - offsetY, + ] as const; } return offsetX || offsetY ? ([point[0] - offsetX, point[1] - offsetY] as const) diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index a7adc4451..bae2bcf18 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -115,7 +115,7 @@ export const mutateElement = >( } if (informMutation) { - Scene.getScene(element)?.informMutation(); + Scene.getScene(element)?.triggerUpdate(); } return element; @@ -124,6 +124,8 @@ export const mutateElement = >( export const newElementWith = ( element: TElement, updates: ElementUpdate, + /** pass `true` to always regenerate */ + force = false, ): TElement => { let didChange = false; let increment = false; @@ -143,7 +145,7 @@ export const newElementWith = ( } } - if (!didChange) { + if (!didChange && !force) { return element; } diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 04b508be7..9dd9d6d5c 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -31,7 +31,6 @@ import { normalizeText, wrapTextElement, getBoundTextMaxWidth, - getDefaultLineHeight, } from "./textElement"; import { DEFAULT_ELEMENT_PROPS, @@ -42,6 +41,7 @@ import { VERTICAL_ALIGN, } from "../constants"; import type { MarkOptional, Merge, Mutable } from "../utility-types"; +import { getLineHeight } from "../fonts"; import { getSubtypeMethods, isValidSubtype } from "./subtypes"; export const maybeGetSubtypeProps = ( @@ -241,6 +241,7 @@ const getTextElementPositionOffsets = ( export const newTextElement = ( opts: { text: string; + originalText?: string; fontSize?: number; fontFamily?: FontFamilyValues; textAlign?: TextAlign; @@ -248,11 +249,12 @@ export const newTextElement = ( containerId?: ExcalidrawTextContainer["id"] | null; lineHeight?: ExcalidrawTextElement["lineHeight"]; strokeWidth?: ExcalidrawTextElement["strokeWidth"]; + autoResize?: ExcalidrawTextElement["autoResize"]; } & ElementConstructorOpts, ): NonDeleted => { const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; - const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily); + const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const text = normalizeText(opts.text); const metrics = measureTextElement( { ...opts, fontSize, fontFamily, lineHeight }, @@ -268,24 +270,28 @@ export const newTextElement = ( metrics, ); - const textElement = newElementWith( - { - ..._newElementBase("text", opts), - text, - fontSize, - fontFamily, - textAlign, - verticalAlign, - x: opts.x - offsets.x, - y: opts.y - offsets.y, - width: metrics.width, - height: metrics.height, - containerId: opts.containerId || null, - originalText: text, - lineHeight, - }, + const textElementProps: ExcalidrawTextElement = { + ..._newElementBase("text", opts), + text, + fontSize, + fontFamily, + textAlign, + verticalAlign, + x: opts.x - offsets.x, + y: opts.y - offsets.y, + width: metrics.width, + height: metrics.height, + containerId: opts.containerId || null, + originalText: opts.originalText ?? text, + autoResize: opts.autoResize ?? true, + lineHeight, + }; + + const textElement: ExcalidrawTextElement = newElementWith( + textElementProps, {}, ); + return textElement; }; @@ -299,16 +305,23 @@ const getAdjustedDimensions = ( width: number; height: number; } => { - const { width: nextWidth, height: nextHeight } = measureTextElement(element, { + let { width: nextWidth, height: nextHeight } = measureTextElement(element, { text: nextText, }); + + // wrapped text + if (!element.autoResize) { + nextWidth = element.width; + } + const { textAlign, verticalAlign } = element; let x: number; let y: number; if ( textAlign === "center" && verticalAlign === VERTICAL_ALIGN.MIDDLE && - !element.containerId + !element.containerId && + element.autoResize ) { const prevMetrics = measureTextElement(element); const offsets = getTextElementPositionOffsets(element, { @@ -365,10 +378,12 @@ export const refreshTextDimensions = ( if (textElement.isDeleted) { return; } - if (container) { + if (container || !textElement.autoResize) { text = wrapTextElement( textElement, - getBoundTextMaxWidth(container, textElement), + container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width, { text, }, @@ -378,27 +393,6 @@ export const refreshTextDimensions = ( return { text, ...dimensions }; }; -export const updateTextElement = ( - textElement: ExcalidrawTextElement, - container: ExcalidrawTextContainer | null, - elementsMap: ElementsMap, - { - text, - isDeleted, - originalText, - }: { - text: string; - isDeleted?: boolean; - originalText: string; - }, -): ExcalidrawTextElement => { - return newElementWith(textElement, { - originalText, - isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, container, elementsMap, originalText), - }); -}; - export const newFreeDrawElement = ( opts: { type: "freedraw"; @@ -550,7 +544,7 @@ export const regenerateId = ( if ( window.h?.app ?.getSceneElementsIncludingDeleted() - .find((el) => el.id === nextId) + .find((el: ExcalidrawElement) => el.id === nextId) ) { nextId += "_copy"; } diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index db2f49625..c069c2e34 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -45,6 +45,9 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, + wrapText, + measureText, + getMinTextElementWidth, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; @@ -84,14 +87,9 @@ export const transformElements = ( shouldRotateWithDiscreteAngle, ); updateBoundElements(element, elementsMap); - } else if ( - isTextElement(element) && - (transformHandleType === "nw" || - transformHandleType === "ne" || - transformHandleType === "sw" || - transformHandleType === "se") - ) { + } else if (isTextElement(element) && transformHandleType) { resizeSingleTextElement( + originalElements, element, elementsMap, transformHandleType, @@ -180,7 +178,7 @@ const rotateSingleElement = ( } }; -const rescalePointsInElement = ( +export const rescalePointsInElement = ( element: NonDeletedExcalidrawElement, width: number, height: number, @@ -197,7 +195,7 @@ const rescalePointsInElement = ( } : {}; -const measureFontSizeFromWidth = ( +export const measureFontSizeFromWidth = ( element: NonDeleted, elementsMap: ElementsMap, nextWidth: number, @@ -223,9 +221,10 @@ const measureFontSizeFromWidth = ( }; const resizeSingleTextElement = ( + originalElements: PointerDownState["originalElements"], element: NonDeleted, elementsMap: ElementsMap, - transformHandleType: "nw" | "ne" | "sw" | "se", + transformHandleType: TransformHandleDirection, shouldResizeFromCenter: boolean, pointerX: number, pointerY: number, @@ -245,17 +244,19 @@ const resizeSingleTextElement = ( let scaleX = 0; let scaleY = 0; - if (transformHandleType.includes("e")) { - scaleX = (rotatedX - x1) / (x2 - x1); - } - if (transformHandleType.includes("w")) { - scaleX = (x2 - rotatedX) / (x2 - x1); - } - if (transformHandleType.includes("n")) { - scaleY = (y2 - rotatedY) / (y2 - y1); - } - if (transformHandleType.includes("s")) { - scaleY = (rotatedY - y1) / (y2 - y1); + if (transformHandleType !== "e" && transformHandleType !== "w") { + if (transformHandleType.includes("e")) { + scaleX = (rotatedX - x1) / (x2 - x1); + } + if (transformHandleType.includes("w")) { + scaleX = (x2 - rotatedX) / (x2 - x1); + } + if (transformHandleType.includes("n")) { + scaleY = (y2 - rotatedY) / (y2 - y1); + } + if (transformHandleType.includes("s")) { + scaleY = (rotatedY - y1) / (y2 - y1); + } } const scale = Math.max(scaleX, scaleY); @@ -318,6 +319,107 @@ const resizeSingleTextElement = ( y: nextY, }); } + + if (transformHandleType === "e" || transformHandleType === "w") { + const stateAtResizeStart = originalElements.get(element.id)!; + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + stateAtResizeStart, + stateAtResizeStart.width, + stateAtResizeStart.height, + true, + ); + const startTopLeft: Point = [x1, y1]; + const startBottomRight: Point = [x2, y2]; + const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + + const rotatedPointer = rotatePoint( + [pointerX, pointerY], + startCenter, + -stateAtResizeStart.angle, + ); + + const [esx1, , esx2] = getResizedElementAbsoluteCoords( + element, + element.width, + element.height, + true, + ); + + const boundsCurrentWidth = esx2 - esx1; + + const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: element.fontSize, + fontFamily: element.fontFamily, + }), + element.lineHeight, + ); + + let scaleX = atStartBoundsWidth / boundsCurrentWidth; + + if (transformHandleType.includes("e")) { + scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; + } + if (transformHandleType.includes("w")) { + scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; + } + + const newWidth = + element.width * scaleX < minWidth ? minWidth : element.width * scaleX; + + const text = wrapText( + element.originalText, + getFontString(element), + Math.abs(newWidth), + ); + const metrics = measureText( + text, + getFontString(element), + element.lineHeight, + ); + + const eleNewHeight = metrics.height; + + const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = + getResizedElementAbsoluteCoords( + stateAtResizeStart, + newWidth, + eleNewHeight, + true, + ); + const newBoundsWidth = newBoundsX2 - newBoundsX1; + const newBoundsHeight = newBoundsY2 - newBoundsY1; + + let newTopLeft = [...startTopLeft] as [number, number]; + if (["n", "w", "nw"].includes(transformHandleType)) { + newTopLeft = [ + startBottomRight[0] - Math.abs(newBoundsWidth), + startTopLeft[1], + ]; + } + + // adjust topLeft to new rotation point + const angle = stateAtResizeStart.angle; + const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); + const newCenter: Point = [ + newTopLeft[0] + Math.abs(newBoundsWidth) / 2, + newTopLeft[1] + Math.abs(newBoundsHeight) / 2, + ]; + const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); + newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + + const resizedElement: Partial = { + width: Math.abs(newWidth), + height: Math.abs(metrics.height), + x: newTopLeft[0], + y: newTopLeft[1], + text, + autoResize: false, + }; + + mutateElement(element, resizedElement); + } }; export const resizeSingleElement = ( @@ -876,7 +978,7 @@ export const resizeMultipleElements = ( } } - Scene.getScene(elementsAndUpdates[0].element)?.informMutation(); + Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate(); }; const rotateMultipleElements = ( @@ -938,7 +1040,7 @@ const rotateMultipleElements = ( } }); - Scene.getScene(elements[0])?.informMutation(); + Scene.getScene(elements[0])?.triggerUpdate(); }; export const getResizeOffsetXY = ( diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 3fea7d960..74ebd8e5d 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -87,12 +87,8 @@ export const resizeTest = ( elementsMap, ); - // Note that for a text element, when "resized" from the side - // we should make it wrap/unwrap - if ( - element.type !== "text" && - !(isLinearElement(element) && element.points.length <= 2) - ) { + // do not resize from the sides for linear elements with only two points + if (!(isLinearElement(element) && element.points.length <= 2)) { const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( [x1 - SPACING, y1 - SPACING], diff --git a/packages/excalidraw/element/subtypes/index.ts b/packages/excalidraw/element/subtypes/index.ts index c148a77ce..243fb6889 100644 --- a/packages/excalidraw/element/subtypes/index.ts +++ b/packages/excalidraw/element/subtypes/index.ts @@ -28,7 +28,6 @@ import { } from "../textElement"; import { ShapeCache } from "../../scene/ShapeCache"; import Scene from "../../scene/Scene"; -import type { RenderableElementsMap } from "../../scene/types"; // Use "let" instead of "const" so we can dynamically add subtypes let subtypeNames: readonly Subtype[] = []; @@ -267,14 +266,14 @@ export type SubtypeMethods = { ) => { width: number; height: number }; render: ( element: NonDeleted, - elementsMap: RenderableElementsMap, + elementsMap: ElementsMap, context: CanvasRenderingContext2D, ) => void; renderSvg: ( svgRoot: SVGElement, addToRoot: (node: SVGElement, element: ExcalidrawElement) => void, element: NonDeleted, - elementsMap: RenderableElementsMap, + elementsMap: ElementsMap, opt?: { offsetX?: number; offsetY?: number }, ) => void; wrapText: ( @@ -507,6 +506,7 @@ export const checkRefreshOnSubtypeLoad = ( element, getContainerElement(element, elementsMap), elementsMap, + false, ); } refreshNeeded = true; @@ -518,7 +518,7 @@ export const checkRefreshOnSubtypeLoad = ( } }); // Only inform each scene once - scenes.forEach((scene) => scene.informMutation()); + scenes.forEach((scene) => scene.triggerUpdate()); return refreshNeeded; }; diff --git a/packages/excalidraw/element/subtypes/mathjax/implementation.tsx b/packages/excalidraw/element/subtypes/mathjax/implementation.tsx index 49973fbc6..a1cc06d96 100644 --- a/packages/excalidraw/element/subtypes/mathjax/implementation.tsx +++ b/packages/excalidraw/element/subtypes/mathjax/implementation.tsx @@ -6,7 +6,6 @@ import { getBoundTextElement, getBoundTextMaxWidth, getContainerElement, - getDefaultLineHeight, getTextWidth, measureText, wrapText, @@ -20,6 +19,7 @@ import type { ExcalidrawTextElement, NonDeleted, } from "../../../element/types"; +import { getLineHeight } from "../../../fonts"; import { newElementWith } from "../../../element/mutateElement"; import { getElementAbsoluteCoords } from "../../../element/bounds"; import Scene from "../../../scene/Scene"; @@ -904,7 +904,7 @@ const cleanMathElementUpdate = function (updates) { } } (updates as any).fontFamily = FONT_FAMILY_MATH; - (updates as any).lineHeight = getDefaultLineHeight(FONT_FAMILY_MATH); + (updates as any).lineHeight = getLineHeight(FONT_FAMILY_MATH); return oldUpdates; } as SubtypeMethods["clean"]; @@ -1025,7 +1025,7 @@ const renderMathElement = function (element, elementMap, context) { if (isMathJaxLoaded) { imageCache[imgKey] = img; } - Scene.getScene(element)?.informMutation(); + Scene.getScene(element)?.triggerUpdate(); }; img.src = reader.result as string; }, diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index bc8186a85..178b30cd5 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -1,4 +1,5 @@ import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { getLineHeight } from "../fonts"; import { API } from "../tests/helpers/api"; import { computeContainerDimensionForBoundText, @@ -8,7 +9,6 @@ import { wrapText, detectLineHeight, getLineHeightInPx, - getDefaultLineHeight, parseTokens, } from "./textElement"; import type { ExcalidrawTextElementWithContainer, FontString } from "./types"; @@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => { describe("Test getDefaultLineHeight", () => { it("should return line height using default font family when not passed", () => { //@ts-ignore - expect(getDefaultLineHeight()).toBe(1.25); + expect(getLineHeight()).toBe(1.25); }); it("should return line height using default font family for unknown font", () => { const UNKNOWN_FONT = 5; - expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25); + expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25); }); it("should return correct line height", () => { - expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); + expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); }); }); diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 55b48cb1d..f878455de 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -8,7 +8,6 @@ import type { ExcalidrawTextContainer, ExcalidrawTextElement, ExcalidrawTextElementWithContainer, - FontFamilyValues, FontString, NonDeletedExcalidrawElement, } from "./types"; @@ -19,7 +18,6 @@ import { BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, - FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN, } from "../constants"; @@ -32,7 +30,7 @@ import { resetOriginalContainerCache, updateOriginalContainerCache, } from "./containerCache"; -import type { ExtractSetType, MakeBrand } from "../utility-types"; +import type { ExtractSetType } from "../utility-types"; export const measureTextElement = function (element, next) { const map = getSubtypeMethods(element.subtype); @@ -74,7 +72,7 @@ export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, elementsMap: ElementsMap, - informMutation: boolean = true, + informMutation = true, ) => { let maxWidth = undefined; const boundTextUpdates = { @@ -88,15 +86,21 @@ export const redrawTextBoundingBox = ( boundTextUpdates.text = textElement.text; - if (container) { - maxWidth = getBoundTextMaxWidth(container, textElement); + if (container || !textElement.autoResize) { + maxWidth = container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width; boundTextUpdates.text = wrapTextElement(textElement, maxWidth); } + const metrics = measureTextElement(textElement, { text: boundTextUpdates.text, }); - boundTextUpdates.width = metrics.width; + // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) + if (textElement.autoResize) { + boundTextUpdates.width = metrics.width; + } boundTextUpdates.height = metrics.height; // Maintain coordX for non left-aligned text in case the width has changed @@ -335,24 +339,6 @@ export const getLineHeightInPx = ( return fontSize * lineHeight; }; -/** - * Calculates vertical offset for a text with alphabetic baseline. - */ -export const getVerticalOffset = ( - fontFamily: ExcalidrawTextElement["fontFamily"], - fontSize: ExcalidrawTextElement["fontSize"], - lineHeightPx: number, -) => { - const { unitsPerEm, ascender, descender } = - FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica]; - - const fontSizeEm = fontSize / unitsPerEm; - const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender; - - const verticalOffset = fontSizeEm * ascender + lineGap; - return verticalOffset; -}; - // FIXME rename to getApproxMinContainerHeight export const getApproxMinLineHeight = ( fontSize: ExcalidrawTextElement["fontSize"], @@ -363,29 +349,72 @@ export const getApproxMinLineHeight = ( let canvas: HTMLCanvasElement | undefined; -const getLineWidth = (text: string, font: FontString) => { +/** + * @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width. + * + * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position. + * + * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for: + * - text wrapping + * - wysiwyg editor (+padding) + * + * Everything else should be based on the actual bounding box width. + * + * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies. + */ +const getLineWidth = ( + text: string, + font: FontString, + forceAdvanceWidth?: true, +) => { if (!canvas) { canvas = document.createElement("canvas"); } const canvas2dContext = canvas.getContext("2d")!; canvas2dContext.font = font; - const width = canvas2dContext.measureText(text).width; + const metrics = canvas2dContext.measureText(text); + + const advanceWidth = metrics.width; + + // retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage) + if ( + !forceAdvanceWidth && + window.TextMetrics && + "actualBoundingBoxLeft" in window.TextMetrics.prototype && + "actualBoundingBoxRight" in window.TextMetrics.prototype + ) { + // could be negative, therefore getting the absolute value + const actualWidth = + Math.abs(metrics.actualBoundingBoxLeft) + + Math.abs(metrics.actualBoundingBoxRight); + + // fallback to advance width if the actual width is zero, i.e. on text editing start + // or when actual width does not respect whitespace chars, i.e. spaces + // otherwise actual width should always be bigger + return Math.max(actualWidth, advanceWidth); + } // since in test env the canvas measureText algo // doesn't measure text and instead just returns number of // characters hence we assume that each letteris 10px if (isTestEnv()) { - return width * 10; + return advanceWidth * 10; } - return width; + + return advanceWidth; }; -export const getTextWidth = (text: string, font: FontString) => { +export const getTextWidth = ( + text: string, + font: FontString, + forceAdvanceWidth?: true, +) => { const lines = splitIntoLines(text); let width = 0; lines.forEach((line) => { - width = Math.max(width, getLineWidth(line, font)); + width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth)); }); + return width; }; @@ -416,7 +445,11 @@ export const parseTokens = (text: string) => { return words.join(" ").split(" "); }; -export const wrapText = (text: string, font: FontString, maxWidth: number) => { +export const wrapText = ( + text: string, + font: FontString, + maxWidth: number, +): string => { // if maxWidth is not finite or NaN which can happen in case of bugs in // computation, we need to make sure we don't continue as we'll end up // in an infinite loop @@ -426,7 +459,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { const lines: Array = []; const originalLines = text.split("\n"); - const spaceWidth = getLineWidth(" ", font); + const spaceAdvanceWidth = getLineWidth(" ", font, true); let currentLine = ""; let currentLineWidthTillNow = 0; @@ -441,13 +474,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { currentLine = ""; currentLineWidthTillNow = 0; }; - originalLines.forEach((originalLine) => { - const currentLineWidth = getTextWidth(originalLine, font); + + for (const originalLine of originalLines) { + const currentLineWidth = getLineWidth(originalLine, font, true); // Push the line if its <= maxWidth if (currentLineWidth <= maxWidth) { lines.push(originalLine); - return; // continue + continue; } const words = parseTokens(originalLine); @@ -456,7 +490,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { let index = 0; while (index < words.length) { - const currentWordWidth = getLineWidth(words[index], font); + const currentWordWidth = getLineWidth(words[index], font, true); // This will only happen when single word takes entire width if (currentWordWidth === maxWidth) { @@ -468,7 +502,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { else if (currentWordWidth > maxWidth) { // push current line since the current word exceeds the max width // so will be appended in next line - push(currentLine); resetParams(); @@ -477,20 +510,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { const currentChar = String.fromCodePoint( words[index].codePointAt(0)!, ); - const width = charWidth.calculate(currentChar, font); - currentLineWidthTillNow += width; + + const line = currentLine + currentChar; + // use advance width instead of the actual width as it's closest to the browser wapping algo + // use width of the whole line instead of calculating individual chars to accomodate for kerning + const lineAdvanceWidth = getLineWidth(line, font, true); + const charAdvanceWidth = charWidth.calculate(currentChar, font); + + currentLineWidthTillNow = lineAdvanceWidth; words[index] = words[index].slice(currentChar.length); if (currentLineWidthTillNow >= maxWidth) { push(currentLine); currentLine = currentChar; - currentLineWidthTillNow = width; + currentLineWidthTillNow = charAdvanceWidth; } else { - currentLine += currentChar; + currentLine = line; } } // push current line if appending space exceeds max width - if (currentLineWidthTillNow + spaceWidth >= maxWidth) { + if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) { push(currentLine); resetParams(); // space needs to be appended before next word @@ -499,14 +538,18 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { // with css word-wrap } else if (!currentLine.endsWith("-")) { currentLine += " "; - currentLineWidthTillNow += spaceWidth; + currentLineWidthTillNow += spaceAdvanceWidth; } index++; } else { // Start appending words in a line till max width reached while (currentLineWidthTillNow < maxWidth && index < words.length) { const word = words[index]; - currentLineWidthTillNow = getLineWidth(currentLine + word, font); + currentLineWidthTillNow = getLineWidth( + currentLine + word, + font, + true, + ); if (currentLineWidthTillNow > maxWidth) { push(currentLine); @@ -526,7 +569,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { } // Push the word if appending space exceeds max width - if (currentLineWidthTillNow + spaceWidth >= maxWidth) { + if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) { if (shouldAppendSpace) { lines.push(currentLine.slice(0, -1)); } else { @@ -538,12 +581,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { } } } + if (currentLine.slice(-1) === " ") { // only remove last trailing space which we have added when joining words currentLine = currentLine.slice(0, -1); push(currentLine); } - }); + } + return lines.join("\n"); }; @@ -556,7 +601,7 @@ export const charWidth = (() => { cachedCharWidth[font] = []; } if (!cachedCharWidth[font][ascii]) { - const width = getLineWidth(char, font); + const width = getLineWidth(char, font, true); cachedCharWidth[font][ascii] = width; } @@ -608,30 +653,6 @@ export const getMaxCharWidth = (font: FontString) => { return Math.max(...cacheWithOutEmpty); }; -export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { - // Generally lower case is used so converting to lower case - const dummyText = DUMMY_TEXT.toLocaleLowerCase(); - const batchLength = 6; - let index = 0; - let widthTillNow = 0; - let str = ""; - while (widthTillNow <= width) { - const batch = dummyText.substr(index, index + batchLength); - str += batch; - widthTillNow += getLineWidth(str, font); - if (index === dummyText.length - 1) { - index = 0; - } - index = index + batchLength; - } - - while (widthTillNow > width) { - str = str.substr(0, str.length - 1); - widthTillNow = getLineWidth(str, font); - } - return str.length; -}; - export const getBoundTextElementId = (container: ExcalidrawElement | null) => { return container?.boundElements?.length ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id || @@ -880,75 +901,9 @@ export const isMeasureTextSupported = () => { return width > 0; }; -/** - * Unitless line height - * - * In previous versions we used `normal` line height, which browsers interpret - * differently, and based on font-family and font-size. - * - * To make line heights consistent across browsers we hardcode the values for - * each of our fonts based on most common average line-heights. - * See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 - * where the values come from. - */ -const DEFAULT_LINE_HEIGHT = { - // ~1.25 is the average for Virgil in WebKit and Blink. - // Gecko (FF) uses ~1.28. - [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], - // ~1.15 is the average for Helvetica in WebKit and Blink. - [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], - // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too - [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], -}; - -/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */ -type sTypoAscender = number & MakeBrand<"sTypoAscender">; - -/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */ -type sTypoDescender = number & MakeBrand<"sTypoDescender">; - -/** head.unitsPerEm, usually either 1000 or 2048 */ -type unitsPerEm = number & MakeBrand<"unitsPerEm">; - -/** - * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html. - * For custom fonts, read these metrics from OS/2 table and extend this object. - * - * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first. - */ -export const FONT_METRICS: Record< - number, - { - unitsPerEm: number; - ascender: sTypoAscender; - descender: sTypoDescender; - } -> = { - [FONT_FAMILY.Virgil]: { - unitsPerEm: 1000 as unitsPerEm, - ascender: 886 as sTypoAscender, - descender: -374 as sTypoDescender, - }, - [FONT_FAMILY.Helvetica]: { - unitsPerEm: 2048 as unitsPerEm, - ascender: 1577 as sTypoAscender, - descender: -471 as sTypoDescender, - }, - [FONT_FAMILY.Cascadia]: { - unitsPerEm: 2048 as unitsPerEm, - ascender: 1977 as sTypoAscender, - descender: -480 as sTypoDescender, - }, - [FONT_FAMILY.Assistant]: { - unitsPerEm: 1000 as unitsPerEm, - ascender: 1021 as sTypoAscender, - descender: -287 as sTypoDescender, - }, -}; - -export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { - if (fontFamily in DEFAULT_LINE_HEIGHT) { - return DEFAULT_LINE_HEIGHT[fontFamily]; - } - return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY]; +export const getMinTextElementWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; }; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 78849376d..32fa7bffe 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -236,6 +236,117 @@ describe("textWysiwyg", () => { }); }); + describe("Test text wrapping", () => { + const { h } = window; + const dimensions = { height: 400, width: 800 }; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + + beforeEach(async () => { + await render(); + // @ts-ignore + h.app.refreshViewportBreakpoints(); + // @ts-ignore + h.app.refreshEditorBreakpoints(); + + h.elements = []; + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should keep width when editing a wrapped text", async () => { + const text = API.createElement({ + type: "text", + text: "Excalidraw\nEditor", + }); + + h.elements = [text]; + + const prevWidth = text.width; + const prevHeight = text.height; + const prevText = text.text; + + // text is wrapped + UI.resize(text, "e", [-20, 0]); + expect(text.width).not.toEqual(prevWidth); + expect(text.height).not.toEqual(prevHeight); + expect(text.text).not.toEqual(prevText); + expect(text.autoResize).toBe(false); + + const wrappedWidth = text.width; + const wrappedHeight = text.height; + const wrappedText = text.text; + + // edit text + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + expect(editor).not.toBe(null); + expect(h.state.editingElement?.id).toBe(text.id); + expect(h.elements.length).toBe(1); + + const nextText = `${wrappedText} is great!`; + updateTextEditor(editor, nextText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + + expect(h.elements[0].width).toEqual(wrappedWidth); + expect(h.elements[0].height).toBeGreaterThan(wrappedHeight); + + // remove all texts and then add it back editing + updateTextEditor(editor, ""); + await new Promise((cb) => setTimeout(cb, 0)); + updateTextEditor(editor, nextText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + + expect(h.elements[0].width).toEqual(wrappedWidth); + }); + + it("should restore original text after unwrapping a wrapped text", async () => { + const originalText = "Excalidraw\neditor\nis great!"; + const text = API.createElement({ + type: "text", + text: originalText, + }); + h.elements = [text]; + + // wrap + UI.resize(text, "e", [-40, 0]); + // enter text editing mode + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + editor.blur(); + // restore after unwrapping + UI.resize(text, "e", [40, 0]); + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + + // wrap again and add a new line + UI.resize(text, "e", [-30, 0]); + const wrappedText = text.text; + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, `${wrappedText}\nA new line!`); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + // remove the newly added line + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, wrappedText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + // unwrap + UI.resize(text, "e", [30, 0]); + // expect the text to be restored the same + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + }); + }); + describe("Test container-unbound text", () => { const { h } = window; const dimensions = { height: 400, width: 800 }; @@ -465,7 +576,7 @@ describe("textWysiwyg", () => { it("text should never go beyond max width", async () => { UI.clickTool("text"); - mouse.clickAt(750, 300); + mouse.click(0, 0); textarea = await getTextEditor(textEditorSelector, true); updateTextEditor( @@ -800,29 +911,18 @@ describe("textWysiwyg", () => { mouse.down(); const text = h.elements[1] as ExcalidrawTextElementWithContainer; - let editor = await getTextEditor(textEditorSelector, true); + const editor = await getTextEditor(textEditorSelector, true); await new Promise((r) => setTimeout(r, 0)); updateTextEditor(editor, "Hello World!"); editor.blur(); - expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); - UI.clickTool("text"); + expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); - mouse.clickAt( - rectangle.x + rectangle.width / 2, - rectangle.y + rectangle.height / 2, - ); - mouse.down(); - editor = await getTextEditor(textEditorSelector, true); - - editor.select(); fireEvent.click(screen.getByTitle(/code/i)); - await new Promise((r) => setTimeout(r, 0)); - editor.blur(); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, - ).toEqual(FONT_FAMILY.Cascadia); + ).toEqual(FONT_FAMILY["Comic Shanns"]); //undo Keyboard.withModifierKeys({ ctrl: true }, () => { @@ -830,7 +930,7 @@ describe("textWysiwyg", () => { }); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, - ).toEqual(FONT_FAMILY.Virgil); + ).toEqual(FONT_FAMILY.Excalifont); //redo Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { @@ -838,7 +938,7 @@ describe("textWysiwyg", () => { }); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, - ).toEqual(FONT_FAMILY.Cascadia); + ).toEqual(FONT_FAMILY["Comic Shanns"]); }); it("should wrap text and vertcially center align once text submitted", async () => { @@ -964,7 +1064,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 85, - 4.999999999999986, + "5.00000", ] `); @@ -1009,8 +1109,8 @@ describe("textWysiwyg", () => { UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ - 374.99999999999994, - -535.0000000000001, + "375.00000", + "-535.00000", ] `); }); @@ -1230,14 +1330,14 @@ describe("textWysiwyg", () => { expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, - ).toEqual(FONT_FAMILY.Cascadia); + ).toEqual(FONT_FAMILY["Comic Shanns"]); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); fireEvent.click(screen.getByTitle(/Very large/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, ).toEqual(36); - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100); }); it("should update line height when font family updated", async () => { @@ -1257,18 +1357,18 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle(/code/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, - ).toEqual(FONT_FAMILY.Cascadia); + ).toEqual(FONT_FAMILY["Comic Shanns"]); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, - ).toEqual(1.2); + ).toEqual(1.25); fireEvent.click(screen.getByTitle(/normal/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, - ).toEqual(FONT_FAMILY.Helvetica); + ).toEqual(FONT_FAMILY.Nunito); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, - ).toEqual(1.15); + ).toEqual(1.35); }); describe("should align correctly", () => { diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 456572715..7aed8612a 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES } from "../constants"; +import { CLASSES, isSafari } from "../constants"; import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -90,19 +90,23 @@ export const textWysiwyg = ({ canvas, excalidrawContainer, app, + autoSelect = true, }: { id: ExcalidrawElement["id"]; - onChange?: (text: string) => void; - onSubmit: (data: { - text: string; - viaKeyboard: boolean; - originalText: string; - }) => void; + /** + * textWysiwyg only deals with `originalText` + * + * Note: `text`, which can be wrapped and therefore different from `originalText`, + * is derived from `originalText` + */ + onChange?: (nextOriginalText: string) => void; + onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void; getViewportCoords: (x: number, y: number) => [number, number]; element: ExcalidrawTextElement; canvas: HTMLCanvasElement; excalidrawContainer: HTMLDivElement | null; app: App; + autoSelect?: boolean; }) => { const textPropertiesUpdated = ( updatedTextElement: ExcalidrawTextElement, @@ -141,7 +145,6 @@ export const textWysiwyg = ({ updatedTextElement, app.scene.getNonDeletedElementsMap(), ); - let maxWidth = updatedTextElement.width; // Editing metrics const eMetrics = measureText( @@ -156,11 +159,14 @@ export const textWysiwyg = ({ updatedTextElement.lineHeight, ); - let maxHeight = eMetrics.height; - let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width); + let width = Math.max(updatedTextElement.width, eMetrics.width); + // Set to element height by default since that's // what is going to be used for unbounded text - const textElementHeight = Math.max(updatedTextElement.height, maxHeight); + let height = Math.max(updatedTextElement.height, eMetrics.height); + + let maxWidth = width; + let maxHeight = height; if (container && updatedTextElement.containerId) { if (isArrowElement(container)) { @@ -202,9 +208,9 @@ export const textWysiwyg = ({ ); // autogrow container height if text exceeds - if (!isArrowElement(container) && textElementHeight > maxHeight) { + if (!isArrowElement(container) && height > maxHeight) { const targetContainerHeight = computeContainerDimensionForBoundText( - textElementHeight, + height, container.type, ); @@ -215,10 +221,10 @@ export const textWysiwyg = ({ // is reached when text is removed !isArrowElement(container) && container.height > originalContainerData.height && - textElementHeight < maxHeight + height < maxHeight ) { const targetContainerHeight = computeContainerDimensionForBoundText( - textElementHeight, + height, container.type, ); mutateElement(container, { height: targetContainerHeight }); @@ -249,10 +255,11 @@ export const textWysiwyg = ({ editable.selectionEnd = editable.value.length - diff; } - const transformWidth = updatedTextElement.width; if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; - textElementWidth = Math.min(textElementWidth, maxWidth); + width = Math.min(width, maxWidth); + } else { + width += 0.5; } // Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype @@ -269,34 +276,47 @@ export const textWysiwyg = ({ : textAlign === "center" ? offWidth / 2 : 0; - const { width: w, height: h } = updatedTextElement; + let { width: w, height: h } = updatedTextElement; + + // add 5% buffer otherwise it causes wysiwyg to jump + height *= 1.05; + h *= 1.05; + const transformOrigin = updatedTextElement.width !== eMetrics.width || updatedTextElement.height !== eMetrics.height ? { transformOrigin: `${w / 2}px ${h / 2}px` } : {}; + const font = getFontString(updatedTextElement); + + // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari) + const padding = !isSafari + ? Math.ceil(updatedTextElement.fontSize / 2) + : 0; + // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = (appState.height - viewportY) / appState.zoom.value; Object.assign(editable.style, { - font: getFontString(updatedTextElement), + font, // must be defined *after* font ¯\_(ツ)_/¯ lineHeight: updatedTextElement.lineHeight, - width: `${Math.min(textElementWidth, maxWidth)}px`, - height: `${textElementHeight}px`, - left: `${viewportX}px`, + width: `${width}px`, + height: `${height}px`, + left: `${viewportX - padding}px`, top: `${viewportY}px`, ...transformOrigin, transform: getTransform( offsetX, - transformWidth, - updatedTextElement.height, + width, + height, getTextElementAngle(updatedTextElement, container), appState, maxWidth, editorMaxHeight, ), + padding: `0 ${padding}px`, textAlign, verticalAlign, color: updatedTextElement.strokeColor, @@ -310,6 +330,7 @@ export const textWysiwyg = ({ if (isTestEnv()) { editable.style.fontFamily = getFontFamilyString(updatedTextElement); } + mutateElement(updatedTextElement, { x: coordX, y: coordY }); } }; @@ -326,7 +347,7 @@ export const textWysiwyg = ({ let whiteSpace = "pre"; let wordBreak = "normal"; - if (isBoundToContainer(element)) { + if (isBoundToContainer(element) || !element.autoResize) { whiteSpace = "pre-wrap"; wordBreak = "break-word"; } @@ -336,7 +357,6 @@ export const textWysiwyg = ({ minHeight: "1em", backfaceVisibility: "hidden", margin: 0, - padding: 0, border: 0, outline: 0, resize: "none", @@ -383,7 +403,7 @@ export const textWysiwyg = ({ font, getBoundTextMaxWidth(container, boundTextElement), ); - const width = getTextWidth(wrappedText, font); + const width = getTextWidth(wrappedText, font, true); editable.style.width = `${width}px`; } }; @@ -532,14 +552,21 @@ export const textWysiwyg = ({ }; const stopEvent = (event: Event) => { - event.preventDefault(); - event.stopPropagation(); + if (event.target instanceof HTMLCanvasElement) { + event.preventDefault(); + event.stopPropagation(); + } }; // using a state variable instead of passing it to the handleSubmit callback // so that we don't need to create separate a callback for event handlers let submittedViaKeyboard = false; const handleSubmit = () => { + // prevent double submit + if (isDestroyed) { + return; + } + isDestroyed = true; // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the // wysiwyg on update @@ -550,14 +577,12 @@ export const textWysiwyg = ({ if (!updateElement) { return; } - let text = editable.value; const container = getContainerElement( updateElement, app.scene.getNonDeletedElementsMap(), ); if (container) { - text = updateElement.text; if (editable.value.trim()) { const boundTextElementId = getBoundTextElementId(container); if (!boundTextElementId || boundTextElementId !== element.id) { @@ -589,17 +614,12 @@ export const textWysiwyg = ({ } onSubmit({ - text, viaKeyboard: submittedViaKeyboard, - originalText: editable.value, + nextOriginalText: editable.value, }); }; const cleanup = () => { - if (isDestroyed) { - return; - } - isDestroyed = true; // remove events to ensure they don't late-fire editable.onblur = null; editable.oninput = null; @@ -628,46 +648,15 @@ export const textWysiwyg = ({ // in that same tick. const target = event?.target; - const isTargetPickerTrigger = + const isPropertiesTrigger = target instanceof HTMLElement && - target.classList.contains("active-color"); + target.classList.contains("properties-trigger"); setTimeout(() => { editable.onblur = handleSubmit; - if (isTargetPickerTrigger) { - const callback = ( - mutationList: MutationRecord[], - observer: MutationObserver, - ) => { - const radixIsRemoved = mutationList.find( - (mutation) => - mutation.removedNodes.length > 0 && - (mutation.removedNodes[0] as HTMLElement).dataset - ?.radixPopperContentWrapper !== undefined, - ); - - if (radixIsRemoved) { - // should work without this in theory - // and i think it does actually but radix probably somewhere, - // somehow sets the focus elsewhere - setTimeout(() => { - editable.focus(); - }); - - observer.disconnect(); - } - }; - - const observer = new MutationObserver(callback); - - observer.observe(document.querySelector(".excalidraw-container")!, { - childList: true, - }); - } - // case: clicking on the same property → no change → no update → no focus - if (!isTargetPickerTrigger) { + if (!isPropertiesTrigger) { editable.focus(); } }); @@ -675,32 +664,50 @@ export const textWysiwyg = ({ // prevent blur when changing properties from the menu const onPointerDown = (event: MouseEvent) => { - const isTargetPickerTrigger = - event.target instanceof HTMLElement && - event.target.classList.contains("active-color"); + const target = event?.target; + + const isPropertiesTrigger = + target instanceof HTMLElement && + target.classList.contains("properties-trigger"); if ( ((event.target instanceof HTMLElement || event.target instanceof SVGElement) && event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) && !isWritableElement(event.target)) || - isTargetPickerTrigger + isPropertiesTrigger ) { editable.onblur = null; window.addEventListener("pointerup", bindBlurEvent); // handle edge-case where pointerup doesn't fire e.g. due to user // alt-tabbing away window.addEventListener("blur", handleSubmit); + } else if ( + event.target instanceof HTMLElement && + event.target instanceof HTMLCanvasElement && + // Vitest simply ignores stopPropagation, capture-mode, or rAF + // so without introducing crazier hacks, nothing we can do + !isTestEnv() + ) { + // On mobile, blur event doesn't seem to always fire correctly, + // so we want to also submit on pointerdown outside the wysiwyg. + // Done in the next frame to prevent pointerdown from creating a new text + // immediately (if tools locked) so that users on mobile have chance + // to submit first (to hide virtual keyboard). + // Note: revisit if we want to differ this behavior on Desktop + requestAnimationFrame(() => { + handleSubmit(); + }); } }; // handle updates of textElement properties of editing element - const unbindUpdate = Scene.getScene(element)!.addCallback(() => { + const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { updateWysiwygStyle(); - const isColorPickerActive = !!document.activeElement?.closest( - ".color-picker-content", + const isPopupOpened = !!document.activeElement?.closest( + ".properties-content", ); - if (!isColorPickerActive) { + if (!isPopupOpened) { editable.focus(); } }); @@ -709,9 +716,11 @@ export const textWysiwyg = ({ let isDestroyed = false; - // select on init (focusing is done separately inside the bindBlurEvent() - // because we need it to happen *after* the blur event from `pointerdown`) - editable.select(); + if (autoSelect) { + // select on init (focusing is done separately inside the bindBlurEvent() + // because we need it to happen *after* the blur event from `pointerdown`) + editable.select(); + } bindBlurEvent(); // reposition wysiwyg in case of canvas is resized. Using ResizeObserver @@ -726,7 +735,13 @@ export const textWysiwyg = ({ window.addEventListener("resize", updateWysiwygStyle); } - window.addEventListener("pointerdown", onPointerDown); + editable.onpointerdown = (event) => event.stopPropagation(); + + // rAF (+ capture to by doubly sure) so we don't catch te pointerdown that + // triggered the wysiwyg + requestAnimationFrame(() => { + window.addEventListener("pointerdown", onPointerDown, { capture: true }); + }); window.addEventListener("wheel", stopEvent, { passive: false, capture: true, diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index a72dcf78a..0b642b274 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -9,7 +9,6 @@ import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { rotate } from "../math"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; -import { isTextElement } from "."; import { isFrameLikeElement, isLinearElement } from "./typeChecks"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, @@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = { rotation: true, }; -const OMIT_SIDES_FOR_TEXT_ELEMENT = { - e: true, - s: true, - n: true, - w: true, -}; - const OMIT_SIDES_FOR_LINE_SLASH = { e: true, s: true, @@ -290,8 +282,6 @@ export const getTransformHandles = ( omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; } } - } else if (isTextElement(element)) { - omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; } else if (isFrameLikeElement(element)) { omitSides = { ...omitSides, diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index e7dea3cef..347f41ce6 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -132,7 +132,7 @@ export const isBindingElementType = ( }; export const isBindableElement = ( - element: ExcalidrawElement | null, + element: ExcalidrawElement | null | undefined, includeLocked = true, ): element is ExcalidrawBindableElement => { return ( diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 31219f1b7..bcf87320d 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -194,6 +194,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; originalText: string; + /** + * If `true` the width will fit the text. If `false`, the text will + * wrap to fit the width. + * + * @default true + */ + autoResize: boolean; /** * Unitless line height (aligned to W3C). To get line height in px, multiply * with font size (using `getLineHeightInPx` helper). diff --git a/packages/excalidraw/fonts/ExcalidrawFont.ts b/packages/excalidraw/fonts/ExcalidrawFont.ts new file mode 100644 index 000000000..5e14c1160 --- /dev/null +++ b/packages/excalidraw/fonts/ExcalidrawFont.ts @@ -0,0 +1,78 @@ +import { stringToBase64, toByteString } from "../data/encode"; + +export interface Font { + url: URL; + fontFace: FontFace; + getContent(): Promise; +} +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 { + 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, + )}`; + } +} diff --git a/public/fonts/Assistant-Bold.woff2 b/packages/excalidraw/fonts/assets/Assistant-Bold.woff2 similarity index 100% rename from public/fonts/Assistant-Bold.woff2 rename to packages/excalidraw/fonts/assets/Assistant-Bold.woff2 diff --git a/public/fonts/Assistant-Medium.woff2 b/packages/excalidraw/fonts/assets/Assistant-Medium.woff2 similarity index 100% rename from public/fonts/Assistant-Medium.woff2 rename to packages/excalidraw/fonts/assets/Assistant-Medium.woff2 diff --git a/public/fonts/Assistant-Regular.woff2 b/packages/excalidraw/fonts/assets/Assistant-Regular.woff2 similarity index 100% rename from public/fonts/Assistant-Regular.woff2 rename to packages/excalidraw/fonts/assets/Assistant-Regular.woff2 diff --git a/public/fonts/Assistant-SemiBold.woff2 b/packages/excalidraw/fonts/assets/Assistant-SemiBold.woff2 similarity index 100% rename from public/fonts/Assistant-SemiBold.woff2 rename to packages/excalidraw/fonts/assets/Assistant-SemiBold.woff2 diff --git a/packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2 b/packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2 new file mode 100644 index 000000000..c43ff84cc Binary files /dev/null and b/packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2 differ diff --git a/packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 b/packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 new file mode 100644 index 000000000..efa4f1c74 Binary files /dev/null and b/packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 b/packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 new file mode 100644 index 000000000..24ce44aa1 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 differ diff --git a/packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 b/packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 new file mode 100644 index 000000000..86ed395a2 Binary files /dev/null and b/packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Virgil-Regular.woff2 b/packages/excalidraw/fonts/assets/Virgil-Regular.woff2 new file mode 100644 index 000000000..bf5d08ce8 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Virgil-Regular.woff2 differ diff --git a/packages/excalidraw/fonts/assets/fonts.css b/packages/excalidraw/fonts/assets/fonts.css new file mode 100644 index 000000000..c56bb3833 --- /dev/null +++ b/packages/excalidraw/fonts/assets/fonts.css @@ -0,0 +1,34 @@ +/* Only UI fonts here, which are needed before the editor initializes. */ +/* These also cannot be preprended with `EXCALIDRAW_ASSET_PATH`. */ + +@font-face { + font-family: "Assistant"; + src: url(./Assistant-Regular.woff2) format("woff2"); + font-weight: 400; + style: normal; + display: swap; +} + +@font-face { + font-family: "Assistant"; + src: url(./Assistant-Medium.woff2) format("woff2"); + font-weight: 500; + style: normal; + display: swap; +} + +@font-face { + font-family: "Assistant"; + src: url(./Assistant-SemiBold.woff2) format("woff2"); + font-weight: 600; + style: normal; + display: swap; +} + +@font-face { + font-family: "Assistant"; + src: url(./Assistant-Bold.woff2) format("woff2"); + font-weight: 700; + style: normal; + display: swap; +} diff --git a/packages/excalidraw/fonts/index.ts b/packages/excalidraw/fonts/index.ts new file mode 100644 index 000000000..cc41ad149 --- /dev/null +++ b/packages/excalidraw/fonts/index.ts @@ -0,0 +1,308 @@ +import type Scene from "../scene/Scene"; +import type { ValueOf } from "../utility-types"; +import type { ExcalidrawTextElement, FontFamilyValues } from "../element/types"; +import { ShapeCache } from "../scene/ShapeCache"; +import { isTextElement } from "../element"; +import { getFontString } from "../utils"; +import { FONT_FAMILY } from "../constants"; +import { + LOCAL_FONT_PROTOCOL, + FONT_METADATA, + RANGES, + type FontMetadata, +} from "./metadata"; +import { ExcalidrawFont, type Font } from "./ExcalidrawFont"; +import { getContainerElement } from "../element/textElement"; + +import Virgil from "./assets/Virgil-Regular.woff2"; +import Excalifont from "./assets/Excalifont-Regular.woff2"; +import Cascadia from "./assets/CascadiaMono-Regular.woff2"; +import ComicShanns from "./assets/ComicShanns-Regular.woff2"; +import LiberationSans from "./assets/LiberationSans-Regular.woff2"; + +import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2"; +import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2"; + +import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"; +import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2"; +import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2"; +import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2"; +import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2"; + +export class Fonts { + // it's ok to track fonts across multiple instances only once, so let's use + // a static member to reduce memory footprint + public static readonly loadedFontsCache = new Set(); + + 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()), + ); + } + + 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, + { 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, + { 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"]; +}; diff --git a/packages/excalidraw/fonts/metadata.ts b/packages/excalidraw/fonts/metadata.ts new file mode 100644 index 000000000..ea13b1543 --- /dev/null +++ b/packages/excalidraw/fonts/metadata.ts @@ -0,0 +1,125 @@ +import { + FontFamilyCodeIcon, + FontFamilyHeadingIcon, + FontFamilyNormalIcon, + FreedrawIcon, +} from "../components/icons"; +import { FONT_FAMILY } from "../constants"; + +/** + * Encapsulates font metrics with additional font metadata. + * */ +export interface FontMetadata { + /** for head & hhea metrics read the woff2 with https://fontdrop.info/ */ + metrics: { + /** head.unitsPerEm metric */ + unitsPerEm: 1000 | 1024 | 2048; + /** hhea.ascender metric */ + ascender: number; + /** hhea.descender metric */ + descender: number; + /** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */ + lineHeight: number; + }; + /** element to be displayed as an icon */ + icon: JSX.Element; + /** flag to indicate a deprecated font */ + deprecated?: true; + /** flag to indicate a server-side only font */ + serverSide?: true; +} + +export const FONT_METADATA: Record = { + [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:"; diff --git a/packages/excalidraw/fractionalIndex.ts b/packages/excalidraw/fractionalIndex.ts index 618500439..01b6d7015 100644 --- a/packages/excalidraw/fractionalIndex.ts +++ b/packages/excalidraw/fractionalIndex.ts @@ -11,15 +11,15 @@ import { InvalidFractionalIndexError } from "./errors"; * Envisioned relation between array order and fractional indices: * * 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation. - * - it's undesirable to to perform reorder for each related operation, thefeore it's necessary to cache the order defined by fractional indices into an ordered data structure + * - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure * - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps) * - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc. * - it's necessary to always keep the fractional indices in sync with the array order * - elements with invalid indices should be detected and synced, without altering the already valid indices * * 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated. - * - as the fractional indices are encoded as part of the elements, it opens up possibilties for incremental-like APIs - * - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & undo/redo + * - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs + * - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo * - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits, * as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order */ @@ -133,27 +133,11 @@ const getMovedIndicesGroups = ( let i = 0; while (i < elements.length) { - if ( - movedElements.has(elements[i].id) && - !isValidFractionalIndex( - elements[i]?.index, - elements[i - 1]?.index, - elements[i + 1]?.index, - ) - ) { + if (movedElements.has(elements[i].id)) { const indicesGroup = [i - 1, i]; // push the lower bound index as the first item while (++i < elements.length) { - if ( - !( - movedElements.has(elements[i].id) && - !isValidFractionalIndex( - elements[i]?.index, - elements[i - 1]?.index, - elements[i + 1]?.index, - ) - ) - ) { + if (!movedElements.has(elements[i].id)) { break; } diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts index 73a6e0f87..87b64f591 100644 --- a/packages/excalidraw/groups.ts +++ b/packages/excalidraw/groups.ts @@ -373,7 +373,9 @@ export const getNonDeletedGroupIds = (elements: ElementsMap) => { return nonDeletedGroupIds; }; -export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => { +export const elementsAreInSameGroup = ( + elements: readonly ExcalidrawElement[], +) => { const allGroups = elements.flatMap((element) => element.groupIds); const groupCount = new Map(); let maxGroup = 0; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 98dd9d8eb..645e5ea8d 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -5,7 +5,7 @@ import { isShallowEqual } from "./utils"; import "./css/app.scss"; import "./css/styles.scss"; -import "../../public/fonts/fonts.css"; +import "./fonts/assets/fonts.css"; import polyfill from "./polyfill"; import type { AppProps, ExcalidrawProps } from "./types"; @@ -50,6 +50,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { validateEmbeddable, renderEmbeddable, aiEnabled, + showDeprecatedFonts, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -137,6 +138,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { validateEmbeddable={validateEmbeddable} renderEmbeddable={renderEmbeddable} aiEnabled={aiEnabled !== false} + showDeprecatedFonts={showDeprecatedFonts} > {children} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 512ec4ea0..2fe441810 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -109,6 +109,7 @@ "share": "Share", "showStroke": "Show stroke color picker", "showBackground": "Show background color picker", + "showFonts": "Show font picker", "toggleTheme": "Toggle light/dark theme", "theme": "Theme", "personalLib": "Personal Library", @@ -148,7 +149,9 @@ "discordChat": "Discord chat", "zoomToFitViewport": "Zoom to fit in viewport", "zoomToFitSelection": "Zoom to fit selection", - "zoomToFit": "Zoom to fit all elements" + "zoomToFit": "Zoom to fit all elements", + "installPWA": "Install Excalidraw locally (PWA)", + "autoResize": "Enable text auto-resizing" }, "library": { "noItems": "No items added yet...", @@ -268,6 +271,22 @@ "mermaidToExcalidraw": "Mermaid to Excalidraw", "magicSettings": "AI settings" }, + "element": { + "rectangle": "Rectangle", + "diamond": "Diamond", + "ellipse": "Ellipse", + "arrow": "Arrow", + "line": "Line", + "freedraw": "Freedraw", + "text": "Text", + "image": "Image", + "group": "Group", + "frame": "Frame", + "magicframe": "Wireframe to code", + "embeddable": "Web Embed", + "selection": "Selection", + "iframe": "IFrame" + }, "headings": { "canvasActions": "Canvas actions", "selectedShapeActions": "Selected shape actions", @@ -441,7 +460,10 @@ "scene": "Scene", "selected": "Selected", "storage": "Storage", - "title": "Stats for nerds", + "fullTitle": "Stats & Element properties", + "title": "Stats", + "generalStats": "General stats", + "elementProperties": "Element properties", "total": "Total", "version": "Version", "versionCopy": "Click to copy", @@ -536,11 +558,19 @@ "syntax": "Mermaid Syntax", "preview": "Preview" }, - "userList": { - "search": { - "placeholder": "Quick search", - "empty": "No users found" + "quickSearch": { + "placeholder": "Quick search" + }, + "fontList": { + "badge": { + "old": "old" }, + "sceneFonts": "In this scene", + "availableFonts": "Available fonts", + "empty": "No fonts found" + }, + "userList": { + "empty": "No users found", "hint": { "text": "Click on user to follow", "followStatus": "You're currently following this user", diff --git a/packages/excalidraw/math.ts b/packages/excalidraw/math.ts index 50b1cbf07..d84ee7e06 100644 --- a/packages/excalidraw/math.ts +++ b/packages/excalidraw/math.ts @@ -475,6 +475,14 @@ export const isRightAngle = (angle: number) => { return Math.round((angle / Math.PI) * 10000) % 5000 === 0; }; +export const radianToDegree = (r: number) => { + return (r * 180) / Math.PI; +}; + +export const degreeToRadian = (d: number) => { + return (d / 180) * Math.PI; +}; + // Given two ranges, return if the two ranges overlap with each other // e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5] export const rangesOverlap = ( diff --git a/packages/excalidraw/mermaid.ts b/packages/excalidraw/mermaid.ts new file mode 100644 index 000000000..6114cd002 --- /dev/null +++ b/packages/excalidraw/mermaid.ts @@ -0,0 +1,32 @@ +/** heuristically checks whether the text may be a mermaid diagram definition */ +export const isMaybeMermaidDefinition = (text: string) => { + const chartTypes = [ + "flowchart", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "stateDiagram-v2", + "erDiagram", + "journey", + "gantt", + "pie", + "quadrantChart", + "requirementDiagram", + "gitGraph", + "C4Context", + "mindmap", + "timeline", + "zenuml", + "sankey", + "xychart", + "block", + ]; + + const re = new RegExp( + `^(?:%%{.*?}%%[\\s\\n]*)?\\b${chartTypes + .map((x) => `${x}(-beta)?`) + .join("|")}\\b`, + ); + + return re.test(text.trim()); +}; diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 05075fbc6..95971eb38 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "0.3.0", + "@excalidraw/mermaid-to-excalidraw": "1.1.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 22d756bf4..d6b27e72d 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -47,13 +47,18 @@ import { getNormalizedCanvasDimensions, } from "./helpers"; import oc from "open-color"; -import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; +import { + isFrameLikeElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; import type { ElementsMap, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, ExcalidrawLinearElement, + ExcalidrawTextElement, GroupId, NonDeleted, } from "../element/types"; @@ -303,7 +308,6 @@ const renderSelectionBorder = ( cy: number; activeEmbeddable: boolean; }, - padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, ) => { const { angle, @@ -320,6 +324,8 @@ const renderSelectionBorder = ( const elementWidth = elementX2 - elementX1; const elementHeight = elementY2 - elementY1; + const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2; + const linePadding = padding / appState.zoom.value; const lineWidth = 8 / appState.zoom.value; const spaceWidth = 4 / appState.zoom.value; @@ -570,11 +576,34 @@ const renderTransformHandles = ( }); }; +const renderTextBox = ( + text: NonDeleted, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], +) => { + context.save(); + const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + const width = text.width + padding * 2; + const height = text.height + padding * 2; + const cx = text.x + width / 2; + const cy = text.y + height / 2; + const shiftX = -(width / 2 + padding); + const shiftY = -(height / 2 + padding); + context.translate(cx + appState.scrollX, cy + appState.scrollY); + context.rotate(text.angle); + context.lineWidth = 1 / appState.zoom.value; + context.strokeStyle = selectionColor; + context.strokeRect(shiftX, shiftY, width, height); + context.restore(); +}; + const _renderInteractiveScene = ({ canvas, elementsMap, visibleElements, selectedElements, + allElementsMap, scale, appState, renderConfig, @@ -626,12 +655,31 @@ const _renderInteractiveScene = ({ // Paint selection element if (appState.selectionElement) { try { - renderSelectionElement(appState.selectionElement, context, appState); + renderSelectionElement( + appState.selectionElement, + context, + appState, + renderConfig.selectionColor, + ); } catch (error: any) { console.error(error); } } + if (appState.editingElement && isTextElement(appState.editingElement)) { + const textElement = allElementsMap.get(appState.editingElement.id) as + | ExcalidrawTextElement + | undefined; + if (textElement && !textElement.autoResize) { + renderTextBox( + textElement, + context, + appState, + renderConfig.selectionColor, + ); + } + } + if (appState.isBindingEnabled) { appState.suggestedBindings .filter((binding) => binding != null) @@ -810,7 +858,12 @@ const _renderInteractiveScene = ({ "mouse", // when we render we don't know which pointer type so use mouse, getOmitSidesForDevice(device), ); - if (!appState.viewModeEnabled && showBoundingBox) { + if ( + !appState.viewModeEnabled && + showBoundingBox && + // do not show transform handles when text is being edited + !isTextElement(appState.editingElement) + ) { renderTransformHandles( context, renderConfig, diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index c4c89cb14..d830cddd7 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -23,7 +23,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { StaticCanvasRenderConfig, - RenderableElementsMap, + InteractiveCanvasRenderConfig, } from "../scene/types"; import { distance, getFontString, isRTL } from "../utils"; import { getCornerRadius, isRightAngle } from "../math"; @@ -53,12 +53,12 @@ import { getLineHeightInPx, getBoundTextMaxHeight, getBoundTextMaxWidth, - getVerticalOffset, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; import { getContainingFrame } from "../frame"; import { ShapeCache } from "../scene/ShapeCache"; +import { getVerticalOffset } from "../fonts"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original @@ -89,8 +89,16 @@ const shouldResetImageFilter = ( ); }; -const getCanvasPadding = (element: ExcalidrawElement) => - element.type === "freedraw" ? element.strokeWidth * 12 : 20; +const getCanvasPadding = (element: ExcalidrawElement) => { + switch (element.type) { + case "freedraw": + return element.strokeWidth * 12; + case "text": + return element.fontSize / 2; + default: + return 20; + } +}; export const getRenderOpacity = ( element: ExcalidrawElement, @@ -118,11 +126,13 @@ export interface ExcalidrawElementWithCanvas { canvas: HTMLCanvasElement; theme: AppState["theme"]; scale: number; + angle: number; zoomValue: AppState["zoom"]["value"]; canvasOffsetX: number; canvasOffsetY: number; boundTextElementVersion: number | null; containingFrameOpacity: number; + boundTextCanvas: HTMLCanvasElement; } const cappedElementCanvasSize = ( @@ -182,7 +192,7 @@ const cappedElementCanvasSize = ( const generateElementCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: NonDeletedSceneElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, @@ -200,7 +210,7 @@ const generateElementCanvas = ( canvas.width = width; canvas.height = height; - let canvasOffsetX = 0; + let canvasOffsetX = -100; let canvasOffsetY = 0; if (isLinearElement(element) || isFreeDrawElement(element)) { @@ -241,8 +251,72 @@ const generateElementCanvas = ( renderConfig, appState, ); + context.restore(); + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextCanvas = document.createElement("canvas"); + const boundTextCanvasContext = boundTextCanvas.getContext("2d")!; + + if (isArrowElement(element) && boundTextElement) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + boundTextCanvas.width = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvas.height = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvasContext.translate( + boundTextCanvas.width / 2, + boundTextCanvas.height / 2, + ); + boundTextCanvasContext.rotate(element.angle); + boundTextCanvasContext.drawImage( + canvas!, + -canvas.width / 2, + -canvas.height / 2, + canvas.width, + canvas.height, + ); + + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); + + boundTextCanvasContext.rotate(-element.angle); + const offsetX = (boundTextCanvas.width - canvas!.width) / 2; + const offsetY = (boundTextCanvas.height - canvas!.height) / 2; + const shiftX = + boundTextCanvas.width / 2 - + (boundTextCx - x1) * window.devicePixelRatio * scale - + offsetX - + padding * scale; + + const shiftY = + boundTextCanvas.height / 2 - + (boundTextCy - y1) * window.devicePixelRatio * scale - + offsetY - + padding * scale; + boundTextCanvasContext.translate(-shiftX, -shiftY); + // Clear the bound text area + boundTextCanvasContext.clearRect( + -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + (boundTextElement.width + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + (boundTextElement.height + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + ); + } + return { element, canvas, @@ -255,6 +329,8 @@ const generateElementCanvas = ( getBoundTextElement(element, elementsMap)?.version || null, containingFrameOpacity: getContainingFrame(element, elementsMap)?.opacity || 100, + boundTextCanvas, + angle: element.angle, }; }; @@ -297,7 +373,7 @@ const drawImagePlaceholder = ( const drawElementOnCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: ElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, @@ -441,7 +517,7 @@ export const elementWithCanvasCache = new WeakMap< const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: NonDeletedSceneElementsMap, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { @@ -451,8 +527,8 @@ const generateElementWithCanvas = ( prevElementWithCanvas && prevElementWithCanvas.zoomValue !== zoom.value && !appState?.shouldCacheIgnoreZoom; - const boundTextElementVersion = - getBoundTextElement(element, elementsMap)?.version || null; + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextElementVersion = boundTextElement?.version || null; const containingFrameOpacity = getContainingFrame(element, elementsMap)?.opacity || 100; @@ -462,7 +538,14 @@ const generateElementWithCanvas = ( shouldRegenerateBecauseZoom || prevElementWithCanvas.theme !== appState.theme || prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || - prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity + prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity || + // since we rotate the canvas when copying from cached canvas, we don't + // regenerate the cached canvas. But we need to in case of labels which are + // cached alongside the arrow, and we want the labels to remain unrotated + // with respect to the arrow. + (isArrowElement(element) && + boundTextElement && + element.angle !== prevElementWithCanvas.angle) ) { const elementWithCanvas = generateElementCanvas( element, @@ -489,16 +572,7 @@ const drawElementFromCanvas = ( const element = elementWithCanvas.element; const padding = getCanvasPadding(element); const zoom = elementWithCanvas.scale; - let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); - - // Free draw elements will otherwise "shuffle" as the min x and y change - if (isFreeDrawElement(element)) { - x1 = Math.floor(x1); - x2 = Math.ceil(x2); - y1 = Math.floor(y1); - y2 = Math.ceil(y2); - } - + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio; const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio; @@ -508,75 +582,21 @@ const drawElementFromCanvas = ( const boundTextElement = getBoundTextElement(element, allElementsMap); if (isArrowElement(element) && boundTextElement) { - const tempCanvas = document.createElement("canvas"); - const tempCanvasContext = tempCanvas.getContext("2d")!; - - // Take max dimensions of arrow canvas so that when canvas is rotated - // the arrow doesn't get clipped - const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); - tempCanvas.width = - maxDim * window.devicePixelRatio * zoom + - padding * elementWithCanvas.scale * 10; - tempCanvas.height = - maxDim * window.devicePixelRatio * zoom + - padding * elementWithCanvas.scale * 10; - const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2; - const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2; - - tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2); - tempCanvasContext.rotate(element.angle); - - tempCanvasContext.drawImage( - elementWithCanvas.canvas!, - -elementWithCanvas.canvas.width / 2, - -elementWithCanvas.canvas.height / 2, - elementWithCanvas.canvas.width, - elementWithCanvas.canvas.height, - ); - - const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( - boundTextElement, - allElementsMap, - ); - - tempCanvasContext.rotate(-element.angle); - - // Shift the canvas to the center of the bound text element - const shiftX = - tempCanvas.width / 2 - - (boundTextCx - x1) * window.devicePixelRatio * zoom - - offsetX - - padding * zoom; - - const shiftY = - tempCanvas.height / 2 - - (boundTextCy - y1) * window.devicePixelRatio * zoom - - offsetY - - padding * zoom; - tempCanvasContext.translate(-shiftX, -shiftY); - // Clear the bound text area - tempCanvasContext.clearRect( - -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * - window.devicePixelRatio * - zoom, - -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * - window.devicePixelRatio * - zoom, - (boundTextElement.width + BOUND_TEXT_PADDING * 2) * - window.devicePixelRatio * - zoom, - (boundTextElement.height + BOUND_TEXT_PADDING * 2) * - window.devicePixelRatio * - zoom, - ); - + const offsetX = + (elementWithCanvas.boundTextCanvas.width - + elementWithCanvas.canvas!.width) / + 2; + const offsetY = + (elementWithCanvas.boundTextCanvas.height - + elementWithCanvas.canvas!.height) / + 2; context.translate(cx, cy); context.drawImage( - tempCanvas, + elementWithCanvas.boundTextCanvas, (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, - tempCanvas.width / zoom, - tempCanvas.height / zoom, + elementWithCanvas.boundTextCanvas.width / zoom, + elementWithCanvas.boundTextCanvas.height / zoom, ); } else { // we translate context to element center so that rotation and scale @@ -637,6 +657,7 @@ export const renderSelectionElement = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], ) => { context.save(); context.translate(element.x + appState.scrollX, element.y + appState.scrollY); @@ -650,7 +671,7 @@ export const renderSelectionElement = ( context.fillRect(offset, offset, element.width, element.height); context.lineWidth = 1 / appState.zoom.value; - context.strokeStyle = " rgb(105, 101, 219)"; + context.strokeStyle = selectionColor; context.strokeRect(offset, offset, element.width, element.height); context.restore(); @@ -658,7 +679,7 @@ export const renderSelectionElement = ( export const renderElement = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: ElementsMap, allElementsMap: NonDeletedSceneElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, @@ -738,7 +759,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, - elementsMap, + allElementsMap, renderConfig, appState, ); @@ -884,7 +905,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, - elementsMap, + allElementsMap, renderConfig, appState, ); diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index 4e84b1b96..3ca749109 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -17,7 +17,6 @@ import { getBoundTextElement, getContainerElement, getLineHeightInPx, - getVerticalOffset, } from "../element/textElement"; import { isArrowElement, @@ -38,6 +37,7 @@ import type { AppState, BinaryFiles } from "../types"; import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; import { getSubtypeMethods } from "../element/subtypes"; +import { getVerticalOffset } from "../fonts"; const roughSVGDrawWithPrecision = ( rsvg: RoughSVG, diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts deleted file mode 100644 index c2f0c38ae..000000000 --- a/packages/excalidraw/scene/Fonts.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { isTextElement, refreshTextDimensions } from "../element"; -import { newElementWith } from "../element/mutateElement"; -import { getContainerElement } from "../element/textElement"; -import { isBoundToContainer } from "../element/typeChecks"; -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; - private onSceneUpdated: () => void; - - constructor({ - scene, - onSceneUpdated, - }: { - scene: Scene; - onSceneUpdated: () => void; - }) { - this.scene = scene; - this.onSceneUpdated = onSceneUpdated; - } - - // 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(); - - /** - * 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; - - this.scene.mapElements((element) => { - if (isTextElement(element) && !isBoundToContainer(element)) { - ShapeCache.delete(element); - didUpdate = true; - return newElementWith(element, { - ...refreshTextDimensions( - element, - getContainerElement(element, this.scene.getNonDeletedElementsMap()), - this.scene.getNonDeletedElementsMap(), - ), - }); - } - return element; - }); - - if (didUpdate) { - this.onSceneUpdated(); - } - }; - - 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[]); - }; -} diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 754bb7d76..63b7e7da7 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -107,9 +107,8 @@ export class Renderer { width, editingElement, pendingImageElementId, - // unused but serves we cache on it to invalidate elements if they - // get mutated - versionNonce: _versionNonce, + // cache-invalidation nonce + sceneNonce: _sceneNonce, }: { zoom: AppState["zoom"]; offsetLeft: AppState["offsetLeft"]; @@ -120,7 +119,7 @@ export class Renderer { width: AppState["width"]; editingElement: AppState["editingElement"]; pendingImageElementId: AppState["pendingImageElementId"]; - versionNonce: ReturnType["getVersionNonce"]>; + sceneNonce: ReturnType["getSceneNonce"]>; }) => { const elements = this.scene.getNonDeletedElements(); diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 2e46d77f5..637415a72 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -105,6 +105,9 @@ class Scene { } } + /** + * @deprecated pass down `app.scene` and use it directly + */ static getScene(elementKey: ElementKey): Scene | null { if (isIdKey(elementKey)) { return this.sceneMapById.get(elementKey) || null; @@ -138,7 +141,17 @@ class Scene { elements: null, cache: new Map(), }; - private versionNonce: number | undefined; + /** + * Random integer regenerated each scene update. + * + * Does not relate to elements versions, it's only a renderer + * cache-invalidation nonce at the moment. + */ + private sceneNonce: number | undefined; + + getSceneNonce() { + return this.sceneNonce; + } getNonDeletedElementsMap() { return this.nonDeletedElementsMap; @@ -214,10 +227,6 @@ class Scene { return (this.elementsMap.get(id) as T | undefined) || null; } - getVersionNonce() { - return this.versionNonce; - } - getNonDeletedElement( id: ExcalidrawElement["id"], ): NonDeleted | null { @@ -286,18 +295,18 @@ class Scene { this.frames = nextFrameLikes; this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; - this.informMutation(); + this.triggerUpdate(); } - informMutation() { - this.versionNonce = randomInteger(); + triggerUpdate() { + this.sceneNonce = randomInteger(); for (const callback of Array.from(this.callbacks)) { callback(); } } - addCallback(cb: SceneStateCallback): SceneStateCallbackRemover { + onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover { if (this.callbacks.has(cb)) { throw new Error(); } diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index c00a8968f..6efc2aec8 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -13,8 +13,8 @@ import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import type { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, - FONT_FAMILY, FRAME_STYLE, + FONT_FAMILY, SVG_NS, THEME, THEME_FILTER, @@ -32,12 +32,18 @@ import { getRootElements, } from "../frame"; import { newTextElement } from "../element"; -import type { Mutable } from "../utility-types"; +import { type Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; -import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; +import { + isFrameElement, + isFrameLikeElement, + isTextElement, +} from "../element/typeChecks"; import type { RenderableElementsMap } from "./types"; import { syncInvalidIndices } from "../fractionalIndex"; import { renderStaticScene } from "../renderer/staticScene"; +import { Fonts } from "../fonts"; +import { LOCAL_FONT_PROTOCOL } from "../fonts/metadata"; const SVG_EXPORT_TAG = ``; @@ -95,7 +101,7 @@ const addFrameLabelsAsTextElements = ( let textElement: Mutable = newTextElement({ x: element.x, y: element.y - FRAME_STYLE.nameOffsetY, - fontFamily: FONT_FAMILY.Assistant, + fontFamily: FONT_FAMILY.Helvetica, fontSize: FRAME_STYLE.nameFontSize, lineHeight: FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"], @@ -269,6 +275,7 @@ export const exportToSvg = async ( */ renderEmbeddables?: boolean; exportingFrame?: ExcalidrawFrameLikeElement | null; + skipInliningFonts?: true; }, ): Promise => { const frameRendering = getFrameRenderingConfig( @@ -333,21 +340,6 @@ export const exportToSvg = async ( svgRoot.setAttribute("filter", THEME_FILTER); } - let assetPath = "https://excalidraw.com/"; - // Asset path needs to be determined only when using package - if (import.meta.env.VITE_IS_EXCALIDRAW_NPM_PACKAGE) { - assetPath = - window.EXCALIDRAW_ASSET_PATH || - `https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${ - import.meta.env.PKG_VERSION - }`; - - if (assetPath?.startsWith("/")) { - assetPath = assetPath.replace("/", `${window.location.origin}/`); - } - assetPath = `${assetPath}/dist/excalidraw-assets/`; - } - const offsetX = -minX + exportPadding; const offsetY = -minY + exportPadding; @@ -371,23 +363,57 @@ export const exportToSvg = async ( `; } + const fontFamilies = elements.reduce((acc, element) => { + if (isTextElement(element)) { + acc.add(element.fontFamily); + } + + return acc; + }, new Set()); + + const fontFaces = opts?.skipInliningFonts + ? [] + : await Promise.all( + Array.from(fontFamilies).map(async (x) => { + const { fontFaces } = Fonts.registered.get(x) ?? {}; + + if (!Array.isArray(fontFaces)) { + console.error( + `Couldn't find registered font-faces for font-family "${x}"`, + Fonts.registered, + ); + return; + } + + return Promise.all( + fontFaces + .filter((font) => font.url.protocol !== LOCAL_FONT_PROTOCOL) + .map(async (font) => { + try { + const content = await font.getContent(); + + return `@font-face { + font-family: ${font.fontFace.family}; + src: url(${content}); + }`; + } catch (e) { + console.error( + `Skipped inlining font with URL "${font.url.toString()}"`, + e, + ); + return ""; + } + }), + ); + }), + ); + svgRoot.innerHTML = ` ${SVG_EXPORT_TAG} ${metadata} ${exportingFrameClipPath} diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 532e8b89c..6f478b310 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -2,7 +2,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Drawable } from "roughjs/bin/core"; import type { ExcalidrawElement, - ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, @@ -55,7 +54,7 @@ export type InteractiveCanvasRenderConfig = { remotePointerUserStates: Map; remotePointerUsernames: Map; remotePointerButton: Map; - selectionColor?: string; + selectionColor: string; // extra options passed to the renderer // --------------------------------------------------------------------------- renderScrollbars?: boolean; @@ -83,6 +82,7 @@ export type InteractiveSceneRenderConfig = { elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; + allElementsMap: NonDeletedSceneElementsMap; scale: number; appState: InteractiveCanvasAppState; renderConfig: InteractiveCanvasRenderConfig; @@ -95,10 +95,6 @@ export type SceneScroll = { scrollY: number; }; -export interface Scene { - elements: ExcalidrawTextElement[]; -} - export type ExportType = | "png" | "clipboard" diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index a8b9b5301..5c0e98676 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -1,3 +1,11 @@ +import { + getClosedCurveShape, + getCurveShape, + getEllipseShape, + getFreedrawShape, + getPolygonShape, + type GeometricShape, +} from "../utils/geometry/shape"; import { ArrowIcon, DiamondIcon, @@ -10,7 +18,11 @@ import { SelectionIcon, TextIcon, } from "./components/icons"; +import { getElementAbsoluteCoords } from "./element"; +import { shouldTestInside } from "./element/collision"; +import type { ElementsMap, ExcalidrawElement } from "./element/types"; import { KEYS } from "./keys"; +import { ShapeCache } from "./scene/ShapeCache"; export const SHAPES = [ { @@ -97,3 +109,53 @@ export const findShapeByKey = (key: string) => { }); return shape?.value || null; }; + +/** + * get the pure geometric shape of an excalidraw element + * which is then used for hit detection + */ +export const getElementShape = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GeometricShape => { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + + return shouldTestInside(element) + ? getClosedCurveShape( + element, + roughShape, + [element.x, element.y], + element.angle, + [cx, cy], + ) + : getCurveShape(roughShape, [element.x, element.y], element.angle, [ + cx, + cy, + ]); + } + + case "ellipse": + return getEllipseShape(element); + + case "freedraw": { + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + } + } +}; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 2052ccdc9..aa79a3ad8 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -1375,6 +1375,7 @@ export const isActiveToolNonLinearSnappable = ( activeToolType === TOOL_TYPE.diamond || activeToolType === TOOL_TYPE.frame || activeToolType === TOOL_TYPE.magicframe || - activeToolType === TOOL_TYPE.image + activeToolType === TOOL_TYPE.image || + activeToolType === TOOL_TYPE.text ); }; diff --git a/packages/excalidraw/store.ts b/packages/excalidraw/store.ts index 62223a8b5..8e934ccba 100644 --- a/packages/excalidraw/store.ts +++ b/packages/excalidraw/store.ts @@ -6,6 +6,7 @@ import { deepCopyElement } from "./element/newElement"; import type { OrderedExcalidrawElement } from "./element/types"; import { Emitter } from "./emitter"; import type { AppState, ObservedAppState } from "./types"; +import type { ValueOf } from "./utility-types"; import { isShallowEqual } from "./utils"; // hidden non-enumerable property for runtime checks @@ -35,16 +36,41 @@ const isObservedAppState = ( ): appState is ObservedAppState => !!Reflect.get(appState, hiddenObservedAppStateProp); -export type StoreActionType = "capture" | "update" | "none"; - -export const StoreAction: { - [K in Uppercase]: StoreActionType; -} = { +export const StoreAction = { + /** + * Immediately undoable. + * + * Use for updates which should be captured. + * Should be used for most of the local updates. + * + * These updates will _immediately_ make it to the local undo / redo stacks. + */ CAPTURE: "capture", + /** + * Never undoable. + * + * Use for updates which should never be recorded, such as remote updates + * or scene initialization. + * + * These updates will _never_ make it to the local undo / redo stacks. + */ UPDATE: "update", + /** + * Eventually undoable. + * + * Use for updates which should not be captured immediately - likely + * exceptions which are part of some async multi-step process. Otherwise, all + * such updates would end up being captured with the next + * `StoreAction.CAPTURE` - triggered either by the next `updateScene` + * or internally by the editor. + * + * These updates will _eventually_ make it to the local undo / redo stacks. + */ NONE: "none", } as const; +export type StoreActionType = ValueOf; + /** * Represent an increment to the Store. */ diff --git a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx index 1e782cfb2..8c5a2acd7 100644 --- a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx +++ b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx @@ -1,28 +1,12 @@ import { act, render, waitFor } from "./test-utils"; import { Excalidraw } from "../index"; -import React from "react"; -import { expect, vi } from "vitest"; -import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw"; +import { expect } from "vitest"; import { getTextEditor, updateTextEditor } from "./queries/dom"; +import { mockMermaidToExcalidraw } from "./helpers/mocks"; -vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => { - const module = (await importActual()) as any; - - return { - __esModule: true, - ...module, - }; -}); -const parseMermaidToExcalidrawSpy = vi.spyOn( - MermaidToExcalidraw, - "parseMermaidToExcalidraw", -); - -parseMermaidToExcalidrawSpy.mockImplementation( - async ( - definition: string, - options?: MermaidToExcalidraw.MermaidOptions | undefined, - ) => { +mockMermaidToExcalidraw({ + mockRef: true, + parseMermaidToExcalidraw: async (definition) => { const firstLine = definition.split("\n")[0]; return new Promise((resolve, reject) => { if (firstLine === "flowchart TD") { @@ -88,12 +72,6 @@ parseMermaidToExcalidrawSpy.mockImplementation( } }); }, -); - -vi.spyOn(React, "useRef").mockReturnValue({ - current: { - parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy, - }, }); describe("Test ", () => { diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f860ae6f..44606feb1 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "collaborators": Map {}, "contextMenu": { "items": [ + "separator", { "icon":