diff --git a/.env.development b/.env.development index 72b67ecea7..0c2fb5527c 100644 --- a/.env.development +++ b/.env.development @@ -22,3 +22,13 @@ REACT_APP_DEV_ENABLE_SW= REACT_APP_DEV_DISABLE_LIVE_RELOAD= FAST_REFRESH=false + +# MATOMO +REACT_APP_MATOMO_URL= +REACT_APP_CDN_MATOMO_TRACKER_URL= +REACT_APP_MATOMO_SITE_ID= + +#Debug flags + +# To enable bounding box for text containers +REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX= diff --git a/.env.production b/.env.production index 183db7ea2a..8737c63c7f 100644 --- a/.env.production +++ b/.env.production @@ -12,6 +12,13 @@ REACT_APP_WS_SERVER_URL= REACT_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"}' # production-only vars +# GOOGLE ANALYTICS REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 +# MATOMO +REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/ +REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js +REACT_APP_MATOMO_SITE_ID=1 + + REACT_APP_PLUS_APP=https://app.excalidraw.com diff --git a/.gitignore b/.gitignore index 4a3f6f367d..e637a8c0f9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ src/packages/excalidraw/types src/packages/excalidraw/example/public/bundle.js src/packages/excalidraw/example/public/excalidraw-assets-dev src/packages/excalidraw/example/public/excalidraw.development.js +coverage diff --git a/README.md b/README.md index c5f7f5cd42..48529165e6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ An open source virtual hand-drawn style whiteboard.
Collaborative and end-to-end encrypted.

- +
@@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports: ## Excalidraw.com -The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features: +The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features: - 📡 PWA support (works offline). - 🤼 Real-time collaboration. diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx index a37843c76d..08e8079071 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx @@ -1,6 +1,19 @@ # ref +
-createRef | useRef | callbackRef | 
{ current: { readyPromise: resolvablePromise } } + + createRef + {" "} + |{" "} + useRef{" "} + |{" "} + + callbackRef + {" "} + |
+ { current: { readyPromise: + resolvablePromise + } }
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs: @@ -139,7 +152,9 @@ function App() { return (

Click to update the scene

- + setExcalidrawAPI(api)} />
); @@ -187,7 +202,8 @@ function App() { return (

Click to update the library items

- + ) : ( + + ), + )}
); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 9a1a91039b..aebb42de7a 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton"; import { useSetAtom } from "jotai"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { useExcalidrawSetAppState } from "./App"; +import { jotaiScope } from "../jotai"; interface Props extends Omit { onConfirm: () => void; @@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => { ...rest } = props; const setAppState = useExcalidrawSetAppState(); - const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); + const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); return ( { }, [islandNode, props.autofocus]); const setAppState = useExcalidrawSetAppState(); - const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); + const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const onClose = () => { setAppState({ openMenu: null }); diff --git a/src/components/ErrorDialog.tsx b/src/components/ErrorDialog.tsx index c1c7899812..56c303c157 100644 --- a/src/components/ErrorDialog.tsx +++ b/src/components/ErrorDialog.tsx @@ -5,13 +5,13 @@ import { Dialog } from "./Dialog"; import { useExcalidrawContainer } from "./App"; export const ErrorDialog = ({ - message, + children, onClose, }: { - message: string; + children?: React.ReactNode; onClose?: () => void; }) => { - const [modalIsShown, setModalIsShown] = useState(!!message); + const [modalIsShown, setModalIsShown] = useState(!!children); const { container: excalidrawContainer } = useExcalidrawContainer(); const handleClose = React.useCallback(() => { @@ -32,7 +32,7 @@ export const ErrorDialog = ({ onCloseRequest={handleClose} title={t("errorDialog.title")} > -
{message}
+
{children}
)} diff --git a/src/components/ExportDialog.scss b/src/components/ExportDialog.scss index 7bc0c808ed..3cb31c4843 100644 --- a/src/components/ExportDialog.scss +++ b/src/components/ExportDialog.scss @@ -9,6 +9,10 @@ text-align: center; padding: var(--preview-padding); margin-bottom: calc(var(--space-factor) * 3); + + display: flex; + justify-content: center; + align-items: center; } .ExportDialog__preview canvas { diff --git a/src/components/HelpButton.tsx b/src/components/HelpButton.tsx index 40c130271e..ce387244c1 100644 --- a/src/components/HelpButton.tsx +++ b/src/components/HelpButton.tsx @@ -1,7 +1,7 @@ +import { t } from "../i18n"; import { HelpIcon } from "./icons"; type HelpButtonProps = { - title?: string; name?: string; id?: string; onClick?(): void; @@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => ( className="help-icon" onClick={props.onClick} type="button" - title={`${props.title} — ?`} - aria-label={props.title} + title={`${t("helpDialog.title")} — ?`} + aria-label={t("helpDialog.title")} > {HelpIcon} diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 8cb775fc5d..bd2f384178 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -165,11 +165,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { shortcuts={[KEYS.E, KEYS["0"]]} /> + } {appState.errorMessage && ( - setAppState({ errorMessage: null })} - /> + setAppState({ errorMessage: null })}> + {appState.errorMessage} + )} {appState.openDialog === "help" && ( { const content = selectedItems.length diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index f6189fcb77..7ae6517a8f 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -12,6 +12,7 @@ import { MIME_TYPES } from "../constants"; import Spinner from "./Spinner"; import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; import clsx from "clsx"; +import { duplicateElements } from "../element/newElement"; const CELLS_PER_ROW = 4; @@ -96,7 +97,14 @@ const LibraryMenuItems = ({ } else { targetElements = libraryItems.filter((item) => item.id === id); } - return targetElements; + return targetElements.map((item) => { + return { + ...item, + // duplicate each library item before inserting on canvas to confine + // ids and bindings to each library item. See #6465 + elements: duplicateElements(item.elements), + }; + }); }; const createLibraryItemCompo = (params: { diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 6723ac800c..749877cddf 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import oc from "open-color"; import { useEffect, useRef, useState } from "react"; import { useDevice } from "../components/App"; -import { exportToSvg } from "../scene/export"; +import { exportToSvg } from "../packages/utils"; import { LibraryItem } from "../types"; import "./LibraryUnit.scss"; import { CheckboxItem } from "./CheckboxItem"; @@ -36,14 +36,14 @@ export const LibraryUnit = ({ if (!elements) { return; } - const svg = await exportToSvg( + const svg = await exportToSvg({ elements, - { + appState: { exportBackground: false, viewBackgroundColor: oc.white, }, - null, - ); + files: null, + }); svg.querySelector(".style-fonts")?.remove(); node.innerHTML = svg.outerHTML; })(); diff --git a/src/components/Popover.scss b/src/components/Popover.scss index 84d16e47f6..9458b50261 100644 --- a/src/components/Popover.scss +++ b/src/components/Popover.scss @@ -3,5 +3,6 @@ position: absolute; z-index: 10; padding: 5px 0 5px; + outline: none; } } diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index 9a046599bc..987e9fb91d 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -29,13 +29,21 @@ export const Popover = ({ }: Props) => { const popoverRef = useRef(null); - const container = popoverRef.current; - useEffect(() => { + const container = popoverRef.current; + if (!container) { return; } + // focus popover only if the caller didn't focus on something else nested + // within the popover, which should take precedence. Fixes cases + // like color picker listening to keydown events on containers nested + // in the popover. + if (!container.contains(document.activeElement)) { + container.focus(); + } + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.TAB) { const focusableElements = queryFocusableElements(container); @@ -44,15 +52,23 @@ export const Popover = ({ (element) => element === activeElement, ); - if (currentIndex === 0 && event.shiftKey) { - focusableElements[focusableElements.length - 1].focus(); + if (activeElement === container) { + if (event.shiftKey) { + focusableElements[focusableElements.length - 1]?.focus(); + } else { + focusableElements[0].focus(); + } + event.preventDefault(); + event.stopImmediatePropagation(); + } else if (currentIndex === 0 && event.shiftKey) { + focusableElements[focusableElements.length - 1]?.focus(); event.preventDefault(); event.stopImmediatePropagation(); } else if ( currentIndex === focusableElements.length - 1 && !event.shiftKey ) { - focusableElements[0].focus(); + focusableElements[0]?.focus(); event.preventDefault(); event.stopImmediatePropagation(); } @@ -62,35 +78,59 @@ export const Popover = ({ container.addEventListener("keydown", handleKeyDown); return () => container.removeEventListener("keydown", handleKeyDown); - }, [container]); + }, []); + + const lastInitializedPosRef = useRef<{ top: number; left: number } | null>( + null, + ); // ensure the popover doesn't overflow the viewport useLayoutEffect(() => { - if (fitInViewport && popoverRef.current) { - const element = popoverRef.current; - const { x, y, width, height } = element.getBoundingClientRect(); + if (fitInViewport && popoverRef.current && top != null && left != null) { + const container = popoverRef.current; + const { width, height } = container.getBoundingClientRect(); - //Position correctly when clicked on rightmost part or the bottom part of viewport - if (x + width - offsetLeft > viewportWidth) { - element.style.left = `${viewportWidth - width - 10}px`; - } - if (y + height - offsetTop > viewportHeight) { - element.style.top = `${viewportHeight - height}px`; + // hack for StrictMode so this effect only runs once for + // the same top/left position, otherwise + // we'd potentically reposition twice (once for viewport overflow) + // and once for top/left position afterwards + if ( + lastInitializedPosRef.current?.top === top && + lastInitializedPosRef.current?.left === left + ) { + return; } + lastInitializedPosRef.current = { top, left }; - //Resize to fit viewport on smaller screens - if (height >= viewportHeight) { - element.style.height = `${viewportHeight - 20}px`; - element.style.top = "10px"; - element.style.overflowY = "scroll"; - } if (width >= viewportWidth) { - element.style.width = `${viewportWidth}px`; - element.style.left = "0px"; - element.style.overflowX = "scroll"; + container.style.width = `${viewportWidth}px`; + container.style.left = "0px"; + container.style.overflowX = "scroll"; + } else if (left + width - offsetLeft > viewportWidth) { + container.style.left = `${viewportWidth - width - 10}px`; + } else { + container.style.left = `${left}px`; + } + + if (height >= viewportHeight) { + container.style.height = `${viewportHeight - 20}px`; + container.style.top = "10px"; + container.style.overflowY = "scroll"; + } else if (top + height - offsetTop > viewportHeight) { + container.style.top = `${viewportHeight - height}px`; + } else { + container.style.top = `${top}px`; } } - }, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]); + }, [ + top, + left, + fitInViewport, + viewportWidth, + viewportHeight, + offsetLeft, + offsetTop, + ]); useEffect(() => { if (onCloseRequest) { @@ -105,7 +145,7 @@ export const Popover = ({ }, [onCloseRequest]); return ( -
+
{children}
); diff --git a/src/components/PublishLibrary.scss b/src/components/PublishLibrary.scss index 6040ff2f48..fd7db0fe47 100644 --- a/src/components/PublishLibrary.scss +++ b/src/components/PublishLibrary.scss @@ -93,4 +93,80 @@ display: block; } } + + .single-library-item { + position: relative; + + &-status { + position: absolute; + top: 0.3rem; + left: 0.3rem; + font-size: 0.7rem; + color: $oc-red-7; + background: rgba(255, 255, 255, 0.9); + padding: 0.1rem 0.2rem; + border-radius: 0.2rem; + } + + &__svg { + background-color: $oc-white; + padding: 0.3rem; + width: 7.5rem; + height: 7.5rem; + border: 1px solid var(--button-gray-2); + svg { + width: 100%; + height: 100%; + } + } + + .ToolIcon__icon { + background-color: $oc-white; + width: auto; + height: auto; + margin: 0 0.5rem; + } + .ToolIcon, + .ToolIcon_type_button:hover { + background-color: white; + } + .required, + .error { + color: $oc-red-8; + font-weight: bold; + font-size: 1rem; + margin: 0.2rem; + } + .error { + font-weight: 500; + margin: 0; + padding: 0.3em 0; + } + + &--remove { + position: absolute; + top: 0.2rem; + right: 1rem; + + .ToolIcon__icon { + margin: 0; + } + .ToolIcon__icon { + background-color: $oc-red-6; + &:hover { + background-color: $oc-red-7; + } + &:active { + background-color: $oc-red-8; + } + } + svg { + color: $oc-white; + padding: 0.26rem; + border-radius: 0.3em; + width: 1rem; + height: 1rem; + } + } + } } diff --git a/src/components/PublishLibrary.tsx b/src/components/PublishLibrary.tsx index 0d060dfaa8..b4233222dd 100644 --- a/src/components/PublishLibrary.tsx +++ b/src/components/PublishLibrary.tsx @@ -1,11 +1,11 @@ -import { ReactNode, useCallback, useEffect, useState } from "react"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import OpenColor from "open-color"; import { Dialog } from "./Dialog"; import { t } from "../i18n"; import { AppState, LibraryItems, LibraryItem } from "../types"; -import { exportToCanvas } from "../packages/utils"; +import { exportToCanvas, exportToSvg } from "../packages/utils"; import { EXPORT_DATA_TYPES, EXPORT_SOURCE, @@ -13,12 +13,13 @@ import { VERSIONS, } from "../constants"; import { ExportedLibraryData } from "../data/types"; - -import "./PublishLibrary.scss"; -import SingleLibraryItem from "./SingleLibraryItem"; import { canvasToBlob, resizeImageFile } from "../data/blob"; import { chunk } from "../utils"; import DialogActionButton from "./DialogActionButton"; +import { CloseIcon } from "./icons"; +import { ToolButton } from "./ToolButton"; + +import "./PublishLibrary.scss"; interface PublishLibraryDataParams { authorName: string; @@ -126,6 +127,99 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => { ); }; +const SingleLibraryItem = ({ + libItem, + appState, + index, + onChange, + onRemove, +}: { + libItem: LibraryItem; + appState: AppState; + index: number; + onChange: (val: string, index: number) => void; + onRemove: (id: string) => void; +}) => { + const svgRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const node = svgRef.current; + if (!node) { + return; + } + (async () => { + const svg = await exportToSvg({ + elements: libItem.elements, + appState: { + ...appState, + viewBackgroundColor: OpenColor.white, + exportBackground: true, + }, + files: null, + }); + node.innerHTML = svg.outerHTML; + })(); + }, [libItem.elements, appState]); + + return ( +
+ {libItem.status === "published" && ( + + {t("labels.statusPublished")} + + )} +
+ +
+ + {libItem.error} +
+
+ ); +}; + const PublishLibrary = ({ onClose, libraryItems, diff --git a/src/components/SingleLibraryItem.scss b/src/components/SingleLibraryItem.scss deleted file mode 100644 index 0a42992a15..0000000000 --- a/src/components/SingleLibraryItem.scss +++ /dev/null @@ -1,79 +0,0 @@ -@import "../css/variables.module"; - -.excalidraw { - .single-library-item { - position: relative; - - &-status { - position: absolute; - top: 0.3rem; - left: 0.3rem; - font-size: 0.7rem; - color: $oc-red-7; - background: rgba(255, 255, 255, 0.9); - padding: 0.1rem 0.2rem; - border-radius: 0.2rem; - } - - &__svg { - background-color: $oc-white; - padding: 0.3rem; - width: 7.5rem; - height: 7.5rem; - border: 1px solid var(--button-gray-2); - svg { - width: 100%; - height: 100%; - } - } - - .ToolIcon__icon { - background-color: $oc-white; - width: auto; - height: auto; - margin: 0 0.5rem; - } - .ToolIcon, - .ToolIcon_type_button:hover { - background-color: white; - } - .required, - .error { - color: $oc-red-8; - font-weight: bold; - font-size: 1rem; - margin: 0.2rem; - } - .error { - font-weight: 500; - margin: 0; - padding: 0.3em 0; - } - - &--remove { - position: absolute; - top: 0.2rem; - right: 1rem; - - .ToolIcon__icon { - margin: 0; - } - .ToolIcon__icon { - background-color: $oc-red-6; - &:hover { - background-color: $oc-red-7; - } - &:active { - background-color: $oc-red-8; - } - } - svg { - color: $oc-white; - padding: 0.26rem; - border-radius: 0.3em; - width: 1rem; - height: 1rem; - } - } - } -} diff --git a/src/components/SingleLibraryItem.tsx b/src/components/SingleLibraryItem.tsx deleted file mode 100644 index 45959199ca..0000000000 --- a/src/components/SingleLibraryItem.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import oc from "open-color"; -import { useEffect, useRef } from "react"; -import { t } from "../i18n"; -import { exportToSvg } from "../packages/utils"; -import { AppState, LibraryItem } from "../types"; -import { CloseIcon } from "./icons"; - -import "./SingleLibraryItem.scss"; -import { ToolButton } from "./ToolButton"; - -const SingleLibraryItem = ({ - libItem, - appState, - index, - onChange, - onRemove, -}: { - libItem: LibraryItem; - appState: AppState; - index: number; - onChange: (val: string, index: number) => void; - onRemove: (id: string) => void; -}) => { - const svgRef = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - const node = svgRef.current; - if (!node) { - return; - } - (async () => { - const svg = await exportToSvg({ - elements: libItem.elements, - appState: { - ...appState, - viewBackgroundColor: oc.white, - exportBackground: true, - }, - files: null, - }); - node.innerHTML = svg.outerHTML; - })(); - }, [libItem.elements, appState]); - - return ( -
- {libItem.status === "published" && ( - - {t("labels.statusPublished")} - - )} -
- -
- - {libItem.error} -
-
- ); -}; - -export default SingleLibraryItem; diff --git a/src/components/__snapshots__/App.test.tsx.snap b/src/components/__snapshots__/App.test.tsx.snap new file mode 100644 index 0000000000..b36d678cdb --- /dev/null +++ b/src/components/__snapshots__/App.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test should show error modal when using brave and measureText API is not working 1`] = ` +
+

+ Looks like you are using Brave browser with the +   + + Aggressively Block Fingerprinting + + + setting enabled + . +
+
+ This could result in breaking the + + + Text Elements + + + in your drawings + . +

+

+ We strongly recommend disabling this setting. You can follow + + + + these steps + + + on how to do so + . +

+

+ If disabling this setting doesn't fix the display of text elements, please open an + + + issue + + + on our GitHub, or write us on + + + Discord + + . +

+
+`; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 046ee490b8..784e810248 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1008,6 +1008,13 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) => ), ); +export const FillZigZagIcon = createIcon( + + + , + modifiedTablerIconProps, +); + export const FillHachureIcon = createIcon( <> { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); if (!actionManager.isActionEnabled(actionLoadScene)) { @@ -57,9 +56,7 @@ export const LoadScene = () => { LoadScene.displayName = "LoadScene"; export const SaveToActiveFile = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); if (!actionManager.isActionEnabled(actionSaveToActiveFile)) { @@ -80,9 +77,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile"; export const SaveAsImage = () => { const setAppState = useExcalidrawSetAppState(); - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); return ( { SaveAsImage.displayName = "SaveAsImage"; export const Help = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -119,10 +112,12 @@ export const Help = () => { Help.displayName = "Help"; export const ClearCanvas = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); - const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); + const { t } = useI18n(); + + const setActiveConfirmDialog = useSetAtom( + activeConfirmDialogAtom, + jotaiScope, + ); const actionManager = useExcalidrawActionManager(); if (!actionManager.isActionEnabled(actionClearCanvas)) { @@ -143,6 +138,7 @@ export const ClearCanvas = () => { ClearCanvas.displayName = "ClearCanvas"; export const ToggleTheme = () => { + const { t } = useI18n(); const appState = useExcalidrawAppState(); const actionManager = useExcalidrawActionManager(); @@ -175,6 +171,7 @@ export const ToggleTheme = () => { ToggleTheme.displayName = "ToggleTheme"; export const ChangeCanvasBackground = () => { + const { t } = useI18n(); const appState = useExcalidrawAppState(); const actionManager = useExcalidrawActionManager(); @@ -195,9 +192,7 @@ export const ChangeCanvasBackground = () => { ChangeCanvasBackground.displayName = "ChangeCanvasBackground"; export const Export = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const setAppState = useExcalidrawSetAppState(); return ( void; isCollaborating: boolean; }) => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); return ( any; }) => { - // FIXME when we tie t() to lang state - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const appState = useExcalidrawAppState(); - + const { t } = useI18n(); return ( {t("labels.liveCollaboration")} diff --git a/src/constants.ts b/src/constants.ts index c3952b92cd..ef563e4a41 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,12 @@ export const isFirefox = "netscape" in window && navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("Gecko") > 1; +export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; +export const isSafari = + !isChrome && navigator.userAgent.indexOf("Safari") !== -1; +// keeping function so it can be mocked in test +export const isBrave = () => + (navigator as any).brave?.isBrave?.name === "isBrave"; export const APP_NAME = "Excalidraw"; diff --git a/src/css/styles.scss b/src/css/styles.scss index 28a42d0696..8dafbfbdfa 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -155,6 +155,9 @@ margin: 1px; } + .welcome-screen-menu-item:focus-visible, + .dropdown-menu-item:focus-visible, + button:focus-visible, .buttonList label:focus-within, input:focus-visible { outline: transparent; @@ -530,6 +533,7 @@ // (doesn't work in Firefox) ::-webkit-scrollbar { width: 3px; + height: 3px; } ::-webkit-scrollbar-thumb { @@ -567,8 +571,8 @@ } .App-toolbar--mobile { - overflow-x: hidden; - max-width: 100vw; + overflow-x: auto; + max-width: 90vw; .ToolIcon__keybinding { display: none; diff --git a/src/data/blob.ts b/src/data/blob.ts index 473042b56f..47cff293fa 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -7,6 +7,7 @@ import { CanvasError } from "../errors"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { AppState, DataURL, LibraryItem } from "../types"; +import { ValueOf } from "../utility-types"; import { bytesToHexString } from "../utils"; import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem"; import { isValidExcalidrawData, isValidLibrary } from "./json"; @@ -156,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async ( }, localAppState, localElements, - { repairBindings: true }, + { repairBindings: true, refreshDimensions: true }, ), }; } else if (isValidLibrary(data)) { diff --git a/src/data/index.ts b/src/data/index.ts index 89877aab9a..20ba75ebe5 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -89,7 +89,9 @@ export const exportCanvas = async ( return await fileSave(blob, { description: "Export to PNG", name, - extension: appState.exportEmbedScene ? "excalidraw.png" : "png", + // FIXME reintroduce `excalidraw.png` when most people upgrade away + // from 111.0.5563.64 (arm64), see #6349 + extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png", fileHandle, }); } else if (type === "clipboard") { diff --git a/src/data/restore.ts b/src/data/restore.ts index 0af2f9dc48..fcf5fa1325 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -31,9 +31,15 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { getUpdatedTimestamp, updateActiveTool } from "../utils"; +import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import oc from "open-color"; +import { MarkOptional, Mutable } from "../utility-types"; +import { + detectLineHeight, + getDefaultLineHeight, + measureBaseline, +} from "../element/textElement"; type RestoredAppState = Omit< AppState, @@ -164,18 +170,40 @@ const restoreElement = ( const [fontPx, _fontFamily]: [string, string] = ( element as any ).font.split(" "); - fontSize = parseInt(fontPx, 10); + fontSize = parseFloat(fontPx); fontFamily = getFontFamilyByName(_fontFamily); } + const text = element.text ?? ""; + + // line-height might not be specified either when creating elements + // programmatically, or when importing old diagrams. + // For the latter we want to detect the original line height which + // will likely differ from our per-font fixed line height we now use, + // to maintain backward compatibility. + const lineHeight = + element.lineHeight || + (element.height + ? // detect line-height from current element height and font-size + detectLineHeight(element) + : // no element height likely means programmatic use, so default + // to a fixed line height + getDefaultLineHeight(element.fontFamily)); + const baseline = measureBaseline( + element.text, + getFontString(element), + lineHeight, + ); element = restoreElementWithProperties(element, { fontSize, fontFamily, - text: element.text ?? "", - baseline: element.baseline, + text, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, - originalText: element.originalText || element.text, + originalText: element.originalText || text, + + lineHeight, + baseline, }); if (refreshDimensions) { @@ -341,6 +369,9 @@ export const restoreElements = ( localElements: readonly ExcalidrawElement[] | null | undefined, opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, ): ExcalidrawElement[] => { + // used to detect duplicate top-level element ids + const existingIds = new Set(); + const localElementsMap = localElements ? arrayToMap(localElements) : null; const restoredElements = (elements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, @@ -355,6 +386,10 @@ export const restoreElements = ( if (localElement && localElement.version > migratedElement.version) { migratedElement = bumpVersion(migratedElement, localElement.version); } + if (existingIds.has(migratedElement.id)) { + migratedElement = { ...migratedElement, id: randomId() }; + } + existingIds.add(migratedElement.id); elements.push(migratedElement); } } @@ -479,7 +514,9 @@ export const restoreAppState = ( ? { value: appState.zoom as NormalizedZoomValue, } - : appState.zoom || defaultAppState.zoom, + : appState.zoom?.value + ? appState.zoom + : defaultAppState.zoom, // when sidebar docked and user left it open in last session, // keep it open. If not docked, keep it closed irrespective of last state. openSidebar: diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 2eab1d93da..3245ca3fc2 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -23,6 +23,7 @@ import { import { rescalePoints } from "../points"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; +import { Mutable } from "../utility-types"; // x and y position of top left corner, x and y position of bottom right corner export type Bounds = readonly [number, number, number, number]; diff --git a/src/element/collision.ts b/src/element/collision.ts index 54540ae5eb..0e7257d796 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -38,6 +38,7 @@ import { isTextElement } from "."; import { isTransparent } from "../utils"; import { shouldShowBoundingBox } from "./transformHandles"; import { getBoundTextElement } from "./textElement"; +import { Mutable } from "../utility-types"; const isElementDraggableFromInside = ( element: NonDeletedExcalidrawElement, @@ -785,7 +786,12 @@ export const findFocusPointForEllipse = ( orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / squares; - const n = (-m * px - 1) / py; + let n = (-m * px - 1) / py; + + if (n === 0) { + // if zero {-0, 0}, fall back to a same-sign value in the similar range + n = (Object.is(n, -0) ? -1 : 1) * 0.01; + } const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); return GA.point(x, (-m * x - 1) / n); diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index 5c478515df..aedc259744 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getShapeForElement } from "../renderer/renderElement"; import { DRAGGING_THRESHOLD } from "../constants"; +import { Mutable } from "../utility-types"; const editorMidPointsCache: { version: number | null; diff --git a/src/element/mutateElement.ts b/src/element/mutateElement.ts index 52038c1630..1c3d661217 100644 --- a/src/element/mutateElement.ts +++ b/src/element/mutateElement.ts @@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points"; import { randomInteger } from "../random"; import { Point } from "../types"; import { getUpdatedTimestamp } from "../utils"; +import { Mutable } from "../utility-types"; type ElementUpdate = Omit< Partial, diff --git a/src/element/newElement.test.ts b/src/element/newElement.test.ts index 991c034e0c..ba7c63ee2d 100644 --- a/src/element/newElement.test.ts +++ b/src/element/newElement.test.ts @@ -1,8 +1,9 @@ -import { duplicateElement } from "./newElement"; +import { duplicateElement, duplicateElements } from "./newElement"; import { mutateElement } from "./mutateElement"; import { API } from "../tests/helpers/api"; import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { isPrimitive } from "../utils"; +import { ExcalidrawLinearElement } from "./types"; const assertCloneObjects = (source: any, clone: any) => { for (const key in clone) { @@ -15,79 +16,353 @@ const assertCloneObjects = (source: any, clone: any) => { } }; -it("clones arrow element", () => { - const element = API.createElement({ - type: "arrow", - x: 0, - y: 0, - strokeColor: "#000000", - backgroundColor: "transparent", - fillStyle: "hachure", - strokeWidth: 1, - strokeStyle: "solid", - roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, - roughness: 1, - opacity: 100, +describe("duplicating single elements", () => { + it("clones arrow element", () => { + const element = API.createElement({ + type: "arrow", + x: 0, + y: 0, + strokeColor: "#000000", + backgroundColor: "transparent", + fillStyle: "hachure", + strokeWidth: 1, + strokeStyle: "solid", + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + roughness: 1, + opacity: 100, + }); + + // @ts-ignore + element.__proto__ = { hello: "world" }; + + mutateElement(element, { + points: [ + [1, 2], + [3, 4], + ], + }); + + const copy = duplicateElement(null, new Map(), element); + + assertCloneObjects(element, copy); + + // assert we clone the object's prototype + // @ts-ignore + expect(copy.__proto__).toEqual({ hello: "world" }); + expect(copy.hasOwnProperty("hello")).toBe(false); + + expect(copy.points).not.toBe(element.points); + expect(copy).not.toHaveProperty("shape"); + expect(copy.id).not.toBe(element.id); + expect(typeof copy.id).toBe("string"); + expect(copy.seed).not.toBe(element.seed); + expect(typeof copy.seed).toBe("number"); + expect(copy).toEqual({ + ...element, + id: copy.id, + seed: copy.seed, + }); }); - // @ts-ignore - element.__proto__ = { hello: "world" }; + it("clones text element", () => { + const element = API.createElement({ + type: "text", + x: 0, + y: 0, + strokeColor: "#000000", + backgroundColor: "transparent", + fillStyle: "hachure", + strokeWidth: 1, + strokeStyle: "solid", + roundness: null, + roughness: 1, + opacity: 100, + text: "hello", + fontSize: 20, + fontFamily: FONT_FAMILY.Virgil, + textAlign: "left", + verticalAlign: "top", + }); - mutateElement(element, { - points: [ - [1, 2], - [3, 4], - ], - }); + const copy = duplicateElement(null, new Map(), element); - const copy = duplicateElement(null, new Map(), element); + assertCloneObjects(element, copy); - assertCloneObjects(element, copy); - - // @ts-ignore - expect(copy.__proto__).toEqual({ hello: "world" }); - expect(copy.hasOwnProperty("hello")).toBe(false); - - expect(copy.points).not.toBe(element.points); - expect(copy).not.toHaveProperty("shape"); - expect(copy.id).not.toBe(element.id); - expect(typeof copy.id).toBe("string"); - expect(copy.seed).not.toBe(element.seed); - expect(typeof copy.seed).toBe("number"); - expect(copy).toEqual({ - ...element, - id: copy.id, - seed: copy.seed, + expect(copy).not.toHaveProperty("points"); + expect(copy).not.toHaveProperty("shape"); + expect(copy.id).not.toBe(element.id); + expect(typeof copy.id).toBe("string"); + expect(typeof copy.seed).toBe("number"); }); }); -it("clones text element", () => { - const element = API.createElement({ - type: "text", - x: 0, - y: 0, - strokeColor: "#000000", - backgroundColor: "transparent", - fillStyle: "hachure", - strokeWidth: 1, - strokeStyle: "solid", - roundness: null, - roughness: 1, - opacity: 100, - text: "hello", - fontSize: 20, - fontFamily: FONT_FAMILY.Virgil, - textAlign: "left", - verticalAlign: "top", +describe("duplicating multiple elements", () => { + it("duplicateElements should clone bindings", () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + { id: "text1", type: "text" }, + ], + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + containerId: "rectangle1", + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + boundElements: [{ id: "text2", type: "text" }], + }); + + const text2 = API.createElement({ + type: "text", + id: "text2", + containerId: "arrow2", + }); + + // ------------------------------------------------------------------------- + + const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; + const clonedElements = duplicateElements(origElements); + + // generic id in-equality checks + // -------------------------------------------------------------------------- + expect(origElements.map((e) => e.type)).toEqual( + clonedElements.map((e) => e.type), + ); + origElements.forEach((origElement, idx) => { + const clonedElement = clonedElements[idx]; + expect(origElement).toEqual( + expect.objectContaining({ + id: expect.not.stringMatching(clonedElement.id), + type: clonedElement.type, + }), + ); + if ("containerId" in origElement) { + expect(origElement.containerId).not.toBe( + (clonedElement as any).containerId, + ); + } + if ("endBinding" in origElement) { + if (origElement.endBinding) { + expect(origElement.endBinding.elementId).not.toBe( + (clonedElement as any).endBinding?.elementId, + ); + } else { + expect((clonedElement as any).endBinding).toBeNull(); + } + } + if ("startBinding" in origElement) { + if (origElement.startBinding) { + expect(origElement.startBinding.elementId).not.toBe( + (clonedElement as any).startBinding?.elementId, + ); + } else { + expect((clonedElement as any).startBinding).toBeNull(); + } + } + }); + // -------------------------------------------------------------------------- + + const clonedArrows = clonedElements.filter( + (e) => e.type === "arrow", + ) as ExcalidrawLinearElement[]; + + const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] = + clonedElements as any as typeof origElements; + + expect(clonedText1.containerId).toBe(clonedRectangle.id); + expect( + clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id), + ).toEqual( + expect.objectContaining({ + id: clonedText1.id, + type: clonedText1.type, + }), + ); + + clonedArrows.forEach((arrow) => { + // console.log(arrow); + expect( + clonedRectangle.boundElements!.find((e) => e.id === arrow.id), + ).toEqual( + expect.objectContaining({ + id: arrow.id, + type: arrow.type, + }), + ); + + if (arrow.endBinding) { + expect(arrow.endBinding.elementId).toBe(clonedRectangle.id); + } + if (arrow.startBinding) { + expect(arrow.startBinding.elementId).toBe(clonedRectangle.id); + } + }); + + expect(clonedArrow2.boundElements).toEqual([ + { type: "text", id: clonedArrowLabel.id }, + ]); + expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id); }); - const copy = duplicateElement(null, new Map(), element); + it("should remove id references of elements that aren't found", () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + boundElements: [ + // should keep + { id: "arrow1", type: "arrow" }, + // should drop + { id: "arrow-not-exists", type: "arrow" }, + // should drop + { id: "text-not-exists", type: "text" }, + ], + }); - assertCloneObjects(element, copy); + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + }); - expect(copy).not.toHaveProperty("points"); - expect(copy).not.toHaveProperty("shape"); - expect(copy.id).not.toBe(element.id); - expect(typeof copy.id).toBe("string"); - expect(typeof copy.seed).toBe("number"); + const text1 = API.createElement({ + type: "text", + id: "text1", + containerId: "rectangle-not-exists", + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + endBinding: { + elementId: "rectangle-not-exists", + focus: 0.2, + gap: 7, + }, + }); + + const arrow3 = API.createElement({ + type: "arrow", + id: "arrow2", + startBinding: { + elementId: "rectangle-not-exists", + focus: 0.2, + gap: 7, + }, + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + }); + + // ------------------------------------------------------------------------- + + const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; + const clonedElements = duplicateElements( + origElements, + ) as any as typeof origElements; + const [ + clonedRectangle, + clonedText1, + clonedArrow1, + clonedArrow2, + clonedArrow3, + ] = clonedElements; + + expect(clonedRectangle.boundElements).toEqual([ + { id: clonedArrow1.id, type: "arrow" }, + ]); + + expect(clonedText1.containerId).toBe(null); + + expect(clonedArrow2.startBinding).toEqual({ + ...arrow2.startBinding, + elementId: clonedRectangle.id, + }); + expect(clonedArrow2.endBinding).toBe(null); + + expect(clonedArrow3.startBinding).toBe(null); + expect(clonedArrow3.endBinding).toEqual({ + ...arrow3.endBinding, + elementId: clonedRectangle.id, + }); + }); + + describe("should duplicate all group ids", () => { + it("should regenerate all group ids and keep them consistent across elements", () => { + const rectangle1 = API.createElement({ + type: "rectangle", + groupIds: ["g1"], + }); + const rectangle2 = API.createElement({ + type: "rectangle", + groupIds: ["g2", "g1"], + }); + const rectangle3 = API.createElement({ + type: "rectangle", + groupIds: ["g2", "g1"], + }); + + const origElements = [rectangle1, rectangle2, rectangle3] as const; + const clonedElements = duplicateElements( + origElements, + ) as any as typeof origElements; + const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = + clonedElements; + + expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); + expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]); + expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]); + + expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]); + expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]); + expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]); + }); + + it("should keep and regenerate ids of groups even if invalid", () => { + // lone element shouldn't be able to be grouped with itself, + // but hard to check against in a performant way so we ignore it + const rectangle1 = API.createElement({ + type: "rectangle", + groupIds: ["g1"], + }); + + const [clonedRectangle1] = duplicateElements([rectangle1]); + + expect(typeof clonedRectangle1.groupIds[0]).toBe("string"); + expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); + }); + }); }); diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 8e7b8ee8a6..e3b25e848d 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -13,7 +13,12 @@ import { FontFamilyValues, ExcalidrawTextContainer, } from "../element/types"; -import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; +import { + arrayToMap, + getFontString, + getUpdatedTimestamp, + isTestEnv, +} from "../utils"; import { randomInteger, randomId } from "../random"; import { mutateElement, newElementWith } from "./mutateElement"; import { getNewGroupIdsForDuplication } from "../groups"; @@ -22,16 +27,18 @@ import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { - getBoundTextElement, getBoundTextElementOffset, getContainerDims, getContainerElement, measureText, normalizeText, wrapText, + getMaxContainerWidth, + getDefaultLineHeight, } from "./textElement"; -import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; +import { VERTICAL_ALIGN } from "../constants"; import { isArrowElement } from "./typeChecks"; +import { MarkOptional, Merge, Mutable } from "../utility-types"; type ElementConstructorOpts = MarkOptional< Omit, @@ -136,11 +143,14 @@ export const newTextElement = ( textAlign: TextAlign; verticalAlign: VerticalAlign; containerId?: ExcalidrawTextContainer["id"]; + lineHeight?: ExcalidrawTextElement["lineHeight"]; } & ElementConstructorOpts, ): NonDeleted => { + const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily); const text = normalizeText(opts.text); - const metrics = measureText(text, getFontString(opts)); + const metrics = measureText(text, getFontString(opts), lineHeight); const offsets = getTextElementPositionOffsets(opts, metrics); + const textElement = newElementWith( { ..._newElementBase("text", opts), @@ -156,6 +166,7 @@ export const newTextElement = ( baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, + lineHeight, }, {}, ); @@ -172,16 +183,13 @@ const getAdjustedDimensions = ( height: number; baseline: number; } => { - let maxWidth = null; const container = getContainerElement(element); - if (container) { - maxWidth = getMaxContainerWidth(container); - } + const { width: nextWidth, height: nextHeight, baseline: nextBaseline, - } = measureText(nextText, getFontString(element), maxWidth); + } = measureText(nextText, getFontString(element), element.lineHeight); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -193,7 +201,7 @@ const getAdjustedDimensions = ( const prevMetrics = measureText( element.text, getFontString(element), - maxWidth, + element.lineHeight, ); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, @@ -256,9 +264,9 @@ const getAdjustedDimensions = ( return { width: nextWidth, height: nextHeight, + baseline: nextBaseline, x: Number.isFinite(x) ? x : element.x, y: Number.isFinite(y) ? y : element.y, - baseline: nextBaseline, }; }; @@ -266,6 +274,9 @@ export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, text = textElement.text, ) => { + if (textElement.isDeleted) { + return; + } const container = getContainerElement(textElement); if (container) { text = wrapText( @@ -278,38 +289,6 @@ export const refreshTextDimensions = ( return { text, ...dimensions }; }; -export const getMaxContainerWidth = (container: ExcalidrawElement) => { - const width = getContainerDims(container).width; - if (isArrowElement(container)) { - const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2; - if (containerWidth <= 0) { - const boundText = getBoundTextElement(container); - if (boundText) { - return boundText.width; - } - return BOUND_TEXT_PADDING * 8 * 2; - } - return containerWidth; - } - return width - BOUND_TEXT_PADDING * 2; -}; - -export const getMaxContainerHeight = (container: ExcalidrawElement) => { - const height = getContainerDims(container).height; - if (isArrowElement(container)) { - const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; - if (containerHeight <= 0) { - const boundText = getBoundTextElement(container); - if (boundText) { - return boundText.height; - } - return BOUND_TEXT_PADDING * 8 * 2; - } - return height; - } - return height - BOUND_TEXT_PADDING * 2; -}; - export const updateTextElement = ( textElement: ExcalidrawTextElement, { @@ -383,16 +362,24 @@ export const newImageElement = ( }; }; -// Simplified deep clone for the purpose of cloning ExcalidrawElement only -// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) +// Simplified deep clone for the purpose of cloning ExcalidrawElement. +// +// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, +// Typed arrays and other non-null objects. // // Adapted from https://github.com/lukeed/klona -export const deepCopyElement = (val: any, depth: number = 0) => { +// +// The reason for `deepCopyElement()` wrapper is type safety (only allow +// passing ExcalidrawElement as the top-level argument). +const _deepCopyElement = (val: any, depth: number = 0) => { + // only clone non-primitives if (val == null || typeof val !== "object") { return val; } - if (Object.prototype.toString.call(val) === "[object Object]") { + const objectType = Object.prototype.toString.call(val); + + if (objectType === "[object Object]") { const tmp = typeof val.constructor === "function" ? Object.create(Object.getPrototypeOf(val)) @@ -404,7 +391,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => { if (depth === 0 && (key === "shape" || key === "canvas")) { continue; } - tmp[key] = deepCopyElement(val[key], depth + 1); + tmp[key] = _deepCopyElement(val[key], depth + 1); } } return tmp; @@ -414,14 +401,67 @@ export const deepCopyElement = (val: any, depth: number = 0) => { let k = val.length; const arr = new Array(k); while (k--) { - arr[k] = deepCopyElement(val[k], depth + 1); + arr[k] = _deepCopyElement(val[k], depth + 1); } return arr; } + // we're not cloning non-array & non-plain-object objects because we + // don't support them on excalidraw elements yet. If we do, we need to make + // sure we start cloning them, so let's warn about it. + if (process.env.NODE_ENV === "development") { + if ( + objectType !== "[object Object]" && + objectType !== "[object Array]" && + objectType.startsWith("[object ") + ) { + console.warn( + `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`, + ); + } + } + return val; }; +/** + * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or + * any value. The purpose is to to break object references for immutability + * reasons, whenever we want to keep the original element, but ensure it's not + * mutated. + * + * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, + * Typed arrays and other non-null objects. + */ +export const deepCopyElement = ( + val: T, +): Mutable => { + return _deepCopyElement(val); +}; + +/** + * utility wrapper to generate new id. In test env it reuses the old + postfix + * for test assertions. + */ +const regenerateId = ( + /** supply null if no previous id exists */ + previousId: string | null, +) => { + if (isTestEnv() && previousId) { + let nextId = `${previousId}_copy`; + // `window.h` may not be defined in some unit tests + if ( + window.h?.app + ?.getSceneElementsIncludingDeleted() + .find((el) => el.id === nextId) + ) { + nextId += "_copy"; + } + return nextId; + } + return randomId(); +}; + /** * Duplicate an element, often used in the alt-drag operation. * Note that this method has gotten a bit complicated since the @@ -436,27 +476,15 @@ export const deepCopyElement = (val: any, depth: number = 0) => { * @param element Element to duplicate * @param overrides Any element properties to override */ -export const duplicateElement = >( +export const duplicateElement = ( editingGroupId: AppState["editingGroupId"], groupIdMapForOperation: Map, element: TElement, overrides?: Partial, -): TElement => { - let copy: TElement = deepCopyElement(element); +): Readonly => { + let copy = deepCopyElement(element); - if (isTestEnv()) { - copy.id = `${copy.id}_copy`; - // `window.h` may not be defined in some unit tests - if ( - window.h?.app - ?.getSceneElementsIncludingDeleted() - .find((el) => el.id === copy.id) - ) { - copy.id += "_copy"; - } - } else { - copy.id = randomId(); - } + copy.id = regenerateId(copy.id); copy.boundElements = null; copy.updated = getUpdatedTimestamp(); copy.seed = randomInteger(); @@ -465,7 +493,7 @@ export const duplicateElement = >( editingGroupId, (groupId) => { if (!groupIdMapForOperation.has(groupId)) { - groupIdMapForOperation.set(groupId, randomId()); + groupIdMapForOperation.set(groupId, regenerateId(groupId)); } return groupIdMapForOperation.get(groupId)!; }, @@ -475,3 +503,102 @@ export const duplicateElement = >( } return copy; }; + +/** + * Clones elements, regenerating their ids (including bindings) and group ids. + * + * If bindings don't exist in the elements array, they are removed. Therefore, + * it's advised to supply the whole elements array, or sets of elements that + * are encapsulated (such as library items), if the purpose is to retain + * bindings to the cloned elements intact. + */ +export const duplicateElements = (elements: readonly ExcalidrawElement[]) => { + const clonedElements: ExcalidrawElement[] = []; + + const origElementsMap = arrayToMap(elements); + + // used for for migrating old ids to new ids + const elementNewIdsMap = new Map< + /* orig */ ExcalidrawElement["id"], + /* new */ ExcalidrawElement["id"] + >(); + + const maybeGetNewId = (id: ExcalidrawElement["id"]) => { + // if we've already migrated the element id, return the new one directly + if (elementNewIdsMap.has(id)) { + return elementNewIdsMap.get(id)!; + } + // if we haven't migrated the element id, but an old element with the same + // id exists, generate a new id for it and return it + if (origElementsMap.has(id)) { + const newId = regenerateId(id); + elementNewIdsMap.set(id, newId); + return newId; + } + // if old element doesn't exist, return null to mark it for removal + return null; + }; + + const groupNewIdsMap = new Map(); + + for (const element of elements) { + const clonedElement: Mutable = _deepCopyElement(element); + + clonedElement.id = maybeGetNewId(element.id)!; + + if (clonedElement.groupIds) { + clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { + if (!groupNewIdsMap.has(groupId)) { + groupNewIdsMap.set(groupId, regenerateId(groupId)); + } + return groupNewIdsMap.get(groupId)!; + }); + } + + if ("containerId" in clonedElement && clonedElement.containerId) { + const newContainerId = maybeGetNewId(clonedElement.containerId); + clonedElement.containerId = newContainerId; + } + + if ("boundElements" in clonedElement && clonedElement.boundElements) { + clonedElement.boundElements = clonedElement.boundElements.reduce( + ( + acc: Mutable>, + binding, + ) => { + const newBindingId = maybeGetNewId(binding.id); + if (newBindingId) { + acc.push({ ...binding, id: newBindingId }); + } + return acc; + }, + [], + ); + } + + if ("endBinding" in clonedElement && clonedElement.endBinding) { + const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId); + clonedElement.endBinding = newEndBindingId + ? { + ...clonedElement.endBinding, + elementId: newEndBindingId, + } + : null; + } + if ("startBinding" in clonedElement && clonedElement.startBinding) { + const newEndBindingId = maybeGetNewId( + clonedElement.startBinding.elementId, + ); + clonedElement.startBinding = newEndBindingId + ? { + ...clonedElement.startBinding, + elementId: newEndBindingId, + } + : null; + } + + clonedElements.push(clonedElement); + } + + return clonedElements; +}; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 0b3ad555b4..478a22d561 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -41,16 +41,16 @@ import { import { Point, PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { - getApproxMinLineHeight, getApproxMinLineWidth, getBoundTextElement, getBoundTextElementId, - getBoundTextElementOffset, getContainerElement, handleBindTextResize, + getMaxContainerWidth, + getApproxMinLineHeight, measureText, + getMaxContainerHeight, } from "./textElement"; -import { getMaxContainerWidth } from "./newElement"; export const normalizeAngle = (angle: number): number => { if (angle < 0) { @@ -197,7 +197,7 @@ const rescalePointsInElement = ( const MIN_FONT_SIZE = 1; -const measureFontSizeFromWH = ( +const measureFontSizeFromWidth = ( element: NonDeleted, nextWidth: number, nextHeight: number, @@ -219,7 +219,7 @@ const measureFontSizeFromWH = ( const metrics = measureText( element.text, getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - element.containerId ? width : null, + element.lineHeight, ); return { size: nextFontSize, @@ -295,8 +295,8 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight); - if (nextFont === null) { + const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight); + if (metrics === null) { return; } const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( @@ -320,10 +320,10 @@ const resizeSingleTextElement = ( deltaY2, ); mutateElement(element, { - fontSize: nextFont.size, + fontSize: metrics.size, width: nextWidth, height: nextHeight, - baseline: nextFont.baseline, + baseline: metrics.baseline, x: nextElementX, y: nextElementY, }); @@ -432,12 +432,16 @@ export const resizeSingleElement = ( }; } if (shouldMaintainAspectRatio) { - const boundTextElementPadding = - getBoundTextElementOffset(boundTextElement); - const nextFont = measureFontSizeFromWH( + const updatedElement = { + ...element, + width: eleNewWidth, + height: eleNewHeight, + }; + + const nextFont = measureFontSizeFromWidth( boundTextElement, - eleNewWidth - boundTextElementPadding * 2, - eleNewHeight - boundTextElementPadding * 2, + getMaxContainerWidth(updatedElement), + getMaxContainerHeight(updatedElement), ); if (nextFont === null) { return; @@ -447,8 +451,14 @@ export const resizeSingleElement = ( baseline: nextFont.baseline, }; } else { - const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); - const minHeight = getApproxMinLineHeight(getFontString(boundTextElement)); + const minWidth = getApproxMinLineWidth( + getFontString(boundTextElement), + boundTextElement.lineHeight, + ); + const minHeight = getApproxMinLineHeight( + boundTextElement.fontSize, + boundTextElement.lineHeight, + ); eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); } @@ -581,8 +591,11 @@ export const resizeSingleElement = ( }); mutateElement(element, resizedElement); - if (boundTextElement && boundTextFont) { - mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); + if (boundTextElement && boundTextFont != null) { + mutateElement(boundTextElement, { + fontSize: boundTextFont.fontSize, + baseline: boundTextFont.baseline, + }); } handleBindTextResize(element, transformHandleDirection); } @@ -716,9 +729,9 @@ export const resizeMultipleElements = ( } if (isTextElement(element)) { - const textMeasurements = measureFontSizeFromWH(element, width, height); - update.fontSize = textMeasurements?.size ?? element.fontSize; - update.baseline = textMeasurements?.baseline ?? element.baseline; + const metrics = measureFontSizeFromWidth(element, width, height); + update.fontSize = metrics?.size ?? element.fontSize; + update.baseline = metrics?.baseline ?? element.baseline; } // TODO remove this after solving the issue with changing bounds of linear diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index e1b9ff6f0f..106ed7beab 100644 --- a/src/element/textElement.test.ts +++ b/src/element/textElement.test.ts @@ -1,5 +1,15 @@ -import { BOUND_TEXT_PADDING } from "../constants"; -import { measureText, wrapText } from "./textElement"; +import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { API } from "../tests/helpers/api"; +import { + computeContainerDimensionForBoundText, + getContainerCoords, + getMaxContainerWidth, + getMaxContainerHeight, + wrapText, + detectLineHeight, + getLineHeightInPx, + getDefaultLineHeight, +} from "./textElement"; import { FontString } from "./types"; describe("Test wrapText", () => { @@ -9,7 +19,7 @@ describe("Test wrapText", () => { const text = "Hello whats up "; const maxWidth = 200 - BOUND_TEXT_PADDING * 2; const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hello whats up "); + expect(res).toBe(text); }); it("should work with emojis", () => { @@ -19,7 +29,7 @@ describe("Test wrapText", () => { expect(res).toBe("😀"); }); - it("should show the text correctly when min width reached", () => { + it("should show the text correctly when max width reached", () => { const text = "Hello😀"; const maxWidth = 10; const res = wrapText(text, font, maxWidth); @@ -28,13 +38,12 @@ describe("Test wrapText", () => { describe("When text doesn't contain new lines", () => { const text = "Hello whats up"; + [ { desc: "break all words when width of each word is less than container width", - width: 90, - res: `Hello -whats -up`, + width: 80, + res: `Hello \nwhats \nup`, }, { desc: "break all characters when width of each character is less than container width", @@ -55,9 +64,8 @@ p`, { desc: "break words as per the width", - width: 150, - res: `Hello whats -up`, + width: 140, + res: `Hello whats \nup`, }, { desc: "fit the container", @@ -65,6 +73,13 @@ up`, width: 250, res: "Hello whats up", }, + { + desc: "should push the word if its equal to max width", + width: 60, + res: `Hello +whats +up`, + }, ].forEach((data) => { it(`should ${data.desc}`, () => { const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); @@ -72,16 +87,15 @@ up`, }); }); }); + describe("When text contain new lines", () => { const text = `Hello whats up`; [ { desc: "break all words when width of each word is less than container width", - width: 90, - res: `Hello -whats -up`, + width: 80, + res: `Hello\nwhats \nup`, }, { desc: "break all characters when width of each character is less than container width", @@ -120,17 +134,14 @@ whats up`, }); }); }); + describe("When text is long", () => { const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; [ { desc: "fit characters of long string as per container width", width: 170, - res: `hellolongtextth -isiswhatsupwith -youIamtypingggg -gandtypinggg -break it now`, + res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`, }, { @@ -149,8 +160,7 @@ now`, desc: "fit the long text when container width is greater than text length and move the rest to next line", width: 600, - res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg -break it now`, + res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`, }, ].forEach((data) => { it(`should ${data.desc}`, () => { @@ -159,38 +169,176 @@ break it now`, }); }); }); + + it("should wrap the text correctly when word length is exactly equal to max width", () => { + const text = "Hello Excalidraw"; + // Length of "Excalidraw" is 100 and exacty equal to max width + const res = wrapText(text, font, 100); + expect(res).toEqual(`Hello \nExcalidraw`); + }); + + it("should return the text as is if max width is invalid", () => { + const text = "Hello Excalidraw"; + expect(wrapText(text, font, NaN)).toEqual(text); + expect(wrapText(text, font, -1)).toEqual(text); + expect(wrapText(text, font, Infinity)).toEqual(text); + }); }); describe("Test measureText", () => { - const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; - const text = "Hello World"; + describe("Test getContainerCoords", () => { + const params = { width: 200, height: 100, x: 10, y: 20 }; - it("should add correct attributes when maxWidth is passed", () => { - const maxWidth = 200 - BOUND_TEXT_PADDING * 2; - const res = measureText(text, font, maxWidth); + it("should compute coords correctly when ellipse", () => { + const element = API.createElement({ + type: "ellipse", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 44.2893218813452455, + y: 39.64466094067262, + }); + }); - expect(res.container).toMatchInlineSnapshot(` -
- -
- `); + it("should compute coords correctly when rectangle", () => { + const element = API.createElement({ + type: "rectangle", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 15, + y: 25, + }); + }); + + it("should compute coords correctly when diamond", () => { + const element = API.createElement({ + type: "diamond", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 65, + y: 50, + }); + }); }); - it("should add correct attributes when maxWidth is not passed", () => { - const res = measureText(text, font); + describe("Test computeContainerDimensionForBoundText", () => { + const params = { + width: 178, + height: 194, + }; - expect(res.container).toMatchInlineSnapshot(` -
- -
- `); + it("should compute container height correctly for rectangle", () => { + const element = API.createElement({ + type: "rectangle", + ...params, + }); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 160, + ); + }); + + it("should compute container height correctly for ellipse", () => { + const element = API.createElement({ + type: "ellipse", + ...params, + }); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 226, + ); + }); + + it("should compute container height correctly for diamond", () => { + const element = API.createElement({ + type: "diamond", + ...params, + }); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 320, + ); + }); + }); + + describe("Test getMaxContainerWidth", () => { + const params = { + width: 178, + height: 194, + }; + + it("should return max width when container is rectangle", () => { + const container = API.createElement({ type: "rectangle", ...params }); + expect(getMaxContainerWidth(container)).toBe(168); + }); + + it("should return max width when container is ellipse", () => { + const container = API.createElement({ type: "ellipse", ...params }); + expect(getMaxContainerWidth(container)).toBe(116); + }); + + it("should return max width when container is diamond", () => { + const container = API.createElement({ type: "diamond", ...params }); + expect(getMaxContainerWidth(container)).toBe(79); + }); + }); + + describe("Test getMaxContainerHeight", () => { + const params = { + width: 178, + height: 194, + }; + + it("should return max height when container is rectangle", () => { + const container = API.createElement({ type: "rectangle", ...params }); + expect(getMaxContainerHeight(container)).toBe(184); + }); + + it("should return max height when container is ellipse", () => { + const container = API.createElement({ type: "ellipse", ...params }); + expect(getMaxContainerHeight(container)).toBe(127); + }); + + it("should return max height when container is diamond", () => { + const container = API.createElement({ type: "diamond", ...params }); + expect(getMaxContainerHeight(container)).toBe(87); + }); + }); +}); + +const textElement = API.createElement({ + type: "text", + text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams", + fontSize: 20, + fontFamily: 1, + height: 175, +}); + +describe("Test detectLineHeight", () => { + it("should return correct line height", () => { + expect(detectLineHeight(textElement)).toBe(1.25); + }); +}); + +describe("Test getLineHeightInPx", () => { + it("should return correct line height", () => { + expect( + getLineHeightInPx(textElement.fontSize, textElement.lineHeight), + ).toBe(25); + }); +}); + +describe("Test getDefaultLineHeight", () => { + it("should return line height using default font family when not passed", () => { + //@ts-ignore + expect(getDefaultLineHeight()).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); + }); + + it("should return correct line height", () => { + expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); }); }); diff --git a/src/element/textElement.ts b/src/element/textElement.ts index c726c1c3fb..38da5df5a4 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -4,20 +4,24 @@ import { ExcalidrawTextContainer, ExcalidrawTextElement, ExcalidrawTextElementWithContainer, + FontFamilyValues, FontString, NonDeletedExcalidrawElement, } from "./types"; import { mutateElement } from "./mutateElement"; -import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; +import { + BOUND_TEXT_PADDING, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + FONT_FAMILY, + isSafari, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; import Scene from "../scene/Scene"; import { isTextElement } from "."; -import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; -import { - isBoundToContainer, - isImageElement, - isArrowElement, -} from "./typeChecks"; +import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; import { AppState } from "../types"; import { isTextBindableContainer } from "./typeChecks"; @@ -28,6 +32,7 @@ import { resetOriginalContainerCache, updateOriginalContainerCache, } from "./textWysiwyg"; +import { ExtractSetType } from "../utility-types"; export const normalizeText = (text: string) => { return ( @@ -39,73 +44,77 @@ export const normalizeText = (text: string) => { ); }; +export const splitIntoLines = (text: string) => { + return normalizeText(text).split("\n"); +}; + export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, ) => { let maxWidth = undefined; - let text = textElement.text; + const boundTextUpdates = { + x: textElement.x, + y: textElement.y, + text: textElement.text, + width: textElement.width, + height: textElement.height, + baseline: textElement.baseline, + }; + + boundTextUpdates.text = textElement.text; + if (container) { maxWidth = getMaxContainerWidth(container); - text = wrapText( + boundTextUpdates.text = wrapText( textElement.originalText, getFontString(textElement), maxWidth, ); } - const metrics = measureText(text, getFontString(textElement), maxWidth); - let coordY = textElement.y; - let coordX = textElement.x; - // Resize container and vertically center align the text + const metrics = measureText( + boundTextUpdates.text, + getFontString(textElement), + textElement.lineHeight, + ); + + boundTextUpdates.width = metrics.width; + boundTextUpdates.height = metrics.height; + boundTextUpdates.baseline = metrics.baseline; + if (container) { - if (!isArrowElement(container)) { - const containerDims = getContainerDims(container); - let nextHeight = containerDims.height; - if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) { - coordY = container.y; - } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - coordY = - container.y + - containerDims.height - - metrics.height - - BOUND_TEXT_PADDING; - } else { - coordY = container.y + containerDims.height / 2 - metrics.height / 2; - if (metrics.height > getMaxContainerHeight(container)) { - nextHeight = metrics.height + BOUND_TEXT_PADDING * 2; - coordY = container.y + nextHeight / 2 - metrics.height / 2; - } - } - if (textElement.textAlign === TEXT_ALIGN.LEFT) { - coordX = container.x + BOUND_TEXT_PADDING; - } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) { - coordX = - container.x + - containerDims.width - - metrics.width - - BOUND_TEXT_PADDING; - } else { - coordX = container.x + containerDims.width / 2 - metrics.width / 2; - } - updateOriginalContainerCache(container.id, nextHeight); - mutateElement(container, { height: nextHeight }); - } else { + if (isArrowElement(container)) { const centerX = textElement.x + textElement.width / 2; const centerY = textElement.y + textElement.height / 2; const diffWidth = metrics.width - textElement.width; const diffHeight = metrics.height - textElement.height; - coordY = centerY - (textElement.height + diffHeight) / 2; - coordX = centerX - (textElement.width + diffWidth) / 2; + boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2; + boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2; + } else { + const containerDims = getContainerDims(container); + let maxContainerHeight = getMaxContainerHeight(container); + + let nextHeight = containerDims.height; + if (metrics.height > maxContainerHeight) { + nextHeight = computeContainerDimensionForBoundText( + metrics.height, + container.type, + ); + mutateElement(container, { height: nextHeight }); + maxContainerHeight = getMaxContainerHeight(container); + updateOriginalContainerCache(container.id, nextHeight); + } + const updatedTextElement = { + ...textElement, + ...boundTextUpdates, + } as ExcalidrawTextElementWithContainer; + const { x, y } = computeBoundTextPosition(container, updatedTextElement); + boundTextUpdates.x = x; + boundTextUpdates.y = y; } } - mutateElement(textElement, { - width: metrics.width, - height: metrics.height, - baseline: metrics.baseline, - y: coordY, - x: coordX, - text, - }); + + mutateElement(textElement, boundTextUpdates); }; export const bindTextToShapeAfterDuplication = ( @@ -186,18 +195,22 @@ export const handleBindTextResize = ( maxWidth, ); } - const dimensions = measureText( + const metrics = measureText( text, getFontString(textElement), - maxWidth, + textElement.lineHeight, ); - nextHeight = dimensions.height; - nextWidth = dimensions.width; - nextBaseLine = dimensions.baseline; + nextHeight = metrics.height; + nextWidth = metrics.width; + nextBaseLine = metrics.baseline; } // increase height in case text element height exceeds if (nextHeight > maxHeight) { - containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2; + containerHeight = computeContainerDimensionForBoundText( + nextHeight, + container.type, + ); + const diff = containerHeight - containerDims.height; // fix the y coord when resizing from ne/nw/n const updatedY = @@ -217,53 +230,63 @@ export const handleBindTextResize = ( text, width: nextWidth, height: nextHeight, - baseline: nextBaseLine, }); + if (!isArrowElement(container)) { - updateBoundTextPosition( - container, - textElement as ExcalidrawTextElementWithContainer, + mutateElement( + textElement, + computeBoundTextPosition( + container, + textElement as ExcalidrawTextElementWithContainer, + ), ); } } }; -const updateBoundTextPosition = ( +export const computeBoundTextPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, ) => { - const containerDims = getContainerDims(container); - const boundTextElementPadding = getBoundTextElementOffset(boundTextElement); + if (isArrowElement(container)) { + return LinearElementEditor.getBoundTextElementPosition( + container, + boundTextElement, + ); + } + const containerCoords = getContainerCoords(container); + const maxContainerHeight = getMaxContainerHeight(container); + const maxContainerWidth = getMaxContainerWidth(container); + + let x; let y; if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { - y = container.y + boundTextElementPadding; + y = containerCoords.y; } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - y = - container.y + - containerDims.height - - boundTextElement.height - - boundTextElementPadding; + y = containerCoords.y + (maxContainerHeight - boundTextElement.height); } else { - y = container.y + containerDims.height / 2 - boundTextElement.height / 2; + y = + containerCoords.y + + (maxContainerHeight / 2 - boundTextElement.height / 2); } - const x = - boundTextElement.textAlign === TEXT_ALIGN.LEFT - ? container.x + boundTextElementPadding - : boundTextElement.textAlign === TEXT_ALIGN.RIGHT - ? container.x + - containerDims.width - - boundTextElement.width - - boundTextElementPadding - : container.x + containerDims.width / 2 - boundTextElement.width / 2; - - mutateElement(boundTextElement, { x, y }); + if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) { + x = containerCoords.x; + } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) { + x = containerCoords.x + (maxContainerWidth - boundTextElement.width); + } else { + x = + containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); + } + return { x, y }; }; + // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js + export const measureText = ( text: string, font: FontString, - maxWidth?: number | null, + lineHeight: ExcalidrawTextElement["lineHeight"], ) => { text = text .split("\n") @@ -271,114 +294,188 @@ export const measureText = ( // lines would be stripped from computation .map((x) => x || " ") .join("\n"); + const fontSize = parseFloat(font); + const height = getTextHeight(text, fontSize, lineHeight); + const width = getTextWidth(text, font); + const baseline = measureBaseline(text, font, lineHeight); + return { width, height, baseline }; +}; + +export const measureBaseline = ( + text: string, + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], + wrapInContainer?: boolean, +) => { const container = document.createElement("div"); container.style.position = "absolute"; container.style.whiteSpace = "pre"; container.style.font = font; container.style.minHeight = "1em"; - - if (maxWidth) { - const lineHeight = getApproxLineHeight(font); - // since we are adding a span of width 1px later - container.style.maxWidth = `${maxWidth + 1}px`; + if (wrapInContainer) { container.style.overflow = "hidden"; container.style.wordBreak = "break-word"; - container.style.lineHeight = `${String(lineHeight)}px`; container.style.whiteSpace = "pre-wrap"; } - document.body.appendChild(container); + + container.style.lineHeight = String(lineHeight); + container.innerText = text; + // Baseline is important for positioning text on canvas + document.body.appendChild(container); + const span = document.createElement("span"); span.style.display = "inline-block"; span.style.overflow = "hidden"; span.style.width = "1px"; span.style.height = "1px"; container.appendChild(span); - // Baseline is important for positioning text on canvas - const baseline = span.offsetTop + span.offsetHeight; - // since we are adding a span of width 1px - const width = container.offsetWidth + 1; + let baseline = span.offsetTop + span.offsetHeight; const height = container.offsetHeight; - document.body.removeChild(container); - if (isTestEnv()) { - return { width, height, baseline, container }; + + if (isSafari) { + const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight); + const fontSize = parseFloat(font); + // In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs + // from the actual canvas height + const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight); + if (canvasHeight > height) { + baseline += canvasHeight - domHeight; + } + + if (height > canvasHeight) { + baseline -= domHeight - canvasHeight; + } } - return { width, height, baseline }; + document.body.removeChild(container); + return baseline; }; -const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); -const cacheApproxLineHeight: { [key: FontString]: number } = {}; +/** + * To get unitless line-height (if unknown) we can calculate it by dividing + * height-per-line by fontSize. + */ +export const detectLineHeight = (textElement: ExcalidrawTextElement) => { + const lineCount = splitIntoLines(textElement.text).length; + return (textElement.height / + lineCount / + textElement.fontSize) as ExcalidrawTextElement["lineHeight"]; +}; -export const getApproxLineHeight = (font: FontString) => { - if (cacheApproxLineHeight[font]) { - return cacheApproxLineHeight[font]; - } - cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height; - return cacheApproxLineHeight[font]; +/** + * We calculate the line height from the font size and the unitless line height, + * aligning with the W3C spec. + */ +export const getLineHeightInPx = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return fontSize * lineHeight; +}; + +// FIXME rename to getApproxMinContainerHeight +export const getApproxMinLineHeight = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; }; let canvas: HTMLCanvasElement | undefined; + const getLineWidth = (text: string, font: FontString) => { 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); // 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 metrics.width * 10; + return width * 10; } - // Since measureText behaves differently in different browsers - // OS so considering a adjustment factor of 0.2 - const adjustmentFactor = 0.2; - - return metrics.width + adjustmentFactor; + return width; }; export const getTextWidth = (text: string, font: FontString) => { - const lines = text.split("\n"); + const lines = splitIntoLines(text); let width = 0; lines.forEach((line) => { width = Math.max(width, getLineWidth(line, font)); }); return width; }; + +export const getTextHeight = ( + text: string, + fontSize: number, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const lineCount = splitIntoLines(text).length; + return getLineHeightInPx(fontSize, lineHeight) * lineCount; +}; + export const wrapText = (text: string, font: FontString, maxWidth: number) => { + // 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 + if (!Number.isFinite(maxWidth) || maxWidth < 0) { + return text; + } + const lines: Array = []; const originalLines = text.split("\n"); const spaceWidth = getLineWidth(" ", font); + let currentLine = ""; + let currentLineWidthTillNow = 0; + const push = (str: string) => { if (str.trim()) { lines.push(str); } }; + + const resetParams = () => { + currentLine = ""; + currentLineWidthTillNow = 0; + }; + originalLines.forEach((originalLine) => { - const words = originalLine.split(" "); - // This means its newline so push it - if (words.length === 1 && words[0] === "") { - lines.push(words[0]); + const currentLineWidth = getTextWidth(originalLine, font); + + //Push the line if its <= maxWidth + if (currentLineWidth <= maxWidth) { + lines.push(originalLine); return; // continue } - let currentLine = ""; - let currentLineWidthTillNow = 0; + const words = originalLine.split(" "); + + resetParams(); let index = 0; + while (index < words.length) { const currentWordWidth = getLineWidth(words[index], font); + // This will only happen when single word takes entire width + if (currentWordWidth === maxWidth) { + push(words[index]); + index++; + } + // Start breaking longer words exceeding max width - if (currentWordWidth >= maxWidth) { + else if (currentWordWidth > maxWidth) { // push current line since the current word exceeds the max width // so will be appended in next line push(currentLine); - currentLine = ""; - currentLineWidthTillNow = 0; + + resetParams(); + while (words[index].length > 0) { const currentChar = String.fromCodePoint( words[index].codePointAt(0)!, @@ -388,10 +485,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { words[index] = words[index].slice(currentChar.length); if (currentLineWidthTillNow >= maxWidth) { - // only remove last trailing space which we have added when joining words - if (currentLine.slice(-1) === " ") { - currentLine = currentLine.slice(0, -1); - } push(currentLine); currentLine = currentChar; currentLineWidthTillNow = width; @@ -399,11 +492,11 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { currentLine += currentChar; } } + // push current line if appending space exceeds max width if (currentLineWidthTillNow + spaceWidth >= maxWidth) { push(currentLine); - currentLine = ""; - currentLineWidthTillNow = 0; + resetParams(); } else { // space needs to be appended before next word // as currentLine contains chars which couldn't be appended @@ -411,7 +504,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { currentLine += " "; currentLineWidthTillNow += spaceWidth; } - index++; } else { // Start appending words in a line till max width reached @@ -419,10 +511,9 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { const word = words[index]; currentLineWidthTillNow = getLineWidth(currentLine + word, font); - if (currentLineWidthTillNow >= maxWidth) { + if (currentLineWidthTillNow > maxWidth) { push(currentLine); - currentLineWidthTillNow = 0; - currentLine = ""; + resetParams(); break; } @@ -433,22 +524,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { if (currentLineWidthTillNow + spaceWidth >= maxWidth) { const word = currentLine.slice(0, -1); push(word); - currentLine = ""; - currentLineWidthTillNow = 0; + resetParams(); break; } } - if (currentLineWidthTillNow === maxWidth) { - currentLine = ""; - currentLineWidthTillNow = 0; - } } } - if (currentLine) { + if (currentLine.slice(-1) === " ") { // only remove last trailing space which we have added when joining words - if (currentLine.slice(-1) === " ") { - currentLine = currentLine.slice(0, -1); - } + currentLine = currentLine.slice(0, -1); push(currentLine); } }); @@ -479,22 +563,24 @@ export const charWidth = (() => { getCache, }; })(); -export const getApproxMinLineWidth = (font: FontString) => { - const maxCharWidth = getMaxCharWidth(font); +const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); + +// FIXME rename to getApproxMinContainerWidth +export const getApproxMinLineWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const maxCharWidth = getMaxCharWidth(font); if (maxCharWidth === 0) { return ( - measureText(DUMMY_TEXT.split("").join("\n"), font).width + + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + BOUND_TEXT_PADDING * 2 ); } return maxCharWidth + BOUND_TEXT_PADDING * 2; }; -export const getApproxMinLineHeight = (font: FontString) => { - return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2; -}; - export const getMinCharWidth = (font: FontString) => { const cache = charWidth.getCache(font); if (!cache) { @@ -621,6 +707,26 @@ export const getContainerCenter = ( return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; }; +export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { + let offsetX = BOUND_TEXT_PADDING; + let offsetY = BOUND_TEXT_PADDING; + + if (container.type === "ellipse") { + // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172 + offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2); + offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2); + } + // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265 + if (container.type === "diamond") { + offsetX += container.width / 4; + offsetY += container.height / 4; + } + return { + x: container.x + offsetX, + y: container.y + offsetY, + }; +}; + export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { const container = getContainerElement(textElement); if (!container || isArrowElement(container)) { @@ -633,12 +739,13 @@ export const getBoundTextElementOffset = ( boundTextElement: ExcalidrawTextElement | null, ) => { const container = getContainerElement(boundTextElement); - if (!container) { + if (!container || !boundTextElement) { return 0; } if (isArrowElement(container)) { return BOUND_TEXT_PADDING * 8; } + return BOUND_TEXT_PADDING; }; @@ -666,14 +773,24 @@ export const shouldAllowVerticalAlign = ( } return true; } - const boundTextElement = getBoundTextElement(element); - if (boundTextElement) { - if (isArrowElement(element)) { + return false; + }); +}; + +export const suppportsHorizontalAlign = ( + selectedElements: NonDeletedExcalidrawElement[], +) => { + return selectedElements.some((element) => { + const hasBoundContainer = isBoundToContainer(element); + if (hasBoundContainer) { + const container = getContainerElement(element); + if (isTextElement(element) && isArrowElement(container)) { return false; } return true; } - return false; + + return isTextElement(element); }); }; @@ -714,12 +831,127 @@ export const getTextBindableContainerAtPosition = ( return isTextBindableContainer(hitElement, false) ? hitElement : null; }; -export const isValidTextContainer = (element: ExcalidrawElement) => { - return ( - element.type === "rectangle" || - element.type === "ellipse" || - element.type === "diamond" || - isImageElement(element) || - isArrowElement(element) - ); +const VALID_CONTAINER_TYPES = new Set([ + "rectangle", + "ellipse", + "diamond", + "image", + "arrow", +]); + +export const isValidTextContainer = (element: ExcalidrawElement) => + VALID_CONTAINER_TYPES.has(element.type); + +export const computeContainerDimensionForBoundText = ( + dimension: number, + containerType: ExtractSetType, +) => { + dimension = Math.ceil(dimension); + const padding = BOUND_TEXT_PADDING * 2; + + if (containerType === "ellipse") { + return Math.round(((dimension + padding) / Math.sqrt(2)) * 2); + } + if (containerType === "arrow") { + return dimension + padding * 8; + } + if (containerType === "diamond") { + return 2 * (dimension + padding); + } + return dimension + padding; +}; + +export const getMaxContainerWidth = (container: ExcalidrawElement) => { + const width = getContainerDims(container).width; + if (isArrowElement(container)) { + const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2; + if (containerWidth <= 0) { + const boundText = getBoundTextElement(container); + if (boundText) { + return boundText.width; + } + return BOUND_TEXT_PADDING * 8 * 2; + } + return containerWidth; + } + + if (container.type === "ellipse") { + // The width of the largest rectangle inscribed inside an ellipse is + // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from + // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172 + return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; + } + if (container.type === "diamond") { + // The width of the largest rectangle inscribed inside a rhombus is + // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265 + return Math.round(width / 2) - BOUND_TEXT_PADDING * 2; + } + return width - BOUND_TEXT_PADDING * 2; +}; + +export const getMaxContainerHeight = (container: ExcalidrawElement) => { + const height = getContainerDims(container).height; + if (isArrowElement(container)) { + const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; + if (containerHeight <= 0) { + const boundText = getBoundTextElement(container); + if (boundText) { + return boundText.height; + } + return BOUND_TEXT_PADDING * 8 * 2; + } + return height; + } + if (container.type === "ellipse") { + // The height of the largest rectangle inscribed inside an ellipse is + // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from + // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172 + return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; + } + if (container.type === "diamond") { + // The height of the largest rectangle inscribed inside a rhombus is + // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265 + return Math.round(height / 2) - BOUND_TEXT_PADDING * 2; + } + return height - BOUND_TEXT_PADDING * 2; +}; + +export const isMeasureTextSupported = () => { + const width = getTextWidth( + DUMMY_TEXT, + getFontString({ + fontSize: DEFAULT_FONT_SIZE, + fontFamily: DEFAULT_FONT_FAMILY, + }), + ); + 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 Virgil in WebKit and Blink. + // Gecko if all over the place. + [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], + // ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too + [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], +}; + +export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { + if (fontFamily in DEFAULT_LINE_HEIGHT) { + return DEFAULT_LINE_HEIGHT[fontFamily]; + } + return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY]; }; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index c61d4e68bc..4ae3f26f97 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -3,19 +3,23 @@ import ExcalidrawApp from "../excalidraw-app"; import { GlobalTestState, render, screen } from "../tests/test-utils"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { CODES, KEYS } from "../keys"; -import { fireEvent } from "../tests/test-utils"; +import { + fireEvent, + mockBoundingClientRect, + restoreOriginalGetBoundingClientRect, +} from "../tests/test-utils"; import { queryByText } from "@testing-library/react"; -import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawTextElement, ExcalidrawTextElementWithContainer, } from "./types"; -import * as textElementUtils from "./textElement"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; import { resize } from "../tests/utils"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; + // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -222,11 +226,19 @@ describe("textWysiwyg", () => { describe("Test container-unbound text", () => { const { h } = window; + const dimensions = { height: 400, width: 800 }; let textarea: HTMLTextAreaElement; let textElement: ExcalidrawTextElement; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + beforeEach(async () => { await render(); + //@ts-ignore + h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!); textElement = UI.createElement("text"); @@ -236,6 +248,10 @@ describe("textWysiwyg", () => { )!; }); + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + it("should add a tab at the start of the first line", () => { const event = new KeyboardEvent("keydown", { key: KEYS.TAB }); textarea.value = "Line#1\nLine#2"; @@ -434,23 +450,33 @@ describe("textWysiwyg", () => { ); expect(h.state.zoom.value).toBe(1); }); + + it("text should never go beyond max width", async () => { + UI.clickTool("text"); + mouse.clickAt(750, 300); + + textarea = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + )!; + fireEvent.change(textarea, { + target: { + value: + "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", + }, + }); + + textarea.dispatchEvent(new Event("input")); + await new Promise((cb) => setTimeout(cb, 0)); + textarea.blur(); + expect(textarea.style.width).toBe("792px"); + expect(h.elements[0].width).toBe(1000); + }); }); describe("Test container-bound text", () => { let rectangle: any; const { h } = window; - const DUMMY_HEIGHT = 240; - const DUMMY_WIDTH = 160; - const APPROX_LINE_HEIGHT = 25; - const INITIAL_WIDTH = 10; - - beforeAll(() => { - jest - .spyOn(textElementUtils, "getApproxLineHeight") - .mockReturnValue(APPROX_LINE_HEIGHT); - }); - beforeEach(async () => { await render(); h.elements = []; @@ -500,6 +526,44 @@ describe("textWysiwyg", () => { ]); }); + it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => { + const diamond = API.createElement({ + type: "diamond", + x: 10, + y: 20, + width: 90, + height: 75, + }); + h.elements = [diamond]; + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(diamond.id); + + API.setSelectedElements([diamond]); + Keyboard.keyPress(KEYS.ENTER); + + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + await new Promise((r) => setTimeout(r, 0)); + const value = new Array(1000).fill("1").join("\n"); + + // Pasting large text to simulate height increase + expect(() => + fireEvent.input(editor, { target: { value } }), + ).not.toThrow(); + + expect(diamond.height).toBe(50020); + + // Clearing text to simulate height decrease + expect(() => + fireEvent.input(editor, { target: { value: "" } }), + ).not.toThrow(); + + expect(diamond.height).toBe(70); + }); + it("should bind text to container when double clicked on center of transparent container", async () => { const rectangle = API.createElement({ type: "rectangle", @@ -643,11 +707,11 @@ describe("textWysiwyg", () => { ["freedraw", "line"].forEach((type: any) => { it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => { h.elements = []; - const elemnet = UI.createElement(type, { + const element = UI.createElement(type, { width: 100, height: 50, }); - API.setSelectedElements([elemnet]); + API.setSelectedElements([element]); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(1); }); @@ -676,6 +740,52 @@ describe("textWysiwyg", () => { expect(rectangle.boundElements).toBe(null); }); + it("should bind text to container when triggered via context menu", async () => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + UI.clickTool("text"); + mouse.clickAt(20, 30); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + fireEvent.change(editor, { + target: { + value: "Excalidraw is an opensource virtual collaborative whiteboard", + }, + }); + + editor.dispatchEvent(new Event("input")); + await new Promise((cb) => setTimeout(cb, 0)); + expect(h.elements.length).toBe(2); + expect(h.elements[1].type).toBe("text"); + + API.setSelectedElements([h.elements[0], h.elements[1]]); + fireEvent.contextMenu(GlobalTestState.canvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + const contextMenu = document.querySelector(".context-menu"); + fireEvent.click( + queryByText(contextMenu as HTMLElement, "Bind text to the container")!, + ); + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(rectangle.boundElements).toStrictEqual([ + { id: h.elements[1].id, type: "text" }, + ]); + expect(text.containerId).toBe(rectangle.id); + expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE); + expect(text.textAlign).toBe(TEXT_ALIGN.CENTER); + expect(text.x).toBe( + h.elements[0].x + h.elements[0].width / 2 - text.width / 2, + ); + expect(text.y).toBe( + h.elements[0].y + h.elements[0].height / 2 - text.height / 2, + ); + }); + it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => { expect(h.elements.length).toBe(1); @@ -732,39 +842,6 @@ describe("textWysiwyg", () => { }); it("should wrap text and vertcially center align once text submitted", async () => { - jest - .spyOn(textElementUtils, "measureText") - .mockImplementation((text, font, maxWidth) => { - let width = INITIAL_WIDTH; - let height = APPROX_LINE_HEIGHT; - let baseline = 10; - if (!text) { - return { - width, - height, - baseline, - }; - } - baseline = 30; - width = DUMMY_WIDTH; - if (text === "Hello \nWorld!") { - height = APPROX_LINE_HEIGHT * 2; - } - if (maxWidth) { - width = maxWidth; - // To capture cases where maxWidth passed is initial width - // due to which the text is not wrapped correctly - if (maxWidth === INITIAL_WIDTH) { - height = DUMMY_HEIGHT; - } - } - return { - width, - height, - baseline, - }; - }); - expect(h.elements.length).toBe(1); Keyboard.keyDown(KEYS.ENTER); @@ -773,11 +850,6 @@ describe("textWysiwyg", () => { ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; - // mock scroll height - jest - .spyOn(editor, "scrollHeight", "get") - .mockImplementation(() => APPROX_LINE_HEIGHT * 2); - fireEvent.change(editor, { target: { value: "Hello World!", @@ -792,11 +864,11 @@ describe("textWysiwyg", () => { expect(text.text).toBe("Hello \nWorld!"); expect(text.originalText).toBe("Hello World!"); expect(text.y).toBe( - rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2, + rectangle.y + h.elements[0].height / 2 - text.height / 2, ); - expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); - expect(text.height).toBe(APPROX_LINE_HEIGHT * 2); - expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); + expect(text.x).toBe(25); + expect(text.height).toBe(50); + expect(text.width).toBe(60); // Edit and text by removing second line and it should // still vertically align correctly @@ -813,11 +885,6 @@ describe("textWysiwyg", () => { }, }); - // mock scroll height - jest - .spyOn(editor, "scrollHeight", "get") - .mockImplementation(() => APPROX_LINE_HEIGHT); - editor.style.height = "25px"; editor.dispatchEvent(new Event("input")); await new Promise((r) => setTimeout(r, 0)); @@ -827,12 +894,12 @@ describe("textWysiwyg", () => { expect(text.text).toBe("Hello"); expect(text.originalText).toBe("Hello"); + expect(text.height).toBe(25); + expect(text.width).toBe(50); expect(text.y).toBe( - rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2, + rectangle.y + h.elements[0].height / 2 - text.height / 2, ); - expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); - expect(text.height).toBe(APPROX_LINE_HEIGHT); - expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); + expect(text.x).toBe(30); }); it("should unbind bound text when unbind action from context menu is triggered", async () => { @@ -919,8 +986,8 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 109.5, - 17, + 85, + 4.5, ] `); @@ -934,6 +1001,8 @@ describe("textWysiwyg", () => { editor.select(); fireEvent.click(screen.getByTitle("Left")); + await new Promise((r) => setTimeout(r, 0)); + fireEvent.click(screen.getByTitle("Align bottom")); await new Promise((r) => setTimeout(r, 0)); @@ -944,7 +1013,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 90, + 65, ] `); @@ -967,7 +1036,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 424, + 375, -539, ] `); @@ -1082,9 +1151,9 @@ describe("textWysiwyg", () => { mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50); expect(rectangle.x).toBe(80); - expect(rectangle.y).toBe(85); - expect(text.x).toBe(89.5); - expect(text.y).toBe(90); + expect(rectangle.y).toBe(-40); + expect(text.x).toBe(85); + expect(text.y).toBe(-35); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); @@ -1114,29 +1183,6 @@ describe("textWysiwyg", () => { }); it("should restore original container height and clear cache once text is unbind", async () => { - jest - .spyOn(textElementUtils, "measureText") - .mockImplementation((text, font, maxWidth) => { - let width = INITIAL_WIDTH; - let height = APPROX_LINE_HEIGHT; - let baseline = 10; - if (!text) { - return { - width, - height, - baseline, - }; - } - baseline = 30; - width = DUMMY_WIDTH; - height = APPROX_LINE_HEIGHT * 5; - - return { - width, - height, - baseline, - }; - }); const originalRectHeight = rectangle.height; expect(rectangle.height).toBe(originalRectHeight); @@ -1150,7 +1196,7 @@ describe("textWysiwyg", () => { target: { value: "Online whiteboard collaboration made easy" }, }); editor.blur(); - expect(rectangle.height).toBe(135); + expect(rectangle.height).toBe(185); mouse.select(rectangle); fireEvent.contextMenu(GlobalTestState.canvas, { button: 2, @@ -1176,7 +1222,7 @@ describe("textWysiwyg", () => { editor.blur(); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); - expect(rectangle.height).toBe(215); + expect(rectangle.height).toBe(156); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); mouse.select(rectangle); @@ -1188,13 +1234,12 @@ describe("textWysiwyg", () => { await new Promise((r) => setTimeout(r, 0)); editor.blur(); - expect(rectangle.height).toBe(215); + expect(rectangle.height).toBe(156); // cache updated again - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156); }); - //@todo fix this test later once measureText is mocked correctly - it.skip("should reset the container height cache when font properties updated", async () => { + it("should reset the container height cache when font properties updated", async () => { Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); @@ -1220,7 +1265,42 @@ describe("textWysiwyg", () => { expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, ).toEqual(36); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97); + }); + + it("should update line height when font family updated", async () => { + Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + await new Promise((r) => setTimeout(r, 0)); + fireEvent.change(editor, { target: { value: "Hello World!" } }); + editor.blur(); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, + ).toEqual(1.25); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + fireEvent.click(screen.getByTitle(/code/i)); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY.Cascadia); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, + ).toEqual(1.2); + + fireEvent.click(screen.getByTitle(/normal/i)); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY.Helvetica); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, + ).toEqual(1.15); }); describe("should align correctly", () => { @@ -1248,7 +1328,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 20, + 25, ] `); }); @@ -1258,8 +1338,8 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 94.5, - 20, + 30, + 25, ] `); }); @@ -1269,22 +1349,22 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 20, - ] - `); + Array [ + 45, + 25, + ] + `); }); it("when center left", async () => { fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Left")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 15, - 25, - ] - `); + Array [ + 15, + 45, + ] + `); }); it("when center center", async () => { @@ -1292,11 +1372,11 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Center vertically")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - -25, - 25, - ] - `); + Array [ + 30, + 45, + ] + `); }); it("when center right", async () => { @@ -1304,11 +1384,11 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Center vertically")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 25, - ] - `); + Array [ + 45, + 45, + ] + `); }); it("when bottom left", async () => { @@ -1316,34 +1396,120 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 15, - 25, - ] - `); + Array [ + 15, + 65, + ] + `); }); it("when bottom center", async () => { fireEvent.click(screen.getByTitle("Center")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 94.5, - 25, - ] - `); + Array [ + 30, + 65, + ] + `); }); it("when bottom right", async () => { fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 25, - ] - `); + Array [ + 45, + 65, + ] + `); }); }); + + it("should wrap text in a container when wrap text in container triggered from context menu", async () => { + UI.clickTool("text"); + mouse.clickAt(20, 30); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + fireEvent.change(editor, { + target: { + value: "Excalidraw is an opensource virtual collaborative whiteboard", + }, + }); + + editor.dispatchEvent(new Event("input")); + await new Promise((cb) => setTimeout(cb, 0)); + + editor.select(); + fireEvent.click(screen.getByTitle("Left")); + await new Promise((r) => setTimeout(r, 0)); + + editor.blur(); + + const textElement = h.elements[1] as ExcalidrawTextElement; + expect(textElement.width).toBe(600); + expect(textElement.height).toBe(25); + expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT); + expect((textElement as ExcalidrawTextElement).text).toBe( + "Excalidraw is an opensource virtual collaborative whiteboard", + ); + + API.setSelectedElements([textElement]); + + fireEvent.contextMenu(GlobalTestState.canvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + + const contextMenu = document.querySelector(".context-menu"); + fireEvent.click( + queryByText(contextMenu as HTMLElement, "Wrap text in a container")!, + ); + expect(h.elements.length).toBe(3); + + expect(h.elements[1]).toEqual( + expect.objectContaining({ + angle: 0, + backgroundColor: "transparent", + boundElements: [ + { + id: h.elements[2].id, + type: "text", + }, + ], + fillStyle: "hachure", + groupIds: [], + height: 35, + isDeleted: false, + link: null, + locked: false, + opacity: 100, + roughness: 1, + roundness: { + type: 3, + }, + strokeColor: "#000000", + strokeStyle: "solid", + strokeWidth: 1, + type: "rectangle", + updated: 1, + version: 1, + width: 610, + x: 15, + y: 25, + }), + ); + expect(h.elements[2] as ExcalidrawTextElement).toEqual( + expect.objectContaining({ + text: "Excalidraw is an opensource virtual collaborative whiteboard", + verticalAlign: VERTICAL_ALIGN.MIDDLE, + textAlign: TEXT_ALIGN.LEFT, + boundElements: null, + }), + ); + }); }); }); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 7b0098ce08..ef4f7c926e 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, VERTICAL_ALIGN } from "../constants"; +import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, @@ -22,15 +22,20 @@ import { import { AppState } from "../types"; import { mutateElement } from "./mutateElement"; import { - getApproxLineHeight, getBoundTextElementId, - getBoundTextElementOffset, + getContainerCoords, getContainerDims, getContainerElement, getTextElementAngle, getTextWidth, + measureText, normalizeText, + redrawTextBoundingBox, wrapText, + getMaxContainerHeight, + getMaxContainerWidth, + computeContainerDimensionForBoundText, + detectLineHeight, } from "./textElement"; import { actionDecreaseFontSize, @@ -38,7 +43,6 @@ import { } from "../actions/actionProperties"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; -import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; @@ -147,9 +151,7 @@ export const textWysiwyg = ({ return; } const { textAlign, verticalAlign } = updatedTextElement; - const approxLineHeight = getApproxLineHeight( - getFontString(updatedTextElement), - ); + if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; @@ -157,7 +159,7 @@ export const textWysiwyg = ({ let maxWidth = updatedTextElement.width; let maxHeight = updatedTextElement.height; - const width = updatedTextElement.width; + let textElementWidth = updatedTextElement.width; // Set to element height by default since that's // what is going to be used for unbounded text let textElementHeight = updatedTextElement.height; @@ -208,11 +210,12 @@ export const textWysiwyg = ({ // autogrow container height if text exceeds if (!isArrowElement(container) && textElementHeight > maxHeight) { - const diff = Math.min( - textElementHeight - maxHeight, - approxLineHeight, + const targetContainerHeight = computeContainerDimensionForBoundText( + textElementHeight, + container.type, ); - mutateElement(container, { height: containerDims.height + diff }); + + mutateElement(container, { height: targetContainerHeight }); return; } else if ( // autoshrink container height until original container height @@ -221,28 +224,26 @@ export const textWysiwyg = ({ containerDims.height > originalContainerData.height && textElementHeight < maxHeight ) { - const diff = Math.min( - maxHeight - textElementHeight, - approxLineHeight, + const targetContainerHeight = computeContainerDimensionForBoundText( + textElementHeight, + container.type, ); - mutateElement(container, { height: containerDims.height - diff }); + mutateElement(container, { height: targetContainerHeight }); } // Start pushing text upward until a diff of 30px (padding) // is reached else { + const containerCoords = getContainerCoords(container); + // vertically center align the text if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (!isArrowElement(container)) { coordY = - container.y + containerDims.height / 2 - textElementHeight / 2; + containerCoords.y + maxHeight / 2 - textElementHeight / 2; } } if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { - coordY = - container.y + - containerDims.height - - textElementHeight - - getBoundTextElementOffset(updatedTextElement); + coordY = containerCoords.y + (maxHeight - textElementHeight); } } } @@ -265,12 +266,21 @@ export const textWysiwyg = ({ editable.selectionEnd = editable.value.length - diff; } - const lines = updatedTextElement.originalText.split("\n"); - const lineHeight = updatedTextElement.containerId - ? approxLineHeight - : updatedTextElement.height / lines.length; if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; + textElementWidth = Math.min(textElementWidth, maxWidth); + } else { + textElementWidth += 0.5; + } + + let lineHeight = updatedTextElement.lineHeight; + + // In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size + if (isSafari) { + lineHeight = detectLineHeight({ + ...updatedTextElement, + fontSize: Math.round(updatedTextElement.fontSize), + }); } // Make sure text editor height doesn't go beyond viewport @@ -279,13 +289,13 @@ export const textWysiwyg = ({ Object.assign(editable.style, { font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ - lineHeight: `${lineHeight}px`, - width: `${Math.min(width, maxWidth)}px`, + lineHeight, + width: `${textElementWidth}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, top: `${viewportY}px`, transform: getTransform( - width, + textElementWidth, textElementHeight, getTextElementAngle(updatedTextElement), appState, @@ -299,6 +309,7 @@ export const textWysiwyg = ({ filter: "var(--theme-filter)", maxHeight: `${editorMaxHeight}px`, }); + editable.scrollTop = 0; // For some reason updating font attribute doesn't set font family // hence updating font family explicitly for test environment if (isTestEnv()) { @@ -378,55 +389,20 @@ export const textWysiwyg = ({ id, ) as ExcalidrawTextElement; const font = getFontString(updatedTextElement); - // using scrollHeight here since we need to calculate - // number of lines so cannot use editable.style.height - // as that gets updated below - // Rounding here so that the lines calculated is more accurate in all browsers. - // The scrollHeight and approxLineHeight differs in diff browsers - // eg it gives 1.05 in firefox for handewritten small font due to which - // height gets updated as lines > 1 and leads to jumping text for first line in bound container - // hence rounding here to avoid that - const lines = Math.round( - editable.scrollHeight / getApproxLineHeight(font), - ); - // auto increase height only when lines > 1 so its - // measured correctly and vertically aligns for - // first line as well as setting height to "auto" - // doubles the height as soon as user starts typing - if (isBoundToContainer(element) && lines > 1) { + if (isBoundToContainer(element)) { const container = getContainerElement(element); - - let height = "auto"; - editable.style.height = "0px"; - let heightSet = false; - if (lines === 2) { - const actualLineCount = wrapText( - editable.value, - font, - getMaxContainerWidth(container!), - ).split("\n").length; - // This is browser behaviour when setting height to "auto" - // It sets the height needed for 2 lines even if actual - // line count is 1 as mentioned above as well - // hence reducing the height by half if actual line count is 1 - // so single line aligns vertically when deleting - if (actualLineCount === 1) { - height = `${editable.scrollHeight / 2}px`; - editable.style.height = height; - heightSet = true; - } - } const wrappedText = wrapText( normalizeText(editable.value), font, getMaxContainerWidth(container!), ); - const width = getTextWidth(wrappedText, font); + const { width, height } = measureText( + wrappedText, + font, + updatedTextElement.lineHeight, + ); editable.style.width = `${width}px`; - - if (!heightSet) { - editable.style.height = `${editable.scrollHeight}px`; - } + editable.style.height = `${height}px`; } onChange(normalizeText(editable.value)); }; @@ -463,7 +439,9 @@ export const textWysiwyg = ({ event.code === CODES.BRACKET_RIGHT)) ) { event.preventDefault(); - if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { + if (event.isComposing) { + return; + } else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { outdent(); } else { indent(); @@ -612,6 +590,7 @@ export const textWysiwyg = ({ ), }); } + redrawTextBoundingBox(updateElement, container); } onSubmit({ diff --git a/src/element/typeChecks.test.ts b/src/element/typeChecks.test.ts new file mode 100644 index 0000000000..ab04c29b79 --- /dev/null +++ b/src/element/typeChecks.test.ts @@ -0,0 +1,66 @@ +import { API } from "../tests/helpers/api"; +import { hasBoundTextElement } from "./typeChecks"; + +describe("Test TypeChecks", () => { + describe("Test hasBoundTextElement", () => { + it("should return true for text bindable containers with bound text", () => { + expect( + hasBoundTextElement( + API.createElement({ + type: "rectangle", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + + expect( + hasBoundTextElement( + API.createElement({ + type: "ellipse", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + + expect( + hasBoundTextElement( + API.createElement({ + type: "arrow", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + + expect( + hasBoundTextElement( + API.createElement({ + type: "image", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + }); + + it("should return false for text bindable containers without bound text", () => { + expect( + hasBoundTextElement( + API.createElement({ + type: "freedraw", + boundElements: [{ type: "arrow", id: "arrow-id" }], + }), + ), + ).toBeFalsy(); + }); + + it("should return false for non text bindable containers", () => { + expect( + hasBoundTextElement( + API.createElement({ + type: "freedraw", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeFalsy(); + }); + }); +}); diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index a6b6cb2db5..164fafe687 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -1,5 +1,6 @@ import { ROUNDNESS } from "../constants"; import { AppState } from "../types"; +import { MarkNonNullable } from "../utility-types"; import { ExcalidrawElement, ExcalidrawTextElement, @@ -139,7 +140,7 @@ export const hasBoundTextElement = ( element: ExcalidrawElement | null, ): element is MarkNonNullable => { return ( - isBindableElement(element) && + isTextBindableContainer(element) && !!element.boundElements?.some(({ type }) => type === "text") ); }; diff --git a/src/element/types.ts b/src/element/types.ts index 01dee1fe1f..4a4db7e8b2 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -6,9 +6,10 @@ import { THEME, VERTICAL_ALIGN, } from "../constants"; +import { MarkNonNullable, ValueOf } from "../utility-types"; export type ChartType = "bar" | "line"; -export type FillStyle = "hachure" | "cross-hatch" | "solid"; +export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; export type Theme = typeof THEME[keyof typeof THEME]; @@ -135,6 +136,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; originalText: string; + /** + * Unitless line height (aligned to W3C). To get line height in px, multiply + * with font size (using `getLineHeightInPx` helper). + */ + lineHeight: number & { _brand: "unitlessLineHeight" }; }>; export type ExcalidrawBindableElement = diff --git a/src/excalidraw-app/app-jotai.ts b/src/excalidraw-app/app-jotai.ts new file mode 100644 index 0000000000..8c6c796f6d --- /dev/null +++ b/src/excalidraw-app/app-jotai.ts @@ -0,0 +1,3 @@ +import { unstable_createStore } from "jotai"; + +export const appJotaiStore = unstable_createStore(); diff --git a/src/excalidraw-app/collab/Collab.tsx b/src/excalidraw-app/collab/Collab.tsx index 22f7487734..e48484ab64 100644 --- a/src/excalidraw-app/collab/Collab.tsx +++ b/src/excalidraw-app/collab/Collab.tsx @@ -70,7 +70,7 @@ import { decryptData } from "../../data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom, useAtom } from "jotai"; -import { jotaiStore } from "../../jotai"; +import { appJotaiStore } from "../app-jotai"; export const collabAPIAtom = atom(null); export const collabDialogShownAtom = atom(false); @@ -167,7 +167,7 @@ class Collab extends PureComponent { setUsername: this.setUsername, }; - jotaiStore.set(collabAPIAtom, collabAPI); + appJotaiStore.set(collabAPIAtom, collabAPI); this.onOfflineStatusToggle(); if ( @@ -185,7 +185,7 @@ class Collab extends PureComponent { } onOfflineStatusToggle = () => { - jotaiStore.set(isOfflineAtom, !window.navigator.onLine); + appJotaiStore.set(isOfflineAtom, !window.navigator.onLine); }; componentWillUnmount() { @@ -208,10 +208,10 @@ class Collab extends PureComponent { } } - isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!; + isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; private setIsCollaborating = (isCollaborating: boolean) => { - jotaiStore.set(isCollaboratingAtom, isCollaborating); + appJotaiStore.set(isCollaboratingAtom, isCollaborating); }; private onUnload = () => { @@ -804,7 +804,7 @@ class Collab extends PureComponent { ); handleClose = () => { - jotaiStore.set(collabDialogShownAtom, false); + appJotaiStore.set(collabDialogShownAtom, false); }; setUsername = (username: string) => { @@ -838,10 +838,9 @@ class Collab extends PureComponent { /> )} {errorMessage && ( - this.setState({ errorMessage: "" })} - /> + this.setState({ errorMessage: "" })}> + {errorMessage} + )} ); diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx index 2c6949aac6..50f586efc6 100644 --- a/src/excalidraw-app/collab/RoomDialog.tsx +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -10,13 +10,13 @@ import { shareWindows, } from "../../components/icons"; import { ToolButton } from "../../components/ToolButton"; -import { t } from "../../i18n"; import "./RoomDialog.scss"; import Stack from "../../components/Stack"; import { AppState } from "../../types"; import { trackEvent } from "../../analytics"; import { getFrame } from "../../utils"; import DialogActionButton from "../../components/DialogActionButton"; +import { useI18n } from "../../i18n"; const getShareIcon = () => { const navigator = window.navigator as any; @@ -51,6 +51,7 @@ const RoomDialog = ({ setErrorMessage: (message: string) => void; theme: AppState["theme"]; }) => { + const { t } = useI18n(); const roomLinkInput = useRef(null); const copyRoomLink = async () => { diff --git a/src/excalidraw-app/components/AppWelcomeScreen.tsx b/src/excalidraw-app/components/AppWelcomeScreen.tsx index 9e760f7348..1e34fa8192 100644 --- a/src/excalidraw-app/components/AppWelcomeScreen.tsx +++ b/src/excalidraw-app/components/AppWelcomeScreen.tsx @@ -1,12 +1,13 @@ import React from "react"; import { PlusPromoIcon } from "../../components/icons"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { WelcomeScreen } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; export const AppWelcomeScreen: React.FC<{ setCollabDialogShown: (toggle: boolean) => any; }> = React.memo((props) => { + const { t } = useI18n(); let headingContent; if (isExcalidrawPlusSignedUser) { diff --git a/src/excalidraw-app/components/EncryptedIcon.tsx b/src/excalidraw-app/components/EncryptedIcon.tsx index a3e6ff0baf..a917689176 100644 --- a/src/excalidraw-app/components/EncryptedIcon.tsx +++ b/src/excalidraw-app/components/EncryptedIcon.tsx @@ -1,17 +1,21 @@ import { shield } from "../../components/icons"; import { Tooltip } from "../../components/Tooltip"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; -export const EncryptedIcon = () => ( - - - {shield} - - -); +export const EncryptedIcon = () => { + const { t } = useI18n(); + + return ( + + + {shield} + + + ); +}; diff --git a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 049a4ddf78..daf4b95c36 100644 --- a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx @@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; import { FileId, NonDeletedExcalidrawElement } from "../../element/types"; import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { nanoid } from "nanoid"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { excalidrawPlusIcon } from "./icons"; import { encryptData, generateEncryptionKey } from "../../data/encryption"; import { isInitializedImageElement } from "../../element/typeChecks"; @@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{ files: BinaryFiles; onError: (error: Error) => void; }> = ({ elements, appState, files, onError }) => { + const { t } = useI18n(); return (
{excalidrawPlusIcon}
diff --git a/src/excalidraw-app/components/LanguageList.tsx b/src/excalidraw-app/components/LanguageList.tsx index 1b3606b57e..aaa5f21373 100644 --- a/src/excalidraw-app/components/LanguageList.tsx +++ b/src/excalidraw-app/components/LanguageList.tsx @@ -1,22 +1,23 @@ -import { useAtom } from "jotai"; +import { useSetAtom } from "jotai"; import React from "react"; -import { langCodeAtom } from ".."; -import * as i18n from "../../i18n"; +import { appLangCodeAtom } from ".."; +import { defaultLang, useI18n } from "../../i18n"; import { languages } from "../../i18n"; export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { - const [langCode, setLangCode] = useAtom(langCodeAtom); + const { t, langCode } = useI18n(); + const setLangCode = useSetAtom(appLangCodeAtom); return (