Compare commits

...

46 Commits

Author SHA1 Message Date
Aakansha Doshi 6117c46466 floor font on canvas 2023-04-07 14:24:15 +05:30
Aakansha Doshi e69c626ead WIP: scale font size in Safari 2023-04-07 13:32:17 +05:30
Aakansha Doshi ca5e246074 Revert "use DOM height only for safari to fix LS"
This reverts commit d75889238d.
2023-04-07 12:06:48 +05:30
Aakansha Doshi d75889238d use DOM height only for safari to fix LS 2023-04-06 14:22:46 +05:30
Aakansha Doshi f21a6b587a cleanup 2023-04-06 12:29:39 +05:30
Aakansha Doshi 038714e715 Revert "floor line height and height when dom height increases than canvas height"
This reverts commit 8de6516823.
2023-04-05 20:41:41 +05:30
Aakansha Doshi 8de6516823 floor line height and height when dom height increases than canvas height 2023-04-05 17:33:05 +05:30
Aakansha Doshi 7f1ef7562c Merge remote-tracking branch 'origin/master' into aakansha-baseline 2023-04-05 13:19:06 +05:30
Aakansha Doshi d61b3cf83d fix: don't jump text to end when out of viewport in safari (#6416) 2023-04-05 12:17:18 +05:30
Aakansha Doshi 6001f59d38 reduce safari LS 2023-04-04 20:03:34 +05:30
Aakansha Doshi 1c66f85ec9 lint 2023-04-03 17:58:24 +05:30
Aakansha Doshi a0b968c8e0 fix for safari 2023-04-03 17:15:29 +05:30
Aakansha Doshi 3a68f0ae7b fix safari backward compat 2023-04-03 16:35:40 +05:30
Aakansha Doshi c0e88fbc54 update baseline when resizing text element 2023-04-01 13:08:02 +05:30
Aakansha Doshi 99500fc255 fix 2023-03-31 16:15:32 +05:30
Aakansha Doshi bc5e5e1ef0 lint 2023-03-31 16:04:49 +05:30
Aakansha Doshi 50ac3bf855 Merge remote-tracking branch 'origin/master' into aakansha-baseline 2023-03-31 16:01:52 +05:30
Aakansha Doshi 2ea883a05e fix 2023-03-31 15:56:49 +05:30
Aakansha Doshi f8b25375a4 introduce baseline in element making it backward compat 2023-03-31 15:53:03 +05:30
Aakansha Doshi 974745b9e5 introduce DOM baseline in canvas rendering instead 2023-03-31 11:50:04 +05:30
dwelle 9dab749dfd [debug] 2023-03-30 14:11:51 +02:00
Aakansha Doshi 895f35ae18 change offset to 8pixels 2023-03-30 16:31:11 +05:30
Coyote d2b8f4d2f8 fix: getDefaultLineHeight should return default font family line height for unknown font (#6399)
* fix(getDefaultLineHeight): make getDefaultLineHeight always has a default value

* test: add getDefaultLineHeight test case when using unknown font

* test: add getDefaultLineHeight test case when using unknown font

* Revert "test: add getDefaultLineHeight test case when using unknown font"

This reverts commit d41da5493b6edab9e599a13a23c387d38345bf03.

* test: add getDefaultLineHeight test case when using unknown font

* newline

* newline

* tweaks

* trigger action

* trigger action

* fix

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-03-30 00:46:23 +05:30
Aakansha Doshi f8e65bb77e fix: Revert use ideographic textBaseline to improve layout shift when editing text" (#6400)
Revert "fix: use `ideographic` textBaseline to improve layout shift when editing text (#6384)"

This reverts commit 9e52c30ce8.
2023-03-29 18:53:03 +05:30
Milos Vetesnik 3030e96d62 feat: starting migration from GA to Matomo for better privacy (#6398)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-29 11:13:06 +02:00
Aakansha Doshi 79be25fbc0 Merge remote-tracking branch 'origin/master' into aakansha-baseline 2023-03-29 14:16:22 +05:30
dongfang 44453b725d fix: call stack size exceeded when paste large text (#6373) (#6396)
* fix: call stack size exceeded when paste large text (#6373)

* fix: add test case for paste multi-line text

* fix

* tweak

* add missing assertion

* add comments

* lint

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-03-29 13:45:30 +05:30
Aakansha Doshi 1dbc599b88 uncomment 2023-03-28 14:03:04 +05:30
Aakansha Doshi fc80fd15dc fix: introduce baseline to fix the layout shift when switching to text editor 2023-03-28 13:48:34 +05:30
Type Horror 25bb6738ea feat: Add fitToContent and animate to scrollToContent (#6319)
Co-authored-by: Brice Leroy <brice@brigalabs.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-25 23:26:58 +01:00
David Luzar 9e52c30ce8 fix: use ideographic textBaseline to improve layout shift when editing text (#6384) 2023-03-22 18:33:37 +01:00
Aakansha Doshi 83383977f5 feat: add line height attribute to text element (#6360)
* feat: add line height attribute to text element

* lint

* update line height when redrawing text bounding box

* fix tests

* retain line height when pasting styles

* fix test

* create a util for calculating ling height using old algo

* update line height when resizing multiple text elements

* make line height backward compatible

* udpate line height for older element when font size updated

* remove logs

* Add specs

* lint

* review fixes

* simplify by changing `lineHeight` from px to unitless

* make param non-optional

* update comment

* fix: jumping text due to font size being calculated incorrectly

* update line height when font family is updated

* lint

* Add spec

* more specs

* rename to getDefaultLineHeight

* fix getting lineHeight for potentially undefined fontFamily

* reduce duplication

* fix fallback

* refactor and comment tweaks

* fix

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-22 11:32:38 +05:30
David Luzar ac4c8b3ca7 fix: chrome crashing when embedding scene on chrome arm (#6383) 2023-03-21 18:48:49 +01:00
zsviczian 5c8941467d fix: division by zero in findFocusPointForEllipse leads to infinite loop in wrapText freezing Excalidraw (#6377)
* Update collision.ts

* Update textElement.ts

* Update textElement.ts

* tweak

* fix

* remove unnecessary `Math.sign`

* change check and add doc

* Add a case for negative max width and specs

* fix

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-03-20 17:50:09 +05:30
Samyat Gautam 0726911fa6 fix: containerizing text incorrectly updates arrow bindings (#6369)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-18 15:00:28 +00:00
dependabot[bot] 7e330c8ee1 build(deps-dev): bump webpack from 5.73.0 to 5.76.0 in /src/packages/utils (#6354)
build(deps-dev): bump webpack in /src/packages/utils

Bumps [webpack](https://github.com/webpack/webpack) from 5.73.0 to 5.76.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.73.0...v5.76.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-15 11:04:13 +05:30
dependabot[bot] 7d21747644 build(deps-dev): bump webpack from 5.73.0 to 5.76.0 in /src/packages/excalidraw (#6355)
build(deps-dev): bump webpack in /src/packages/excalidraw

Bumps [webpack](https://github.com/webpack/webpack) from 5.73.0 to 5.76.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.73.0...v5.76.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-15 11:04:01 +05:30
dependabot[bot] e718136aea build(deps): bump webpack from 5.74.0 to 5.76.1 in /dev-docs (#6356)
Bumps [webpack](https://github.com/webpack/webpack) from 5.74.0 to 5.76.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.74.0...v5.76.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-15 11:03:40 +05:30
Salah Eddine Daci fe83e2922d build: move TS and types to devDependencies (#6346) 2023-03-14 12:06:57 +00:00
David Luzar 20edddcd4e fix: ensure export preview is centered (#6337) 2023-03-14 13:03:55 +01:00
Aakansha Doshi f6e8be399e fix: hide text align for labelled arrows (#6339)
* fix: hide text align for labelled arrows

* lintttt

* since we fetch seledcted Elements including the bound text hence this block can be removed

* fix
2023-03-14 17:21:46 +05:30
Aakansha Doshi ab49cad6b1 perf: break early if the line width <= max width of the container (#6347)
* fix: break early if the line width <= max width of the container

* Remove dead code

* remove dead code

* lint

* remove
2023-03-14 17:18:16 +05:30
Aakansha Doshi 6aeb18b784 fix: refresh dimensions when elements loaded from shareable link and blob (#6333)
* fix: refresh dimensions when elements loaded from shareable link

* refresh text dimensions when loading from file

* remove log
2023-03-14 17:08:23 +05:30
Aakansha Doshi 023313e92f fix: show error message when measureText API breaks in brave (#6336)
* fix: show error message when measureText API breaks in brave

* Add docs

* Add assets

* tweak message

* fix

* tweak message

* add translations

* lint

* fix

* fix

* lint

* lint

* lint please work now

* tweak doc

* fix

* split error component to new file

* add specs

* tweaks

Co-authored-by: David Luzar <luzar.david@gmail.com>

* wrap in div with a width of 30rem

* fix spec

* fix

* Fix typo

---------

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Daniel J. Geiger <1852529+DanielJGeiger@users.noreply.github.com>
2023-03-13 19:46:09 +05:30
David Luzar 1eee488dab feat: add thai lang support (#6314) 2023-03-11 22:15:52 +01:00
Aakansha Doshi dd4c333925 fix: add an offset of 0.5px for text editor in containers (#6328)
* fix: add an offset of 0.5px for text editor in containers

* fix specs and lint
2023-03-09 13:07:36 +05:30
57 changed files with 1546 additions and 397 deletions
+5
View File
@@ -23,6 +23,11 @@ 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
+7
View File
@@ -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
+1
View File
@@ -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
@@ -1,6 +1,19 @@
# ref
<pre>
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> &#124; <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> &#124; <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> &#124; <br/>&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } }
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
createRef
</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
resolvablePromise
</a> } }
</pre>
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 (
<div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
<button className="custom-button" onClick={updateScene}>Update Scene</button>
<button className="custom-button" onClick={updateScene}>
Update Scene
</button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
</div>
);
@@ -187,7 +202,8 @@ function App() {
return (
<div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
<button className="custom-button"
<button
className="custom-button"
onClick={() => {
const libraryItems = [
{
@@ -205,10 +221,8 @@ function App() {
];
excalidrawAPI.updateLibrary({
libraryItems,
openLibraryMenu: true
openLibraryMenu: true,
});
}}
>
Update Library
@@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
<pre>
() =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement[]
</a>
</pre>
@@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
<pre>
() => NonDeleted&#60;
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[]&#62;
@@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
<pre>
(target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[]) => void
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene.
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
## refresh
@@ -323,7 +350,7 @@ For any other cases if the position of excalidraw is updated (example due to scr
This API can be used to show the toast with custom message.
```tsx
({ message: string, closable?:boolean,duration?:number
({ message: string, closable?:boolean,duration?:number
} | null) => void
```
@@ -358,15 +385,18 @@ This API can be used to get the files present in the scene. It may contain files
This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre>
(tool: <br/> &#123; type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]&#124; "eraser" &#125; &#124;<br/> &#123; type: "custom"; customType: string &#125;) => void
(tool: <br /> &#123; type:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => void
</pre>
## setCursor
This API can be used to customise the mouse cursor on the canvas and has the below signature.
It sets the mouse cursor to the cursor passed in param.
This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param.
```tsx
(cursor: string) => void
@@ -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)
<div style={{width:'30rem'}}>
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 🎉
</div>
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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+3 -3
View File
@@ -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"
+7 -7
View File
@@ -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"
+31 -2
View File
@@ -146,8 +146,10 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
@@ -160,6 +162,33 @@
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<!-- Matomo -->
<% if (process.env.REACT_APP_MATOMO_URL &&
process.env.REACT_APP_MATOMO_SITE_ID &&
process.env.REACT_APP_CDN_MATOMO_TRACKER_URL) { %>
<script>
var _paq = (window._paq = window._paq || []);
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
var u = "%REACT_APP_MATOMO_URL%";
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", "%REACT_APP_MATOMO_SITE_ID%"]);
var d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = "%REACT_APP_CDN_MATOMO_TRACKER_URL%";
s.parentNode.insertBefore(g, s);
})();
</script>
<% } %>
<!-- end Matomo analytics -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
+6
View File
@@ -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(
+18 -8
View File
@@ -42,9 +42,10 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height } = measureText(
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
@@ -55,6 +56,7 @@ export const actionUnbindText = register({
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
@@ -239,15 +241,23 @@ export const actionCreateContainerFromText = register({
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = null;
let endBinding = null;
if (ele.startBinding) {
startBinding = { ...ele.startBinding, elementId: container.id };
let startBinding = ele.startBinding;
let endBinding = ele.endBinding;
if (startBinding?.elementId === textElement.id) {
startBinding = {
...startBinding,
elementId: container.id,
};
}
if (ele.endBinding) {
endBinding = { ...ele.endBinding, elementId: container.id };
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding });
}
mutateElement(ele, { startBinding, endBinding });
});
}
+1 -1
View File
@@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
const zoomToFitElements = (
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
+5
View File
@@ -54,6 +54,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import {
getBoundTextElement,
getContainerElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
isBoundToContainer,
@@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
oldElement,
{
fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
@@ -745,16 +747,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(
+13 -4
View File
@@ -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) {
+30 -22
View File
@@ -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);
}
};
+6 -2
View File
@@ -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")}
</>
)}
+45
View File
@@ -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 <App/>", () => {
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(<ExcalidrawApp />);
expect(
queryByTestId(
document.querySelector(".excalidraw-modal-container")!,
"brave-measure-text-error",
),
).toMatchSnapshot();
});
});
+134 -36
View File
@@ -62,6 +62,7 @@ import {
GRID_SIZE,
IMAGE_RENDER_TIMEOUT,
isAndroid,
isBrave,
LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES,
@@ -228,6 +229,7 @@ import {
updateActiveTool,
getShortcutKey,
isTransparent,
easeToValuesRAF,
} from "../utils";
import {
ContextMenu,
@@ -259,14 +261,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";
@@ -281,10 +285,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 { actionCreateContainerFromText } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
const deviceContextInitialValue = {
isSmScreen: false,
@@ -429,7 +437,6 @@ class App extends React.Component<AppProps, AppState> {
};
this.id = nanoid();
this.library = new Library(this);
if (excalidrawRef) {
const readyPromise =
@@ -711,6 +718,8 @@ class App extends React.Component<AppProps, AppState> {
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;
}
@@ -726,7 +735,6 @@ class App extends React.Component<AppProps, AppState> {
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
@@ -744,6 +752,7 @@ class App extends React.Component<AppProps, AppState> {
gridSize,
theme,
name,
errorMessage,
});
},
() => {
@@ -872,7 +881,6 @@ class App extends React.Component<AppProps, AppState> {
),
};
}
// 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
@@ -1000,6 +1008,13 @@ class App extends React.Component<AppProps, AppState> {
} else {
this.updateDOMRect(this.initializeScene);
}
// note that this check seems to always pass in localhost
if (isBrave() && !isMeasureTextSupported()) {
this.setState({
errorMessage: <BraveMeasureTextError />,
});
}
}
public componentWillUnmount() {
@@ -1721,12 +1736,14 @@ class App extends React.Component<AppProps, AppState> {
(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;
@@ -1735,14 +1752,9 @@ class App extends React.Component<AppProps, AppState> {
// 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;
}
}
@@ -1835,18 +1847,89 @@ class App extends React.Component<AppProps, AppState> {
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<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgresAnimation?.();
this.setState(state);
};
setToast = (
@@ -2047,9 +2130,13 @@ class App extends React.Component<AppProps, AppState> {
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,
}));
}
}
@@ -2597,6 +2684,13 @@ class App extends React.Component<AppProps, AppState> {
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 &&
@@ -2604,11 +2698,14 @@ class App extends React.Component<AppProps, AppState> {
!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);
@@ -2642,8 +2739,8 @@ class App extends React.Component<AppProps, AppState> {
opacity: this.state.currentItemOpacity,
roundness: null,
text: "",
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
fontSize,
fontFamily,
textAlign: parentCenterPosition
? "center"
: this.state.currentItemTextAlign,
@@ -2653,6 +2750,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [],
locked: false,
lineHeight,
});
if (!existingTextElement && shouldBindToContainer && container) {
@@ -2919,12 +3017,12 @@ class App extends React.Component<AppProps, AppState> {
state,
);
return {
this.translateCanvas({
zoom: zoomState.zoom,
scrollX: zoomState.scrollX + deltaX / nextZoom,
scrollY: zoomState.scrollY + deltaY / nextZoom,
shouldCacheIgnoreZoom: true,
};
});
});
this.resetShouldCacheIgnoreZoomDebounced();
} else {
@@ -3700,7 +3798,7 @@ class App extends React.Component<AppProps, AppState> {
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,
});
@@ -4846,7 +4944,7 @@ class App extends React.Component<AppProps, AppState> {
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;
@@ -4856,7 +4954,7 @@ class App extends React.Component<AppProps, AppState> {
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;
@@ -6285,7 +6383,7 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
this.setState((state) => ({
this.translateCanvas((state) => ({
...getStateForZoom(
{
viewportX: cursorX,
@@ -6302,14 +6400,14 @@ class App extends React.Component<AppProps, AppState> {
// 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,
}));
+42
View File
@@ -0,0 +1,42 @@
import { t } from "../i18n";
const BraveMeasureTextError = () => {
return (
<div data-testid="brave-measure-text-error">
<p>
{t("errors.brave_measure_text_error.start")} &nbsp;
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
</span>{" "}
{t("errors.brave_measure_text_error.setting_enabled")}.
<br />
<br />
{t("errors.brave_measure_text_error.break")}{" "}
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.text_elements")}
</span>{" "}
{t("errors.brave_measure_text_error.in_your_drawings")}.
</p>
<p>
{t("errors.brave_measure_text_error.strongly_recommend")}{" "}
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
{" "}
{t("errors.brave_measure_text_error.steps")}
</a>{" "}
{t("errors.brave_measure_text_error.how")}.
</p>
<p>
{t("errors.brave_measure_text_error.disable_setting")}{" "}
<a href="https://github.com/excalidraw/excalidraw/issues/new">
{t("errors.brave_measure_text_error.issue")}
</a>{" "}
{t("errors.brave_measure_text_error.write")}{" "}
<a href="https://discord.gg/UexuTaE">
{t("errors.brave_measure_text_error.discord")}
</a>
.
</p>
</div>
);
};
export default BraveMeasureTextError;
+4 -4
View File
@@ -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")}
>
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
<div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
</Dialog>
)}
</>
+4
View File
@@ -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 {
+3 -4
View File
@@ -364,10 +364,9 @@ const LayerUI = ({
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
onClose={() => setAppState({ errorMessage: null })}
/>
<ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
{appState.errorMessage}
</ErrorDialog>
)}
{appState.openDialog === "help" && (
<HelpDialog
@@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Test <App/> should show error modal when using brave and measureText API is not working 1`] = `
<div
data-testid="brave-measure-text-error"
>
<p>
Looks like you are using Brave browser with the
 
<span
style="font-weight: 600;"
>
Aggressively Block Fingerprinting
</span>
setting enabled
.
<br />
<br />
This could result in breaking the
<span
style="font-weight: 600;"
>
Text Elements
</span>
in your drawings
.
</p>
<p>
We strongly recommend disabling this setting. You can follow
<a
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
>
these steps
</a>
on how to do so
.
</p>
<p>
If disabling this setting doesn't fix the display of text elements, please open an
<a
href="https://github.com/excalidraw/excalidraw/issues/new"
>
issue
</a>
on our GitHub, or write us on
<a
href="https://discord.gg/UexuTaE"
>
Discord
</a>
.
</p>
</div>
`;
+3
View File
@@ -12,6 +12,9 @@ export const isFirefox =
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";
+1 -1
View File
@@ -157,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
},
localAppState,
localElements,
{ repairBindings: true },
{ repairBindings: true, refreshDimensions: true },
),
};
} else if (isValidLibrary(data)) {
+3 -1
View File
@@ -89,7 +89,9 @@ export const exportCanvas = async (
return await fileSave(blob, {
description: "Export to PNG",
name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
// FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle,
});
} else if (type === "clipboard") {
+35 -5
View File
@@ -31,10 +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,
getDOMMetrics,
} from "../element/textElement";
type RestoredAppState = Omit<
AppState,
@@ -165,17 +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 } = getDOMMetrics(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
text: element.text ?? "",
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) {
@@ -479,7 +507,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:
+6 -1
View File
@@ -786,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);
+19 -6
View File
@@ -29,6 +29,7 @@ import {
normalizeText,
wrapText,
getMaxContainerWidth,
getDefaultLineHeight,
} from "./textElement";
import { VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
@@ -137,11 +138,14 @@ export const newTextElement = (
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts));
const metrics = measureText(text, getFontString(opts), lineHeight);
const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith(
{
..._newElementBase<ExcalidrawTextElement>("text", opts),
@@ -154,8 +158,10 @@ export const newTextElement = (
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
lineHeight,
},
{},
);
@@ -170,13 +176,15 @@ const getAdjustedDimensions = (
y: number;
width: number;
height: number;
baseline: number;
} => {
const container = getContainerElement(element);
const { width: nextWidth, height: nextHeight } = measureText(
nextText,
getFontString(element),
);
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -185,7 +193,11 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureText(element.text, getFontString(element));
const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@@ -247,6 +259,7 @@ const getAdjustedDimensions = (
return {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
};
+49 -20
View File
@@ -39,13 +39,15 @@ import {
import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getContainerElement,
handleBindTextResize,
getMaxContainerWidth,
getApproxMinLineHeight,
measureText,
getMaxContainerHeight,
} from "./textElement";
export const normalizeAngle = (angle: number): number => {
@@ -193,7 +195,8 @@ const MIN_FONT_SIZE = 1;
const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number,
): number | null => {
nextHeight: number,
): { size: number; baseline: number } | null => {
// We only use width to scale font on resize
let width = element.width;
@@ -208,8 +211,15 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
return nextFontSize;
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
};
const getSidesForTransformHandle = (
@@ -280,8 +290,8 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
if (nextFontSize === null) {
const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
if (metrics === null) {
return;
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@@ -305,9 +315,10 @@ const resizeSingleTextElement = (
deltaY2,
);
mutateElement(element, {
fontSize: nextFontSize,
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
baseline: metrics.baseline,
x: nextElementX,
y: nextElementY,
});
@@ -360,7 +371,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number } = {};
let boundTextFont: { fontSize?: number; baseline?: number } = {};
const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) {
@@ -412,6 +423,7 @@ export const resizeSingleElement = (
if (stateOfBoundTextElementAtResize) {
boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
}
if (shouldMaintainAspectRatio) {
@@ -421,19 +433,27 @@ export const resizeSingleElement = (
height: eleNewHeight,
};
const nextFontSize = measureFontSizeFromWidth(
const nextFont = measureFontSizeFromWidth(
boundTextElement,
getMaxContainerWidth(updatedElement),
getMaxContainerHeight(updatedElement),
);
if (nextFontSize === null) {
if (nextFont === null) {
return;
}
boundTextFont = {
fontSize: nextFontSize,
fontSize: nextFont.size,
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));
}
@@ -566,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);
}
@@ -673,6 +696,7 @@ const resizeMultipleElements = (
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
@@ -681,7 +705,7 @@ const resizeMultipleElements = (
...rescaledPoints,
};
let boundTextUpdates: { fontSize: number } | null = null;
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
@@ -691,24 +715,29 @@ const resizeMultipleElements = (
width,
height,
};
const fontSize = measureFontSizeFromWidth(
const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
boundTextElement
? getMaxContainerWidth(updatedElement)
: updatedElement.width,
boundTextElement
? getMaxContainerHeight(updatedElement)
: updatedElement.height,
);
if (!fontSize) {
if (!metrics) {
return;
}
if (isTextElement(element.orig)) {
update.fontSize = fontSize;
update.fontSize = metrics.size;
update.baseline = metrics.baseline;
}
if (boundTextElement) {
boundTextUpdates = {
fontSize,
fontSize: metrics.size,
baseline: metrics.baseline,
};
}
}
+64 -18
View File
@@ -1,4 +1,4 @@
import { BOUND_TEXT_PADDING } from "../constants";
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { API } from "../tests/helpers/api";
import {
computeContainerDimensionForBoundText,
@@ -6,6 +6,9 @@ import {
getMaxContainerWidth,
getMaxContainerHeight,
wrapText,
detectLineHeight,
getLineHeightInPx,
getDefaultLineHeight,
} from "./textElement";
import { FontString } from "./types";
@@ -16,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", () => {
@@ -26,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);
@@ -40,9 +43,7 @@ describe("Test wrapText", () => {
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello
whats
up`,
res: `Hello \nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
@@ -64,8 +65,7 @@ p`,
desc: "break words as per the width",
width: 140,
res: `Hello whats
up`,
res: `Hello whats \nup`,
},
{
desc: "fit the container",
@@ -95,9 +95,7 @@ whats up`;
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello
whats
up`,
res: `Hello\nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
@@ -136,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`,
},
{
@@ -165,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}`, () => {
@@ -175,6 +169,20 @@ 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", () => {
@@ -296,3 +304,41 @@ describe("Test measureText", () => {
});
});
});
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);
});
});
+220 -62
View File
@@ -4,11 +4,20 @@ 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 ".";
@@ -35,18 +44,22 @@ 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;
const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
baseline: textElement.baseline,
};
boundTextUpdates.text = textElement.text;
@@ -62,10 +75,12 @@ export const redrawTextBoundingBox = (
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)) {
@@ -171,6 +186,7 @@ export const handleBindTextResize = (
const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapText(
@@ -179,9 +195,14 @@ export const handleBindTextResize = (
maxWidth,
);
}
const dimensions = measureText(text, getFontString(textElement));
nextHeight = dimensions.height;
nextWidth = dimensions.width;
const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
@@ -209,6 +230,7 @@ export const handleBindTextResize = (
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
@@ -255,32 +277,106 @@ const computeBoundTextPosition = (
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (text: string, font: FontString) => {
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const height = getTextHeight(text, font);
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
return { width, height };
const { baseline } = getDOMMetrics(text, font, lineHeight);
return { width, height, baseline };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
const cacheApproxLineHeight: { [key: FontString]: number } = {};
export const getApproxLineHeight = (font: FontString) => {
if (cacheApproxLineHeight[font]) {
return cacheApproxLineHeight[font];
export const getDOMMetrics = (
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 (wrapInContainer) {
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.whiteSpace = "pre-wrap";
}
const fontSize = parseInt(font);
// Calculate line height relative to font size
cacheApproxLineHeight[font] = fontSize * 1.2;
return cacheApproxLineHeight[font];
container.style.lineHeight = String(lineHeight);
const canvasHeight = getTextHeight(text, parseFloat(font), 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);
let baseline = span.offsetTop + span.offsetHeight;
const height = container.offsetHeight;
if (isSafari) {
// In Safari sometimes DOM height could be less than canvas height due to
// which text could go out of the bounding box hence shifting the baseline
// to make sure text is rendered correctly
if (canvasHeight > height) {
baseline += canvasHeight - height;
}
// In Safari sometimes DOM height could be more than canvas height due to
// which text could go out of the bounding box hence shifting the baseline
// to make sure text is rendered correctly
if (height > canvasHeight) {
baseline -= height - canvasHeight;
}
}
document.body.removeChild(container);
return { baseline, height };
};
/**
* 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"];
};
/**
* 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"],
) => {
const res = fontSize * lineHeight;
return res;
};
// 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;
@@ -303,7 +399,7 @@ const getLineWidth = (text: string, font: FontString) => {
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
@@ -311,35 +407,58 @@ export const getTextWidth = (text: string, font: FontString) => {
return width;
};
export const getTextHeight = (text: string, font: FontString) => {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = getApproxLineHeight(font);
return lineHeight * lines.length;
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<string> = [];
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]);
@@ -351,8 +470,8 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// 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(
@@ -363,10 +482,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;
@@ -374,11 +489,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
@@ -386,7 +501,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
@@ -396,8 +510,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
resetParams();
break;
}
@@ -408,22 +521,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);
}
});
@@ -455,21 +561,23 @@ export const charWidth = (() => {
};
})();
export const getApproxMinLineWidth = (font: FontString) => {
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) {
@@ -662,14 +770,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);
});
};
@@ -794,3 +912,43 @@ export const getMaxContainerHeight = (container: ExcalidrawElement) => {
}
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];
};
+89 -18
View File
@@ -526,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",
@@ -783,7 +821,7 @@ describe("textWysiwyg", () => {
rectangle.y + h.elements[0].height / 2 - text.height / 2,
);
expect(text.x).toBe(25);
expect(text.height).toBe(48);
expect(text.height).toBe(50);
expect(text.width).toBe(60);
// Edit and text by removing second line and it should
@@ -810,7 +848,7 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello");
expect(text.height).toBe(24);
expect(text.height).toBe(25);
expect(text.width).toBe(50);
expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2,
@@ -903,7 +941,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
85,
5,
4.5,
]
`);
@@ -929,7 +967,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
66,
65,
]
`);
@@ -1067,9 +1105,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(-35);
expect(rectangle.y).toBe(-40);
expect(text.x).toBe(85);
expect(text.y).toBe(-30);
expect(text.y).toBe(-35);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
@@ -1112,7 +1150,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" },
});
editor.blur();
expect(rectangle.height).toBe(178);
expect(rectangle.height).toBe(185);
mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
@@ -1181,9 +1219,42 @@ describe("textWysiwyg", () => {
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
96.39999999999999,
);
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", () => {
@@ -1245,7 +1316,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
45.5,
45,
]
`);
});
@@ -1257,7 +1328,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
30,
45.5,
45,
]
`);
});
@@ -1269,7 +1340,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
45,
45.5,
45,
]
`);
});
@@ -1281,7 +1352,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
66,
65,
]
`);
});
@@ -1292,7 +1363,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
30,
66,
65,
]
`);
});
@@ -1303,7 +1374,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
45,
66,
65,
]
`);
});
@@ -1333,7 +1404,7 @@ describe("textWysiwyg", () => {
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600);
expect(textElement.height).toBe(24);
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",
@@ -1365,7 +1436,7 @@ describe("textWysiwyg", () => {
],
fillStyle: "hachure",
groupIds: [],
height: 34,
height: 35,
isDeleted: false,
link: null,
locked: false,
+45 -30
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -22,7 +22,6 @@ import {
import { AppState } from "../types";
import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
getContainerCoords,
getContainerDims,
@@ -35,6 +34,10 @@ import {
wrapText,
getMaxContainerHeight,
getMaxContainerWidth,
computeContainerDimensionForBoundText,
getDOMMetrics,
splitIntoLines,
detectLineHeight,
} from "./textElement";
import {
actionDecreaseFontSize,
@@ -150,9 +153,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;
@@ -211,11 +212,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
@@ -224,11 +226,11 @@ 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
@@ -266,36 +268,44 @@ 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 if (isFirefox || isSafari) {
// As firefox, Safari needs little higher dimensions on DOM
} else {
textElementWidth += 0.5;
}
const { height: domHeight } = getDOMMetrics(
updatedTextElement.text,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
let lineHeight = element.lineHeight;
const fontSize = Math.floor(updatedTextElement.fontSize);
if (isSafari) {
//@ts-ignore
lineHeight =
updatedTextElement.height /
splitIntoLines(updatedTextElement.text).length /
fontSize;
}
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
font: getFontString({
fontSize,
fontFamily: updatedTextElement.fontFamily,
}),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
lineHeight,
width: `${textElementWidth}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transform: getTransform(
textElementWidth,
textElementHeight,
getTextElementAngle(updatedTextElement),
appState,
maxWidth,
editorMaxHeight,
),
transform: `scale(${updatedTextElement.fontSize / fontSize})`,
textAlign,
verticalAlign,
color: updatedTextElement.strokeColor,
@@ -303,6 +313,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()) {
@@ -389,7 +400,11 @@ export const textWysiwyg = ({
font,
getMaxContainerWidth(container!),
);
const { width, height } = measureText(wrappedText, font);
const { width, height } = measureText(
wrappedText,
font,
updatedTextElement.lineHeight,
);
editable.style.width = `${width}px`;
editable.style.height = `${height}px`;
}
+6
View File
@@ -131,10 +131,16 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number;
fontFamily: FontFamilyValues;
text: string;
baseline: number;
textAlign: TextAlign;
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 =
+3 -4
View File
@@ -838,10 +838,9 @@ class Collab extends PureComponent<Props, CollabState> {
/>
)}
{errorMessage && (
<ErrorDialog
message={errorMessage}
onClose={() => this.setState({ errorMessage: "" })}
/>
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
{errorMessage}
</ErrorDialog>
)}
</>
);
+1 -1
View File
@@ -263,7 +263,7 @@ export const loadScene = async (
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{ repairBindings: true },
{ repairBindings: true, refreshDimensions: true },
);
} else {
data = restore(localDataState || null, null, null, {
+3 -4
View File
@@ -673,10 +673,9 @@ const ExcalidrawWrapper = () => {
</Excalidraw>
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
<ErrorDialog
message={errorMessage}
onClose={() => setErrorMessage("")}
/>
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</div>
);
+2
View File
@@ -18,6 +18,8 @@ interface Window {
EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
gtag: Function;
_paq: any[];
_mtm: any[];
}
interface CanvasRenderingContext2D {
+16 -2
View File
@@ -120,7 +120,6 @@
"edit": "Edit line",
"exit": "Exit line editor"
},
"elementLock": {
"lock": "Lock",
"unlock": "Unlock",
@@ -206,7 +205,22 @@
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library",
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work."
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
"brave_measure_text_error": {
"start": "Looks like you are using Brave browser with the",
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
"setting_enabled": "setting enabled",
"break": "This could result in breaking the",
"text_elements": "Text Elements",
"in_your_drawings": "in your drawings",
"strongly_recommend": "We strongly recommend disabling this setting. You can follow",
"steps": "these steps",
"how": "on how to do so",
"disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
"issue": "issue",
"write": "on our GitHub, or write us on",
"discord": "Discord"
}
},
"toolBar": {
"selection": "Selection",
+2
View File
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features
- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes
+1 -1
View File
@@ -64,7 +64,7 @@
"terser-webpack-plugin": "5.3.3",
"ts-loader": "9.3.1",
"typescript": "4.7.4",
"webpack": "5.73.0",
"webpack": "5.76.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.9.3",
+19 -19
View File
@@ -1393,10 +1393,10 @@ acorn-walk@^8.0.0:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0:
version "8.7.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
version "8.8.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
ajv-formats@^2.1.1:
version "2.1.1"
@@ -2068,10 +2068,10 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3:
version "5.10.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6"
integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==
enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0:
version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@@ -3751,10 +3751,10 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
watchpack@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
@@ -3858,21 +3858,21 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@5.73.0:
version "5.73.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38"
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==
webpack@5.76.0:
version "5.76.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c"
integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==
dependencies:
"@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51"
"@webassemblyjs/ast" "1.11.1"
"@webassemblyjs/wasm-edit" "1.11.1"
"@webassemblyjs/wasm-parser" "1.11.1"
acorn "^8.4.1"
acorn "^8.7.1"
acorn-import-assertions "^1.7.6"
browserslist "^4.14.5"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.9.3"
enhanced-resolve "^5.10.0"
es-module-lexer "^0.9.0"
eslint-scope "5.1.1"
events "^3.2.0"
@@ -3885,7 +3885,7 @@ webpack@5.73.0:
schema-utils "^3.1.0"
tapable "^2.1.1"
terser-webpack-plugin "^5.1.3"
watchpack "^2.3.1"
watchpack "^2.4.0"
webpack-sources "^3.2.3"
websocket-driver@>=0.5.1, websocket-driver@^0.7.4:
+1 -1
View File
@@ -48,7 +48,7 @@
"file-loader": "6.2.0",
"sass-loader": "13.0.2",
"ts-loader": "9.3.1",
"webpack": "5.73.0",
"webpack": "5.76.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.10.0"
},
+20 -46
View File
@@ -1187,10 +1187,10 @@ acorn-walk@^8.0.0:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0:
version "8.7.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
version "8.8.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
ajv-keywords@^3.5.2:
version "3.5.2"
@@ -1383,18 +1383,7 @@ braces@^3.0.1:
dependencies:
fill-range "^7.0.1"
browserslist@^4.14.5:
version "4.19.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.3.tgz#29b7caad327ecf2859485f696f9604214bedd383"
integrity sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg==
dependencies:
caniuse-lite "^1.0.30001312"
electron-to-chromium "^1.4.71"
escalade "^3.1.1"
node-releases "^2.0.2"
picocolors "^1.0.0"
browserslist@^4.20.2, browserslist@^4.21.2:
browserslist@^4.14.5, browserslist@^4.20.2, browserslist@^4.21.2:
version "4.21.2"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf"
integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA==
@@ -1417,11 +1406,6 @@ call-bind@^1.0.0:
function-bind "^1.1.1"
get-intrinsic "^1.0.2"
caniuse-lite@^1.0.30001312:
version "1.0.30001312"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz#e11eba4b87e24d22697dae05455d5aea28550d5f"
integrity sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ==
caniuse-lite@^1.0.30001366:
version "1.0.30001367"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001367.tgz#2b97fe472e8fa29c78c5970615d7cd2ee414108a"
@@ -1601,20 +1585,15 @@ electron-to-chromium@^1.4.188:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.195.tgz#139b2d95a42a3f17df217589723a1deac71d1473"
integrity sha512-vefjEh0sk871xNmR5whJf9TEngX+KTKS3hOHpjoMpauKkwlGwtMz1H8IaIjAT/GNnX0TbGwAdmVoXCAzXf+PPg==
electron-to-chromium@^1.4.71:
version "1.4.75"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.75.tgz#d1ad9bb46f2f1bf432118c2be21d27ffeae82fdd"
integrity sha512-LxgUNeu3BVU7sXaKjUDD9xivocQLxFtq6wgERrutdY/yIOps3ODOZExK1jg8DTEg4U8TUCb5MLGeWFOYuxjF3Q==
emojis-list@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88"
integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==
enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0:
version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@@ -2011,11 +1990,6 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
node-releases@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
node-releases@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
@@ -2494,10 +2468,10 @@ util-deprecate@^1.0.2:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
watchpack@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
@@ -2548,21 +2522,21 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@5.73.0:
version "5.73.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38"
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==
webpack@5.76.0:
version "5.76.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c"
integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==
dependencies:
"@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51"
"@webassemblyjs/ast" "1.11.1"
"@webassemblyjs/wasm-edit" "1.11.1"
"@webassemblyjs/wasm-parser" "1.11.1"
acorn "^8.4.1"
acorn "^8.7.1"
acorn-import-assertions "^1.7.6"
browserslist "^4.14.5"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.9.3"
enhanced-resolve "^5.10.0"
es-module-lexer "^0.9.0"
eslint-scope "5.1.1"
events "^3.2.0"
@@ -2575,7 +2549,7 @@ webpack@5.73.0:
schema-utils "^3.1.0"
tapable "^2.1.1"
terser-webpack-plugin "^5.1.3"
watchpack "^2.3.1"
watchpack "^2.4.0"
webpack-sources "^3.2.3"
which@^2.0.1:
+17 -11
View File
@@ -40,10 +40,10 @@ import {
} from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
import {
getApproxLineHeight,
getBoundTextElement,
getContainerCoords,
getContainerElement,
getLineHeightInPx,
getMaxContainerHeight,
getMaxContainerWidth,
} from "../element/textElement";
@@ -200,7 +200,6 @@ const drawImagePlaceholder = (
size,
);
};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
@@ -273,28 +272,32 @@ const drawElementOnCanvas = (
}
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.font = getFontString({
...element,
fontSize: Math.floor(element.fontSize),
});
context.fillStyle = element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.containerId
? getApproxLineHeight(getFontString(element))
: element.height / lines.length;
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
: element.textAlign === "right"
? element.width
: 0;
context.textBaseline = "bottom";
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const verticalOffset = element.height - element.baseline;
for (let index = 0; index < lines.length; index++) {
context.fillText(
lines[index],
horizontalOffset,
(index + 1) * lineHeight,
(index + 1) * lineHeightPx - verticalOffset,
);
}
context.restore();
@@ -1313,7 +1316,10 @@ export const renderElementToSvg = (
}) rotate(${degree} ${cx} ${cy})`,
);
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.height / lines.length;
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
@@ -1331,7 +1337,7 @@ export const renderElementToSvg = (
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeight}`);
text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
@@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>
+100
View File
@@ -4,6 +4,7 @@ import { UI, Pointer, Keyboard } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles";
import { API } from "./helpers/api";
import { KEYS } from "../keys";
import { actionCreateContainerFromText } from "../actions/actionBoundText";
const { h } = window;
@@ -209,4 +210,103 @@ describe("element binding", () => {
).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id);
});
it("should update binding when text containerized", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
width: 100,
height: 100,
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
points: [
[0, 0],
[0, -87.45777932247563],
],
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
points: [
[0, 0],
[0, -87.45777932247563],
],
startBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
const text1 = API.createElement({
type: "text",
id: "text1",
text: "ola",
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
h.elements = [rectangle1, arrow1, arrow2, text1];
API.setSelectedElements([text1]);
expect(h.state.selectedElementIds[text1.id]).toBe(true);
h.app.actionManager.executeAction(actionCreateContainerFromText);
// new text container will be placed before the text element
const container = h.elements.at(-2)!;
expect(container.type).toBe("rectangle");
expect(container.id).not.toBe(rectangle1.id);
expect(container).toEqual(
expect.objectContaining({
boundElements: expect.arrayContaining([
{
type: "text",
id: text1.id,
},
{
type: "arrow",
id: arrow1.id,
},
{
type: "arrow",
id: arrow2.id,
},
]),
}),
);
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
expect(arrow1.endBinding?.elementId).toBe(container.id);
expect(arrow2.startBinding?.elementId).toBe(container.id);
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
});
});
+16 -18
View File
@@ -3,8 +3,10 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui";
import ExcalidrawApp from "../excalidraw-app";
import { KEYS } from "../keys";
import { getApproxLineHeight } from "../element/textElement";
import { getFontString } from "../utils";
import {
getDefaultLineHeight,
getLineHeightInPx,
} from "../element/textElement";
import { getElementBounds } from "../element";
import { NormalizedZoomValue } from "../types";
@@ -118,12 +120,10 @@ describe("paste text as single lines", () => {
it("should space items correctly", async () => {
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
const lineHeight =
getApproxLineHeight(
getFontString({
fontSize: h.app.state.currentItemFontSize,
fontFamily: h.app.state.currentItemFontFamily,
}),
const lineHeightPx =
getLineHeightInPx(
h.app.state.currentItemFontSize,
getDefaultLineHeight(h.state.currentItemFontFamily),
) +
10 / h.app.state.zoom.value;
mouse.moveTo(100, 100);
@@ -135,19 +135,17 @@ describe("paste text as single lines", () => {
for (let i = 1; i < h.elements.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [fx, elY] = getElementBounds(h.elements[i]);
expect(elY).toEqual(firstElY + lineHeight * i);
expect(elY).toEqual(firstElY + lineHeightPx * i);
}
});
});
it("should leave a space for blank new lines", async () => {
const text = "hkhkjhki\n\njgkjhffjh";
const lineHeight =
getApproxLineHeight(
getFontString({
fontSize: h.app.state.currentItemFontSize,
fontFamily: h.app.state.currentItemFontFamily,
}),
const lineHeightPx =
getLineHeightInPx(
h.app.state.currentItemFontSize,
getDefaultLineHeight(h.state.currentItemFontFamily),
) +
10 / h.app.state.zoom.value;
mouse.moveTo(100, 100);
@@ -158,7 +156,7 @@ describe("paste text as single lines", () => {
const [fx, firstElY] = getElementBounds(h.elements[0]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [lx, lastElY] = getElementBounds(h.elements[1]);
expect(lastElY).toEqual(firstElY + lineHeight * 2);
expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
});
});
});
@@ -224,7 +222,7 @@ describe("Paste bound text container", () => {
await sleep(1);
expect(h.elements.length).toEqual(2);
const container = h.elements[0];
expect(container.height).toBe(354);
expect(container.height).toBe(368);
expect(container.width).toBe(166);
});
});
@@ -247,7 +245,7 @@ describe("Paste bound text container", () => {
await sleep(1);
expect(h.elements.length).toEqual(2);
const container = h.elements[0];
expect(container.height).toBe(740);
expect(container.height).toBe(770);
expect(container.width).toBe(166);
});
});
@@ -282,6 +282,7 @@ exports[`restoreElements should restore text element correctly passing value for
Object {
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": Array [],
"containerId": null,
"fillStyle": "hachure",
@@ -291,6 +292,7 @@ Object {
"height": 100,
"id": "id-text01",
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
@@ -312,7 +314,7 @@ Object {
"verticalAlign": "middle",
"width": 100,
"x": -20,
"y": -8.4,
"y": -8.75,
}
`;
@@ -320,6 +322,7 @@ exports[`restoreElements should restore text element correctly with unknown font
Object {
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": Array [],
"containerId": null,
"fillStyle": "hachure",
@@ -329,6 +332,7 @@ Object {
"height": 100,
"id": "id-text01",
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
+189
View File
@@ -0,0 +1,189 @@
import { render } from "./test-utils";
import { API } from "./helpers/api";
import ExcalidrawApp from "../excalidraw-app";
const { h } = window;
describe("fitToContent", () => {
it("should zoom to fit the selected element", async () => {
await render(<ExcalidrawApp />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 50,
height: 100,
x: 50,
y: 100,
});
expect(h.state.zoom.value).toBe(1);
h.app.scrollToContent(rectElement, { fitToContent: true });
// element is 10x taller than the viewport size,
// zoom should be at least 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should zoom to fit multiple elements", async () => {
await render(<ExcalidrawApp />);
const topLeft = API.createElement({
width: 20,
height: 20,
x: 0,
y: 0,
});
const bottomRight = API.createElement({
width: 20,
height: 20,
x: 80,
y: 80,
});
h.state.width = 10;
h.state.height = 10;
expect(h.state.zoom.value).toBe(1);
h.app.scrollToContent([topLeft, bottomRight], {
fitToContent: true,
});
// elements take 100x100, which is 10x bigger than the viewport size,
// zoom should be at least 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: 100,
y: 100,
});
expect(h.state.zoom.value).toBe(1);
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
h.app.scrollToContent(rectElement);
// zoom level should stay the same
expect(h.state.zoom.value).toBe(1);
// state should reflect some scrolling
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
});
});
const waitForNextAnimationFrame = () => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
};
describe("fitToContent animated", () => {
beforeEach(() => {
jest.spyOn(window, "requestAnimationFrame");
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should ease scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: -100,
y: -100,
});
h.app.scrollToContent(rectElement, { animate: true });
expect(window.requestAnimationFrame).toHaveBeenCalled();
// Since this is an animation, we expect values to change through time.
// We'll verify that the scroll values change at 50ms and 100ms
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
await waitForNextAnimationFrame();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForNextAnimationFrame();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
it("should animate the scroll but not the zoom", async () => {
await render(<ExcalidrawApp />);
h.state.width = 50;
h.state.height = 50;
const rectElement = API.createElement({
width: 100,
height: 100,
x: 100,
y: 100,
});
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
expect(window.requestAnimationFrame).toHaveBeenCalled();
// Since this is an animation, we expect values to change through time.
// We'll verify that the zoom/scroll values change in each animation frame
// zoom is not animated, it should be set to its final value, which in our
// case zooms out to 50% so that th element is fully visible (it's 2x large
// as the canvas)
expect(h.state.zoom.value).toBeLessThanOrEqual(0.5);
// FIXME I think this should be [-100, -100] so we may have a bug in our zoom
// hadnling, alas
expect(h.state.scrollX).toBe(25);
expect(h.state.scrollY).toBe(25);
await waitForNextAnimationFrame();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForNextAnimationFrame();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
});
+11 -2
View File
@@ -111,6 +111,9 @@ export class API {
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
startBinding?: T extends "arrow"
? ExcalidrawLinearElement["startBinding"]
: never;
endBinding?: T extends "arrow"
? ExcalidrawLinearElement["endBinding"]
: never;
@@ -178,11 +181,13 @@ export class API {
});
break;
case "text":
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
element = newTextElement({
...base,
text: rest.text || "test",
fontSize: rest.fontSize ?? appState.currentItemFontSize,
fontFamily: rest.fontFamily ?? appState.currentItemFontFamily,
fontSize,
fontFamily,
textAlign: rest.textAlign ?? appState.currentItemTextAlign,
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
containerId: rest.containerId ?? undefined,
@@ -221,6 +226,10 @@ export class API {
});
break;
}
if (element.type === "arrow") {
element.startBinding = rest.startBinding ?? null;
element.endBinding = rest.endBinding ?? null;
}
if (id) {
element.id = id;
}
+19 -7
View File
@@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
Object {
"height": 128,
"height": 130,
"width": 367,
}
`);
@@ -1040,7 +1040,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(`
Object {
"x": 272,
"y": 46,
"y": 45,
}
`);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
@@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(`
Array [
20,
36,
35,
502,
94,
95,
205.9061448421403,
53,
52.5,
]
`);
});
@@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
Object {
"height": 128,
"height": 130,
"width": 340,
}
`);
@@ -1099,7 +1099,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(`
Object {
"x": 75,
"y": -4,
"y": -5,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
@@ -1179,5 +1179,17 @@ describe("Test Linear Elements", () => {
easy"
`);
});
it("should not render horizontal align tool when element selected", () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
createBoundTextElement(DEFAULT_TEXT, arrow);
API.setSelectedElements([arrow]);
expect(queryByTestId(container, "align-left")).toBeNull();
expect(queryByTestId(container, "align-horizontal-center")).toBeNull();
expect(queryByTestId(container, "align-right")).toBeNull();
});
});
});
+2 -1
View File
@@ -32,6 +32,7 @@ import type { FileSystemHandle } from "./data/filesystem";
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import { ContextMenuItems } from "./components/ContextMenu";
import { Merge, ForwardRef } from "./utility-types";
import React from "react";
export type Point = Readonly<RoughPoint>;
@@ -101,7 +102,7 @@ export type AppState = {
} | null;
showWelcomeScreen: boolean;
isLoading: boolean;
errorMessage: string | null;
errorMessage: React.ReactNode;
draggingElement: NonDeletedExcalidrawElement | null;
resizingElement: NonDeletedExcalidrawElement | null;
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
+74 -5
View File
@@ -181,6 +181,79 @@ export const throttleRAF = <T extends any[]>(
return ret;
};
/**
* Exponential ease-out method
*
* @param {number} k - The value to be tweened.
* @returns {number} The tweened value.
*/
function easeOut(k: number): number {
return 1 - Math.pow(1 - k, 4);
}
/**
* Compute new values based on the same ease function and trigger the
* callback through a requestAnimationFrame call
*
* use `opts` to define a duration and/or an easeFn
*
* for example:
* ```ts
* easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c))
* ```
*
* @param fromValues The initial values, must be numeric
* @param toValues The destination values, must also be numeric
* @param callback The callback receiving the values
* @param opts default to 250ms duration and the easeOut function
*/
export const easeToValuesRAF = (
fromValues: number[],
toValues: number[],
callback: (...values: number[]) => void,
opts?: { duration?: number; easeFn?: (value: number) => number },
) => {
let canceled = false;
let frameId = 0;
let startTime: number;
const duration = opts?.duration || 250; // default animation to 0.25 seconds
const easeFn = opts?.easeFn || easeOut; // default the easeFn to easeOut
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
if (elapsed < duration) {
// console.log(elapsed, duration, elapsed / duration);
const factor = easeFn(elapsed / duration);
const newValues = fromValues.map(
(fromValue, index) =>
(toValues[index] - fromValue) * factor + fromValue,
);
callback(...newValues);
frameId = window.requestAnimationFrame(step);
} else {
// ensure final values are reached at the end of the transition
callback(...toValues);
}
}
frameId = window.requestAnimationFrame(step);
return () => {
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>(
array: readonly T[],
@@ -616,11 +689,7 @@ export const arrayToMapWithIndex = <T extends { id: string }>(
return acc;
}, new Map<string, [element: T, index: number]>());
export const isTestEnv = () =>
typeof process !== "undefined" && process.env?.NODE_ENV === "test";
export const isProdEnv = () =>
typeof process !== "undefined" && process.env?.NODE_ENV === "production";
export const isTestEnv = () => process.env.NODE_ENV === "test";
export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
return new CustomEvent(name, {