diff --git a/dev-docs/src/css/custom.scss b/dev-docs/src/css/custom.scss index 93c7f90ab..0ab28c9bd 100644 --- a/dev-docs/src/css/custom.scss +++ b/dev-docs/src/css/custom.scss @@ -59,7 +59,7 @@ pre a { padding: 5px; background: #70b1ec; color: white; - font-weight: bold; + font-weight: 700; border: none; } diff --git a/examples/excalidraw/components/App.tsx b/examples/excalidraw/components/App.tsx index 3b553a453..7cfd8a05a 100644 --- a/examples/excalidraw/components/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -872,7 +872,7 @@ export default function App({ files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; - ctx.font = "30px Virgil"; + ctx.font = "30px Excalifont"; ctx.strokeText("My custom text", 50, 60); setCanvasUrl(canvas.toDataURL()); }} @@ -893,7 +893,7 @@ export default function App({ files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; - ctx.font = "30px Virgil"; + ctx.font = "30px Excalifont"; ctx.strokeText("My custom text", 50, 60); setCanvasUrl(canvas.toDataURL()); }} diff --git a/examples/excalidraw/initialData.tsx b/examples/excalidraw/initialData.tsx index 3cb5e7af4..0db23d5f2 100644 --- a/examples/excalidraw/initialData.tsx +++ b/examples/excalidraw/initialData.tsx @@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [ ]; export default { elements, - appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, + appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 }, scrollToContent: true, libraryItems: [ [ diff --git a/examples/excalidraw/with-nextjs/.gitignore b/examples/excalidraw/with-nextjs/.gitignore index fd3dbb571..2279431c5 100644 --- a/examples/excalidraw/with-nextjs/.gitignore +++ b/examples/excalidraw/with-nextjs/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# copied assets +public/*.woff2 \ No newline at end of file diff --git a/examples/excalidraw/with-nextjs/package.json b/examples/excalidraw/with-nextjs/package.json index 177952407..5b4590ac5 100644 --- a/examples/excalidraw/with-nextjs/package.json +++ b/examples/excalidraw/with-nextjs/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", "dev": "yarn build:workspace && next dev -p 3005", "build": "yarn build:workspace && next build", "start": "next start -p 3006", diff --git a/examples/excalidraw/with-nextjs/src/app/page.tsx b/examples/excalidraw/with-nextjs/src/app/page.tsx index bc8c98fcf..191aca120 100644 --- a/examples/excalidraw/with-nextjs/src/app/page.tsx +++ b/examples/excalidraw/with-nextjs/src/app/page.tsx @@ -1,4 +1,5 @@ import dynamic from "next/dynamic"; +import Script from "next/script"; import "../common.scss"; // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically @@ -15,7 +16,9 @@ export default function Page() { <> Switch to Pages router

App Router

- + {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} diff --git a/examples/excalidraw/with-nextjs/src/common.scss b/examples/excalidraw/with-nextjs/src/common.scss index 1a77600a9..456bc7635 100644 --- a/examples/excalidraw/with-nextjs/src/common.scss +++ b/examples/excalidraw/with-nextjs/src/common.scss @@ -7,7 +7,7 @@ a { color: #1c7ed6; font-size: 20px; text-decoration: none; - font-weight: 550; + font-weight: 500; } .page-title { diff --git a/examples/excalidraw/with-script-in-browser/.gitignore b/examples/excalidraw/with-script-in-browser/.gitignore new file mode 100644 index 000000000..215fc2008 --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/.gitignore @@ -0,0 +1,2 @@ +# copied assets +public/*.woff2 \ No newline at end of file diff --git a/examples/excalidraw/with-script-in-browser/index.html b/examples/excalidraw/with-script-in-browser/index.html index a56d7f421..8e29a1d8a 100644 --- a/examples/excalidraw/with-script-in-browser/index.html +++ b/examples/excalidraw/with-script-in-browser/index.html @@ -11,6 +11,7 @@ React App diff --git a/examples/excalidraw/with-script-in-browser/package.json b/examples/excalidraw/with-script-in-browser/package.json index d721ac162..e1c8ac37a 100644 --- a/examples/excalidraw/with-script-in-browser/package.json +++ b/examples/excalidraw/with-script-in-browser/package.json @@ -12,8 +12,10 @@ "typescript": "^5" }, "scripts": { - "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", - "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", + "start": "yarn build:workspace && vite", + "build": "yarn build:workspace && vite build", "build:preview": "yarn build && vite preview --port 5002" } } diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 2fd21f722..3a5c01e10 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -95,6 +95,11 @@ color: #fff; } + + + + + <% if (typeof PROD != 'undefined' && PROD == true) { %> + + + + + <% } else { %> + + + + + + <% } %> + + + + + + @@ -124,22 +176,6 @@ - - - - <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> <% } %> diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index f066cebc7..d0a30b6d9 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -36,7 +36,8 @@ "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", "start": "yarn && vite", - "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", + "start:production": "yarn build && yarn serve", + "serve": "npx http-server build -a localhost -p 5001 -o", "build:preview": "yarn build && vite preview --port 5000" } } diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap index 4e526a998..77fc14757 100644 --- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u class="welcome-screen-center" >
All your data is saved locally in your browser.
diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index 39417de36..ee1256263 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; import { createHtmlPlugin } from "vite-plugin-html"; +import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; // To load .env.local variables const envVars = loadEnv("", `../`); @@ -22,6 +23,14 @@ export default defineConfig({ outDir: "build", rollupOptions: { output: { + assetFileNames(chunkInfo) { + if (chunkInfo?.name?.endsWith(".woff2")) { + // put on root so we are flexible about the CDN path + return '[name]-[hash][extname]'; + } + + return 'assets/[name]-[hash][extname]'; + }, // Creating separate chunk for locales except for en and percentages.json so they // can be cached at runtime and not merged with // app precache. en.json and percentages.json are needed for first load @@ -35,12 +44,13 @@ export default defineConfig({ // Taking the substring after "locales/" return `locales/${id.substring(index + 8)}`; } - }, + } }, }, sourcemap: true, }, plugins: [ + woff2BrowserPlugin(), react(), checker({ typescript: true, diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index cb58d6ab6..c5e633ad6 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -19,6 +19,8 @@ Please add the latest change on the top under the correct section. - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) +- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`. + - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index a61373f0a..139c8bbbc 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -4,10 +4,7 @@ import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; import { register } from "./register"; import { getNonDeletedElements } from "../element"; -import type { - ExcalidrawArrowElement, - ExcalidrawElement, -} from "../element/types"; +import type { ExcalidrawArrowElement, ExcalidrawElement } from "../element/types"; import type { AppClassProperties, AppState } from "../types"; import { mutateElement, newElementWith } from "../element/mutateElement"; import { getElementsInGroup } from "../groups"; diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 2524e8b44..f433ef9c0 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -93,13 +93,13 @@ export const createRedoAction: ActionCreator = (history, store, scene) => ({ icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, - perform: (elements, appState) => + perform: (elements, appState, _, app) => writeData(appState, () => history.redo( arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` appState, store.snapshot, - scene, + app.scene, ), ), keyTest: (event) => 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 3cb4c7782..add2e34e3 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, Point, 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, @@ -50,11 +50,12 @@ import { ArrowheadDiamondIcon, ArrowheadDiamondOutlineIcon, fontSizeIcon, - arrowUpRightIcon, - arrowCurveRight, - arrowGuideIcon, + sharpArrowIcon, + roundArrowIcon, + elbowArrowIcon, } from "../components/icons"; import { + ARROW_TYPE, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, @@ -68,10 +69,7 @@ import { redrawTextBoundingBox, } from "../element"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { - getBoundTextElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; import { isArrowElement, isBoundToContainer, @@ -81,6 +79,7 @@ import { } from "../element/typeChecks"; import type { Arrowhead, + ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, @@ -99,15 +98,23 @@ import { isSomeElementSelected, } from "../scene"; import { hasStrokeColor } from "../scene/comparisons"; -import { arrayToMap, getShortcutKey, tupleToCoors } from "../utils"; +import { + arrayToMap, + getFontFamilyString, + getShortcutKey, + tupleToCoors, +} from "../utils"; import { register } from "./register"; import { StoreAction } from "../store"; -import { getArrowLocalFixedPoints, mutateElbowArrow } from "../element/routing"; +import { Fonts, getLineHeight } from "../fonts"; import { bindLinearElement, bindPointToSnapToElementOutline, + calculateFixedPointForElbowArrowBinding, getHoveredElementForBinding, } from "../element/binding"; +import { mutateElbowArrow } from "../element/routing"; +import { LinearElementEditor } from "../element/linearElementEditor"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -740,104 +747,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(); + } + }} />
); @@ -1262,33 +1556,36 @@ export const actionChangeArrowType = register({ } const newElement = newElementWith(el, { roundness: - value === "round" + value === ARROW_TYPE.round ? { type: ROUNDNESS.PROPORTIONAL_RADIUS, } : null, - elbowed: value === "elbow", + elbowed: value === ARROW_TYPE.elbow, points: - value === "elbow" + value === ARROW_TYPE.elbow || el.elbowed ? [el.points[0], el.points[el.points.length - 1]] : el.points, }); - if (value === "elbow") { + if (isElbowArrow(newElement)) { const elementsMap = app.scene.getNonDeletedElementsMap(); - const [startPoint, endPoint] = getArrowLocalFixedPoints( - newElement, - elementsMap, - ); - const startGlobalPoint = [ - newElement.x + startPoint[0], - newElement.y + startPoint[1], - ] as Point; - const endGlobalPoint = [ - newElement.x + endPoint[0], - newElement.y + endPoint[1], - ] as Point; - const startElement = + + app.dismissLinearEditor(); + + const startGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + 0, + elementsMap, + ); + const endGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + -1, + elementsMap, + ); + const startHoveredElement = !newElement.startBinding && getHoveredElementForBinding( tupleToCoors(startGlobalPoint), @@ -1296,15 +1593,7 @@ export const actionChangeArrowType = register({ elementsMap, true, ); - const finalStartPoint = startElement - ? bindPointToSnapToElementOutline( - startGlobalPoint, - endGlobalPoint, - startElement, - elementsMap, - ) - : startGlobalPoint; - const endElement = + const endHoveredElement = !newElement.endBinding && getHoveredElementForBinding( tupleToCoors(endGlobalPoint), @@ -1312,18 +1601,51 @@ export const actionChangeArrowType = register({ elementsMap, true, ); - const finalEndPoint = endElement + const startElement = startHoveredElement + ? startHoveredElement + : newElement.startBinding && + (elementsMap.get( + newElement.startBinding.elementId, + ) as ExcalidrawBindableElement); + const endElement = endHoveredElement + ? endHoveredElement + : newElement.endBinding && + (elementsMap.get( + newElement.endBinding.elementId, + ) as ExcalidrawBindableElement); + + const finalStartPoint = startHoveredElement + ? bindPointToSnapToElementOutline( + startGlobalPoint, + endGlobalPoint, + startHoveredElement, + elementsMap, + ) + : startGlobalPoint; + const finalEndPoint = endHoveredElement ? bindPointToSnapToElementOutline( endGlobalPoint, startGlobalPoint, - endElement, + endHoveredElement, elementsMap, ) : endGlobalPoint; - startElement && - bindLinearElement(newElement, startElement, "start", elementsMap); - endElement && - bindLinearElement(newElement, endElement, "end", elementsMap); + + startHoveredElement && + bindLinearElement( + newElement, + startHoveredElement, + "start", + elementsMap, + ); + endHoveredElement && + bindLinearElement( + newElement, + endHoveredElement, + "end", + elementsMap, + ); + mutateElbowArrow( newElement, app.scene, @@ -1332,6 +1654,49 @@ export const actionChangeArrowType = register({ [point[0] - newElement.x, point[1] - newElement.y] as Point, ), [0, 0], + { + ...(startElement && newElement.startBinding + ? { + startBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.startBinding!, + ...calculateFixedPointForElbowArrowBinding( + newElement, + startElement, + "start", + elementsMap, + ), + }, + } + : {}), + ...(endElement && newElement.endBinding + ? { + endBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.endBinding, + ...calculateFixedPointForElbowArrowBinding( + newElement, + endElement, + "end", + elementsMap, + ), + }, + } + : {}), + }, + ); + } else { + mutateElement( + newElement, + { + startBinding: newElement.startBinding + ? { ...newElement.startBinding, fixedPoint: null } + : null, + endBinding: newElement.endBinding + ? { ...newElement.endBinding, fixedPoint: null } + : null, + }, + false, ); } @@ -1339,8 +1704,7 @@ export const actionChangeArrowType = register({ }), appState: { ...appState, - currentItemRoundness: value === "round" ? value : null, - currentItemElbowArrow: value === "elbow", + currentItemArrowType: value, }, storeAction: StoreAction.CAPTURE, }; @@ -1353,19 +1717,22 @@ export const actionChangeArrowType = register({ group="arrowtypes" options={[ { - value: "sharp", + value: ARROW_TYPE.sharp, text: t("labels.arrowtype_sharp"), - icon: arrowUpRightIcon, + icon: sharpArrowIcon, + testId: "sharp-arrow", }, { - value: "round", + value: ARROW_TYPE.round, text: t("labels.arrowtype_round"), - icon: arrowCurveRight, + icon: roundArrowIcon, + testId: "round-arrow", }, { - value: "elbow", + value: ARROW_TYPE.elbow, text: t("labels.arrowtype_elbowed"), - icon: arrowGuideIcon, + icon: elbowArrowIcon, + testId: "elbow-arrow", }, ]} value={getFormValue( @@ -1374,23 +1741,17 @@ export const actionChangeArrowType = register({ (element) => { if (isArrowElement(element)) { return element.elbowed - ? "elbow" + ? ARROW_TYPE.elbow : element.roundness - ? "round" - : "sharp"; + ? ARROW_TYPE.round + : ARROW_TYPE.sharp; } return null; }, (element) => isArrowElement(element), (hasSelection) => - hasSelection - ? null - : appState.currentItemElbowArrow - ? "elbow" - : appState.currentItemRoundness - ? "round" - : "sharp", + hasSelection ? null : appState.currentItemArrowType, )} onChange={(value) => updateData(value)} /> diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 9483476f8..1a17bf9de 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -12,10 +12,7 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, } from "../constants"; -import { - getBoundTextElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; import { hasBoundTextElement, canApplyRoundnessTypeToElement, @@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene"; import type { ExcalidrawTextElement } from "../element/types"; import { paintIcon } from "../components/icons"; import { StoreAction } from "../store"; +import { getLineHeight } from "../fonts"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -122,7 +120,7 @@ export const actionPasteStyles = register({ DEFAULT_TEXT_ALIGN, lineHeight: (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || - getDefaultLineHeight(fontFamily), + getLineHeight(fontFamily), }); let container = null; if (newElement.containerId) { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index e7141884f..9c7c43e28 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -1,5 +1,6 @@ import { COLOR_PALETTE } from "./colors"; import { + ARROW_TYPE, DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, @@ -33,10 +34,11 @@ export const getDefaultAppState = (): Omit< currentItemStartArrowhead: null, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemRoundness: "round", - currentItemElbowArrow: false, + currentItemArrowType: ARROW_TYPE.round, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, + currentHoveredFontFamily: null, cursorButton: "up", activeEmbeddable: null, draggingElement: null, @@ -143,7 +145,7 @@ const APP_STATE_STORAGE_CONF = (< export: false, server: false, }, - currentItemElbowArrow: { + currentItemArrowType: { browser: true, export: false, server: false, @@ -155,6 +157,7 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false }, + currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false }, draggingElement: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/binaryheap.ts b/packages/excalidraw/binaryheap.ts index 02bce567c..0bacadceb 100644 --- a/packages/excalidraw/binaryheap.ts +++ b/packages/excalidraw/binaryheap.ts @@ -27,7 +27,7 @@ export default class BinaryHeap { const child2N = (idx + 1) << 1; const child1N = child2N - 1; let swap = null; - let child1Score = 0; // MARK + let child1Score = 0; if (child1N < length) { const child1 = this.content[child1N]; @@ -84,7 +84,6 @@ export default class BinaryHeap { const i = this.content.indexOf(node); const end = this.content.pop()!; - // MARK if (i < this.content.length) { this.content[i] = end; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 099932fe5..91102aef2 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -165,10 +165,8 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontSize")} - {renderAction("changeFontFamily")} - + {renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d5aac6217..fce85c032 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -48,7 +48,7 @@ import { } from "../appState"; import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; -import type { EXPORT_IMAGE_TYPES } from "../constants"; +import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -157,7 +157,6 @@ import { isLinearElement, isLinearElementType, isUsingAdaptiveRadius, - isFrameElement, isIframeElement, isIframeLikeElement, isMagicFrameElement, @@ -227,8 +226,7 @@ import type { ScrollBars, } from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; -import { findShapeByKey, getElementShape } from "../shapes"; -import type { GeometricShape } from "../../utils/geometry/shape"; +import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes"; import { getSelectionBoxShape } from "../../utils/geometry/shape"; import { isPointInShape } from "../../utils/collision"; import type { @@ -324,7 +322,6 @@ import { getBoundTextElement, getContainerCenter, getContainerElement, - getDefaultLineHeight, getLineHeightInPx, getMinTextElementWidth, isMeasureTextSupported, @@ -340,7 +337,7 @@ import { import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; -import { Fonts } from "../scene/Fonts"; +import { Fonts, getLineHeight } from "../fonts"; import { getFrameChildren, isCursorInFrame, @@ -536,8 +533,8 @@ class App extends React.Component { private excalidrawContainerRef = React.createRef(); public scene: Scene; + public fonts: Fonts; public renderer: Renderer; - private fonts: Fonts; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -1294,15 +1291,7 @@ class App extends React.Component { const isDarkTheme = this.state.theme === THEME.DARK; - let frameIndex = 0; - let magicFrameIndex = 0; - return this.scene.getNonDeletedFramesLikes().map((f) => { - if (isFrameElement(f)) { - frameIndex++; - } else { - magicFrameIndex++; - } if ( !isElementInViewport( f, @@ -1336,10 +1325,7 @@ class App extends React.Component { let frameNameJSX; - const frameName = getFrameLikeTitle( - f, - isFrameElement(f) ? frameIndex : magicFrameIndex, - ); + const frameName = getFrameLikeTitle(f); if (f.id === this.state.editingFrame) { const frameNameInEdit = frameName; @@ -2130,6 +2116,14 @@ class App extends React.Component { }); }; + public dismissLinearEditor = () => { + setTimeout(() => { + this.setState({ + editingLinearElement: null, + }); + }); + }; + public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => { if (this.unmounted || actionResult === false) { return; @@ -2339,11 +2333,6 @@ class App extends React.Component { }), }; } - // FontFaceSet loadingdone event we listen on may not always fire - // (looking at you Safari), so on init we manually load fonts for current - // text elements on canvas, and rerender them once done. This also - // seems faster even in browsers that do fire the loadingdone event. - this.fonts.loadFontsForElements(scene.elements); this.resetStore(); this.resetHistory(); @@ -2351,6 +2340,12 @@ class App extends React.Component { ...scene, storeAction: StoreAction.UPDATE, }); + + // FontFaceSet loadingdone event we listen on may not always + // fire (looking at you Safari), so on init we manually load all + // fonts and rerender scene text elements once done. This also + // seems faster even in browsers that do fire the loadingdone event. + this.fonts.loadSceneFonts(); }; private isMobileBreakpoint = (width: number, height: number) => { @@ -2443,6 +2438,10 @@ class App extends React.Component { configurable: true, value: this.store, }, + fonts: { + configurable: true, + value: this.fonts, + }, }); } @@ -2580,7 +2579,7 @@ class App extends React.Component { // rerender text elements on font load to fix #637 && #1553 addEventListener(document.fonts, "loadingdone", (event) => { const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onFontsLoaded(loadedFontFaces); + this.fonts.onLoaded(loadedFontFaces); }), // Safari-only desktop pinch zoom addEventListener( @@ -3384,7 +3383,7 @@ class App extends React.Component { fontSize: textElementProps.fontSize, fontFamily: textElementProps.fontFamily, }); - const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); + const lineHeight = getLineHeight(textElementProps.fontFamily); const [x1, , x2] = getVisibleSceneBounds(this.state); // long texts should not go beyond 800 pixels in width nor should it go below 200 px const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200); @@ -3402,13 +3401,13 @@ class App extends React.Component { }); let metrics = measureText(originalText, fontString, lineHeight); - const isTextWrapped = metrics.width > maxTextWidth; + const isTextUnwrapped = metrics.width > maxTextWidth; - const text = isTextWrapped + const text = isTextUnwrapped ? wrapText(originalText, fontString, maxTextWidth) : originalText; - metrics = isTextWrapped + metrics = isTextUnwrapped ? measureText(text, fontString, lineHeight) : metrics; @@ -3422,7 +3421,7 @@ class App extends React.Component { text, originalText, lineHeight, - autoResize: !isTextWrapped, + autoResize: !isTextUnwrapped, frameId: topLayerFrame ? topLayerFrame.id : null, }); acc.push(element); @@ -4088,6 +4087,16 @@ class App extends React.Component { })`, ); } + if (shape === "arrow" && this.state.activeTool.type === "arrow") { + this.setState((prevState) => ({ + currentItemArrowType: + prevState.currentItemArrowType === ARROW_TYPE.sharp + ? ARROW_TYPE.round + : prevState.currentItemArrowType === ARROW_TYPE.round + ? ARROW_TYPE.elbow + : ARROW_TYPE.sharp, + })); + } this.setActiveTool({ type: shape }); event.stopPropagation(); } else if (event.key === KEYS.Q) { @@ -4128,6 +4137,36 @@ class App extends React.Component { } } + if ( + !event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + event.key.toLowerCase() === KEYS.F + ) { + const selectedElements = this.scene.getSelectedElements(this.state); + + if ( + this.state.activeTool.type === "selection" && + !selectedElements.length + ) { + return; + } + + if ( + this.state.activeTool.type === "text" || + selectedElements.find( + (element) => + isTextElement(element) || + getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ), + ) + ) { + event.preventDefault(); + this.setState({ openPopup: "fontFamily" }); + } + } + if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (this.state.activeTool.type === "laser") { this.setActiveTool({ type: "selection" }); @@ -4504,37 +4543,6 @@ class App extends React.Component { return null; } - private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null { - const boundTextElement = getBoundTextElement( - element, - this.scene.getNonDeletedElementsMap(), - ); - - if (boundTextElement) { - if (element.type === "arrow") { - return getElementShape( - { - ...boundTextElement, - // arrow's bound text accurate position is not stored in the element's property - // but rather calculated and returned from the following static method - ...LinearElementEditor.getBoundTextElementPosition( - element, - boundTextElement, - this.scene.getNonDeletedElementsMap(), - ), - }, - this.scene.getNonDeletedElementsMap(), - ); - } - return getElementShape( - boundTextElement, - this.scene.getNonDeletedElementsMap(), - ); - } - - return null; - } - private getElementAtPosition( x: number, y: number, @@ -4666,7 +4674,7 @@ class App extends React.Component { const hitBoundTextOfElement = hitElementBoundText( x, y, - this.getBoundTextShape(element), + getBoundTextShape(element, this.scene.getNonDeletedElementsMap()), ); if (hitBoundTextOfElement) { return true; @@ -4784,7 +4792,7 @@ class App extends React.Component { existingTextElement?.fontFamily || this.state.currentItemFontFamily; const lineHeight = - existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); + existingTextElement?.lineHeight || getLineHeight(fontFamily); const fontSize = this.state.currentItemFontSize; if ( @@ -4904,7 +4912,9 @@ class App extends React.Component { if ( event[KEYS.CTRL_OR_CMD] && (!this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== selectedElements[0].id) + this.state.editingLinearElement.elementId !== + selectedElements[0].id) && + !isElbowArrow(selectedElements[0]) ) { this.store.shouldCaptureIncrement(); this.setState({ @@ -5362,7 +5372,7 @@ class App extends React.Component { } if (isElbowArrow(multiElement)) { mutateElbowArrow( - multiElement as ExcalidrawArrowElement, + multiElement, this.scene, [ ...points.slice(0, -1), @@ -7067,14 +7077,16 @@ class App extends React.Component { roughness: this.state.currentItemRoughness, opacity: this.state.currentItemOpacity, roundness: - this.state.currentItemRoundness === "round" + this.state.currentItemArrowType === ARROW_TYPE.round ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } - : null, + : // note, roundness doesn't have any effect for elbow arrows, + // but it's best to set it to null as well + null, startArrowhead, endArrowhead, locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, - elbowed: this.state.currentItemElbowArrow, + elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, }) : newLinearElement({ type: elementType, @@ -7113,6 +7125,7 @@ class App extends React.Component { pointerDownState.origin, this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), + isElbowArrow(element), ); this.scene.insertElement(element); @@ -7578,19 +7591,18 @@ class App extends React.Component { event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, ); - // TODO: Check if needed to re-add this - // if ( - // selectedElements.length !== 1 || - // !isArrowElement(selectedElements[0]) || - // !pointerDownState.drag.hasOccurred || - // !!this.state.editingLinearElement - // ) { - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - ), - }); + if ( + selectedElements.length !== 1 || + !isElbowArrow(selectedElements[0]) + ) { + this.setState({ + suggestedBindings: getSuggestedBindingsForArrows( + selectedElements, + this.scene.getNonDeletedElementsMap(), + ), + }); + } + //} // We duplicate the selected element if alt is pressed on pointer move @@ -7733,7 +7745,7 @@ class App extends React.Component { }); } else if (points.length > 1 && isElbowArrow(draggingElement)) { mutateElbowArrow( - draggingElement as ExcalidrawArrowElement, + draggingElement, this.scene, [...points.slice(0, -1), [dx, dy]], [0, 0], diff --git a/packages/excalidraw/components/ButtonIcon.scss b/packages/excalidraw/components/ButtonIcon.scss new file mode 100644 index 000000000..e435b69e4 --- /dev/null +++ b/packages/excalidraw/components/ButtonIcon.scss @@ -0,0 +1,12 @@ +@import "../css/theme"; + +.excalidraw { + button.standalone { + @include outlineButtonIconStyles; + + & > * { + // dissalow pointer events on children, so we always have event.target on the button itself + pointer-events: none; + } + } +} diff --git a/packages/excalidraw/components/ButtonIcon.tsx b/packages/excalidraw/components/ButtonIcon.tsx new file mode 100644 index 000000000..5421f4c3a --- /dev/null +++ b/packages/excalidraw/components/ButtonIcon.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; +import clsx from "clsx"; + +import "./ButtonIcon.scss"; + +interface ButtonIconProps { + icon: JSX.Element; + title: string; + className?: string; + testId?: string; + /** if not supplied, defaults to value identity check */ + active?: boolean; + /** include standalone style (could interfere with parent styles) */ + standalone?: boolean; + onClick: (event: React.MouseEvent) => void; +} + +export const ButtonIcon = forwardRef( + (props, ref) => { + const { title, className, testId, active, standalone, icon, onClick } = + props; + return ( + + ); + }, +); diff --git a/packages/excalidraw/components/ButtonIconSelect.tsx b/packages/excalidraw/components/ButtonIconSelect.tsx index 6933f0304..c3a390257 100644 --- a/packages/excalidraw/components/ButtonIconSelect.tsx +++ b/packages/excalidraw/components/ButtonIconSelect.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import { ButtonIcon } from "./ButtonIcon"; // TODO: It might be "clever" to add option.icon to the existing component export const ButtonIconSelect = ( @@ -24,21 +25,17 @@ export const ButtonIconSelect = ( } ), ) => ( -
+
{props.options.map((option) => props.type === "button" ? ( - + testId={option.testId} + active={option.active ?? props.value === option.value} + onClick={(event) => props.onClick(option.value, event)} + /> ) : (