diff --git a/.env.development b/.env.development index 72b67ecea..0c2fb5527 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 183db7ea2..8737c63c7 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 4a3f6f367..e637a8c0f 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 c5f7f5cd4..48529165e 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 a37843c76..08e807907 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/dev-docs/docs/@excalidraw/excalidraw/faq.mdx b/dev-docs/docs/@excalidraw/excalidraw/faq.mdx index 6f0fd30a7..4684d6c79 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/faq.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/faq.mdx @@ -4,6 +4,34 @@ No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same. +### Turning off Aggressive Anti-Fingerprinting in Brave browser + +When *Aggressive Anti-Fingerprinting* is turned on, the `measureText` API breaks which in turn breaks the Text Elements in your drawings. Here is more [info](https://github.com/excalidraw/excalidraw/pull/6336) on the same. + +We strongly recommend turning it off. You can follow the steps below on how to do so. + + +1. Open [excalidraw.com](https://excalidraw.com) in Brave and click on the **Shield** button +![Shield button](../../assets/brave-shield.png) + +
+ +2. Once opened, look for **Aggressively Block Fingerprinting** + +![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png) + +3. Switch to **Block Fingerprinting** + +![Block filtering](../../assets/block-fingerprint.png) + +4. Thats all. All text elements should be fixed now 🎉 + +
+ +If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE). + + + ## Need help? Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). diff --git a/dev-docs/docs/assets/aggressive-block-fingerprint.png b/dev-docs/docs/assets/aggressive-block-fingerprint.png new file mode 100644 index 000000000..236a12dbe Binary files /dev/null and b/dev-docs/docs/assets/aggressive-block-fingerprint.png differ diff --git a/dev-docs/docs/assets/block-fingerprint.png b/dev-docs/docs/assets/block-fingerprint.png new file mode 100644 index 000000000..bbbf4d26d Binary files /dev/null and b/dev-docs/docs/assets/block-fingerprint.png differ diff --git a/dev-docs/docs/assets/brave-shield.png b/dev-docs/docs/assets/brave-shield.png new file mode 100644 index 000000000..bbb121653 Binary files /dev/null and b/dev-docs/docs/assets/brave-shield.png differ diff --git a/dev-docs/package.json b/dev-docs/package.json index dd3c45872..1e8745910 100644 --- a/dev-docs/package.json +++ b/dev-docs/package.json @@ -18,7 +18,7 @@ "@docusaurus/core": "2.2.0", "@docusaurus/preset-classic": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0", - "@excalidraw/excalidraw": "0.14.2", + "@excalidraw/excalidraw": "0.15.0", "@mdx-js/react": "^1.6.22", "clsx": "^1.2.1", "docusaurus-plugin-sass": "0.2.3", diff --git a/dev-docs/src/theme/ReactLiveScope/index.js b/dev-docs/src/theme/ReactLiveScope/index.js index a282ad6f0..e5263e1db 100644 --- a/dev-docs/src/theme/ReactLiveScope/index.js +++ b/dev-docs/src/theme/ReactLiveScope/index.js @@ -24,6 +24,7 @@ const ExcalidrawScope = { Sidebar: ExcalidrawComp.Sidebar, exportToCanvas: ExcalidrawComp.exportToCanvas, initialData, + useI18n: ExcalidrawComp.useI18n, }; export default ExcalidrawScope; diff --git a/dev-docs/yarn.lock b/dev-docs/yarn.lock index 5aaa9689f..ee3d50cbf 100644 --- a/dev-docs/yarn.lock +++ b/dev-docs/yarn.lock @@ -1631,10 +1631,10 @@ url-loader "^4.1.1" webpack "^5.73.0" -"@excalidraw/excalidraw@0.14.2": - version "0.14.2" - resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.14.2.tgz#150cb4b7a1bf0d11cd64295936c930e7e0db8375" - integrity sha512-8LdjpTBWEK5waDWB7Bt/G9YBI4j0OxkstUhvaDGz7dwQGfzF6FW5CXBoYHNEoX0qmb+Fg/NPOlZ7FrKsrSVCqg== +"@excalidraw/excalidraw@0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.0.tgz#47170de8d3ff006e9d09dfede2815682b0d4485b" + integrity sha512-PJmh1VcuRHG4l+Zgt9qhezxrJ16tYCZFZ8if5IEfmTL9A/7c5mXxY/qrPTqiGlVC7jYs+ciePXQ0YUDzfOfbzw== "@hapi/hoek@^9.0.0": version "9.3.0" @@ -1785,9 +1785,9 @@ "@hapi/hoek" "^9.0.0" "@sideway/formula@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" - integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== "@sideway/pinpoint@^2.0.0": version "2.0.0" @@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1: entities "^4.3.0" http-cache-semantics@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== http-deceiver@^1.2.7: version "1.2.7" @@ -7542,9 +7542,9 @@ webpack-sources@^3.2.2, webpack-sources@^3.2.3: integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== webpack@^5.73.0: - version "5.74.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980" - integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA== + version "5.76.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c" + integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51" diff --git a/package.json b/package.json index a19b2fb89..5816786e3 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,6 @@ "@testing-library/jest-dom": "5.16.2", "@testing-library/react": "12.1.5", "@tldraw/vec": "1.7.1", - "@types/jest": "27.4.0", - "@types/pica": "5.1.3", - "@types/react": "18.0.15", - "@types/react-dom": "18.0.6", - "@types/socket.io-client": "1.4.36", "browser-fs-access": "0.29.1", "clsx": "1.1.1", "cross-env": "7.0.3", @@ -57,7 +52,6 @@ "sass": "1.51.0", "socket.io-client": "2.3.1", "tunnel-rat": "0.1.0", - "typescript": "4.9.4", "workbox-background-sync": "^6.5.4", "workbox-broadcast-update": "^6.5.4", "workbox-cacheable-response": "^6.5.4", @@ -75,9 +69,14 @@ "@excalidraw/eslint-config": "1.0.0", "@excalidraw/prettier-config": "1.0.2", "@types/chai": "4.3.0", + "@types/jest": "27.4.0", "@types/lodash.throttle": "4.1.7", "@types/pako": "1.0.3", + "@types/pica": "5.1.3", + "@types/react": "18.0.15", + "@types/react-dom": "18.0.6", "@types/resize-observer-browser": "0.1.7", + "@types/socket.io-client": "1.4.36", "chai": "4.3.6", "dotenv": "16.0.1", "eslint-config-prettier": "8.5.0", @@ -88,7 +87,8 @@ "lint-staged": "12.3.7", "pepjs": "0.5.3", "prettier": "2.6.2", - "rewire": "6.0.0" + "rewire": "6.0.0", + "typescript": "4.9.4" }, "engines": { "node": ">=14.0.0" diff --git a/public/index.html b/public/index.html index 35640c0dc..a8633fc4d 100644 --- a/public/index.html +++ b/public/index.html @@ -79,6 +79,7 @@ + <% if (process.env.NODE_ENV === "production") { %> + <% } %> @@ -146,8 +148,10 @@ // setting this so that libraries installation reuses this window tab. window.name = "_excalidraw"; - <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' && - process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> + <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %> + + + <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> + <% } %> + + <% } %> diff --git a/scripts/locales-coverage-description.js b/scripts/locales-coverage-description.js index 08db5b841..0f9bacfaa 100644 --- a/scripts/locales-coverage-description.js +++ b/scripts/locales-coverage-description.js @@ -2,6 +2,9 @@ const fs = require("fs"); const THRESSHOLD = 85; +// we're using BCP 47 language tags as keys +// e.g. https://gist.github.com/typpo/b2b828a35e683b9bf8db91b5404f1bd1 + const crowdinMap = { "ar-SA": "en-ar", "bg-BG": "en-bg", @@ -52,6 +55,7 @@ const crowdinMap = { "kk-KZ": "en-kk", "vi-VN": "en-vi", "mr-IN": "en-mr", + "th-TH": "en-th", }; const flags = { @@ -104,6 +108,7 @@ const flags = { "eu-ES": "🇪🇦", "vi-VN": "🇻🇳", "mr-IN": "🇮🇳", + "th-TH": "🇹🇭", }; const languages = { @@ -156,6 +161,7 @@ const languages = { "zh-TW": "繁體中文", "vi-VN": "Tiếng Việt", "mr-IN": "मराठी", + "th-TH": "ภาษาไทย", }; const percentages = fs.readFileSync( diff --git a/scripts/release.js b/scripts/release.js index 986eadc2a..24ac89c6b 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -1,22 +1,9 @@ -const fs = require("fs"); const { execSync } = require("child_process"); const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawPackage = `${excalidrawDir}/package.json`; const pkg = require(excalidrawPackage); -const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8"); - -const updateReadme = () => { - const excalidrawIndex = originalReadMe.indexOf("### Excalidraw"); - - // remove note for stable readme - const data = originalReadMe.slice(excalidrawIndex); - - // update readme - fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8"); -}; - const publish = () => { try { execSync(`yarn --frozen-lockfile`); @@ -30,15 +17,8 @@ const publish = () => { }; const release = () => { - updateReadme(); - console.info("Note for stable readme removed"); - publish(); console.info(`Published ${pkg.version}!`); - - // revert readme after release - fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8"); - console.info("Readme reverted"); }; release(); diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index ed6efe971..658bdf8ce 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -1,7 +1,14 @@ -import { VERTICAL_ALIGN } from "../constants"; -import { getNonDeletedElements, isTextElement } from "../element"; +import { + BOUND_TEXT_PADDING, + ROUNDNESS, + VERTICAL_ALIGN, + TEXT_ALIGN, +} from "../constants"; +import { getNonDeletedElements, isTextElement, newElement } from "../element"; import { mutateElement } from "../element/mutateElement"; import { + computeBoundTextPosition, + computeContainerDimensionForBoundText, getBoundTextElement, measureText, redrawTextBoundingBox, @@ -9,16 +16,21 @@ import { import { getOriginalContainerHeightFromCache, resetOriginalContainerCache, + updateOriginalContainerCache, } from "../element/textWysiwyg"; import { hasBoundTextElement, isTextBindableContainer, + isUsingAdaptiveRadius, } from "../element/typeChecks"; import { + ExcalidrawElement, + ExcalidrawLinearElement, ExcalidrawTextContainer, ExcalidrawTextElement, } from "../element/types"; import { getSelectedElements } from "../scene"; +import { AppState } from "../types"; import { getFontString } from "../utils"; import { register } from "./register"; @@ -28,6 +40,7 @@ export const actionUnbindText = register({ trackEvent: { category: "element" }, predicate: (elements, appState) => { const selectedElements = getSelectedElements(elements, appState); + return selectedElements.some((element) => hasBoundTextElement(element)); }, perform: (elements, appState) => { @@ -41,18 +54,21 @@ export const actionUnbindText = register({ const { width, height, baseline } = measureText( boundTextElement.originalText, getFontString(boundTextElement), + boundTextElement.lineHeight, ); const originalContainerHeight = getOriginalContainerHeightFromCache( element.id, ); resetOriginalContainerCache(element.id); - + const { x, y } = computeBoundTextPosition(element, boundTextElement); mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, height, baseline, text: boundTextElement.originalText, + x, + y, }); mutateElement(element, { boundElements: element.boundElements?.filter( @@ -122,6 +138,7 @@ export const actionBindText = register({ mutateElement(textElement, { containerId: container.id, verticalAlign: VERTICAL_ALIGN.MIDDLE, + textAlign: TEXT_ALIGN.CENTER, }); mutateElement(container, { boundElements: (container.boundElements || []).concat({ @@ -129,20 +146,168 @@ export const actionBindText = register({ id: textElement.id, }), }); + const originalContainerHeight = container.height; redrawTextBoundingBox(textElement, container); - const updatedElements = elements.slice(); - const textElementIndex = updatedElements.findIndex( - (ele) => ele.id === textElement.id, - ); - updatedElements.splice(textElementIndex, 1); - const containerIndex = updatedElements.findIndex( - (ele) => ele.id === container.id, - ); - updatedElements.splice(containerIndex + 1, 0, textElement); + // overwritting the cache with original container height so + // it can be restored when unbind + updateOriginalContainerCache(container.id, originalContainerHeight); + return { - elements: updatedElements, + elements: pushTextAboveContainer(elements, container, textElement), appState: { ...appState, selectedElementIds: { [container.id]: true } }, commitToHistory: true, }; }, }); + +const pushTextAboveContainer = ( + elements: readonly ExcalidrawElement[], + container: ExcalidrawElement, + textElement: ExcalidrawTextElement, +) => { + const updatedElements = elements.slice(); + const textElementIndex = updatedElements.findIndex( + (ele) => ele.id === textElement.id, + ); + updatedElements.splice(textElementIndex, 1); + + const containerIndex = updatedElements.findIndex( + (ele) => ele.id === container.id, + ); + updatedElements.splice(containerIndex + 1, 0, textElement); + return updatedElements; +}; + +const pushContainerBelowText = ( + elements: readonly ExcalidrawElement[], + container: ExcalidrawElement, + textElement: ExcalidrawTextElement, +) => { + const updatedElements = elements.slice(); + const containerIndex = updatedElements.findIndex( + (ele) => ele.id === container.id, + ); + updatedElements.splice(containerIndex, 1); + + const textElementIndex = updatedElements.findIndex( + (ele) => ele.id === textElement.id, + ); + updatedElements.splice(textElementIndex, 0, container); + return updatedElements; +}; + +export const actionWrapTextInContainer = register({ + name: "wrapTextInContainer", + contextItemLabel: "labels.createContainerFromText", + trackEvent: { category: "element" }, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + const areTextElements = selectedElements.every((el) => isTextElement(el)); + return selectedElements.length > 0 && areTextElements; + }, + perform: (elements, appState) => { + const selectedElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + ); + let updatedElements: readonly ExcalidrawElement[] = elements.slice(); + const containerIds: AppState["selectedElementIds"] = {}; + + for (const textElement of selectedElements) { + if (isTextElement(textElement)) { + const container = newElement({ + type: "rectangle", + backgroundColor: appState.currentItemBackgroundColor, + boundElements: [ + ...(textElement.boundElements || []), + { id: textElement.id, type: "text" }, + ], + angle: textElement.angle, + fillStyle: appState.currentItemFillStyle, + strokeColor: appState.currentItemStrokeColor, + roughness: appState.currentItemRoughness, + strokeWidth: appState.currentItemStrokeWidth, + strokeStyle: appState.currentItemStrokeStyle, + roundness: + appState.currentItemRoundness === "round" + ? { + type: isUsingAdaptiveRadius("rectangle") + ? ROUNDNESS.ADAPTIVE_RADIUS + : ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + opacity: 100, + locked: false, + x: textElement.x - BOUND_TEXT_PADDING, + y: textElement.y - BOUND_TEXT_PADDING, + width: computeContainerDimensionForBoundText( + textElement.width, + "rectangle", + ), + height: computeContainerDimensionForBoundText( + textElement.height, + "rectangle", + ), + groupIds: textElement.groupIds, + }); + + // update bindings + if (textElement.boundElements?.length) { + const linearElementIds = textElement.boundElements + .filter((ele) => ele.type === "arrow") + .map((el) => el.id); + const linearElements = updatedElements.filter((ele) => + linearElementIds.includes(ele.id), + ) as ExcalidrawLinearElement[]; + linearElements.forEach((ele) => { + let startBinding = ele.startBinding; + let endBinding = ele.endBinding; + + if (startBinding?.elementId === textElement.id) { + startBinding = { + ...startBinding, + elementId: container.id, + }; + } + + if (endBinding?.elementId === textElement.id) { + endBinding = { ...endBinding, elementId: container.id }; + } + + if (startBinding || endBinding) { + mutateElement(ele, { startBinding, endBinding }, false); + } + }); + } + + mutateElement( + textElement, + { + containerId: container.id, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + boundElements: null, + textAlign: TEXT_ALIGN.CENTER, + }, + false, + ); + redrawTextBoundingBox(textElement, container); + + updatedElements = pushContainerBelowText( + [...updatedElements, container], + container, + textElement, + ); + containerIds[container.id] = true; + } + } + + return { + elements: updatedElements, + appState: { + ...appState, + selectedElementIds: containerIds, + }, + commitToHistory: true, + }; + }, +}); diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 1154d1ef1..440c59191 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = ( return clampedZoomValueToFitElements as NormalizedZoomValue; }; -const zoomToFitElements = ( +export const zoomToFitElements = ( elements: readonly ExcalidrawElement[], appState: Readonly, zoomToSelection: boolean, diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 4e6f0d587..ed714816b 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -1,4 +1,5 @@ import { AppState } from "../../src/types"; +import { trackEvent } from "../analytics"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ColorPicker } from "../components/ColorPicker"; import { IconPicker } from "../components/IconPicker"; @@ -37,6 +38,7 @@ import { TextAlignLeftIcon, TextAlignCenterIcon, TextAlignRightIcon, + FillZigZagIcon, } from "../components/icons"; import { DEFAULT_FONT_FAMILY, @@ -54,6 +56,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement"; import { getBoundTextElement, getContainerElement, + getDefaultLineHeight, } from "../element/textElement"; import { isBoundToContainer, @@ -293,7 +296,12 @@ export const actionChangeBackgroundColor = register({ export const actionChangeFillStyle = register({ name: "changeFillStyle", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { + trackEvent( + "element", + "changeFillStyle", + `${value} (${app.device.isMobile ? "mobile" : "desktop"})`, + ); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -304,40 +312,55 @@ export const actionChangeFillStyle = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => ( -
- {t("labels.fill")} - element.fillStyle, - appState.currentItemFillStyle, - )} - onChange={(value) => { - updateData(value); - }} - /> -
- ), + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + const allElementsZigZag = selectedElements.every( + (el) => el.fillStyle === "zigzag", + ); + + return ( +
+ {t("labels.fill")} + element.fillStyle, + appState.currentItemFillStyle, + )} + onClick={(value, event) => { + const nextValue = + event.altKey && + value === "hachure" && + selectedElements.every((el) => el.fillStyle === "hachure") + ? "zigzag" + : value; + + updateData(nextValue); + }} + /> +
+ ); + }, }); export const actionChangeStrokeWidth = register({ @@ -637,6 +660,7 @@ export const actionChangeFontFamily = register({ oldElement, { fontFamily: value, + lineHeight: getDefaultLineHeight(value), }, ); redrawTextBoundingBox(newElement, getContainerElement(oldElement)); @@ -745,16 +769,19 @@ export const actionChangeTextAlign = register({ value: "left", text: t("labels.left"), icon: TextAlignLeftIcon, + testId: "align-left", }, { value: "center", text: t("labels.center"), icon: TextAlignCenterIcon, + testId: "align-horizontal-center", }, { value: "right", text: t("labels.right"), icon: TextAlignRightIcon, + testId: "align-right", }, ]} value={getFormValue( diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index b2be3853d..aaa59d324 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -12,7 +12,10 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, } from "../constants"; -import { getBoundTextElement } from "../element/textElement"; +import { + getBoundTextElement, + getDefaultLineHeight, +} from "../element/textElement"; import { hasBoundTextElement, canApplyRoundnessTypeToElement, @@ -92,12 +95,18 @@ export const actionPasteStyles = register({ }); if (isTextElement(newElement)) { + const fontSize = + elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE; + const fontFamily = + elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY; newElement = newElementWith(newElement, { - fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, - fontFamily: - elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY, + fontSize, + fontFamily, textAlign: elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, + lineHeight: + elementStylesToCopyFrom.lineHeight || + getDefaultLineHeight(fontFamily), }); let container = null; if (newElement.containerId) { diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index ba66c5a75..be48c6470 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -1,5 +1,6 @@ import { isDarwin } from "../constants"; import { t } from "../i18n"; +import { SubtypeOf } from "../utility-types"; import { getShortcutKey } from "../utils"; import { ActionName } from "./types"; diff --git a/src/actions/types.ts b/src/actions/types.ts index 54bd5a26f..b03e1053b 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -6,6 +6,7 @@ import { ExcalidrawProps, BinaryFiles, } from "../types"; +import { MarkOptional } from "../utility-types"; export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; @@ -113,7 +114,8 @@ export type ActionName = | "toggleLock" | "toggleLinearEditor" | "toggleEraserTool" - | "toggleHandTool"; + | "toggleHandTool" + | "wrapTextInContainer"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/src/analytics.ts b/src/analytics.ts index 668d49c25..1e9a429b6 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,22 +1,30 @@ -export const trackEvent = - typeof process !== "undefined" && - process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && - typeof window !== "undefined" && - window.gtag - ? (category: string, action: string, label?: string, value?: number) => { - try { - window.gtag("event", action, { - event_category: category, - event_label: label, - value, - }); - } catch (error) { - console.error("error logging to ga", error); - } - } - : typeof process !== "undefined" && process.env?.JEST_WORKER_ID - ? (category: string, action: string, label?: string, value?: number) => {} - : (category: string, action: string, label?: string, value?: number) => { - // Uncomment the next line to track locally - // console.log("Track Event", { category, action, label, value }); - }; +export const trackEvent = ( + category: string, + action: string, + label?: string, + value?: number, +) => { + try { + // Uncomment the next line to track locally + // console.log("Track Event", { category, action, label, value }); + + if (typeof window === "undefined" || process.env.JEST_WORKER_ID) { + return; + } + + if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) { + window.gtag("event", action, { + event_category: category, + event_label: label, + value, + }); + } + + // MATOMO event tracking _paq must be same as the one in index.html + if (window._paq) { + window._paq.push(["trackEvent", category, action, label, value]); + } + } catch (error) { + console.error("error during analytics", error); + } +}; diff --git a/src/appState.ts b/src/appState.ts index ebc19b92b..ccdf2d692 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -1,8 +1,8 @@ import { DEFAULT_BACKGROUND_COLOR, + DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, - DEFAULT_STROKE_COLOR, DEFAULT_TEXT_ALIGN, DEFAULT_ZOOM_VALUE, EXPORT_SCALES, @@ -25,18 +25,18 @@ export const getDefaultAppState = (): Omit< theme: THEME.LIGHT, collaborators: new Map(), currentChartType: "bar", - currentItemBackgroundColor: "transparent", + currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor, currentItemEndArrowhead: "arrow", - currentItemFillStyle: "hachure", + currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle, currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontSize: DEFAULT_FONT_SIZE, - currentItemOpacity: 100, - currentItemRoughness: 1, + currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity, + currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemStartArrowhead: null, - currentItemStrokeColor: DEFAULT_STROKE_COLOR, + currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemRoundness: "round", - currentItemStrokeStyle: "solid", - currentItemStrokeWidth: 1, + currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, + currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, cursorButton: "up", draggingElement: null, @@ -46,7 +46,7 @@ export const getDefaultAppState = (): Omit< activeTool: { type: "selection", customType: null, - locked: false, + locked: DEFAULT_ELEMENT_PROPS.locked, lastActiveTool: null, }, penMode: false, diff --git a/src/charts.ts b/src/charts.ts index e8980db6c..c3b0950d1 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -1,10 +1,5 @@ import colors from "./colors"; -import { - DEFAULT_FONT_FAMILY, - DEFAULT_FONT_SIZE, - ENV, - VERTICAL_ALIGN, -} from "./constants"; +import { DEFAULT_FONT_SIZE, ENV } from "./constants"; import { newElement, newLinearElement, newTextElement } from "./element"; import { NonDeletedExcalidrawElement } from "./element/types"; import { randomId } from "./random"; @@ -166,17 +161,7 @@ const bgColors = colors.elementBackground.slice( // Put all the common properties here so when the whole chart is selected // the properties dialog shows the correct selected values const commonProps = { - fillStyle: "hachure", - fontFamily: DEFAULT_FONT_FAMILY, - fontSize: DEFAULT_FONT_SIZE, - opacity: 100, - roughness: 1, strokeColor: colors.elementStroke[0], - roundness: null, - strokeStyle: "solid", - strokeWidth: 1, - verticalAlign: VERTICAL_ALIGN.MIDDLE, - locked: false, } as const; const getChartDimentions = (spreadsheet: Spreadsheet) => { @@ -323,7 +308,6 @@ const chartBaseElements = ( x: x + chartWidth / 2, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, roundness: null, - strokeStyle: "solid", textAlign: "center", }) : null; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 2ee9babfc..3bbc0ff1a 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -30,7 +30,10 @@ import clsx from "clsx"; import { actionToggleZenMode } from "../actions"; import "./Actions.scss"; import { Tooltip } from "./Tooltip"; -import { shouldAllowVerticalAlign } from "../element/textElement"; +import { + shouldAllowVerticalAlign, + suppportsHorizontalAlign, +} from "../element/textElement"; export const SelectedShapeActions = ({ appState, @@ -122,7 +125,8 @@ export const SelectedShapeActions = ({ {renderAction("changeFontFamily")} - {renderAction("changeTextAlign")} + {suppportsHorizontalAlign(targetElements) && + renderAction("changeTextAlign")} )} diff --git a/src/components/ActiveConfirmDialog.tsx b/src/components/ActiveConfirmDialog.tsx index 3c79a5190..44a26e9a6 100644 --- a/src/components/ActiveConfirmDialog.tsx +++ b/src/components/ActiveConfirmDialog.tsx @@ -1,6 +1,7 @@ import { atom, useAtom } from "jotai"; import { actionClearCanvas } from "../actions"; import { t } from "../i18n"; +import { jotaiScope } from "../jotai"; import { useExcalidrawActionManager } from "./App"; import ConfirmDialog from "./ConfirmDialog"; @@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null); export const ActiveConfirmDialog = () => { const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( activeConfirmDialogAtom, + jotaiScope, ); const actionManager = useExcalidrawActionManager(); diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx new file mode 100644 index 000000000..baf25ab9b --- /dev/null +++ b/src/components/App.test.tsx @@ -0,0 +1,45 @@ +import ReactDOM from "react-dom"; +import * as Renderer from "../renderer/renderScene"; +import { reseed } from "../random"; +import { render, queryByTestId } from "../tests/test-utils"; + +import ExcalidrawApp from "../excalidraw-app"; + +const renderScene = jest.spyOn(Renderer, "renderScene"); + +describe("Test ", () => { + beforeEach(async () => { + // Unmount ReactDOM from root + ReactDOM.unmountComponentAtNode(document.getElementById("root")!); + localStorage.clear(); + renderScene.mockClear(); + reseed(7); + }); + + it("should show error modal when using brave and measureText API is not working", async () => { + (global.navigator as any).brave = { + isBrave: { + name: "isBrave", + }, + }; + + const originalContext = global.HTMLCanvasElement.prototype.getContext("2d"); + //@ts-ignore + global.HTMLCanvasElement.prototype.getContext = (contextId) => { + return { + ...originalContext, + measureText: () => ({ + width: 0, + }), + }; + }; + + await render(); + expect( + queryByTestId( + document.querySelector(".excalidraw-modal-container")!, + "brave-measure-text-error", + ), + ).toMatchSnapshot(); + }); +}); diff --git a/src/components/App.tsx b/src/components/App.tsx index dd2ffcdf5..68d6d0672 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -62,6 +62,7 @@ import { GRID_SIZE, IMAGE_RENDER_TIMEOUT, isAndroid, + isBrave, LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, MIME_TYPES, @@ -108,6 +109,7 @@ import { textWysiwyg, transformElements, updateTextElement, + redrawTextBoundingBox, } from "../element"; import { bindOrUnbindLinearElement, @@ -125,7 +127,11 @@ import { } from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; +import { + deepCopyElement, + duplicateElements, + newFreeDrawElement, +} from "../element/newElement"; import { hasBoundTextElement, isArrowElement, @@ -227,6 +233,7 @@ import { updateActiveTool, getShortcutKey, isTransparent, + easeToValuesRAF, } from "../utils"; import { ContextMenu, @@ -258,13 +265,16 @@ import throttle from "lodash.throttle"; import { fileOpen, FileSystemHandle } from "../data/filesystem"; import { bindTextToShapeAfterDuplication, - getApproxLineHeight, getApproxMinLineHeight, getApproxMinLineWidth, getBoundTextElement, getContainerCenter, getContainerDims, + getContainerElement, + getDefaultLineHeight, + getLineHeightInPx, getTextBindableContainerAtPosition, + isMeasureTextSupported, isValidTextContainer, } from "../element/textElement"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; @@ -279,9 +289,14 @@ import { import { shouldShowBoundingBox } from "../element/transformHandles"; import { Fonts } from "../scene/Fonts"; import { actionPaste } from "../actions/actionClipboard"; -import { actionToggleHandTool } from "../actions/actionCanvas"; +import { + actionToggleHandTool, + zoomToFitElements, +} from "../actions/actionCanvas"; import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; +import { actionWrapTextInContainer } from "../actions/actionBoundText"; +import BraveMeasureTextError from "./BraveMeasureTextError"; const deviceContextInitialValue = { isSmScreen: false, @@ -426,7 +441,6 @@ class App extends React.Component { }; this.id = nanoid(); - this.library = new Library(this); if (excalidrawRef) { const readyPromise = @@ -708,6 +722,8 @@ class App extends React.Component { const theme = actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; let name = actionResult?.appState?.name ?? this.state.name; + const errorMessage = + actionResult?.appState?.errorMessage ?? this.state.errorMessage; if (typeof this.props.viewModeEnabled !== "undefined") { viewModeEnabled = this.props.viewModeEnabled; } @@ -723,7 +739,6 @@ class App extends React.Component { if (typeof this.props.name !== "undefined") { name = this.props.name; } - this.setState( (state) => { // using Object.assign instead of spread to fool TS 4.2.2+ into @@ -741,6 +756,7 @@ class App extends React.Component { gridSize, theme, name, + errorMessage, }); }, () => { @@ -869,7 +885,6 @@ class App extends React.Component { ), }; } - // FontFaceSet loadingdone event we listen on may not always fire // (looking at you Safari), so on init we manually load fonts for current // text elements on canvas, and rerender them once done. This also @@ -997,6 +1012,13 @@ class App extends React.Component { } else { this.updateDOMRect(this.initializeScene); } + + // note that this check seems to always pass in localhost + if (isBrave() && !isMeasureTextSupported()) { + this.setState({ + errorMessage: , + }); + } } public componentWillUnmount() { @@ -1607,36 +1629,36 @@ class App extends React.Component { const dx = x - elementsCenterX; const dy = y - elementsCenterY; - const groupIdMap = new Map(); const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize); - const oldIdToDuplicatedId = new Map(); - const newElements = elements.map((element) => { - const newElement = duplicateElement( - this.state.editingGroupId, - groupIdMap, - element, - { + const newElements = duplicateElements( + elements.map((element) => { + return newElementWith(element, { x: element.x + gridX - minX, y: element.y + gridY - minY, - }, - ); - oldIdToDuplicatedId.set(element.id, newElement.id); - return newElement; - }); - bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId); + }); + }), + ); + const nextElements = [ ...this.scene.getElementsIncludingDeleted(), ...newElements, ]; - fixBindingsAfterDuplication(nextElements, elements, oldIdToDuplicatedId); + + this.scene.replaceAllElements(nextElements); + + newElements.forEach((newElement) => { + if (isTextElement(newElement) && isBoundToContainer(newElement)) { + const container = getContainerElement(newElement); + redrawTextBoundingBox(newElement, container); + } + }); if (opts.files) { this.files = { ...this.files, ...opts.files }; } - this.scene.replaceAllElements(nextElements); this.history.resumeRecording(); this.setState( @@ -1709,12 +1731,14 @@ class App extends React.Component { (acc: ExcalidrawTextElement[], line, idx) => { const text = line.trim(); + const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); if (text.length) { const element = newTextElement({ ...textElementProps, x, y: currentY, text, + lineHeight, }); acc.push(element); currentY += element.height + LINE_GAP; @@ -1723,14 +1747,9 @@ class App extends React.Component { // add paragraph only if previous line was not empty, IOW don't add // more than one empty line if (prevLine) { - const defaultLineHeight = getApproxLineHeight( - getFontString({ - fontSize: textElementProps.fontSize, - fontFamily: textElementProps.fontFamily, - }), - ); - - currentY += defaultLineHeight + LINE_GAP; + currentY += + getLineHeightInPx(textElementProps.fontSize, lineHeight) + + LINE_GAP; } } @@ -1823,18 +1842,89 @@ class App extends React.Component { this.actionManager.executeAction(actionToggleHandTool); }; + /** + * Zooms on canvas viewport center + */ + zoomCanvas = ( + /** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */ + value: number, + ) => { + this.setState({ + ...getStateForZoom( + { + viewportX: this.state.width / 2 + this.state.offsetLeft, + viewportY: this.state.height / 2 + this.state.offsetTop, + nextZoom: getNormalizedZoom(value), + }, + this.state, + ), + }); + }; + + private cancelInProgresAnimation: (() => void) | null = null; + scrollToContent = ( target: | ExcalidrawElement | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), + opts?: { fitToContent?: boolean; animate?: boolean; duration?: number }, ) => { - this.setState({ - ...calculateScrollCenter( - Array.isArray(target) ? target : [target], - this.state, - this.canvas, - ), - }); + this.cancelInProgresAnimation?.(); + + // convert provided target into ExcalidrawElement[] if necessary + const targets = Array.isArray(target) ? target : [target]; + + let zoom = this.state.zoom; + let scrollX = this.state.scrollX; + let scrollY = this.state.scrollY; + + if (opts?.fitToContent) { + // compute an appropriate viewport location (scroll X, Y) and zoom level + // that fit the target elements on the scene + const { appState } = zoomToFitElements(targets, this.state, false); + zoom = appState.zoom; + scrollX = appState.scrollX; + scrollY = appState.scrollY; + } else { + // compute only the viewport location, without any zoom adjustment + const scroll = calculateScrollCenter(targets, this.state, this.canvas); + scrollX = scroll.scrollX; + scrollY = scroll.scrollY; + } + + // when animating, we use RequestAnimationFrame to prevent the animation + // from slowing down other processes + if (opts?.animate) { + const origScrollX = this.state.scrollX; + const origScrollY = this.state.scrollY; + + // zoom animation could become problematic on scenes with large number + // of elements, setting it to its final value to improve user experience. + // + // using zoomCanvas() to zoom on current viewport center + this.zoomCanvas(zoom.value); + + const cancel = easeToValuesRAF( + [origScrollX, origScrollY], + [scrollX, scrollY], + (scrollX, scrollY) => this.setState({ scrollX, scrollY }), + { duration: opts?.duration ?? 500 }, + ); + this.cancelInProgresAnimation = () => { + cancel(); + this.cancelInProgresAnimation = null; + }; + } else { + this.setState({ scrollX, scrollY, zoom }); + } + }; + + /** use when changing scrollX/scrollY/zoom based on user interaction */ + private translateCanvas: React.Component["setState"] = ( + state, + ) => { + this.cancelInProgresAnimation?.(); + this.setState(state); }; setToast = ( @@ -2035,9 +2125,13 @@ class App extends React.Component { offset = -offset; } if (event.shiftKey) { - this.setState((state) => ({ scrollX: state.scrollX + offset })); + this.translateCanvas((state) => ({ + scrollX: state.scrollX + offset, + })); } else { - this.setState((state) => ({ scrollY: state.scrollY + offset })); + this.translateCanvas((state) => ({ + scrollY: state.scrollY + offset, + })); } } @@ -2585,6 +2679,13 @@ class App extends React.Component { existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } + const fontFamily = + existingTextElement?.fontFamily || this.state.currentItemFontFamily; + + const lineHeight = + existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); + const fontSize = this.state.currentItemFontSize; + if ( !existingTextElement && shouldBindToContainer && @@ -2592,11 +2693,14 @@ class App extends React.Component { !isArrowElement(container) ) { const fontString = { - fontSize: this.state.currentItemFontSize, - fontFamily: this.state.currentItemFontFamily, + fontSize, + fontFamily, }; - const minWidth = getApproxMinLineWidth(getFontString(fontString)); - const minHeight = getApproxMinLineHeight(getFontString(fontString)); + const minWidth = getApproxMinLineWidth( + getFontString(fontString), + lineHeight, + ); + const minHeight = getApproxMinLineHeight(fontSize, lineHeight); const containerDims = getContainerDims(container); const newHeight = Math.max(containerDims.height, minHeight); const newWidth = Math.max(containerDims.width, minWidth); @@ -2628,10 +2732,9 @@ class App extends React.Component { strokeStyle: this.state.currentItemStrokeStyle, roughness: this.state.currentItemRoughness, opacity: this.state.currentItemOpacity, - roundness: null, text: "", - fontSize: this.state.currentItemFontSize, - fontFamily: this.state.currentItemFontFamily, + fontSize, + fontFamily, textAlign: parentCenterPosition ? "center" : this.state.currentItemTextAlign, @@ -2640,7 +2743,7 @@ class App extends React.Component { : DEFAULT_VERTICAL_ALIGN, containerId: shouldBindToContainer ? container?.id : undefined, groupIds: container?.groupIds ?? [], - locked: false, + lineHeight, }); if (!existingTextElement && shouldBindToContainer && container) { @@ -2663,14 +2766,6 @@ class App extends React.Component { element, ]); } - - // case: creating new text not centered to parent element → offset Y - // so that the text is centered to cursor position - if (!parentCenterPosition) { - mutateElement(element, { - y: element.y - element.baseline / 2, - }); - } } this.setState({ @@ -2764,7 +2859,6 @@ class App extends React.Component { ); if (container) { if ( - isArrowElement(container) || hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || isHittingElementNotConsideringBoundingBox(container, this.state, [ @@ -2916,12 +3010,12 @@ class App extends React.Component { state, ); - return { + this.translateCanvas({ zoom: zoomState.zoom, scrollX: zoomState.scrollX + deltaX / nextZoom, scrollY: zoomState.scrollY + deltaY / nextZoom, shouldCacheIgnoreZoom: true, - }; + }); }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -3399,6 +3493,43 @@ class App extends React.Component { this.setState({ contextMenu: null }); } + this.updateGestureOnPointerDown(event); + + // if dragging element is freedraw and another pointerdown event occurs + // a second finger is on the screen + // discard the freedraw element if it is very short because it is likely + // just a spike, otherwise finalize the freedraw element when the second + // finger is lifted + if ( + event.pointerType === "touch" && + this.state.draggingElement && + this.state.draggingElement.type === "freedraw" + ) { + const element = this.state.draggingElement as ExcalidrawFreeDrawElement; + this.updateScene({ + ...(element.points.length < 10 + ? { + elements: this.scene + .getElementsIncludingDeleted() + .filter((el) => el.id !== element.id), + } + : {}), + appState: { + draggingElement: null, + editingElement: null, + startBoundElement: null, + suggestedBindings: [], + selectedElementIds: Object.keys(this.state.selectedElementIds) + .filter((key) => key !== element.id) + .reduce((obj: { [id: string]: boolean }, key) => { + obj[key] = this.state.selectedElementIds[key]; + return obj; + }, {}), + }, + }); + return; + } + // remove any active selection when we start to interact with canvas // (mainly, we care about removing selection outside the component which // would prevent our copy handling otherwise) @@ -3438,8 +3569,6 @@ class App extends React.Component { }); this.savePointer(event.clientX, event.clientY, "down"); - this.updateGestureOnPointerDown(event); - if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) { return; } @@ -3697,7 +3826,7 @@ class App extends React.Component { window.addEventListener(EVENT.POINTER_UP, enableNextPaste); } - this.setState({ + this.translateCanvas({ scrollX: this.state.scrollX - deltaX / this.state.zoom.value, scrollY: this.state.scrollY - deltaY / this.state.zoom.value, }); @@ -4843,7 +4972,7 @@ class App extends React.Component { if (pointerDownState.scrollbars.isOverHorizontal) { const x = event.clientX; const dx = x - pointerDownState.lastCoords.x; - this.setState({ + this.translateCanvas({ scrollX: this.state.scrollX - dx / this.state.zoom.value, }); pointerDownState.lastCoords.x = x; @@ -4853,7 +4982,7 @@ class App extends React.Component { if (pointerDownState.scrollbars.isOverVertical) { const y = event.clientY; const dy = y - pointerDownState.lastCoords.y; - this.setState({ + this.translateCanvas({ scrollY: this.state.scrollY - dy / this.state.zoom.value, }); pointerDownState.lastCoords.y = y; @@ -6235,6 +6364,7 @@ class App extends React.Component { actionGroup, actionUnbindText, actionBindText, + actionWrapTextInContainer, actionUngroup, CONTEXT_MENU_SEPARATOR, actionAddToLibrary, @@ -6281,7 +6411,7 @@ class App extends React.Component { // reduced amplification for small deltas (small movements on a trackpad) Math.min(1, absDelta / 20); - this.setState((state) => ({ + this.translateCanvas((state) => ({ ...getStateForZoom( { viewportX: cursorX, @@ -6298,14 +6428,14 @@ class App extends React.Component { // scroll horizontally when shift pressed if (event.shiftKey) { - this.setState(({ zoom, scrollX }) => ({ + this.translateCanvas(({ zoom, scrollX }) => ({ // on Mac, shift+wheel tends to result in deltaX scrollX: scrollX - (deltaY || deltaX) / zoom.value, })); return; } - this.setState(({ zoom, scrollX, scrollY }) => ({ + this.translateCanvas(({ zoom, scrollX, scrollY }) => ({ scrollX: scrollX - deltaX / zoom.value, scrollY: scrollY - deltaY / zoom.value, })); diff --git a/src/components/BraveMeasureTextError.tsx b/src/components/BraveMeasureTextError.tsx new file mode 100644 index 000000000..8a4a71e4f --- /dev/null +++ b/src/components/BraveMeasureTextError.tsx @@ -0,0 +1,42 @@ +import { t } from "../i18n"; +const BraveMeasureTextError = () => { + return ( +
+

+ {t("errors.brave_measure_text_error.start")}   + + {t("errors.brave_measure_text_error.aggressive_block_fingerprint")} + {" "} + {t("errors.brave_measure_text_error.setting_enabled")}. +
+
+ {t("errors.brave_measure_text_error.break")}{" "} + + {t("errors.brave_measure_text_error.text_elements")} + {" "} + {t("errors.brave_measure_text_error.in_your_drawings")}. +

+

+ {t("errors.brave_measure_text_error.strongly_recommend")}{" "} + + {" "} + {t("errors.brave_measure_text_error.steps")} + {" "} + {t("errors.brave_measure_text_error.how")}. +

+

+ {t("errors.brave_measure_text_error.disable_setting")}{" "} + + {t("errors.brave_measure_text_error.issue")} + {" "} + {t("errors.brave_measure_text_error.write")}{" "} + + {t("errors.brave_measure_text_error.discord")} + + . +

+
+ ); +}; + +export default BraveMeasureTextError; diff --git a/src/components/ButtonIconSelect.tsx b/src/components/ButtonIconSelect.tsx index 899ec150d..eec8870a9 100644 --- a/src/components/ButtonIconSelect.tsx +++ b/src/components/ButtonIconSelect.tsx @@ -1,33 +1,59 @@ import clsx from "clsx"; // TODO: It might be "clever" to add option.icon to the existing component -export const ButtonIconSelect = ({ - options, - value, - onChange, - group, -}: { - options: { value: T; text: string; icon: JSX.Element; testId?: string }[]; - value: T | null; - onChange: (value: T) => void; - group: string; -}) => ( +export const ButtonIconSelect = ( + props: { + options: { + value: T; + text: string; + icon: JSX.Element; + testId?: string; + /** if not supplied, defaults to value identity check */ + active?: boolean; + }[]; + value: T | null; + type?: "radio" | "button"; + } & ( + | { type?: "radio"; group: string; onChange: (value: T) => void } + | { + type: "button"; + onClick: ( + value: T, + event: React.MouseEvent, + ) => void; + } + ), +) => (
- {options.map((option) => ( -
); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 9a1a91039..aebb42de7 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 c1c789981..56c303c15 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 7bc0c808e..3cb31c484 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 40c130271..ce387244c 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 8cb775fc5..bd2f38417 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"]]} /> + (null); - const { exportBackground, viewBackgroundColor } = appState; const [renderError, setRenderError] = useState(null); const exportedElements = exportSelected @@ -99,6 +98,10 @@ const ImageExportModal = ({ if (!previewNode) { return; } + const maxWidth = previewNode.offsetWidth; + if (!maxWidth) { + return; + } exportToCanvas({ data: { elements: exportedElements, @@ -106,10 +109,13 @@ const ImageExportModal = ({ files, }, config: { - canvasBackgroundColor: !exportBackground ? false : viewBackgroundColor, + canvasBackgroundColor: !appState.exportBackground + ? false + : appState.viewBackgroundColor, padding: exportPadding, theme: appState.exportWithDarkMode ? "dark" : "light", scale: appState.exportScale, + maxWidthOrHeight: maxWidth, }, }) .then((canvas) => { @@ -124,14 +130,7 @@ const ImageExportModal = ({ console.error(error); setRenderError(error); }); - }, [ - appState, - files, - exportedElements, - exportBackground, - exportPadding, - viewBackgroundColor, - ]); + }, [appState, files, exportedElements, exportPadding]); return (
diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index eed24f842..3deeeb031 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -364,10 +364,9 @@ const LayerUI = ({ {appState.isLoading && } {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 f6189fcb7..7ae6517a8 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 6723ac800..33ddb4097 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,16 @@ export const LibraryUnit = ({ if (!elements) { return; } - const svg = await exportToSvg( - elements, - { - exportBackground: false, - viewBackgroundColor: oc.white, + const svg = await exportToSvg({ + data: { + elements, + appState: { + exportBackground: false, + viewBackgroundColor: oc.white, + }, + files: null, }, - null, - ); + }); svg.querySelector(".style-fonts")?.remove(); node.innerHTML = svg.outerHTML; })(); diff --git a/src/components/Popover.scss b/src/components/Popover.scss index 84d16e47f..9458b5026 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 9a046599b..987e9fb91 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 6040ff2f4..fd7db0fe4 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 6852d4f40..b760ad891 100644 --- a/src/components/PublishLibrary.tsx +++ b/src/components/PublishLibrary.tsx @@ -1,10 +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, exportToSvg } from "../packages/utils"; import { EXPORT_DATA_TYPES, EXPORT_SOURCE, @@ -12,13 +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 { exportToCanvas } from "../scene/export"; +import { CloseIcon } from "./icons"; +import { ToolButton } from "./ToolButton"; + +import "./PublishLibrary.scss"; interface PublishLibraryDataParams { authorName: string; @@ -130,6 +131,101 @@ 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({ + data: { + 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 0a42992a1..000000000 --- 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 3affe1465..000000000 --- a/src/components/SingleLibraryItem.tsx +++ /dev/null @@ -1,106 +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({ - data: { - 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 000000000..b36d678cd --- /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 046ee490b..784e81024 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 a3dbf751f..40304bfc0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,7 @@ import cssVariables from "./css/variables.module.scss"; import { AppProps, NormalizedZoomValue } from "./types"; -import { FontFamilyValues } from "./element/types"; +import { ExcalidrawElement, FontFamilyValues } from "./element/types"; +import oc from "open-color"; export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); @@ -9,6 +10,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"; @@ -252,3 +259,23 @@ export const ROUNDNESS = { /** key containt id of precedeing elemnt id we use in reconciliation during * collaboration */ export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; + +export const DEFAULT_ELEMENT_PROPS: { + strokeColor: ExcalidrawElement["strokeColor"]; + backgroundColor: ExcalidrawElement["backgroundColor"]; + fillStyle: ExcalidrawElement["fillStyle"]; + strokeWidth: ExcalidrawElement["strokeWidth"]; + strokeStyle: ExcalidrawElement["strokeStyle"]; + roughness: ExcalidrawElement["roughness"]; + opacity: ExcalidrawElement["opacity"]; + locked: ExcalidrawElement["locked"]; +} = { + strokeColor: oc.black, + backgroundColor: "transparent", + fillStyle: "hachure", + strokeWidth: 1, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + locked: false, +}; diff --git a/src/css/styles.scss b/src/css/styles.scss index 28a42d069..8dafbfbdf 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 473042b56..47cff293f 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 e5a782ec3..f10fa23b6 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -97,7 +97,9 @@ export const exportAsImage = 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 0af2f9dc4..fcf5fa132 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 2eab1d93d..3245ca3fc 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 54540ae5e..0e7257d79 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 5c478515d..aedc25974 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 52038c163..1c3d66121 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 991c034e0..ba7c63ee2 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 8e7b8ee8a..36c8cc0e0 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,25 @@ 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 { + DEFAULT_ELEMENT_PROPS, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + DEFAULT_TEXT_ALIGN, + DEFAULT_VERTICAL_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; import { isArrowElement } from "./typeChecks"; +import { MarkOptional, Merge, Mutable } from "../utility-types"; type ElementConstructorOpts = MarkOptional< Omit, @@ -44,6 +58,15 @@ type ElementConstructorOpts = MarkOptional< | "version" | "versionNonce" | "link" + | "strokeStyle" + | "fillStyle" + | "strokeColor" + | "backgroundColor" + | "roughness" + | "strokeWidth" + | "roundness" + | "locked" + | "opacity" >; const _newElementBase = ( @@ -51,13 +74,13 @@ const _newElementBase = ( { x, y, - strokeColor, - backgroundColor, - fillStyle, - strokeWidth, - strokeStyle, - roughness, - opacity, + strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor, + backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor, + fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle, + strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth, + strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle, + roughness = DEFAULT_ELEMENT_PROPS.roughness, + opacity = DEFAULT_ELEMENT_PROPS.opacity, width = 0, height = 0, angle = 0, @@ -65,7 +88,7 @@ const _newElementBase = ( roundness = null, boundElements = null, link = null, - locked, + locked = DEFAULT_ELEMENT_PROPS.locked, ...rest }: ElementConstructorOpts & Omit, "type">, ) => { @@ -131,24 +154,39 @@ const getTextElementPositionOffsets = ( export const newTextElement = ( opts: { text: string; - fontSize: number; - fontFamily: FontFamilyValues; - textAlign: TextAlign; - verticalAlign: VerticalAlign; + fontSize?: number; + fontFamily?: FontFamilyValues; + textAlign?: TextAlign; + verticalAlign?: VerticalAlign; containerId?: ExcalidrawTextContainer["id"]; + lineHeight?: ExcalidrawTextElement["lineHeight"]; + strokeWidth?: ExcalidrawTextElement["strokeWidth"]; } & ElementConstructorOpts, ): NonDeleted => { + const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; + const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; + const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily); const text = normalizeText(opts.text); - const metrics = measureText(text, getFontString(opts)); - const offsets = getTextElementPositionOffsets(opts, metrics); + const metrics = measureText( + text, + getFontString({ fontFamily, fontSize }), + lineHeight, + ); + const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; + const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; + const offsets = getTextElementPositionOffsets( + { textAlign, verticalAlign }, + metrics, + ); + const textElement = newElementWith( { ..._newElementBase("text", opts), text, - fontSize: opts.fontSize, - fontFamily: opts.fontFamily, - textAlign: opts.textAlign, - verticalAlign: opts.verticalAlign, + fontSize, + fontFamily, + textAlign, + verticalAlign, x: opts.x - offsets.x, y: opts.y - offsets.y, width: metrics.width, @@ -156,6 +194,7 @@ export const newTextElement = ( baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, + lineHeight, }, {}, ); @@ -172,16 +211,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 +229,7 @@ const getAdjustedDimensions = ( const prevMetrics = measureText( element.text, getFontString(element), - maxWidth, + element.lineHeight, ); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, @@ -256,9 +292,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 +302,9 @@ export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, text = textElement.text, ) => { + if (textElement.isDeleted) { + return; + } const container = getContainerElement(textElement); if (container) { text = wrapText( @@ -278,38 +317,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 +390,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 +419,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 +429,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 +504,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 +521,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 +531,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 605ab0c2b..69b8afae7 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -39,16 +39,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 >= 2 * Math.PI) { @@ -192,7 +192,7 @@ const rescalePointsInElement = ( const MIN_FONT_SIZE = 1; -const measureFontSizeFromWH = ( +const measureFontSizeFromWidth = ( element: NonDeleted, nextWidth: number, nextHeight: number, @@ -214,7 +214,7 @@ const measureFontSizeFromWH = ( const metrics = measureText( element.text, getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - element.containerId ? width : null, + element.lineHeight, ); return { size: nextFontSize, @@ -290,8 +290,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( @@ -315,10 +315,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, }); @@ -427,12 +427,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; @@ -442,8 +446,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)); } @@ -576,8 +586,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); } @@ -697,26 +710,34 @@ const resizeMultipleElements = ( const boundTextElement = getBoundTextElement(element.latest); if (boundTextElement || isTextElement(element.orig)) { - const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2; - const textMeasurements = measureFontSizeFromWH( + const updatedElement = { + ...element.latest, + width, + height, + }; + const metrics = measureFontSizeFromWidth( boundTextElement ?? (element.orig as ExcalidrawTextElement), - width - optionalPadding, - height - optionalPadding, + boundTextElement + ? getMaxContainerWidth(updatedElement) + : updatedElement.width, + boundTextElement + ? getMaxContainerHeight(updatedElement) + : updatedElement.height, ); - if (!textMeasurements) { + if (!metrics) { return; } if (isTextElement(element.orig)) { - update.fontSize = textMeasurements.size; - update.baseline = textMeasurements.baseline; + update.fontSize = metrics.size; + update.baseline = metrics.baseline; } if (boundTextElement) { boundTextUpdates = { - fontSize: textMeasurements.size, - baseline: textMeasurements.baseline, + fontSize: metrics.size, + baseline: metrics.baseline, }; } } diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index e1b9ff6f0..106ed7bea 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 c726c1c3f..38da5df5a 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 c61d4e68b..71c75c5a0 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.CENTER, + boundElements: null, + }), + ); + }); }); }); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 7b0098ce0..ef4f7c926 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 000000000..ab04c29b7 --- /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 a6b6cb2db..164fafe68 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 01dee1fe1..4a4db7e8b 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 000000000..8c6c796f6 --- /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 22f748773..e48484ab6 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 2c6949aac..50f586efc 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 9e760f734..1e34fa819 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 a3e6ff0ba..a91768917 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 049a4ddf7..daf4b95c3 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 1b3606b57..aaa5f2137 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 (