Merge branch 'master' into dwelle/fix-export

# Conflicts:
#	src/appState.ts
#	src/components/ImageExportDialog.tsx
#	src/components/PublishLibrary.tsx
#	src/components/SingleLibraryItem.tsx
#	src/constants.ts
#	src/packages/utils.ts
This commit is contained in:
dwelle 2023-04-18 23:43:23 +02:00
commit f1923acf05
165 changed files with 6340 additions and 2051 deletions

View File

@ -22,3 +22,13 @@ REACT_APP_DEV_ENABLE_SW=
REACT_APP_DEV_DISABLE_LIVE_RELOAD= REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags
# To enable bounding box for text containers
REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=

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"}' 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 # production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 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 REACT_APP_PLUS_APP=https://app.excalidraw.com

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ src/packages/excalidraw/types
src/packages/excalidraw/example/public/bundle.js src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js src/packages/excalidraw/example/public/excalidraw.development.js
coverage

View File

@ -17,7 +17,7 @@
An open source virtual hand-drawn style whiteboard. </br> An open source virtual hand-drawn style whiteboard. </br>
Collaborative and end-to-end encrypted. </br> Collaborative and end-to-end encrypted. </br>
<br /> <br />
</h3> </h2>
</div> </div>
<br /> <br />
@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
## Excalidraw.com ## Excalidraw.com
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features: The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
- 📡&nbsp;PWA support (works offline). - 📡&nbsp;PWA support (works offline).
- 🤼&nbsp;Real-time collaboration. - 🤼&nbsp;Real-time collaboration.

View File

@ -1,6 +1,19 @@
# ref # ref
<pre> <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> </pre>
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs: 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 ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the scene</p> <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)} /> <Excalidraw ref={(api) => setExcalidrawAPI(api)} />
</div> </div>
); );
@ -187,7 +202,8 @@ function App() {
return ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the library items</p> <p style={{ fontSize: "16px" }}> Click to update the library items</p>
<button className="custom-button" <button
className="custom-button"
onClick={() => { onClick={() => {
const libraryItems = [ const libraryItems = [
{ {
@ -205,10 +221,8 @@ function App() {
]; ];
excalidrawAPI.updateLibrary({ excalidrawAPI.updateLibrary({
libraryItems, libraryItems,
openLibraryMenu: true openLibraryMenu: true,
}); });
}} }}
> >
Update Library Update Library
@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
<pre> <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[] ExcalidrawElement[]
</a> </a>
</pre> </pre>
@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
<pre> <pre>
() => NonDeleted&#60; () => 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 ExcalidrawElement
</a> </a>
[]&#62; []&#62;
@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
## scrollToContent ## scrollToContent
<pre> <pre>
(target?:{" "} (<br />
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114"> {" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement ExcalidrawElement
</a>{" "} </a>{" "}
&#124;{" "} &#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 ExcalidrawElement
</a> </a>
[]) => void [],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre> </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 ## refresh
@ -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. This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre> <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> </pre>
## setCursor ## setCursor
This API can be used to customise the mouse cursor on the canvas and has the below signature. 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.
It sets the mouse cursor to the cursor passed in param.
```tsx ```tsx
(cursor: string) => void (cursor: string) => void

View File

@ -31,10 +31,29 @@ You can pass `null` / `undefined` if not applicable.
restoreElements( restoreElements(
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp; elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp; localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp;
refreshDimensions?: boolean<br/> opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>
| Prop | Type | Description |
| ---- | ---- | ---- |
| `elements` | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a> | The `elements` to be restored |
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
| [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements
#### localElements
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`.
Use this when you `import` elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the update
#### opts
The extra optional parameter to configure restored elements. It has the following attributes
| Prop | Type | Description|
| --- | --- | ------|
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
**_How to use_** **_How to use_**
```js ```js
@ -43,9 +62,6 @@ import { restoreElements } from "@excalidraw/excalidraw";
This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to its default value. This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to its default value.
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`.
Use this when you import elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the updates.
Parameter `refreshDimensions` indicates whether we should also `recalculate` text element dimensions. Defaults to `false`. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. Parameter `refreshDimensions` indicates whether we should also `recalculate` text element dimensions. Defaults to `false`. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration.
### restore ### restore
@ -56,7 +72,9 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
restore( restore(
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp; data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp;
localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp; localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a> localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a><br/>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>

View File

@ -339,3 +339,47 @@ The `device` has the following `attributes`
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` | | `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices | | `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` | | `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
### i18n
To help with localization, we export the following.
| name | type |
| --- | --- |
| `defaultLang` | `string` |
| `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
| `useI18n` | [`() => { langCode, t }`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
```js
import { defaultLang, languages, useI18n } from "@excalidraw/excalidraw";
```
#### defaultLang
Default language code, `en`.
#### languages
List of supported language codes. You can pass any of these to `Excalidraw`'s [`langCode` prop](/docs/@excalidraw/excalidraw/api/props/#langcode).
#### useI18n
A hook that returns the current language code and translation helper function. You can use this to translate strings in the components you render as children of `<Excalidraw>`.
```jsx live
function App() {
const { t } = useI18n();
return (
<div style={{ height: "500px" }}>
<Excalidraw>
<button
style={{ position: "absolute", zIndex: 10, height: "2rem" }}
onClick={() => window.alert(t("labels.madeWithExcalidraw"))}
>
{t("buttons.confirm")}
</button>
</Excalidraw>
</div>
);
}
```

View File

@ -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. 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? ## 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). 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

View File

@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.14.2", "@excalidraw/excalidraw": "0.15.0",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",

View File

@ -24,6 +24,7 @@ const ExcalidrawScope = {
Sidebar: ExcalidrawComp.Sidebar, Sidebar: ExcalidrawComp.Sidebar,
exportToCanvas: ExcalidrawComp.exportToCanvas, exportToCanvas: ExcalidrawComp.exportToCanvas,
initialData, initialData,
useI18n: ExcalidrawComp.useI18n,
}; };
export default ExcalidrawScope; export default ExcalidrawScope;

View File

@ -1631,10 +1631,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.14.2": "@excalidraw/excalidraw@0.15.0":
version "0.14.2" version "0.15.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.14.2.tgz#150cb4b7a1bf0d11cd64295936c930e7e0db8375" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.0.tgz#47170de8d3ff006e9d09dfede2815682b0d4485b"
integrity sha512-8LdjpTBWEK5waDWB7Bt/G9YBI4j0OxkstUhvaDGz7dwQGfzF6FW5CXBoYHNEoX0qmb+Fg/NPOlZ7FrKsrSVCqg== integrity sha512-PJmh1VcuRHG4l+Zgt9qhezxrJ16tYCZFZ8if5IEfmTL9A/7c5mXxY/qrPTqiGlVC7jYs+ciePXQ0YUDzfOfbzw==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"
@ -1785,9 +1785,9 @@
"@hapi/hoek" "^9.0.0" "@hapi/hoek" "^9.0.0"
"@sideway/formula@^3.0.0": "@sideway/formula@^3.0.0":
version "3.0.0" version "3.0.1"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
"@sideway/pinpoint@^2.0.0": "@sideway/pinpoint@^2.0.0":
version "2.0.0" version "2.0.0"
@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1:
entities "^4.3.0" entities "^4.3.0"
http-cache-semantics@^4.0.0: http-cache-semantics@^4.0.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
http-deceiver@^1.2.7: http-deceiver@^1.2.7:
version "1.2.7" version "1.2.7"
@ -7542,9 +7542,9 @@ webpack-sources@^3.2.2, webpack-sources@^3.2.3:
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.73.0: webpack@^5.73.0:
version "5.74.0" version "5.76.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c"
integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA== integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==
dependencies: dependencies:
"@types/eslint-scope" "^3.7.3" "@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51" "@types/estree" "^0.0.51"

View File

@ -25,11 +25,6 @@
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1", "@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", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
@ -57,7 +52,6 @@
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"tunnel-rat": "0.1.0", "tunnel-rat": "0.1.0",
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4", "workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4", "workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4", "workbox-cacheable-response": "^6.5.4",
@ -75,9 +69,14 @@
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2", "@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.7",
"@types/pako": "1.0.3", "@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/resize-observer-browser": "0.1.7",
"@types/socket.io-client": "1.4.36",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
@ -88,7 +87,8 @@
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0" "rewire": "6.0.0",
"typescript": "4.9.4"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@ -79,6 +79,7 @@
</style> </style>
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if (process.env.NODE_ENV === "production") { %>
<script> <script>
// Redirect Excalidraw+ users which have auto-redirect enabled. // Redirect Excalidraw+ users which have auto-redirect enabled.
// //
@ -97,6 +98,7 @@
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
</script> </script>
<% } %>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
@ -146,8 +148,10 @@
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' && <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
async async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%" src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
@ -160,6 +164,33 @@
gtag("js", new Date()); gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%"); gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script> </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) --> <!-- FIXME: remove this when we update CRA (fix SW caching) -->

View File

@ -2,6 +2,9 @@ const fs = require("fs");
const THRESSHOLD = 85; const THRESSHOLD = 85;
// we're using BCP 47 language tags as keys
// e.g. https://gist.github.com/typpo/b2b828a35e683b9bf8db91b5404f1bd1
const crowdinMap = { const crowdinMap = {
"ar-SA": "en-ar", "ar-SA": "en-ar",
"bg-BG": "en-bg", "bg-BG": "en-bg",
@ -52,6 +55,7 @@ const crowdinMap = {
"kk-KZ": "en-kk", "kk-KZ": "en-kk",
"vi-VN": "en-vi", "vi-VN": "en-vi",
"mr-IN": "en-mr", "mr-IN": "en-mr",
"th-TH": "en-th",
}; };
const flags = { const flags = {
@ -104,6 +108,7 @@ const flags = {
"eu-ES": "🇪🇦", "eu-ES": "🇪🇦",
"vi-VN": "🇻🇳", "vi-VN": "🇻🇳",
"mr-IN": "🇮🇳", "mr-IN": "🇮🇳",
"th-TH": "🇹🇭",
}; };
const languages = { const languages = {
@ -156,6 +161,7 @@ const languages = {
"zh-TW": "繁體中文", "zh-TW": "繁體中文",
"vi-VN": "Tiếng Việt", "vi-VN": "Tiếng Việt",
"mr-IN": "मराठी", "mr-IN": "मराठी",
"th-TH": "ภาษาไทย",
}; };
const percentages = fs.readFileSync( const percentages = fs.readFileSync(

View File

@ -1,22 +1,9 @@
const fs = require("fs");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
const updateReadme = () => {
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
// remove note for stable readme
const data = originalReadMe.slice(excalidrawIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
const publish = () => { const publish = () => {
try { try {
execSync(`yarn --frozen-lockfile`); execSync(`yarn --frozen-lockfile`);
@ -30,15 +17,8 @@ const publish = () => {
}; };
const release = () => { const release = () => {
updateReadme();
console.info("Note for stable readme removed");
publish(); publish();
console.info(`Published ${pkg.version}!`); console.info(`Published ${pkg.version}!`);
// revert readme after release
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Readme reverted");
}; };
release(); release();

View File

@ -1,7 +1,14 @@
import { VERTICAL_ALIGN } from "../constants"; import {
import { getNonDeletedElements, isTextElement } from "../element"; BOUND_TEXT_PADDING,
ROUNDNESS,
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText, measureText,
redrawTextBoundingBox, redrawTextBoundingBox,
@ -9,16 +16,21 @@ import {
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache,
} from "../element/textWysiwyg"; } from "../element/textWysiwyg";
import { import {
hasBoundTextElement, hasBoundTextElement,
isTextBindableContainer, isTextBindableContainer,
isUsingAdaptiveRadius,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../element/types"; } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -28,6 +40,7 @@ export const actionUnbindText = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element)); return selectedElements.some((element) => hasBoundTextElement(element));
}, },
perform: (elements, appState) => { perform: (elements, appState) => {
@ -41,18 +54,21 @@ export const actionUnbindText = register({
const { width, height, baseline } = measureText( const { width, height, baseline } = measureText(
boundTextElement.originalText, boundTextElement.originalText,
getFontString(boundTextElement), getFontString(boundTextElement),
boundTextElement.lineHeight,
); );
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
resetOriginalContainerCache(element.id); resetOriginalContainerCache(element.id);
const { x, y } = computeBoundTextPosition(element, boundTextElement);
mutateElement(boundTextElement as ExcalidrawTextElement, { mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
height, height,
baseline, baseline,
text: boundTextElement.originalText, text: boundTextElement.originalText,
x,
y,
}); });
mutateElement(element, { mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
@ -122,6 +138,7 @@ export const actionBindText = register({
mutateElement(textElement, { mutateElement(textElement, {
containerId: container.id, containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
}); });
mutateElement(container, { mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
@ -129,20 +146,168 @@ export const actionBindText = register({
id: textElement.id, id: textElement.id,
}), }),
}); });
const originalContainerHeight = container.height;
redrawTextBoundingBox(textElement, container); redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice(); // overwritting the cache with original container height so
const textElementIndex = updatedElements.findIndex( // it can be restored when unbind
(ele) => ele.id === textElement.id, updateOriginalContainerCache(container.id, originalContainerHeight);
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return { return {
elements: updatedElements, elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } }, appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true, commitToHistory: true,
}; };
}, },
}); });
const pushTextAboveContainer = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return updatedElements;
};
const pushContainerBelowText = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex, 1);
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
return updatedElements;
};
export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: AppState["selectedElementIds"] = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement)) {
const container = newElement({
type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor,
boundElements: [
...(textElement.boundElements || []),
{ id: textElement.id, type: "text" },
],
angle: textElement.angle,
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: appState.currentItemStrokeWidth,
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius("rectangle")
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
});
// update bindings
if (textElement.boundElements?.length) {
const linearElementIds = textElement.boundElements
.filter((ele) => ele.type === "arrow")
.map((el) => el.id);
const linearElements = updatedElements.filter((ele) =>
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = ele.startBinding;
let endBinding = ele.endBinding;
if (startBinding?.elementId === textElement.id) {
startBinding = {
...startBinding,
elementId: container.id,
};
}
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false);
}
});
}
mutateElement(
textElement,
{
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
},
false,
);
redrawTextBoundingBox(textElement, container);
updatedElements = pushContainerBelowText(
[...updatedElements, container],
container,
textElement,
);
containerIds[container.id] = true;
}
}
return {
elements: updatedElements,
appState: {
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true,
};
},
});

View File

@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
const zoomToFitElements = ( export const zoomToFitElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
zoomToSelection: boolean, zoomToSelection: boolean,

View File

@ -1,4 +1,5 @@
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@ -37,6 +38,7 @@ import {
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
FillZigZagIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@ -54,6 +56,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getDefaultLineHeight,
} from "../element/textElement"; } from "../element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
@ -293,7 +296,12 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
@ -304,40 +312,55 @@ export const actionChangeFillStyle = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
<fieldset> const selectedElements = getSelectedElements(elements, appState);
<legend>{t("labels.fill")}</legend> const allElementsZigZag = selectedElements.every(
<ButtonIconSelect (el) => el.fillStyle === "zigzag",
options={[ );
{
value: "hachure", return (
text: t("labels.hachure"), <fieldset>
icon: FillHachureIcon, <legend>{t("labels.fill")}</legend>
}, <ButtonIconSelect
{ type="button"
value: "cross-hatch", options={[
text: t("labels.crossHatch"), {
icon: FillCrossHatchIcon, value: "hachure",
}, text: t("labels.hachure"),
{ icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
value: "solid", active: allElementsZigZag ? true : undefined,
text: t("labels.solid"), },
icon: FillSolidIcon, {
}, value: "cross-hatch",
]} text: t("labels.crossHatch"),
group="fill" icon: FillCrossHatchIcon,
value={getFormValue( },
elements, {
appState, value: "solid",
(element) => element.fillStyle, text: t("labels.solid"),
appState.currentItemFillStyle, icon: FillSolidIcon,
)} },
onChange={(value) => { ]}
updateData(value); value={getFormValue(
}} elements,
/> appState,
</fieldset> (element) => element.fillStyle,
), appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
event.altKey &&
value === "hachure" &&
selectedElements.every((el) => el.fillStyle === "hachure")
? "zigzag"
: value;
updateData(nextValue);
}}
/>
</fieldset>
);
},
}); });
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
@ -637,6 +660,7 @@ export const actionChangeFontFamily = register({
oldElement, oldElement,
{ {
fontFamily: value, fontFamily: value,
lineHeight: getDefaultLineHeight(value),
}, },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(newElement, getContainerElement(oldElement));
@ -745,16 +769,19 @@ export const actionChangeTextAlign = register({
value: "left", value: "left",
text: t("labels.left"), text: t("labels.left"),
icon: TextAlignLeftIcon, icon: TextAlignLeftIcon,
testId: "align-left",
}, },
{ {
value: "center", value: "center",
text: t("labels.center"), text: t("labels.center"),
icon: TextAlignCenterIcon, icon: TextAlignCenterIcon,
testId: "align-horizontal-center",
}, },
{ {
value: "right", value: "right",
text: t("labels.right"), text: t("labels.right"),
icon: TextAlignRightIcon, icon: TextAlignRightIcon,
testId: "align-right",
}, },
]} ]}
value={getFormValue( value={getFormValue(

View File

@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@ -92,12 +95,18 @@ export const actionPasteStyles = register({
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, { newElement = newElementWith(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, fontSize,
fontFamily: fontFamily,
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
getDefaultLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {

View File

@ -1,5 +1,6 @@
import { isDarwin } from "../constants"; import { isDarwin } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { ActionName } from "./types"; import { ActionName } from "./types";

View File

@ -6,6 +6,7 @@ import {
ExcalidrawProps, ExcalidrawProps,
BinaryFiles, BinaryFiles,
} from "../types"; } from "../types";
import { MarkOptional } from "../utility-types";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
@ -113,7 +114,8 @@ export type ActionName =
| "toggleLock" | "toggleLock"
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool"; | "toggleHandTool"
| "wrapTextInContainer";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View File

@ -1,22 +1,30 @@
export const trackEvent = export const trackEvent = (
typeof process !== "undefined" && category: string,
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && action: string,
typeof window !== "undefined" && label?: string,
window.gtag value?: number,
? (category: string, action: string, label?: string, value?: number) => { ) => {
try { try {
window.gtag("event", action, { // Uncomment the next line to track locally
event_category: category, // console.log("Track Event", { category, action, label, value });
event_label: label,
value, if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
}); return;
} catch (error) { }
console.error("error logging to ga", error);
} if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
} window.gtag("event", action, {
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID event_category: category,
? (category: string, action: string, label?: string, value?: number) => {} event_label: label,
: (category: string, action: string, label?: string, value?: number) => { value,
// Uncomment the next line to track locally });
// console.log("Track Event", { category, action, 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);
}
};

View File

@ -1,8 +1,8 @@
import { import {
DEFAULT_BACKGROUND_COLOR, DEFAULT_BACKGROUND_COLOR,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_STROKE_COLOR,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_ZOOM_VALUE, DEFAULT_ZOOM_VALUE,
EXPORT_SCALES, EXPORT_SCALES,
@ -25,18 +25,18 @@ export const getDefaultAppState = (): Omit<
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
currentItemBackgroundColor: "transparent", currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
currentItemEndArrowhead: "arrow", currentItemEndArrowhead: "arrow",
currentItemFillStyle: "hachure", currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: 100, currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemRoughness: 1, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_STROKE_COLOR, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: "round",
currentItemStrokeStyle: "solid", currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: 1, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up", cursorButton: "up",
draggingElement: null, draggingElement: null,
@ -46,7 +46,7 @@ export const getDefaultAppState = (): Omit<
activeTool: { activeTool: {
type: "selection", type: "selection",
customType: null, customType: null,
locked: false, locked: DEFAULT_ELEMENT_PROPS.locked,
lastActiveTool: null, lastActiveTool: null,
}, },
penMode: false, penMode: false,

View File

@ -1,10 +1,5 @@
import colors from "./colors"; import colors from "./colors";
import { import { DEFAULT_FONT_SIZE, ENV } from "./constants";
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types"; import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
@ -166,17 +161,7 @@ const bgColors = colors.elementBackground.slice(
// Put all the common properties here so when the whole chart is selected // Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values // the properties dialog shows the correct selected values
const commonProps = { const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0], strokeColor: colors.elementStroke[0],
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {
@ -323,7 +308,6 @@ const chartBaseElements = (
x: x + chartWidth / 2, x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, roundness: null,
strokeStyle: "solid",
textAlign: "center", textAlign: "center",
}) })
: null; : null;

View File

@ -30,7 +30,10 @@ import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import "./Actions.scss"; import "./Actions.scss";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { shouldAllowVerticalAlign } from "../element/textElement"; import {
shouldAllowVerticalAlign,
suppportsHorizontalAlign,
} from "../element/textElement";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@ -122,7 +125,8 @@ export const SelectedShapeActions = ({
{renderAction("changeFontFamily")} {renderAction("changeFontFamily")}
{renderAction("changeTextAlign")} {suppportsHorizontalAlign(targetElements) &&
renderAction("changeTextAlign")}
</> </>
)} )}

View File

@ -1,6 +1,7 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions"; import { actionClearCanvas } from "../actions";
import { t } from "../i18n"; import { t } from "../i18n";
import { jotaiScope } from "../jotai";
import { useExcalidrawActionManager } from "./App"; import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => { export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom, activeConfirmDialogAtom,
jotaiScope,
); );
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();

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();
});
});

View File

@ -62,6 +62,7 @@ import {
GRID_SIZE, GRID_SIZE,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
isAndroid, isAndroid,
isBrave,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES, MAX_ALLOWED_FILE_BYTES,
MIME_TYPES, MIME_TYPES,
@ -108,6 +109,7 @@ import {
textWysiwyg, textWysiwyg,
transformElements, transformElements,
updateTextElement, updateTextElement,
redrawTextBoundingBox,
} from "../element"; } from "../element";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
@ -125,7 +127,11 @@ import {
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import {
deepCopyElement,
duplicateElements,
newFreeDrawElement,
} from "../element/newElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement, isArrowElement,
@ -227,6 +233,7 @@ import {
updateActiveTool, updateActiveTool,
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
easeToValuesRAF,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@ -258,13 +265,16 @@ import throttle from "lodash.throttle";
import { fileOpen, FileSystemHandle } from "../data/filesystem"; import { fileOpen, FileSystemHandle } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getApproxLineHeight,
getApproxMinLineHeight, getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerDims, getContainerDims,
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getTextBindableContainerAtPosition, getTextBindableContainerAtPosition,
isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
} from "../element/textElement"; } from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
@ -279,9 +289,14 @@ import {
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { Fonts } from "../scene/Fonts"; import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard"; import { actionPaste } from "../actions/actionClipboard";
import { actionToggleHandTool } from "../actions/actionCanvas"; import {
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
const deviceContextInitialValue = { const deviceContextInitialValue = {
isSmScreen: false, isSmScreen: false,
@ -426,7 +441,6 @@ class App extends React.Component<AppProps, AppState> {
}; };
this.id = nanoid(); this.id = nanoid();
this.library = new Library(this); this.library = new Library(this);
if (excalidrawRef) { if (excalidrawRef) {
const readyPromise = const readyPromise =
@ -708,6 +722,8 @@ class App extends React.Component<AppProps, AppState> {
const theme = const theme =
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
let name = actionResult?.appState?.name ?? this.state.name; let name = actionResult?.appState?.name ?? this.state.name;
const errorMessage =
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
if (typeof this.props.viewModeEnabled !== "undefined") { if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled; viewModeEnabled = this.props.viewModeEnabled;
} }
@ -723,7 +739,6 @@ class App extends React.Component<AppProps, AppState> {
if (typeof this.props.name !== "undefined") { if (typeof this.props.name !== "undefined") {
name = this.props.name; name = this.props.name;
} }
this.setState( this.setState(
(state) => { (state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into // using Object.assign instead of spread to fool TS 4.2.2+ into
@ -741,6 +756,7 @@ class App extends React.Component<AppProps, AppState> {
gridSize, gridSize,
theme, theme,
name, name,
errorMessage,
}); });
}, },
() => { () => {
@ -869,7 +885,6 @@ class App extends React.Component<AppProps, AppState> {
), ),
}; };
} }
// FontFaceSet loadingdone event we listen on may not always fire // FontFaceSet loadingdone event we listen on may not always fire
// (looking at you Safari), so on init we manually load fonts for current // (looking at you Safari), so on init we manually load fonts for current
// text elements on canvas, and rerender them once done. This also // text elements on canvas, and rerender them once done. This also
@ -997,6 +1012,13 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
this.updateDOMRect(this.initializeScene); this.updateDOMRect(this.initializeScene);
} }
// note that this check seems to always pass in localhost
if (isBrave() && !isMeasureTextSupported()) {
this.setState({
errorMessage: <BraveMeasureTextError />,
});
}
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -1607,36 +1629,36 @@ class App extends React.Component<AppProps, AppState> {
const dx = x - elementsCenterX; const dx = x - elementsCenterX;
const dy = y - elementsCenterY; const dy = y - elementsCenterY;
const groupIdMap = new Map();
const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize); const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
const oldIdToDuplicatedId = new Map(); const newElements = duplicateElements(
const newElements = elements.map((element) => { elements.map((element) => {
const newElement = duplicateElement( return newElementWith(element, {
this.state.editingGroupId,
groupIdMap,
element,
{
x: element.x + gridX - minX, x: element.x + gridX - minX,
y: element.y + gridY - minY, y: element.y + gridY - minY,
}, });
); }),
oldIdToDuplicatedId.set(element.id, newElement.id); );
return newElement;
});
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
const nextElements = [ const nextElements = [
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
...newElements, ...newElements,
]; ];
fixBindingsAfterDuplication(nextElements, elements, oldIdToDuplicatedId);
this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement(newElement);
redrawTextBoundingBox(newElement, container);
}
});
if (opts.files) { if (opts.files) {
this.files = { ...this.files, ...opts.files }; this.files = { ...this.files, ...opts.files };
} }
this.scene.replaceAllElements(nextElements);
this.history.resumeRecording(); this.history.resumeRecording();
this.setState( this.setState(
@ -1709,12 +1731,14 @@ class App extends React.Component<AppProps, AppState> {
(acc: ExcalidrawTextElement[], line, idx) => { (acc: ExcalidrawTextElement[], line, idx) => {
const text = line.trim(); const text = line.trim();
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) { if (text.length) {
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x, x,
y: currentY, y: currentY,
text, text,
lineHeight,
}); });
acc.push(element); acc.push(element);
currentY += element.height + LINE_GAP; currentY += element.height + LINE_GAP;
@ -1723,14 +1747,9 @@ class App extends React.Component<AppProps, AppState> {
// add paragraph only if previous line was not empty, IOW don't add // add paragraph only if previous line was not empty, IOW don't add
// more than one empty line // more than one empty line
if (prevLine) { if (prevLine) {
const defaultLineHeight = getApproxLineHeight( currentY +=
getFontString({ getLineHeightInPx(textElementProps.fontSize, lineHeight) +
fontSize: textElementProps.fontSize, LINE_GAP;
fontFamily: textElementProps.fontFamily,
}),
);
currentY += defaultLineHeight + LINE_GAP;
} }
} }
@ -1823,18 +1842,89 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionToggleHandTool); 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 = ( scrollToContent = (
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
) => { ) => {
this.setState({ this.cancelInProgresAnimation?.();
...calculateScrollCenter(
Array.isArray(target) ? target : [target], // convert provided target into ExcalidrawElement[] if necessary
this.state, const targets = Array.isArray(target) ? target : [target];
this.canvas,
), 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 = ( setToast = (
@ -2035,9 +2125,13 @@ class App extends React.Component<AppProps, AppState> {
offset = -offset; offset = -offset;
} }
if (event.shiftKey) { if (event.shiftKey) {
this.setState((state) => ({ scrollX: state.scrollX + offset })); this.translateCanvas((state) => ({
scrollX: state.scrollX + offset,
}));
} else { } else {
this.setState((state) => ({ scrollY: state.scrollY + offset })); this.translateCanvas((state) => ({
scrollY: state.scrollY + offset,
}));
} }
} }
@ -2585,6 +2679,13 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if ( if (
!existingTextElement && !existingTextElement &&
shouldBindToContainer && shouldBindToContainer &&
@ -2592,11 +2693,14 @@ class App extends React.Component<AppProps, AppState> {
!isArrowElement(container) !isArrowElement(container)
) { ) {
const fontString = { const fontString = {
fontSize: this.state.currentItemFontSize, fontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily,
}; };
const minWidth = getApproxMinLineWidth(getFontString(fontString)); const minWidth = getApproxMinLineWidth(
const minHeight = getApproxMinLineHeight(getFontString(fontString)); getFontString(fontString),
lineHeight,
);
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
const newHeight = Math.max(containerDims.height, minHeight); const newHeight = Math.max(containerDims.height, minHeight);
const newWidth = Math.max(containerDims.width, minWidth); const newWidth = Math.max(containerDims.width, minWidth);
@ -2628,10 +2732,9 @@ class App extends React.Component<AppProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle, strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: null,
text: "", text: "",
fontSize: this.state.currentItemFontSize, fontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily,
textAlign: parentCenterPosition textAlign: parentCenterPosition
? "center" ? "center"
: this.state.currentItemTextAlign, : this.state.currentItemTextAlign,
@ -2640,7 +2743,7 @@ class App extends React.Component<AppProps, AppState> {
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
locked: false, lineHeight,
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
@ -2663,14 +2766,6 @@ class App extends React.Component<AppProps, AppState> {
element, element,
]); ]);
} }
// case: creating new text not centered to parent element → offset Y
// so that the text is centered to cursor position
if (!parentCenterPosition) {
mutateElement(element, {
y: element.y - element.baseline / 2,
});
}
} }
this.setState({ this.setState({
@ -2764,7 +2859,6 @@ class App extends React.Component<AppProps, AppState> {
); );
if (container) { if (container) {
if ( if (
isArrowElement(container) ||
hasBoundTextElement(container) || hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) || !isTransparent(container.backgroundColor) ||
isHittingElementNotConsideringBoundingBox(container, this.state, [ isHittingElementNotConsideringBoundingBox(container, this.state, [
@ -2916,12 +3010,12 @@ class App extends React.Component<AppProps, AppState> {
state, state,
); );
return { this.translateCanvas({
zoom: zoomState.zoom, zoom: zoomState.zoom,
scrollX: zoomState.scrollX + deltaX / nextZoom, scrollX: zoomState.scrollX + deltaX / nextZoom,
scrollY: zoomState.scrollY + deltaY / nextZoom, scrollY: zoomState.scrollY + deltaY / nextZoom,
shouldCacheIgnoreZoom: true, shouldCacheIgnoreZoom: true,
}; });
}); });
this.resetShouldCacheIgnoreZoomDebounced(); this.resetShouldCacheIgnoreZoomDebounced();
} else { } else {
@ -3399,6 +3493,43 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ contextMenu: null }); this.setState({ contextMenu: null });
} }
this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs
// a second finger is on the screen
// discard the freedraw element if it is very short because it is likely
// just a spike, otherwise finalize the freedraw element when the second
// finger is lifted
if (
event.pointerType === "touch" &&
this.state.draggingElement &&
this.state.draggingElement.type === "freedraw"
) {
const element = this.state.draggingElement as ExcalidrawFreeDrawElement;
this.updateScene({
...(element.points.length < 10
? {
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== element.id),
}
: {}),
appState: {
draggingElement: null,
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: boolean }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
},
});
return;
}
// remove any active selection when we start to interact with canvas // remove any active selection when we start to interact with canvas
// (mainly, we care about removing selection outside the component which // (mainly, we care about removing selection outside the component which
// would prevent our copy handling otherwise) // would prevent our copy handling otherwise)
@ -3438,8 +3569,6 @@ class App extends React.Component<AppProps, AppState> {
}); });
this.savePointer(event.clientX, event.clientY, "down"); this.savePointer(event.clientX, event.clientY, "down");
this.updateGestureOnPointerDown(event);
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) { if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return; return;
} }
@ -3697,7 +3826,7 @@ class App extends React.Component<AppProps, AppState> {
window.addEventListener(EVENT.POINTER_UP, enableNextPaste); window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
} }
this.setState({ this.translateCanvas({
scrollX: this.state.scrollX - deltaX / this.state.zoom.value, scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
scrollY: this.state.scrollY - deltaY / this.state.zoom.value, scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
}); });
@ -4843,7 +4972,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverHorizontal) { if (pointerDownState.scrollbars.isOverHorizontal) {
const x = event.clientX; const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x; const dx = x - pointerDownState.lastCoords.x;
this.setState({ this.translateCanvas({
scrollX: this.state.scrollX - dx / this.state.zoom.value, scrollX: this.state.scrollX - dx / this.state.zoom.value,
}); });
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = x;
@ -4853,7 +4982,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverVertical) { if (pointerDownState.scrollbars.isOverVertical) {
const y = event.clientY; const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y; const dy = y - pointerDownState.lastCoords.y;
this.setState({ this.translateCanvas({
scrollY: this.state.scrollY - dy / this.state.zoom.value, scrollY: this.state.scrollY - dy / this.state.zoom.value,
}); });
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = y;
@ -6235,6 +6364,7 @@ class App extends React.Component<AppProps, AppState> {
actionGroup, actionGroup,
actionUnbindText, actionUnbindText,
actionBindText, actionBindText,
actionWrapTextInContainer,
actionUngroup, actionUngroup,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionAddToLibrary, actionAddToLibrary,
@ -6281,7 +6411,7 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad) // reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20); Math.min(1, absDelta / 20);
this.setState((state) => ({ this.translateCanvas((state) => ({
...getStateForZoom( ...getStateForZoom(
{ {
viewportX: cursorX, viewportX: cursorX,
@ -6298,14 +6428,14 @@ class App extends React.Component<AppProps, AppState> {
// scroll horizontally when shift pressed // scroll horizontally when shift pressed
if (event.shiftKey) { if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({ this.translateCanvas(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX // on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value, scrollX: scrollX - (deltaY || deltaX) / zoom.value,
})); }));
return; return;
} }
this.setState(({ zoom, scrollX, scrollY }) => ({ this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value, scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value, scrollY: scrollY - deltaY / zoom.value,
})); }));

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;

View File

@ -1,33 +1,59 @@
import clsx from "clsx"; import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>({ export const ButtonIconSelect = <T extends Object>(
options, props: {
value, options: {
onChange, value: T;
group, text: string;
}: { icon: JSX.Element;
options: { value: T; text: string; icon: JSX.Element; testId?: string }[]; testId?: string;
value: T | null; /** if not supplied, defaults to value identity check */
onChange: (value: T) => void; active?: boolean;
group: string; }[];
}) => ( value: T | null;
type?: "radio" | "button";
} & (
| { type?: "radio"; group: string; onChange: (value: T) => void }
| {
type: "button";
onClick: (
value: T,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
),
) => (
<div className="buttonList buttonListIcon"> <div className="buttonList buttonListIcon">
{options.map((option) => ( {props.options.map((option) =>
<label props.type === "button" ? (
key={option.text} <button
className={clsx({ active: value === option.value })} key={option.text}
title={option.text} onClick={(event) => props.onClick(option.value, event)}
> className={clsx({
<input active: option.active ?? props.value === option.value,
type="radio" })}
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
data-testid={option.testId} data-testid={option.testId}
/> title={option.text}
{option.icon} >
</label> {option.icon}
))} </button>
) : (
<label
key={option.text}
className={clsx({ active: props.value === option.value })}
title={option.text}
>
<input
type="radio"
name={props.group}
onChange={() => props.onChange(option.value)}
checked={props.value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
),
)}
</div> </div>
); );

View File

@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => {
...rest ...rest
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
return ( return (
<Dialog <Dialog

View File

@ -16,6 +16,7 @@ import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { jotaiScope } from "../jotai";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@ -72,7 +73,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const onClose = () => { const onClose = () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });

View File

@ -5,13 +5,13 @@ import { Dialog } from "./Dialog";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
export const ErrorDialog = ({ export const ErrorDialog = ({
message, children,
onClose, onClose,
}: { }: {
message: string; children?: React.ReactNode;
onClose?: () => void; onClose?: () => void;
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(!!message); const [modalIsShown, setModalIsShown] = useState(!!children);
const { container: excalidrawContainer } = useExcalidrawContainer(); const { container: excalidrawContainer } = useExcalidrawContainer();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
@ -32,7 +32,7 @@ export const ErrorDialog = ({
onCloseRequest={handleClose} onCloseRequest={handleClose}
title={t("errorDialog.title")} title={t("errorDialog.title")}
> >
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div> <div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
</Dialog> </Dialog>
)} )}
</> </>

View File

@ -9,6 +9,10 @@
text-align: center; text-align: center;
padding: var(--preview-padding); padding: var(--preview-padding);
margin-bottom: calc(var(--space-factor) * 3); margin-bottom: calc(var(--space-factor) * 3);
display: flex;
justify-content: center;
align-items: center;
} }
.ExportDialog__preview canvas { .ExportDialog__preview canvas {

View File

@ -1,7 +1,7 @@
import { t } from "../i18n";
import { HelpIcon } from "./icons"; import { HelpIcon } from "./icons";
type HelpButtonProps = { type HelpButtonProps = {
title?: string;
name?: string; name?: string;
id?: string; id?: string;
onClick?(): void; onClick?(): void;
@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => (
className="help-icon" className="help-icon"
onClick={props.onClick} onClick={props.onClick}
type="button" type="button"
title={`${props.title} — ?`} title={`${t("helpDialog.title")} — ?`}
aria-label={props.title} aria-label={t("helpDialog.title")}
> >
{HelpIcon} {HelpIcon}
</button> </button>

View File

@ -165,11 +165,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editLineArrowPoints")}
shortcuts={[ shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
getShortcutKey("CtrlOrCmd+Enter"), />
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`), <Shortcut
]} label={t("helpDialog.editText")}
shortcuts={[getShortcutKey("Enter")]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}

View File

@ -4,7 +4,6 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard } from "./icons"; import { clipboard } from "./icons";
@ -15,6 +14,7 @@ import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants"; import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { exportToCanvas } from "../packages/utils";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@ -83,7 +83,6 @@ const ImageExportModal = ({
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
@ -99,6 +98,10 @@ const ImageExportModal = ({
if (!previewNode) { if (!previewNode) {
return; return;
} }
const maxWidth = previewNode.offsetWidth;
if (!maxWidth) {
return;
}
exportToCanvas({ exportToCanvas({
data: { data: {
elements: exportedElements, elements: exportedElements,
@ -106,10 +109,13 @@ const ImageExportModal = ({
files, files,
}, },
config: { config: {
canvasBackgroundColor: !exportBackground ? false : viewBackgroundColor, canvasBackgroundColor: !appState.exportBackground
? false
: appState.viewBackgroundColor,
padding: exportPadding, padding: exportPadding,
theme: appState.exportWithDarkMode ? "dark" : "light", theme: appState.exportWithDarkMode ? "dark" : "light",
scale: appState.exportScale, scale: appState.exportScale,
maxWidthOrHeight: maxWidth,
}, },
}) })
.then((canvas) => { .then((canvas) => {
@ -124,14 +130,7 @@ const ImageExportModal = ({
console.error(error); console.error(error);
setRenderError(error); setRenderError(error);
}); });
}, [ }, [appState, files, exportedElements, exportPadding]);
appState,
files,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
]);
return ( return (
<div className="ExportDialog"> <div className="ExportDialog">

View File

@ -364,10 +364,9 @@ const LayerUI = ({
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && ( {appState.errorMessage && (
<ErrorDialog <ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
message={appState.errorMessage} {appState.errorMessage}
onClose={() => setAppState({ errorMessage: null })} </ErrorDialog>
/>
)} )}
{appState.openDialog === "help" && ( {appState.openDialog === "help" && (
<HelpDialog <HelpDialog

View File

@ -48,6 +48,7 @@ export const LibraryMenuHeader: React.FC<{
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope,
); );
const renderRemoveLibAlert = useCallback(() => { const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length const content = selectedItems.length

View File

@ -12,6 +12,7 @@ import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import clsx from "clsx"; import clsx from "clsx";
import { duplicateElements } from "../element/newElement";
const CELLS_PER_ROW = 4; const CELLS_PER_ROW = 4;
@ -96,7 +97,14 @@ const LibraryMenuItems = ({
} else { } else {
targetElements = libraryItems.filter((item) => item.id === id); targetElements = libraryItems.filter((item) => item.id === id);
} }
return targetElements; return targetElements.map((item) => {
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements),
};
});
}; };
const createLibraryItemCompo = (params: { const createLibraryItemCompo = (params: {

View File

@ -2,7 +2,7 @@ import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../packages/utils";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
@ -36,14 +36,16 @@ export const LibraryUnit = ({
if (!elements) { if (!elements) {
return; return;
} }
const svg = await exportToSvg( const svg = await exportToSvg({
elements, data: {
{ elements,
exportBackground: false, appState: {
viewBackgroundColor: oc.white, exportBackground: false,
viewBackgroundColor: oc.white,
},
files: null,
}, },
null, });
);
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();

View File

@ -3,5 +3,6 @@
position: absolute; position: absolute;
z-index: 10; z-index: 10;
padding: 5px 0 5px; padding: 5px 0 5px;
outline: none;
} }
} }

View File

@ -29,13 +29,21 @@ export const Popover = ({
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const container = popoverRef.current;
useEffect(() => { useEffect(() => {
const container = popoverRef.current;
if (!container) { if (!container) {
return; return;
} }
// focus popover only if the caller didn't focus on something else nested
// within the popover, which should take precedence. Fixes cases
// like color picker listening to keydown events on containers nested
// in the popover.
if (!container.contains(document.activeElement)) {
container.focus();
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) { if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(container); const focusableElements = queryFocusableElements(container);
@ -44,15 +52,23 @@ export const Popover = ({
(element) => element === activeElement, (element) => element === activeElement,
); );
if (currentIndex === 0 && event.shiftKey) { if (activeElement === container) {
focusableElements[focusableElements.length - 1].focus(); if (event.shiftKey) {
focusableElements[focusableElements.length - 1]?.focus();
} else {
focusableElements[0].focus();
}
event.preventDefault();
event.stopImmediatePropagation();
} else if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1]?.focus();
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} else if ( } else if (
currentIndex === focusableElements.length - 1 && currentIndex === focusableElements.length - 1 &&
!event.shiftKey !event.shiftKey
) { ) {
focusableElements[0].focus(); focusableElements[0]?.focus();
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
@ -62,35 +78,59 @@ export const Popover = ({
container.addEventListener("keydown", handleKeyDown); container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown); return () => container.removeEventListener("keydown", handleKeyDown);
}, [container]); }, []);
const lastInitializedPosRef = useRef<{ top: number; left: number } | null>(
null,
);
// ensure the popover doesn't overflow the viewport // ensure the popover doesn't overflow the viewport
useLayoutEffect(() => { useLayoutEffect(() => {
if (fitInViewport && popoverRef.current) { if (fitInViewport && popoverRef.current && top != null && left != null) {
const element = popoverRef.current; const container = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect(); const { width, height } = container.getBoundingClientRect();
//Position correctly when clicked on rightmost part or the bottom part of viewport // hack for StrictMode so this effect only runs once for
if (x + width - offsetLeft > viewportWidth) { // the same top/left position, otherwise
element.style.left = `${viewportWidth - width - 10}px`; // we'd potentically reposition twice (once for viewport overflow)
} // and once for top/left position afterwards
if (y + height - offsetTop > viewportHeight) { if (
element.style.top = `${viewportHeight - height}px`; lastInitializedPosRef.current?.top === top &&
lastInitializedPosRef.current?.left === left
) {
return;
} }
lastInitializedPosRef.current = { top, left };
//Resize to fit viewport on smaller screens
if (height >= viewportHeight) {
element.style.height = `${viewportHeight - 20}px`;
element.style.top = "10px";
element.style.overflowY = "scroll";
}
if (width >= viewportWidth) { if (width >= viewportWidth) {
element.style.width = `${viewportWidth}px`; container.style.width = `${viewportWidth}px`;
element.style.left = "0px"; container.style.left = "0px";
element.style.overflowX = "scroll"; container.style.overflowX = "scroll";
} else if (left + width - offsetLeft > viewportWidth) {
container.style.left = `${viewportWidth - width - 10}px`;
} else {
container.style.left = `${left}px`;
}
if (height >= viewportHeight) {
container.style.height = `${viewportHeight - 20}px`;
container.style.top = "10px";
container.style.overflowY = "scroll";
} else if (top + height - offsetTop > viewportHeight) {
container.style.top = `${viewportHeight - height}px`;
} else {
container.style.top = `${top}px`;
} }
} }
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]); }, [
top,
left,
fitInViewport,
viewportWidth,
viewportHeight,
offsetLeft,
offsetTop,
]);
useEffect(() => { useEffect(() => {
if (onCloseRequest) { if (onCloseRequest) {
@ -105,7 +145,7 @@ export const Popover = ({
}, [onCloseRequest]); }, [onCloseRequest]);
return ( return (
<div className="popover" style={{ top, left }} ref={popoverRef}> <div className="popover" ref={popoverRef} tabIndex={-1}>
{children} {children}
</div> </div>
); );

View File

@ -93,4 +93,80 @@
display: block; display: block;
} }
} }
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
} }

View File

@ -1,10 +1,11 @@
import { ReactNode, useCallback, useEffect, useState } from "react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, LibraryItems, LibraryItem } from "../types"; import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils";
import { import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
@ -12,13 +13,13 @@ import {
VERSIONS, VERSIONS,
} from "../constants"; } from "../constants";
import { ExportedLibraryData } from "../data/types"; import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils"; import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { exportToCanvas } from "../scene/export"; import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import "./PublishLibrary.scss";
interface PublishLibraryDataParams { interface PublishLibraryDataParams {
authorName: string; authorName: string;
@ -130,6 +131,101 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
); );
}; };
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: AppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
data: {
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: OpenColor.white,
exportBackground: true,
},
files: null,
},
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={CloseIcon}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: OpenColor.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
const PublishLibrary = ({ const PublishLibrary = ({
onClose, onClose,
libraryItems, libraryItems,

View File

@ -1,79 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
}

View File

@ -1,106 +0,0 @@
import oc from "open-color";
import { useEffect, useRef } from "react";
import { t } from "../i18n";
import { exportToSvg } from "../packages/utils";
import { AppState, LibraryItem } from "../types";
import { CloseIcon } from "./icons";
import "./SingleLibraryItem.scss";
import { ToolButton } from "./ToolButton";
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: AppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
data: {
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
},
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={CloseIcon}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
export default SingleLibraryItem;

View File

@ -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>
`;

View File

@ -1008,6 +1008,13 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
), ),
); );
export const FillZigZagIcon = createIcon(
<g strokeWidth={1.25}>
<path d="M5.879 2.625h8.242a3.27 3.27 0 0 1 3.254 3.254v8.242a3.27 3.27 0 0 1-3.254 3.254H5.88a3.27 3.27 0 0 1-3.254-3.254V5.88A3.27 3.27 0 0 1 5.88 2.626l-.001-.001ZM4.518 16.118l7.608-12.83m.198 13.934 5.051-9.897M2.778 9.675l9.348-6.387m-7.608 12.83 12.857-8.793" />
</g>,
modifiedTablerIconProps,
);
export const FillHachureIcon = createIcon( export const FillHachureIcon = createIcon(
<> <>
<path <path

View File

@ -1,5 +1,5 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { import {
useExcalidrawAppState, useExcalidrawAppState,
useExcalidrawSetAppState, useExcalidrawSetAppState,
@ -31,11 +31,10 @@ import "./DefaultItems.scss";
import clsx from "clsx"; import clsx from "clsx";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
export const LoadScene = () => { export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionLoadScene)) { if (!actionManager.isActionEnabled(actionLoadScene)) {
@ -57,9 +56,7 @@ export const LoadScene = () => {
LoadScene.displayName = "LoadScene"; LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => { export const SaveToActiveFile = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) { if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
@ -80,9 +77,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => { export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return ( return (
<DropdownMenuItem <DropdownMenuItem
icon={ExportImageIcon} icon={ExportImageIcon}
@ -98,9 +93,7 @@ export const SaveAsImage = () => {
SaveAsImage.displayName = "SaveAsImage"; SaveAsImage.displayName = "SaveAsImage";
export const Help = () => { export const Help = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -119,10 +112,12 @@ export const Help = () => {
Help.displayName = "Help"; Help.displayName = "Help";
export const ClearCanvas = () => { export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState(); const setActiveConfirmDialog = useSetAtom(
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) { if (!actionManager.isActionEnabled(actionClearCanvas)) {
@ -143,6 +138,7 @@ export const ClearCanvas = () => {
ClearCanvas.displayName = "ClearCanvas"; ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => { export const ToggleTheme = () => {
const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -175,6 +171,7 @@ export const ToggleTheme = () => {
ToggleTheme.displayName = "ToggleTheme"; ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => { export const ChangeCanvasBackground = () => {
const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -195,9 +192,7 @@ export const ChangeCanvasBackground = () => {
ChangeCanvasBackground.displayName = "ChangeCanvasBackground"; ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => { export const Export = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
return ( return (
<DropdownMenuItem <DropdownMenuItem
@ -248,9 +243,7 @@ export const LiveCollaborationTrigger = ({
onSelect: () => void; onSelect: () => void;
isCollaborating: boolean; isCollaborating: boolean;
}) => { }) => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return ( return (
<DropdownMenuItem <DropdownMenuItem
data-testid="collab-button" data-testid="collab-button"

View File

@ -1,6 +1,6 @@
import { actionLoadScene, actionShortcuts } from "../../actions"; import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n"; import { t, useI18n } from "../../i18n";
import { import {
useDevice, useDevice,
useExcalidrawActionManager, useExcalidrawActionManager,
@ -172,10 +172,7 @@ const MenuItemLiveCollaborationTrigger = ({
}: { }: {
onSelect: () => any; onSelect: () => any;
}) => { }) => {
// FIXME when we tie t() to lang state const { t } = useI18n();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const appState = useExcalidrawAppState();
return ( return (
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}> <WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
{t("labels.liveCollaboration")} {t("labels.liveCollaboration")}

View File

@ -1,6 +1,7 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps, NormalizedZoomValue } from "./types"; import { AppProps, NormalizedZoomValue } from "./types";
import { FontFamilyValues } from "./element/types"; import { ExcalidrawElement, FontFamilyValues } from "./element/types";
import oc from "open-color";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
@ -9,6 +10,12 @@ export const isFirefox =
"netscape" in window && "netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1; navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
@ -252,3 +259,23 @@ export const ROUNDNESS = {
/** key containt id of precedeing elemnt id we use in reconciliation during /** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */ * collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
fillStyle: ExcalidrawElement["fillStyle"];
strokeWidth: ExcalidrawElement["strokeWidth"];
strokeStyle: ExcalidrawElement["strokeStyle"];
roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: oc.black,
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
locked: false,
};

View File

@ -155,6 +155,9 @@
margin: 1px; margin: 1px;
} }
.welcome-screen-menu-item:focus-visible,
.dropdown-menu-item:focus-visible,
button:focus-visible,
.buttonList label:focus-within, .buttonList label:focus-within,
input:focus-visible { input:focus-visible {
outline: transparent; outline: transparent;
@ -530,6 +533,7 @@
// (doesn't work in Firefox) // (doesn't work in Firefox)
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 3px; width: 3px;
height: 3px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@ -567,8 +571,8 @@
} }
.App-toolbar--mobile { .App-toolbar--mobile {
overflow-x: hidden; overflow-x: auto;
max-width: 100vw; max-width: 90vw;
.ToolIcon__keybinding { .ToolIcon__keybinding {
display: none; display: none;

View File

@ -7,6 +7,7 @@ import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types"; import { AppState, DataURL, LibraryItem } from "../types";
import { ValueOf } from "../utility-types";
import { bytesToHexString } from "../utils"; import { bytesToHexString } from "../utils";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem"; import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json"; import { isValidExcalidrawData, isValidLibrary } from "./json";
@ -156,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true }, { repairBindings: true, refreshDimensions: true },
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {

View File

@ -97,7 +97,9 @@ export const exportAsImage = async (
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, 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, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {

View File

@ -31,9 +31,15 @@ import {
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import oc from "open-color"; import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -164,18 +170,40 @@ const restoreElement = (
const [fontPx, _fontFamily]: [string, string] = ( const [fontPx, _fontFamily]: [string, string] = (
element as any element as any
).font.split(" "); ).font.split(" ");
fontSize = parseInt(fontPx, 10); fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily); fontFamily = getFontFamilyByName(_fontFamily);
} }
const text = element.text ?? "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
const lineHeight =
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
text: element.text ?? "", text,
baseline: element.baseline,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || element.text, originalText: element.originalText || text,
lineHeight,
baseline,
}); });
if (refreshDimensions) { if (refreshDimensions) {
@ -341,6 +369,9 @@ export const restoreElements = (
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => { const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
@ -355,6 +386,10 @@ export const restoreElements = (
if (localElement && localElement.version > migratedElement.version) { if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version); migratedElement = bumpVersion(migratedElement, localElement.version);
} }
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement); elements.push(migratedElement);
} }
} }
@ -479,7 +514,9 @@ export const restoreAppState = (
? { ? {
value: appState.zoom as NormalizedZoomValue, 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, // when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state. // keep it open. If not docked, keep it closed irrespective of last state.
openSidebar: openSidebar:

View File

@ -23,6 +23,7 @@ import {
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number]; export type Bounds = readonly [number, number, number, number];

View File

@ -38,6 +38,7 @@ import { isTextElement } from ".";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles"; import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { Mutable } from "../utility-types";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@ -785,7 +786,12 @@ export const findFocusPointForEllipse = (
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
squares; 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); const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
return GA.point(x, (-m * x - 1) / n); return GA.point(x, (-m * x - 1) / n);

View File

@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants"; import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;

View File

@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { Point } from "../types"; import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit< type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,

View File

@ -1,8 +1,9 @@
import { duplicateElement } from "./newElement"; import { duplicateElement, duplicateElements } from "./newElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import { ExcalidrawLinearElement } from "./types";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
@ -15,79 +16,353 @@ const assertCloneObjects = (source: any, clone: any) => {
} }
}; };
it("clones arrow element", () => { describe("duplicating single elements", () => {
const element = API.createElement({ it("clones arrow element", () => {
type: "arrow", const element = API.createElement({
x: 0, type: "arrow",
y: 0, x: 0,
strokeColor: "#000000", y: 0,
backgroundColor: "transparent", strokeColor: "#000000",
fillStyle: "hachure", backgroundColor: "transparent",
strokeWidth: 1, fillStyle: "hachure",
strokeStyle: "solid", strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, strokeStyle: "solid",
roughness: 1, roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
opacity: 100, roughness: 1,
opacity: 100,
});
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(null, new Map(), element);
assertCloneObjects(element, copy);
// assert we clone the object's prototype
// @ts-ignore
expect(copy.__proto__).toEqual({ hello: "world" });
expect(copy.hasOwnProperty("hello")).toBe(false);
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
});
}); });
// @ts-ignore it("clones text element", () => {
element.__proto__ = { hello: "world" }; const element = API.createElement({
type: "text",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 1,
opacity: 100,
text: "hello",
fontSize: 20,
fontFamily: FONT_FAMILY.Virgil,
textAlign: "left",
verticalAlign: "top",
});
mutateElement(element, { const copy = duplicateElement(null, new Map(), element);
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(null, new Map(), element); assertCloneObjects(element, copy);
assertCloneObjects(element, copy); expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape");
// @ts-ignore expect(copy.id).not.toBe(element.id);
expect(copy.__proto__).toEqual({ hello: "world" }); expect(typeof copy.id).toBe("string");
expect(copy.hasOwnProperty("hello")).toBe(false); expect(typeof copy.seed).toBe("number");
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
}); });
}); });
it("clones text element", () => { describe("duplicating multiple elements", () => {
const element = API.createElement({ it("duplicateElements should clone bindings", () => {
type: "text", const rectangle1 = API.createElement({
x: 0, type: "rectangle",
y: 0, id: "rectangle1",
strokeColor: "#000000", boundElements: [
backgroundColor: "transparent", { id: "arrow1", type: "arrow" },
fillStyle: "hachure", { id: "arrow2", type: "arrow" },
strokeWidth: 1, { id: "text1", type: "text" },
strokeStyle: "solid", ],
roundness: null, });
roughness: 1,
opacity: 100, const text1 = API.createElement({
text: "hello", type: "text",
fontSize: 20, id: "text1",
fontFamily: FONT_FAMILY.Virgil, containerId: "rectangle1",
textAlign: "left", });
verticalAlign: "top",
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
boundElements: [{ id: "text2", type: "text" }],
});
const text2 = API.createElement({
type: "text",
id: "text2",
containerId: "arrow2",
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const clonedElements = duplicateElements(origElements);
// generic id in-equality checks
// --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual(
clonedElements.map((e) => e.type),
);
origElements.forEach((origElement, idx) => {
const clonedElement = clonedElements[idx];
expect(origElement).toEqual(
expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id),
type: clonedElement.type,
}),
);
if ("containerId" in origElement) {
expect(origElement.containerId).not.toBe(
(clonedElement as any).containerId,
);
}
if ("endBinding" in origElement) {
if (origElement.endBinding) {
expect(origElement.endBinding.elementId).not.toBe(
(clonedElement as any).endBinding?.elementId,
);
} else {
expect((clonedElement as any).endBinding).toBeNull();
}
}
if ("startBinding" in origElement) {
if (origElement.startBinding) {
expect(origElement.startBinding.elementId).not.toBe(
(clonedElement as any).startBinding?.elementId,
);
} else {
expect((clonedElement as any).startBinding).toBeNull();
}
}
});
// --------------------------------------------------------------------------
const clonedArrows = clonedElements.filter(
(e) => e.type === "arrow",
) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
clonedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect(
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
).toEqual(
expect.objectContaining({
id: clonedText1.id,
type: clonedText1.type,
}),
);
clonedArrows.forEach((arrow) => {
// console.log(arrow);
expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual(
expect.objectContaining({
id: arrow.id,
type: arrow.type,
}),
);
if (arrow.endBinding) {
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
}
if (arrow.startBinding) {
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
}
});
expect(clonedArrow2.boundElements).toEqual([
{ type: "text", id: clonedArrowLabel.id },
]);
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
}); });
const copy = duplicateElement(null, new Map(), element); it("should remove id references of elements that aren't found", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
// should keep
{ id: "arrow1", type: "arrow" },
// should drop
{ id: "arrow-not-exists", type: "arrow" },
// should drop
{ id: "text-not-exists", type: "text" },
],
});
assertCloneObjects(element, copy); const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
expect(copy).not.toHaveProperty("points"); const text1 = API.createElement({
expect(copy).not.toHaveProperty("shape"); type: "text",
expect(copy.id).not.toBe(element.id); id: "text1",
expect(typeof copy.id).toBe("string"); containerId: "rectangle-not-exists",
expect(typeof copy.seed).toBe("number"); });
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
},
});
const arrow3 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const [
clonedRectangle,
clonedText1,
clonedArrow1,
clonedArrow2,
clonedArrow3,
] = clonedElements;
expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" },
]);
expect(clonedText1.containerId).toBe(null);
expect(clonedArrow2.startBinding).toEqual({
...arrow2.startBinding,
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
elementId: clonedRectangle.id,
});
});
describe("should duplicate all group ids", () => {
it("should regenerate all group ids and keep them consistent across elements", () => {
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const origElements = [rectangle1, rectangle2, rectangle3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
});
it("should keep and regenerate ids of groups even if invalid", () => {
// lone element shouldn't be able to be grouped with itself,
// but hard to check against in a performant way so we ignore it
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const [clonedRectangle1] = duplicateElements([rectangle1]);
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
});
});
}); });

View File

@ -13,7 +13,12 @@ import {
FontFamilyValues, FontFamilyValues,
ExcalidrawTextContainer, ExcalidrawTextContainer,
} from "../element/types"; } from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
@ -22,16 +27,25 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math"; import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
getBoundTextElement,
getBoundTextElementOffset, getBoundTextElementOffset,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
measureText, measureText,
normalizeText, normalizeText,
wrapText, wrapText,
getMaxContainerWidth,
getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "./typeChecks";
import { MarkOptional, Merge, Mutable } from "../utility-types";
type ElementConstructorOpts = MarkOptional< type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -44,6 +58,15 @@ type ElementConstructorOpts = MarkOptional<
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link" | "link"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
| "backgroundColor"
| "roughness"
| "strokeWidth"
| "roundness"
| "locked"
| "opacity"
>; >;
const _newElementBase = <T extends ExcalidrawElement>( const _newElementBase = <T extends ExcalidrawElement>(
@ -51,13 +74,13 @@ const _newElementBase = <T extends ExcalidrawElement>(
{ {
x, x,
y, y,
strokeColor, strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
backgroundColor, backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
fillStyle, fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
strokeWidth, strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
strokeStyle, strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
roughness, roughness = DEFAULT_ELEMENT_PROPS.roughness,
opacity, opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0, width = 0,
height = 0, height = 0,
angle = 0, angle = 0,
@ -65,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness = null, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
locked, locked = DEFAULT_ELEMENT_PROPS.locked,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
@ -131,24 +154,39 @@ const getTextElementPositionOffsets = (
export const newTextElement = ( export const newTextElement = (
opts: { opts: {
text: string; text: string;
fontSize: number; fontSize?: number;
fontFamily: FontFamilyValues; fontFamily?: FontFamilyValues;
textAlign: TextAlign; textAlign?: TextAlign;
verticalAlign: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts)); const metrics = measureText(
const offsets = getTextElementPositionOffsets(opts, metrics); text,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
const offsets = getTextElementPositionOffsets(
{ textAlign, verticalAlign },
metrics,
);
const textElement = newElementWith( const textElement = newElementWith(
{ {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
text, text,
fontSize: opts.fontSize, fontSize,
fontFamily: opts.fontFamily, fontFamily,
textAlign: opts.textAlign, textAlign,
verticalAlign: opts.verticalAlign, verticalAlign,
x: opts.x - offsets.x, x: opts.x - offsets.x,
y: opts.y - offsets.y, y: opts.y - offsets.y,
width: metrics.width, width: metrics.width,
@ -156,6 +194,7 @@ export const newTextElement = (
baseline: metrics.baseline, baseline: metrics.baseline,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight,
}, },
{}, {},
); );
@ -172,16 +211,13 @@ const getAdjustedDimensions = (
height: number; height: number;
baseline: number; baseline: number;
} => { } => {
let maxWidth = null;
const container = getContainerElement(element); const container = getContainerElement(element);
if (container) {
maxWidth = getMaxContainerWidth(container);
}
const { const {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseline, baseline: nextBaseline,
} = measureText(nextText, getFontString(element), maxWidth); } = measureText(nextText, getFontString(element), element.lineHeight);
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
@ -193,7 +229,7 @@ const getAdjustedDimensions = (
const prevMetrics = measureText( const prevMetrics = measureText(
element.text, element.text,
getFontString(element), getFontString(element),
maxWidth, element.lineHeight,
); );
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
@ -256,9 +292,9 @@ const getAdjustedDimensions = (
return { return {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x, x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y, y: Number.isFinite(y) ? y : element.y,
baseline: nextBaseline,
}; };
}; };
@ -266,6 +302,9 @@ export const refreshTextDimensions = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
text = textElement.text, text = textElement.text,
) => { ) => {
if (textElement.isDeleted) {
return;
}
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (container) { if (container) {
text = wrapText( text = wrapText(
@ -278,38 +317,6 @@ export const refreshTextDimensions = (
return { text, ...dimensions }; return { text, ...dimensions };
}; };
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = ( export const updateTextElement = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
{ {
@ -383,16 +390,24 @@ export const newImageElement = (
}; };
}; };
// Simplified deep clone for the purpose of cloning ExcalidrawElement only // Simplified deep clone for the purpose of cloning ExcalidrawElement.
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) //
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
// //
// Adapted from https://github.com/lukeed/klona // Adapted from https://github.com/lukeed/klona
export const deepCopyElement = (val: any, depth: number = 0) => { //
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") { if (val == null || typeof val !== "object") {
return val; return val;
} }
if (Object.prototype.toString.call(val) === "[object Object]") { const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp = const tmp =
typeof val.constructor === "function" typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val)) ? Object.create(Object.getPrototypeOf(val))
@ -404,7 +419,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
if (depth === 0 && (key === "shape" || key === "canvas")) { if (depth === 0 && (key === "shape" || key === "canvas")) {
continue; continue;
} }
tmp[key] = deepCopyElement(val[key], depth + 1); tmp[key] = _deepCopyElement(val[key], depth + 1);
} }
} }
return tmp; return tmp;
@ -414,14 +429,67 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
let k = val.length; let k = val.length;
const arr = new Array(k); const arr = new Array(k);
while (k--) { while (k--) {
arr[k] = deepCopyElement(val[k], depth + 1); arr[k] = _deepCopyElement(val[k], depth + 1);
} }
return arr; return arr;
} }
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (process.env.NODE_ENV === "development") {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val; return val;
}; };
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
/**
* utility wrapper to generate new id. In test env it reuses the old + postfix
* for test assertions.
*/
const regenerateId = (
/** supply null if no previous id exists */
previousId: string | null,
) => {
if (isTestEnv() && previousId) {
let nextId = `${previousId}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === nextId)
) {
nextId += "_copy";
}
return nextId;
}
return randomId();
};
/** /**
* Duplicate an element, often used in the alt-drag operation. * Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the * Note that this method has gotten a bit complicated since the
@ -436,27 +504,15 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
* @param element Element to duplicate * @param element Element to duplicate
* @param overrides Any element properties to override * @param overrides Any element properties to override
*/ */
export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>( export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"], editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>, groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement, element: TElement,
overrides?: Partial<TElement>, overrides?: Partial<TElement>,
): TElement => { ): Readonly<TElement> => {
let copy: TElement = deepCopyElement(element); let copy = deepCopyElement(element);
if (isTestEnv()) { copy.id = regenerateId(copy.id);
copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === copy.id)
) {
copy.id += "_copy";
}
} else {
copy.id = randomId();
}
copy.boundElements = null; copy.boundElements = null;
copy.updated = getUpdatedTimestamp(); copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger(); copy.seed = randomInteger();
@ -465,7 +521,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
editingGroupId, editingGroupId,
(groupId) => { (groupId) => {
if (!groupIdMapForOperation.has(groupId)) { if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, randomId()); groupIdMapForOperation.set(groupId, regenerateId(groupId));
} }
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
@ -475,3 +531,102 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
return copy; return copy;
}; };
/**
* Clones elements, regenerating their ids (including bindings) and group ids.
*
* If bindings don't exist in the elements array, they are removed. Therefore,
* it's advised to supply the whole elements array, or sets of elements that
* are encapsulated (such as library items), if the purpose is to retain
* bindings to the cloned elements intact.
*/
export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
const clonedElements: ExcalidrawElement[] = [];
const origElementsMap = arrayToMap(elements);
// used for for migrating old ids to new ids
const elementNewIdsMap = new Map<
/* orig */ ExcalidrawElement["id"],
/* new */ ExcalidrawElement["id"]
>();
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
// if we've already migrated the element id, return the new one directly
if (elementNewIdsMap.has(id)) {
return elementNewIdsMap.get(id)!;
}
// if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it
if (origElementsMap.has(id)) {
const newId = regenerateId(id);
elementNewIdsMap.set(id, newId);
return newId;
}
// if old element doesn't exist, return null to mark it for removal
return null;
};
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
for (const element of elements) {
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!;
if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId(groupId));
}
return groupNewIdsMap.get(groupId)!;
});
}
if ("containerId" in clonedElement && clonedElement.containerId) {
const newContainerId = maybeGetNewId(clonedElement.containerId);
clonedElement.containerId = newContainerId;
}
if ("boundElements" in clonedElement && clonedElement.boundElements) {
clonedElement.boundElements = clonedElement.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = maybeGetNewId(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
);
}
if ("endBinding" in clonedElement && clonedElement.endBinding) {
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
clonedElement.endBinding = newEndBindingId
? {
...clonedElement.endBinding,
elementId: newEndBindingId,
}
: null;
}
if ("startBinding" in clonedElement && clonedElement.startBinding) {
const newEndBindingId = maybeGetNewId(
clonedElement.startBinding.elementId,
);
clonedElement.startBinding = newEndBindingId
? {
...clonedElement.startBinding,
elementId: newEndBindingId,
}
: null;
}
clonedElements.push(clonedElement);
}
return clonedElements;
};

View File

@ -39,16 +39,16 @@ import {
import { Point, PointerDownState } from "../types"; import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getMaxContainerWidth,
getApproxMinLineHeight,
measureText, measureText,
getMaxContainerHeight,
} from "./textElement"; } from "./textElement";
import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
@ -192,7 +192,7 @@ const rescalePointsInElement = (
const MIN_FONT_SIZE = 1; const MIN_FONT_SIZE = 1;
const measureFontSizeFromWH = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
@ -214,7 +214,7 @@ const measureFontSizeFromWH = (
const metrics = measureText( const metrics = measureText(
element.text, element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.containerId ? width : null, element.lineHeight,
); );
return { return {
size: nextFontSize, size: nextFontSize,
@ -290,8 +290,8 @@ const resizeSingleTextElement = (
if (scale > 0) { if (scale > 0) {
const nextWidth = element.width * scale; const nextWidth = element.width * scale;
const nextHeight = element.height * scale; const nextHeight = element.height * scale;
const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight); const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
if (nextFont === null) { if (metrics === null) {
return; return;
} }
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@ -315,10 +315,10 @@ const resizeSingleTextElement = (
deltaY2, deltaY2,
); );
mutateElement(element, { mutateElement(element, {
fontSize: nextFont.size, fontSize: metrics.size,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextFont.baseline, baseline: metrics.baseline,
x: nextElementX, x: nextElementX,
y: nextElementY, y: nextElementY,
}); });
@ -427,12 +427,16 @@ export const resizeSingleElement = (
}; };
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const boundTextElementPadding = const updatedElement = {
getBoundTextElementOffset(boundTextElement); ...element,
const nextFont = measureFontSizeFromWH( width: eleNewWidth,
height: eleNewHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement, boundTextElement,
eleNewWidth - boundTextElementPadding * 2, getMaxContainerWidth(updatedElement),
eleNewHeight - boundTextElementPadding * 2, getMaxContainerHeight(updatedElement),
); );
if (nextFont === null) { if (nextFont === null) {
return; return;
@ -442,8 +446,14 @@ export const resizeSingleElement = (
baseline: nextFont.baseline, baseline: nextFont.baseline,
}; };
} else { } else {
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); const minWidth = getApproxMinLineWidth(
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement)); getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
} }
@ -576,8 +586,11 @@ export const resizeSingleElement = (
}); });
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline,
});
} }
handleBindTextResize(element, transformHandleDirection); handleBindTextResize(element, transformHandleDirection);
} }
@ -697,26 +710,34 @@ const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element.latest); const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) { if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2; const updatedElement = {
const textMeasurements = measureFontSizeFromWH( ...element.latest,
width,
height,
};
const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding, boundTextElement
height - optionalPadding, ? getMaxContainerWidth(updatedElement)
: updatedElement.width,
boundTextElement
? getMaxContainerHeight(updatedElement)
: updatedElement.height,
); );
if (!textMeasurements) { if (!metrics) {
return; return;
} }
if (isTextElement(element.orig)) { if (isTextElement(element.orig)) {
update.fontSize = textMeasurements.size; update.fontSize = metrics.size;
update.baseline = textMeasurements.baseline; update.baseline = metrics.baseline;
} }
if (boundTextElement) { if (boundTextElement) {
boundTextUpdates = { boundTextUpdates = {
fontSize: textMeasurements.size, fontSize: metrics.size,
baseline: textMeasurements.baseline, baseline: metrics.baseline,
}; };
} }
} }

View File

@ -1,5 +1,15 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { measureText, wrapText } from "./textElement"; import { API } from "../tests/helpers/api";
import {
computeContainerDimensionForBoundText,
getContainerCoords,
getMaxContainerWidth,
getMaxContainerHeight,
wrapText,
detectLineHeight,
getLineHeightInPx,
getDefaultLineHeight,
} from "./textElement";
import { FontString } from "./types"; import { FontString } from "./types";
describe("Test wrapText", () => { describe("Test wrapText", () => {
@ -9,7 +19,7 @@ describe("Test wrapText", () => {
const text = "Hello whats up "; const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2; const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth); const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello whats up "); expect(res).toBe(text);
}); });
it("should work with emojis", () => { it("should work with emojis", () => {
@ -19,7 +29,7 @@ describe("Test wrapText", () => {
expect(res).toBe("😀"); 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 text = "Hello😀";
const maxWidth = 10; const maxWidth = 10;
const res = wrapText(text, font, maxWidth); const res = wrapText(text, font, maxWidth);
@ -28,13 +38,12 @@ describe("Test wrapText", () => {
describe("When text doesn't contain new lines", () => { describe("When text doesn't contain new lines", () => {
const text = "Hello whats up"; const text = "Hello whats up";
[ [
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 90, width: 80,
res: `Hello res: `Hello \nwhats \nup`,
whats
up`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@ -55,9 +64,8 @@ p`,
{ {
desc: "break words as per the width", desc: "break words as per the width",
width: 150, width: 140,
res: `Hello whats res: `Hello whats \nup`,
up`,
}, },
{ {
desc: "fit the container", desc: "fit the container",
@ -65,6 +73,13 @@ up`,
width: 250, width: 250,
res: "Hello whats up", res: "Hello whats up",
}, },
{
desc: "should push the word if its equal to max width",
width: 60,
res: `Hello
whats
up`,
},
].forEach((data) => { ].forEach((data) => {
it(`should ${data.desc}`, () => { it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
@ -72,16 +87,15 @@ up`,
}); });
}); });
}); });
describe("When text contain new lines", () => { describe("When text contain new lines", () => {
const text = `Hello const text = `Hello
whats up`; whats up`;
[ [
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 90, width: 80,
res: `Hello res: `Hello\nwhats \nup`,
whats
up`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@ -120,17 +134,14 @@ whats up`,
}); });
}); });
}); });
describe("When text is long", () => { describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[ [
{ {
desc: "fit characters of long string as per container width", desc: "fit characters of long string as per container width",
width: 170, width: 170,
res: `hellolongtextth res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
isiswhatsupwith
youIamtypingggg
gandtypinggg
break it now`,
}, },
{ {
@ -149,8 +160,7 @@ now`,
desc: "fit the long text when container width is greater than text length and move the rest to next line", desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600, width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
break it now`,
}, },
].forEach((data) => { ].forEach((data) => {
it(`should ${data.desc}`, () => { it(`should ${data.desc}`, () => {
@ -159,38 +169,176 @@ break it now`,
}); });
}); });
}); });
it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello \nExcalidraw`);
});
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
});
}); });
describe("Test measureText", () => { describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; describe("Test getContainerCoords", () => {
const text = "Hello World"; const params = { width: 200, height: 100, x: 10, y: 20 };
it("should add correct attributes when maxWidth is passed", () => { it("should compute coords correctly when ellipse", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2; const element = API.createElement({
const res = measureText(text, font, maxWidth); type: "ellipse",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 44.2893218813452455,
y: 39.64466094067262,
});
});
expect(res.container).toMatchInlineSnapshot(` it("should compute coords correctly when rectangle", () => {
<div const element = API.createElement({
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;" type: "rectangle",
> ...params,
<span });
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;" expect(getContainerCoords(element)).toEqual({
/> x: 15,
</div> y: 25,
`); });
});
it("should compute coords correctly when diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 65,
y: 50,
});
});
}); });
it("should add correct attributes when maxWidth is not passed", () => { describe("Test computeContainerDimensionForBoundText", () => {
const res = measureText(text, font); const params = {
width: 178,
height: 194,
};
expect(res.container).toMatchInlineSnapshot(` it("should compute container height correctly for rectangle", () => {
<div const element = API.createElement({
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;" type: "rectangle",
> ...params,
<span });
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;" expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
/> 160,
</div> );
`); });
it("should compute container height correctly for ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
226,
);
});
it("should compute container height correctly for diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
320,
);
});
});
describe("Test getMaxContainerWidth", () => {
const params = {
width: 178,
height: 194,
};
it("should return max width when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerWidth(container)).toBe(168);
});
it("should return max width when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerWidth(container)).toBe(116);
});
it("should return max width when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerWidth(container)).toBe(79);
});
});
describe("Test getMaxContainerHeight", () => {
const params = {
width: 178,
height: 194,
};
it("should return max height when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerHeight(container)).toBe(184);
});
it("should return max height when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerHeight(container)).toBe(127);
});
it("should return max height when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerHeight(container)).toBe(87);
});
});
});
const textElement = API.createElement({
type: "text",
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
fontSize: 20,
fontFamily: 1,
height: 175,
});
describe("Test detectLineHeight", () => {
it("should return correct line height", () => {
expect(detectLineHeight(textElement)).toBe(1.25);
});
});
describe("Test getLineHeightInPx", () => {
it("should return correct line height", () => {
expect(
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
).toBe(25);
});
});
describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => {
//@ts-ignore
expect(getDefaultLineHeight()).toBe(1.25);
});
it("should return line height using default font family for unknown font", () => {
const UNKNOWN_FONT = 5;
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
});
it("should return correct line height", () => {
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });

View File

@ -4,20 +4,24 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
FontString, FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { mutateElement } from "./mutateElement"; 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 { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isTextElement } from "."; import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { isBoundToContainer, isArrowElement } from "./typeChecks";
import {
isBoundToContainer,
isImageElement,
isArrowElement,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types"; import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks"; import { isTextBindableContainer } from "./typeChecks";
@ -28,6 +32,7 @@ import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./textWysiwyg"; } from "./textWysiwyg";
import { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => { export const normalizeText = (text: string) => {
return ( return (
@ -39,73 +44,77 @@ export const normalizeText = (text: string) => {
); );
}; };
export const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
let text = textElement.text; const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
baseline: textElement.baseline,
};
boundTextUpdates.text = textElement.text;
if (container) { if (container) {
maxWidth = getMaxContainerWidth(container); maxWidth = getMaxContainerWidth(container);
text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
maxWidth, maxWidth,
); );
} }
const metrics = measureText(text, getFontString(textElement), maxWidth); const metrics = measureText(
let coordY = textElement.y; boundTextUpdates.text,
let coordX = textElement.x; getFontString(textElement),
// Resize container and vertically center align the text textElement.lineHeight,
);
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
if (container) { if (container) {
if (!isArrowElement(container)) { if (isArrowElement(container)) {
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
BOUND_TEXT_PADDING;
} else {
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + BOUND_TEXT_PADDING;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x +
containerDims.width -
metrics.width -
BOUND_TEXT_PADDING;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
mutateElement(container, { height: nextHeight });
} else {
const centerX = textElement.x + textElement.width / 2; const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2; const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width; const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height; const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2; boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2; boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
} else {
const containerDims = getContainerDims(container);
let maxContainerHeight = getMaxContainerHeight(container);
let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerDimensionForBoundText(
metrics.height,
container.type,
);
mutateElement(container, { height: nextHeight });
maxContainerHeight = getMaxContainerHeight(container);
updateOriginalContainerCache(container.id, nextHeight);
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
} }
} }
mutateElement(textElement, {
width: metrics.width, mutateElement(textElement, boundTextUpdates);
height: metrics.height,
baseline: metrics.baseline,
y: coordY,
x: coordX,
text,
});
}; };
export const bindTextToShapeAfterDuplication = ( export const bindTextToShapeAfterDuplication = (
@ -186,18 +195,22 @@ export const handleBindTextResize = (
maxWidth, maxWidth,
); );
} }
const dimensions = measureText( const metrics = measureText(
text, text,
getFontString(textElement), getFontString(textElement),
maxWidth, textElement.lineHeight,
); );
nextHeight = dimensions.height; nextHeight = metrics.height;
nextWidth = dimensions.width; nextWidth = metrics.width;
nextBaseLine = dimensions.baseline; nextBaseLine = metrics.baseline;
} }
// increase height in case text element height exceeds // increase height in case text element height exceeds
if (nextHeight > maxHeight) { if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2; containerHeight = computeContainerDimensionForBoundText(
nextHeight,
container.type,
);
const diff = containerHeight - containerDims.height; const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n // fix the y coord when resizing from ne/nw/n
const updatedY = const updatedY =
@ -217,53 +230,63 @@ export const handleBindTextResize = (
text, text,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseLine, baseline: nextBaseLine,
}); });
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
updateBoundTextPosition( mutateElement(
container, textElement,
textElement as ExcalidrawTextElementWithContainer, computeBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
),
); );
} }
} }
}; };
const updateBoundTextPosition = ( export const computeBoundTextPosition = (
container: ExcalidrawElement, container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
) => { ) => {
const containerDims = getContainerDims(container); if (isArrowElement(container)) {
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement); return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
);
}
const containerCoords = getContainerCoords(container);
const maxContainerHeight = getMaxContainerHeight(container);
const maxContainerWidth = getMaxContainerWidth(container);
let x;
let y; let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding; y = containerCoords.y;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y = y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
} else { } else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2; y =
containerCoords.y +
(maxContainerHeight / 2 - boundTextElement.height / 2);
} }
const x = if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
boundTextElement.textAlign === TEXT_ALIGN.LEFT x = containerCoords.x;
? container.x + boundTextElementPadding } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
? container.x + } else {
containerDims.width - x =
boundTextElement.width - containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
boundTextElementPadding }
: container.x + containerDims.width / 2 - boundTextElement.width / 2; return { x, y };
mutateElement(boundTextElement, { x, y });
}; };
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = ( export const measureText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth?: number | null, lineHeight: ExcalidrawTextElement["lineHeight"],
) => { ) => {
text = text text = text
.split("\n") .split("\n")
@ -271,114 +294,188 @@ export const measureText = (
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
const baseline = measureBaseline(text, font, lineHeight);
return { width, height, baseline };
};
export const measureBaseline = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
wrapInContainer?: boolean,
) => {
const container = document.createElement("div"); const container = document.createElement("div");
container.style.position = "absolute"; container.style.position = "absolute";
container.style.whiteSpace = "pre"; container.style.whiteSpace = "pre";
container.style.font = font; container.style.font = font;
container.style.minHeight = "1em"; container.style.minHeight = "1em";
if (wrapInContainer) {
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
// since we are adding a span of width 1px later
container.style.maxWidth = `${maxWidth + 1}px`;
container.style.overflow = "hidden"; container.style.overflow = "hidden";
container.style.wordBreak = "break-word"; container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
container.style.whiteSpace = "pre-wrap"; container.style.whiteSpace = "pre-wrap";
} }
document.body.appendChild(container);
container.style.lineHeight = String(lineHeight);
container.innerText = text; container.innerText = text;
// Baseline is important for positioning text on canvas
document.body.appendChild(container);
const span = document.createElement("span"); const span = document.createElement("span");
span.style.display = "inline-block"; span.style.display = "inline-block";
span.style.overflow = "hidden"; span.style.overflow = "hidden";
span.style.width = "1px"; span.style.width = "1px";
span.style.height = "1px"; span.style.height = "1px";
container.appendChild(span); container.appendChild(span);
// Baseline is important for positioning text on canvas let baseline = span.offsetTop + span.offsetHeight;
const baseline = span.offsetTop + span.offsetHeight;
// since we are adding a span of width 1px
const width = container.offsetWidth + 1;
const height = container.offsetHeight; const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) { if (isSafari) {
return { width, height, baseline, container }; const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
const fontSize = parseFloat(font);
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
// from the actual canvas height
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
if (canvasHeight > height) {
baseline += canvasHeight - domHeight;
}
if (height > canvasHeight) {
baseline -= domHeight - canvasHeight;
}
} }
return { width, height, baseline }; document.body.removeChild(container);
return baseline;
}; };
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); /**
const cacheApproxLineHeight: { [key: FontString]: number } = {}; * To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
export const getApproxLineHeight = (font: FontString) => { /**
if (cacheApproxLineHeight[font]) { * We calculate the line height from the font size and the unitless line height,
return cacheApproxLineHeight[font]; * aligning with the W3C spec.
} */
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height; export const getLineHeightInPx = (
return cacheApproxLineHeight[font]; fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
}; };
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => { const getLineWidth = (text: string, font: FontString) => {
if (!canvas) { if (!canvas) {
canvas = document.createElement("canvas"); canvas = document.createElement("canvas");
} }
const canvas2dContext = canvas.getContext("2d")!; const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font; canvas2dContext.font = font;
const width = canvas2dContext.measureText(text).width;
const metrics = canvas2dContext.measureText(text);
// since in test env the canvas measureText algo // since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of // doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px // characters hence we assume that each letteris 10px
if (isTestEnv()) { if (isTestEnv()) {
return metrics.width * 10; return width * 10;
} }
// Since measureText behaves differently in different browsers return width;
// OS so considering a adjustment factor of 0.2
const adjustmentFactor = 0.2;
return metrics.width + adjustmentFactor;
}; };
export const getTextWidth = (text: string, font: FontString) => { export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n"); const lines = splitIntoLines(text);
let width = 0; let width = 0;
lines.forEach((line) => { lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font)); width = Math.max(width, getLineWidth(line, font));
}); });
return width; return width;
}; };
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => { 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 lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font); const spaceWidth = getLineWidth(" ", font);
let currentLine = "";
let currentLineWidthTillNow = 0;
const push = (str: string) => { const push = (str: string) => {
if (str.trim()) { if (str.trim()) {
lines.push(str); lines.push(str);
} }
}; };
const resetParams = () => {
currentLine = "";
currentLineWidthTillNow = 0;
};
originalLines.forEach((originalLine) => { originalLines.forEach((originalLine) => {
const words = originalLine.split(" "); const currentLineWidth = getTextWidth(originalLine, font);
// This means its newline so push it
if (words.length === 1 && words[0] === "") { //Push the line if its <= maxWidth
lines.push(words[0]); if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
return; // continue return; // continue
} }
let currentLine = ""; const words = originalLine.split(" ");
let currentLineWidthTillNow = 0;
resetParams();
let index = 0; let index = 0;
while (index < words.length) { while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font); const currentWordWidth = getLineWidth(words[index], font);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
push(words[index]);
index++;
}
// Start breaking longer words exceeding max width // Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) { else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width // push current line since the current word exceeds the max width
// so will be appended in next line // so will be appended in next line
push(currentLine); push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0; resetParams();
while (words[index].length > 0) { while (words[index].length > 0) {
const currentChar = String.fromCodePoint( const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!, words[index].codePointAt(0)!,
@ -388,10 +485,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
words[index] = words[index].slice(currentChar.length); words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) { 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); push(currentLine);
currentLine = currentChar; currentLine = currentChar;
currentLineWidthTillNow = width; currentLineWidthTillNow = width;
@ -399,11 +492,11 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
currentLine += currentChar; currentLine += currentChar;
} }
} }
// push current line if appending space exceeds max width // push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine); push(currentLine);
currentLine = ""; resetParams();
currentLineWidthTillNow = 0;
} else { } else {
// space needs to be appended before next word // space needs to be appended before next word
// as currentLine contains chars which couldn't be appended // as currentLine contains chars which couldn't be appended
@ -411,7 +504,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
currentLine += " "; currentLine += " ";
currentLineWidthTillNow += spaceWidth; currentLineWidthTillNow += spaceWidth;
} }
index++; index++;
} else { } else {
// Start appending words in a line till max width reached // Start appending words in a line till max width reached
@ -419,10 +511,9 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const word = words[index]; const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font); currentLineWidthTillNow = getLineWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) { if (currentLineWidthTillNow > maxWidth) {
push(currentLine); push(currentLine);
currentLineWidthTillNow = 0; resetParams();
currentLine = "";
break; break;
} }
@ -433,22 +524,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1); const word = currentLine.slice(0, -1);
push(word); push(word);
currentLine = ""; resetParams();
currentLineWidthTillNow = 0;
break; 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 // 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); push(currentLine);
} }
}); });
@ -479,22 +563,24 @@ export const charWidth = (() => {
getCache, getCache,
}; };
})(); })();
export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font);
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) { if (maxCharWidth === 0) {
return ( return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2 BOUND_TEXT_PADDING * 2
); );
} }
return maxCharWidth + 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) => { export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font); const cache = charWidth.getCache(font);
if (!cache) { if (!cache) {
@ -621,6 +707,26 @@ export const getContainerCenter = (
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
}; };
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
}
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
if (container.type === "diamond") {
offsetX += container.width / 4;
offsetY += container.height / 4;
}
return {
x: container.x + offsetX,
y: container.y + offsetY,
};
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) { if (!container || isArrowElement(container)) {
@ -633,12 +739,13 @@ export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null, boundTextElement: ExcalidrawTextElement | null,
) => { ) => {
const container = getContainerElement(boundTextElement); const container = getContainerElement(boundTextElement);
if (!container) { if (!container || !boundTextElement) {
return 0; return 0;
} }
if (isArrowElement(container)) { if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8; return BOUND_TEXT_PADDING * 8;
} }
return BOUND_TEXT_PADDING; return BOUND_TEXT_PADDING;
}; };
@ -666,14 +773,24 @@ export const shouldAllowVerticalAlign = (
} }
return true; return true;
} }
const boundTextElement = getBoundTextElement(element); return false;
if (boundTextElement) { });
if (isArrowElement(element)) { };
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 false;
} }
return true; return true;
} }
return false;
return isTextElement(element);
}); });
}; };
@ -714,12 +831,127 @@ export const getTextBindableContainerAtPosition = (
return isTextBindableContainer(hitElement, false) ? hitElement : null; return isTextBindableContainer(hitElement, false) ? hitElement : null;
}; };
export const isValidTextContainer = (element: ExcalidrawElement) => { const VALID_CONTAINER_TYPES = new Set([
return ( "rectangle",
element.type === "rectangle" || "ellipse",
element.type === "ellipse" || "diamond",
element.type === "diamond" || "image",
isImageElement(element) || "arrow",
isArrowElement(element) ]);
);
export const isValidTextContainer = (element: ExcalidrawElement) =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
) => {
dimension = Math.ceil(dimension);
const padding = BOUND_TEXT_PADDING * 2;
if (containerType === "ellipse") {
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
}
if (containerType === "arrow") {
return dimension + padding * 8;
}
if (containerType === "diamond") {
return 2 * (dimension + padding);
}
return dimension + padding;
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
if (container.type === "ellipse") {
// The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The width of the largest rectangle inscribed inside a rhombus is
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
if (container.type === "ellipse") {
// The height of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The height of the largest rectangle inscribed inside a rhombus is
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
}
return height - BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
/**
* Unitless line height
*
* In previous versions we used `normal` line height, which browsers interpret
* differently, and based on font-family and font-size.
*
* To make line heights consistent across browsers we hardcode the values for
* each of our fonts based on most common average line-heights.
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
* where the values come from.
*/
const DEFAULT_LINE_HEIGHT = {
// ~1.25 is the average for Virgil in WebKit and Blink.
// Gecko (FF) uses ~1.28.
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
// ~1.15 is the average for Virgil in WebKit and Blink.
// Gecko if all over the place.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily in DEFAULT_LINE_HEIGHT) {
return DEFAULT_LINE_HEIGHT[fontFamily];
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
}; };

View File

@ -3,19 +3,23 @@ import ExcalidrawApp from "../excalidraw-app";
import { GlobalTestState, render, screen } from "../tests/test-utils"; import { GlobalTestState, render, screen } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { fireEvent } from "../tests/test-utils"; import {
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
} from "../tests/test-utils";
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import * as textElementUtils from "./textElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils"; import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -222,11 +226,19 @@ describe("textWysiwyg", () => {
describe("Test container-unbound text", () => { describe("Test container-unbound text", () => {
const { h } = window; const { h } = window;
const dimensions = { height: 400, width: 800 };
let textarea: HTMLTextAreaElement; let textarea: HTMLTextAreaElement;
let textElement: ExcalidrawTextElement; let textElement: ExcalidrawTextElement;
beforeAll(() => {
mockBoundingClientRect(dimensions);
});
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
//@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
textElement = UI.createElement("text"); textElement = UI.createElement("text");
@ -236,6 +248,10 @@ describe("textWysiwyg", () => {
)!; )!;
}); });
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should add a tab at the start of the first line", () => { it("should add a tab at the start of the first line", () => {
const event = new KeyboardEvent("keydown", { key: KEYS.TAB }); const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
textarea.value = "Line#1\nLine#2"; textarea.value = "Line#1\nLine#2";
@ -434,23 +450,33 @@ describe("textWysiwyg", () => {
); );
expect(h.state.zoom.value).toBe(1); expect(h.state.zoom.value).toBe(1);
}); });
it("text should never go beyond max width", async () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
textarea = document.querySelector(
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.change(textarea, {
target: {
value:
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
},
});
textarea.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
textarea.blur();
expect(textarea.style.width).toBe("792px");
expect(h.elements[0].width).toBe(1000);
});
}); });
describe("Test container-bound text", () => { describe("Test container-bound text", () => {
let rectangle: any; let rectangle: any;
const { h } = window; const { h } = window;
const DUMMY_HEIGHT = 240;
const DUMMY_WIDTH = 160;
const APPROX_LINE_HEIGHT = 25;
const INITIAL_WIDTH = 10;
beforeAll(() => {
jest
.spyOn(textElementUtils, "getApproxLineHeight")
.mockReturnValue(APPROX_LINE_HEIGHT);
});
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
h.elements = []; h.elements = [];
@ -500,6 +526,44 @@ describe("textWysiwyg", () => {
]); ]);
}); });
it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => {
const diamond = API.createElement({
type: "diamond",
x: 10,
y: 20,
width: 90,
height: 75,
});
h.elements = [diamond];
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(diamond.id);
API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n");
// Pasting large text to simulate height increase
expect(() =>
fireEvent.input(editor, { target: { value } }),
).not.toThrow();
expect(diamond.height).toBe(50020);
// Clearing text to simulate height decrease
expect(() =>
fireEvent.input(editor, { target: { value: "" } }),
).not.toThrow();
expect(diamond.height).toBe(70);
});
it("should bind text to container when double clicked on center of transparent container", async () => { it("should bind text to container when double clicked on center of transparent container", async () => {
const rectangle = API.createElement({ const rectangle = API.createElement({
type: "rectangle", type: "rectangle",
@ -643,11 +707,11 @@ describe("textWysiwyg", () => {
["freedraw", "line"].forEach((type: any) => { ["freedraw", "line"].forEach((type: any) => {
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => { it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
h.elements = []; h.elements = [];
const elemnet = UI.createElement(type, { const element = UI.createElement(type, {
width: 100, width: 100,
height: 50, height: 50,
}); });
API.setSelectedElements([elemnet]); API.setSelectedElements([element]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
@ -676,6 +740,52 @@ describe("textWysiwyg", () => {
expect(rectangle.boundElements).toBe(null); expect(rectangle.boundElements).toBe(null);
}); });
it("should bind text to container when triggered via context menu", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Excalidraw is an opensource virtual collaborative whiteboard",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
expect(h.elements.length).toBe(2);
expect(h.elements[1].type).toBe("text");
API.setSelectedElements([h.elements[0], h.elements[1]]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
expect(text.containerId).toBe(rectangle.id);
expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE);
expect(text.textAlign).toBe(TEXT_ALIGN.CENTER);
expect(text.x).toBe(
h.elements[0].x + h.elements[0].width / 2 - text.width / 2,
);
expect(text.y).toBe(
h.elements[0].y + h.elements[0].height / 2 - text.height / 2,
);
});
it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => { it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
@ -732,39 +842,6 @@ describe("textWysiwyg", () => {
}); });
it("should wrap text and vertcially center align once text submitted", async () => { it("should wrap text and vertcially center align once text submitted", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
if (text === "Hello \nWorld!") {
height = APPROX_LINE_HEIGHT * 2;
}
if (maxWidth) {
width = maxWidth;
// To capture cases where maxWidth passed is initial width
// due to which the text is not wrapped correctly
if (maxWidth === INITIAL_WIDTH) {
height = DUMMY_HEIGHT;
}
}
return {
width,
height,
baseline,
};
});
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
Keyboard.keyDown(KEYS.ENTER); Keyboard.keyDown(KEYS.ENTER);
@ -773,11 +850,6 @@ describe("textWysiwyg", () => {
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
fireEvent.change(editor, { fireEvent.change(editor, {
target: { target: {
value: "Hello World!", value: "Hello World!",
@ -792,11 +864,11 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello \nWorld!"); expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!"); expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
); );
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); expect(text.x).toBe(25);
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2); expect(text.height).toBe(50);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); expect(text.width).toBe(60);
// Edit and text by removing second line and it should // Edit and text by removing second line and it should
// still vertically align correctly // still vertically align correctly
@ -813,11 +885,6 @@ describe("textWysiwyg", () => {
}, },
}); });
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT);
editor.style.height = "25px";
editor.dispatchEvent(new Event("input")); editor.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -827,12 +894,12 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello"); expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello"); expect(text.originalText).toBe("Hello");
expect(text.height).toBe(25);
expect(text.width).toBe(50);
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
); );
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); expect(text.x).toBe(30);
expect(text.height).toBe(APPROX_LINE_HEIGHT);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
}); });
it("should unbind bound text when unbind action from context menu is triggered", async () => { it("should unbind bound text when unbind action from context menu is triggered", async () => {
@ -919,8 +986,8 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
109.5, 85,
17, 4.5,
] ]
`); `);
@ -934,6 +1001,8 @@ describe("textWysiwyg", () => {
editor.select(); editor.select();
fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -944,7 +1013,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
90, 65,
] ]
`); `);
@ -967,7 +1036,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
424, 375,
-539, -539,
] ]
`); `);
@ -1082,9 +1151,9 @@ describe("textWysiwyg", () => {
mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80); expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85); expect(rectangle.y).toBe(-40);
expect(text.x).toBe(89.5); expect(text.x).toBe(85);
expect(text.y).toBe(90); expect(text.y).toBe(-35);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z);
@ -1114,29 +1183,6 @@ describe("textWysiwyg", () => {
}); });
it("should restore original container height and clear cache once text is unbind", async () => { it("should restore original container height and clear cache once text is unbind", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
});
const originalRectHeight = rectangle.height; const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight); expect(rectangle.height).toBe(originalRectHeight);
@ -1150,7 +1196,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" }, target: { value: "Online whiteboard collaboration made easy" },
}); });
editor.blur(); editor.blur();
expect(rectangle.height).toBe(135); expect(rectangle.height).toBe(185);
mouse.select(rectangle); mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
@ -1176,7 +1222,7 @@ describe("textWysiwyg", () => {
editor.blur(); editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215); expect(rectangle.height).toBe(156);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle); mouse.select(rectangle);
@ -1188,13 +1234,12 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
expect(rectangle.height).toBe(215); expect(rectangle.height).toBe(156);
// cache updated again // cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
}); });
//@todo fix this test later once measureText is mocked correctly it("should reset the container height cache when font properties updated", async () => {
it.skip("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
@ -1220,7 +1265,42 @@ describe("textWysiwyg", () => {
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36); ).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
});
it("should update line height when font family updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); 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", () => { describe("should align correctly", () => {
@ -1248,7 +1328,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
20, 25,
] ]
`); `);
}); });
@ -1258,8 +1338,8 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top")); fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
94.5, 30,
20, 25,
] ]
`); `);
}); });
@ -1269,22 +1349,22 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top")); fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 45,
20, 25,
] ]
`); `);
}); });
it("when center left", async () => { it("when center left", async () => {
fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Center vertically"));
fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Left"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
25, 45,
] ]
`); `);
}); });
it("when center center", async () => { it("when center center", async () => {
@ -1292,11 +1372,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
-25, 30,
25, 45,
] ]
`); `);
}); });
it("when center right", async () => { it("when center right", async () => {
@ -1304,11 +1384,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 45,
25, 45,
] ]
`); `);
}); });
it("when bottom left", async () => { it("when bottom left", async () => {
@ -1316,34 +1396,120 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
25, 65,
] ]
`); `);
}); });
it("when bottom center", async () => { it("when bottom center", async () => {
fireEvent.click(screen.getByTitle("Center")); fireEvent.click(screen.getByTitle("Center"));
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
94.5, 30,
25, 65,
] ]
`); `);
}); });
it("when bottom right", async () => { it("when bottom right", async () => {
fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Right"));
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 45,
25, 65,
] ]
`); `);
}); });
}); });
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Excalidraw is an opensource virtual collaborative whiteboard",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600);
expect(textElement.height).toBe(25);
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
expect((textElement as ExcalidrawTextElement).text).toBe(
"Excalidraw is an opensource virtual collaborative whiteboard",
);
API.setSelectedElements([textElement]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Wrap text in a container")!,
);
expect(h.elements.length).toBe(3);
expect(h.elements[1]).toEqual(
expect.objectContaining({
angle: 0,
backgroundColor: "transparent",
boundElements: [
{
id: h.elements[2].id,
type: "text",
},
],
fillStyle: "hachure",
groupIds: [],
height: 35,
isDeleted: false,
link: null,
locked: false,
opacity: 100,
roughness: 1,
roundness: {
type: 3,
},
strokeColor: "#000000",
strokeStyle: "solid",
strokeWidth: 1,
type: "rectangle",
updated: 1,
version: 1,
width: 610,
x: 15,
y: 25,
}),
);
expect(h.elements[2] as ExcalidrawTextElement).toEqual(
expect.objectContaining({
text: "Excalidraw is an opensource virtual collaborative whiteboard",
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
boundElements: null,
}),
);
});
}); });
}); });

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants"; import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -22,15 +22,20 @@ import {
import { AppState } from "../types"; import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset, getContainerCoords,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
measureText,
normalizeText, normalizeText,
redrawTextBoundingBox,
wrapText, wrapText,
getMaxContainerHeight,
getMaxContainerWidth,
computeContainerDimensionForBoundText,
detectLineHeight,
} from "./textElement"; } from "./textElement";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
@ -38,7 +43,6 @@ import {
} from "../actions/actionProperties"; } from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App"; import App from "../components/App";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
@ -147,9 +151,7 @@ export const textWysiwyg = ({
return; return;
} }
const { textAlign, verticalAlign } = updatedTextElement; const { textAlign, verticalAlign } = updatedTextElement;
const approxLineHeight = getApproxLineHeight(
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) { if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x; let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y; let coordY = updatedTextElement.y;
@ -157,7 +159,7 @@ export const textWysiwyg = ({
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height; let maxHeight = updatedTextElement.height;
const width = updatedTextElement.width; let textElementWidth = updatedTextElement.width;
// Set to element height by default since that's // Set to element height by default since that's
// what is going to be used for unbounded text // what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height; let textElementHeight = updatedTextElement.height;
@ -208,11 +210,12 @@ export const textWysiwyg = ({
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) { if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min( const targetContainerHeight = computeContainerDimensionForBoundText(
textElementHeight - maxHeight, textElementHeight,
approxLineHeight, container.type,
); );
mutateElement(container, { height: containerDims.height + diff });
mutateElement(container, { height: targetContainerHeight });
return; return;
} else if ( } else if (
// autoshrink container height until original container height // autoshrink container height until original container height
@ -221,28 +224,26 @@ export const textWysiwyg = ({
containerDims.height > originalContainerData.height && containerDims.height > originalContainerData.height &&
textElementHeight < maxHeight textElementHeight < maxHeight
) { ) {
const diff = Math.min( const targetContainerHeight = computeContainerDimensionForBoundText(
maxHeight - textElementHeight, textElementHeight,
approxLineHeight, container.type,
); );
mutateElement(container, { height: containerDims.height - diff }); mutateElement(container, { height: targetContainerHeight });
} }
// Start pushing text upward until a diff of 30px (padding) // Start pushing text upward until a diff of 30px (padding)
// is reached // is reached
else { else {
const containerCoords = getContainerCoords(container);
// vertically center align the text // vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
coordY = coordY =
container.y + containerDims.height / 2 - textElementHeight / 2; containerCoords.y + maxHeight / 2 - textElementHeight / 2;
} }
} }
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY = coordY = containerCoords.y + (maxHeight - textElementHeight);
container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
} }
} }
} }
@ -265,12 +266,21 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff; editable.selectionEnd = editable.value.length - diff;
} }
const lines = updatedTextElement.originalText.split("\n");
const lineHeight = updatedTextElement.containerId
? approxLineHeight
: updatedTextElement.height / lines.length;
if (!container) { if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
}
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
if (isSafari) {
lineHeight = detectLineHeight({
...updatedTextElement,
fontSize: Math.round(updatedTextElement.fontSize),
});
} }
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
@ -279,13 +289,13 @@ export const textWysiwyg = ({
Object.assign(editable.style, { Object.assign(editable.style, {
font: getFontString(updatedTextElement), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`, lineHeight,
width: `${Math.min(width, maxWidth)}px`, width: `${textElementWidth}px`,
height: `${textElementHeight}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
transform: getTransform( transform: getTransform(
width, textElementWidth,
textElementHeight, textElementHeight,
getTextElementAngle(updatedTextElement), getTextElementAngle(updatedTextElement),
appState, appState,
@ -299,6 +309,7 @@ export const textWysiwyg = ({
filter: "var(--theme-filter)", filter: "var(--theme-filter)",
maxHeight: `${editorMaxHeight}px`, maxHeight: `${editorMaxHeight}px`,
}); });
editable.scrollTop = 0;
// For some reason updating font attribute doesn't set font family // For some reason updating font attribute doesn't set font family
// hence updating font family explicitly for test environment // hence updating font family explicitly for test environment
if (isTestEnv()) { if (isTestEnv()) {
@ -378,55 +389,20 @@ export const textWysiwyg = ({
id, id,
) as ExcalidrawTextElement; ) as ExcalidrawTextElement;
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
// using scrollHeight here since we need to calculate if (isBoundToContainer(element)) {
// number of lines so cannot use editable.style.height
// as that gets updated below
// Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
// auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for
// first line as well as setting height to "auto"
// doubles the height as soon as user starts typing
if (isBoundToContainer(element) && lines > 1) {
const container = getContainerElement(element); const container = getContainerElement(element);
let height = "auto";
editable.style.height = "0px";
let heightSet = false;
if (lines === 2) {
const actualLineCount = wrapText(
editable.value,
font,
getMaxContainerWidth(container!),
).split("\n").length;
// This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well
// hence reducing the height by half if actual line count is 1
// so single line aligns vertically when deleting
if (actualLineCount === 1) {
height = `${editable.scrollHeight / 2}px`;
editable.style.height = height;
heightSet = true;
}
}
const wrappedText = wrapText( const wrappedText = wrapText(
normalizeText(editable.value), normalizeText(editable.value),
font, font,
getMaxContainerWidth(container!), getMaxContainerWidth(container!),
); );
const width = getTextWidth(wrappedText, font); const { width, height } = measureText(
wrappedText,
font,
updatedTextElement.lineHeight,
);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
editable.style.height = `${height}px`;
if (!heightSet) {
editable.style.height = `${editable.scrollHeight}px`;
}
} }
onChange(normalizeText(editable.value)); onChange(normalizeText(editable.value));
}; };
@ -463,7 +439,9 @@ export const textWysiwyg = ({
event.code === CODES.BRACKET_RIGHT)) event.code === CODES.BRACKET_RIGHT))
) { ) {
event.preventDefault(); event.preventDefault();
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { if (event.isComposing) {
return;
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
outdent(); outdent();
} else { } else {
indent(); indent();
@ -612,6 +590,7 @@ export const textWysiwyg = ({
), ),
}); });
} }
redrawTextBoundingBox(updateElement, container);
} }
onSubmit({ onSubmit({

View File

@ -0,0 +1,66 @@
import { API } from "../tests/helpers/api";
import { hasBoundTextElement } from "./typeChecks";
describe("Test TypeChecks", () => {
describe("Test hasBoundTextElement", () => {
it("should return true for text bindable containers with bound text", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "rectangle",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "ellipse",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "arrow",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
});
it("should return false for text bindable containers without bound text", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "freedraw",
boundElements: [{ type: "arrow", id: "arrow-id" }],
}),
),
).toBeFalsy();
});
it("should return false for non text bindable containers", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "freedraw",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
});

View File

@ -1,5 +1,6 @@
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { AppState } from "../types"; import { AppState } from "../types";
import { MarkNonNullable } from "../utility-types";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@ -139,7 +140,7 @@ export const hasBoundTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => { ): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
return ( return (
isBindableElement(element) && isTextBindableContainer(element) &&
!!element.boundElements?.some(({ type }) => type === "text") !!element.boundElements?.some(({ type }) => type === "text")
); );
}; };

View File

@ -6,9 +6,10 @@ import {
THEME, THEME,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { MarkNonNullable, ValueOf } from "../utility-types";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
export type Theme = typeof THEME[keyof typeof THEME]; export type Theme = typeof THEME[keyof typeof THEME];
@ -135,6 +136,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;
originalText: string; 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 = export type ExcalidrawBindableElement =

View File

@ -0,0 +1,3 @@
import { unstable_createStore } from "jotai";
export const appJotaiStore = unstable_createStore();

View File

@ -70,7 +70,7 @@ import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiStore } from "../../jotai"; import { appJotaiStore } from "../app-jotai";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false); export const collabDialogShownAtom = atom(false);
@ -167,7 +167,7 @@ class Collab extends PureComponent<Props, CollabState> {
setUsername: this.setUsername, setUsername: this.setUsername,
}; };
jotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
this.onOfflineStatusToggle(); this.onOfflineStatusToggle();
if ( if (
@ -185,7 +185,7 @@ class Collab extends PureComponent<Props, CollabState> {
} }
onOfflineStatusToggle = () => { onOfflineStatusToggle = () => {
jotaiStore.set(isOfflineAtom, !window.navigator.onLine); appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
}; };
componentWillUnmount() { componentWillUnmount() {
@ -208,10 +208,10 @@ class Collab extends PureComponent<Props, CollabState> {
} }
} }
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!; isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
private setIsCollaborating = (isCollaborating: boolean) => { private setIsCollaborating = (isCollaborating: boolean) => {
jotaiStore.set(isCollaboratingAtom, isCollaborating); appJotaiStore.set(isCollaboratingAtom, isCollaborating);
}; };
private onUnload = () => { private onUnload = () => {
@ -804,7 +804,7 @@ class Collab extends PureComponent<Props, CollabState> {
); );
handleClose = () => { handleClose = () => {
jotaiStore.set(collabDialogShownAtom, false); appJotaiStore.set(collabDialogShownAtom, false);
}; };
setUsername = (username: string) => { setUsername = (username: string) => {
@ -838,10 +838,9 @@ class Collab extends PureComponent<Props, CollabState> {
/> />
)} )}
{errorMessage && ( {errorMessage && (
<ErrorDialog <ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
message={errorMessage} {errorMessage}
onClose={() => this.setState({ errorMessage: "" })} </ErrorDialog>
/>
)} )}
</> </>
); );

View File

@ -10,13 +10,13 @@ import {
shareWindows, shareWindows,
} from "../../components/icons"; } from "../../components/icons";
import { ToolButton } from "../../components/ToolButton"; import { ToolButton } from "../../components/ToolButton";
import { t } from "../../i18n";
import "./RoomDialog.scss"; import "./RoomDialog.scss";
import Stack from "../../components/Stack"; import Stack from "../../components/Stack";
import { AppState } from "../../types"; import { AppState } from "../../types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils"; import { getFrame } from "../../utils";
import DialogActionButton from "../../components/DialogActionButton"; import DialogActionButton from "../../components/DialogActionButton";
import { useI18n } from "../../i18n";
const getShareIcon = () => { const getShareIcon = () => {
const navigator = window.navigator as any; const navigator = window.navigator as any;
@ -51,6 +51,7 @@ const RoomDialog = ({
setErrorMessage: (message: string) => void; setErrorMessage: (message: string) => void;
theme: AppState["theme"]; theme: AppState["theme"];
}) => { }) => {
const { t } = useI18n();
const roomLinkInput = useRef<HTMLInputElement>(null); const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => { const copyRoomLink = async () => {

View File

@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../components/icons"; import { PlusPromoIcon } from "../../components/icons";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index"; import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
}> = React.memo((props) => { }> = React.memo((props) => {
const { t } = useI18n();
let headingContent; let headingContent;
if (isExcalidrawPlusSignedUser) { if (isExcalidrawPlusSignedUser) {

View File

@ -1,17 +1,21 @@
import { shield } from "../../components/icons"; import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip"; import { Tooltip } from "../../components/Tooltip";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
export const EncryptedIcon = () => ( export const EncryptedIcon = () => {
<a const { t } = useI18n();
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/" return (
target="_blank" <a
rel="noopener noreferrer" className="encrypted-icon tooltip"
aria-label={t("encrypted.link")} href="https://blog.excalidraw.com/end-to-end-encryption/"
> target="_blank"
<Tooltip label={t("encrypted.tooltip")} long={true}> rel="noopener noreferrer"
{shield} aria-label={t("encrypted.link")}
</Tooltip> >
</a> <Tooltip label={t("encrypted.tooltip")} long={true}>
); {shield}
</Tooltip>
</a>
);
};

View File

@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types"; import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { excalidrawPlusIcon } from "./icons"; import { excalidrawPlusIcon } from "./icons";
import { encryptData, generateEncryptionKey } from "../../data/encryption"; import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../element/typeChecks";
@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{
files: BinaryFiles; files: BinaryFiles;
onError: (error: Error) => void; onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => { }> = ({ elements, appState, files, onError }) => {
const { t } = useI18n();
return ( return (
<Card color="primary"> <Card color="primary">
<div className="Card-icon">{excalidrawPlusIcon}</div> <div className="Card-icon">{excalidrawPlusIcon}</div>

View File

@ -1,22 +1,23 @@
import { useAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { langCodeAtom } from ".."; import { appLangCodeAtom } from "..";
import * as i18n from "../../i18n"; import { defaultLang, useI18n } from "../../i18n";
import { languages } from "../../i18n"; import { languages } from "../../i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const [langCode, setLangCode] = useAtom(langCodeAtom); const { t, langCode } = useI18n();
const setLangCode = useSetAtom(appLangCodeAtom);
return ( return (
<select <select
className="dropdown-select dropdown-select__language" className="dropdown-select dropdown-select__language"
onChange={({ target }) => setLangCode(target.value)} onChange={({ target }) => setLangCode(target.value)}
value={langCode} value={langCode}
aria-label={i18n.t("buttons.selectLanguage")} aria-label={t("buttons.selectLanguage")}
style={style} style={style}
> >
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}> <option key={defaultLang.code} value={defaultLang.code}>
{i18n.defaultLang.label} {defaultLang.label}
</option> </option>
{languages.map((lang) => ( {languages.map((lang) => (
<option key={lang.code} value={lang.code}> <option key={lang.code} value={lang.code}>

View File

@ -14,6 +14,7 @@ import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation"; import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from "."; import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../utility-types";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@ -263,7 +263,7 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ repairBindings: true }, { repairBindings: true, refreshDimensions: true },
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {

View File

@ -75,15 +75,17 @@ import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData"; import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx"; import clsx from "clsx";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation"; import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss"; import "./index.scss";
import { ResolutionType } from "../utility-types";
polyfill(); polyfill();
@ -226,15 +228,15 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false }; return { scene: null, isExternalScene: false };
}; };
const currentLangCode = languageDetector.detect() || defaultLang.code; const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
export const langCodeAtom = atom( Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
Array.isArray(currentLangCode) ? currentLangCode[0] : currentLangCode,
); );
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(langCodeAtom); const [langCode, setLangCode] = useAtom(appLangCodeAtom);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -671,10 +673,9 @@ const ExcalidrawWrapper = () => {
</Excalidraw> </Excalidraw>
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
{errorMessage && ( {errorMessage && (
<ErrorDialog <ErrorDialog onClose={() => setErrorMessage("")}>
message={errorMessage} {errorMessage}
onClose={() => setErrorMessage("")} </ErrorDialog>
/>
)} )}
</div> </div>
); );
@ -683,7 +684,7 @@ const ExcalidrawWrapper = () => {
const ExcalidrawApp = () => { const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => jotaiStore}> <Provider unstable_createStore={() => appJotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawWrapper />
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>

49
src/global.d.ts vendored
View File

@ -18,6 +18,8 @@ interface Window {
EXCALIDRAW_EXPORT_SOURCE: string; EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined; EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
gtag: Function; gtag: Function;
_paq: any[];
_mtm: any[];
} }
interface CanvasRenderingContext2D { interface CanvasRenderingContext2D {
@ -50,36 +52,6 @@ interface Clipboard extends EventTarget {
write(data: any[]): Promise<void>; write(data: any[]): Promise<void>;
} }
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type ValueOf<T> = T[keyof T];
type Merge<M, N> = Omit<M, keyof N> & N;
/** utility type to assert that the second type is a subtype of the first type.
* Returns the subtype. */
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
type ResolutionType<T extends (...args: any) => any> = T extends (
...args: any
) => Promise<infer R>
? R
: any;
// https://github.com/krzkaczor/ts-essentials
type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
Required<Pick<T, RK>>;
type MarkNonNullable<T, K extends keyof T> = {
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
} & { [P in keyof T]: T[P] };
type NonOptional<T> = Exclude<T, undefined>;
// PNG encoding/decoding // PNG encoding/decoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array }; type TEXtChunk = { name: "tEXt"; data: Uint8Array };
@ -101,23 +73,6 @@ declare module "png-chunks-extract" {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// type getter for interface's callable type
// src: https://stackoverflow.com/a/58658851/927631
// -----------------------------------------------------------------------------
type SignatureType<T> = T extends (...args: infer R) => any ? R : never;
type CallableType<T extends (...args: any[]) => any> = (
...args: SignatureType<T>
) => ReturnType<T>;
// --------------------------------------------------------------------------—
// Type for React.forwardRef --- supply only the first generic argument T
type ForwardRef<T, P = any> = Parameters<
CallableType<React.ForwardRefRenderFunction<T, P>>
>[1];
// --------------------------------------------------------------------------—
interface Blob { interface Blob {
handle?: import("browser-fs-acces").FileSystemHandle; handle?: import("browser-fs-acces").FileSystemHandle;
name?: string; name?: string;

View File

@ -2,6 +2,7 @@ import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks"; import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement"; import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
export interface HistoryEntry { export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>; appState: ReturnType<typeof clearAppStatePropertiesForHistory>;

View File

@ -1,6 +1,8 @@
import fallbackLangData from "./locales/en.json"; import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json"; import percentages from "./locales/percentages.json";
import { ENV } from "./constants"; import { ENV } from "./constants";
import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai";
const COMPLETION_THRESHOLD = 85; const COMPLETION_THRESHOLD = 85;
@ -99,6 +101,8 @@ export const setLanguage = async (lang: Language) => {
currentLangData = fallbackLangData; currentLangData = fallbackLangData;
} }
} }
jotaiStore.set(editorLangCodeAtom, lang.code);
}; };
export const getLanguage = () => currentLang; export const getLanguage = () => currentLang;
@ -143,3 +147,15 @@ export const t = (
} }
return translation; return translation;
}; };
/** @private atom used solely to rerender components using `useI18n` hook */
const editorLangCodeAtom = atom(defaultLang.code);
// Should be used in components that fall under these cases:
// - component is rendered as an <Excalidraw> child
// - component is rendered internally by <Excalidraw>, but the component
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
export const useI18n = () => {
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
return { t, langCode };
};

View File

@ -1,4 +1,4 @@
import { unstable_createStore, useAtom, WritableAtom } from "jotai"; import { PrimitiveAtom, unstable_createStore, useAtom } from "jotai";
import { useLayoutEffect } from "react"; import { useLayoutEffect } from "react";
export const jotaiScope = Symbol(); export const jotaiScope = Symbol();
@ -6,7 +6,7 @@ export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = < export const useAtomWithInitialValue = <
T extends unknown, T extends unknown,
A extends WritableAtom<T, T>, A extends PrimitiveAtom<T>,
>( >(
atom: A, atom: A,
initialValue: T | (() => T), initialValue: T | (() => T),

View File

@ -110,6 +110,7 @@
"increaseFontSize": "تكبير حجم الخط", "increaseFontSize": "تكبير حجم الخط",
"unbindText": "فك ربط النص", "unbindText": "فك ربط النص",
"bindText": "ربط النص بالحاوية", "bindText": "ربط النص بالحاوية",
"createContainerFromText": "",
"link": { "link": {
"edit": "تعديل الرابط", "edit": "تعديل الرابط",
"create": "إنشاء رابط", "create": "إنشاء رابط",
@ -192,7 +193,8 @@
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.", "invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟", "resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟", "removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل." "invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع الملف غير مدعوم.", "unsupportedFileType": "نوع الملف غير مدعوم.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "تعذر الاتصال بخادم التعاون. الرجاء إعادة تحميل الصفحة والمحاولة مرة أخرى.", "cannotResolveCollabServer": "تعذر الاتصال بخادم التعاون. الرجاء إعادة تحميل الصفحة والمحاولة مرة أخرى.",
"importLibraryError": "تعذر تحميل المكتبة", "importLibraryError": "تعذر تحميل المكتبة",
"collabSaveFailed": "تعذر الحفظ في قاعدة البيانات. إذا استمرت المشاكل، يفضل أن تحفظ ملفك محليا كي لا تفقد عملك.", "collabSaveFailed": "تعذر الحفظ في قاعدة البيانات. إذا استمرت المشاكل، يفضل أن تحفظ ملفك محليا كي لا تفقد عملك.",
"collabSaveFailed_sizeExceeded": "تعذر الحفظ في قاعدة البيانات، يبدو أن القماش كبير للغاية، يفضّل حفظ الملف محليا كي لا تفقد عملك." "collabSaveFailed_sizeExceeded": "تعذر الحفظ في قاعدة البيانات، يبدو أن القماش كبير للغاية، يفضّل حفظ الملف محليا كي لا تفقد عملك.",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "تحديد", "selection": "تحديد",
@ -302,7 +319,8 @@
"doubleClick": "انقر مرتين", "doubleClick": "انقر مرتين",
"drag": "اسحب", "drag": "اسحب",
"editor": "المحرر", "editor": "المحرر",
"editSelectedShape": "تعديل الشكل المحدد (النص/السهم/الخط)", "editLineArrowPoints": "",
"editText": "",
"github": "عثرت على مشكلة؟ إرسال", "github": "عثرت على مشكلة؟ إرسال",
"howto": "اتبع التعليمات", "howto": "اتبع التعليمات",
"or": "أو", "or": "أو",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "", "increaseFontSize": "",
"unbindText": "", "unbindText": "",
"bindText": "", "bindText": "",
"createContainerFromText": "",
"link": { "link": {
"edit": "", "edit": "",
"create": "", "create": "",
@ -192,7 +193,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Този файлов формат не се поддържа.", "unsupportedFileType": "Този файлов формат не се поддържа.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "", "cannotResolveCollabServer": "",
"importLibraryError": "", "importLibraryError": "",
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "Селекция", "selection": "Селекция",
@ -302,7 +319,8 @@
"doubleClick": "", "doubleClick": "",
"drag": "плъзнете", "drag": "плъзнете",
"editor": "Редактор", "editor": "Редактор",
"editSelectedShape": "", "editLineArrowPoints": "",
"editText": "",
"github": "Намерихте проблем? Изпратете", "github": "Намерихте проблем? Изпратете",
"howto": "Следвайте нашите ръководства", "howto": "Следвайте нашите ръководства",
"or": "или", "or": "или",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "লেখনীর মাত্রা বাড়ান", "increaseFontSize": "লেখনীর মাত্রা বাড়ান",
"unbindText": "", "unbindText": "",
"bindText": "", "bindText": "",
"createContainerFromText": "",
"link": { "link": {
"edit": "লিঙ্ক সংশোধন", "edit": "লিঙ্ক সংশোধন",
"create": "লিঙ্ক তৈরী", "create": "লিঙ্ক তৈরী",
@ -192,7 +193,8 @@
"invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷", "invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷",
"resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?", "resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?",
"removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?", "removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?",
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।" "invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "অসমর্থিত ফাইল।", "unsupportedFileType": "অসমর্থিত ফাইল।",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "কোল্যাব সার্ভারের সাথে সংযোগ করা যায়নি। পৃষ্ঠাটি পুনরায় লোড করে আবার চেষ্টা করুন।", "cannotResolveCollabServer": "কোল্যাব সার্ভারের সাথে সংযোগ করা যায়নি। পৃষ্ঠাটি পুনরায় লোড করে আবার চেষ্টা করুন।",
"importLibraryError": "সংগ্রহ লোড করা যায়নি", "importLibraryError": "সংগ্রহ লোড করা যায়নি",
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "বাছাই", "selection": "বাছাই",
@ -302,7 +319,8 @@
"doubleClick": "", "doubleClick": "",
"drag": "", "drag": "",
"editor": "", "editor": "",
"editSelectedShape": "", "editLineArrowPoints": "",
"editText": "",
"github": "", "github": "",
"howto": "", "howto": "",
"or": "অথবা", "or": "অথবা",

View File

@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "Enganxa", "paste": "Enganxa",
"pasteAsPlaintext": "", "pasteAsPlaintext": "Enganxar com a text pla",
"pasteCharts": "Enganxa els diagrames", "pasteCharts": "Enganxa els diagrames",
"selectAll": "Selecciona-ho tot", "selectAll": "Selecciona-ho tot",
"multiSelect": "Afegeix un element a la selecció", "multiSelect": "Afegeix un element a la selecció",
@ -72,7 +72,7 @@
"layers": "Capes", "layers": "Capes",
"actions": "Accions", "actions": "Accions",
"language": "Llengua", "language": "Llengua",
"liveCollaboration": "", "liveCollaboration": "Col·laboració en directe...",
"duplicateSelection": "Duplica", "duplicateSelection": "Duplica",
"untitled": "Sense títol", "untitled": "Sense títol",
"name": "Nom", "name": "Nom",
@ -110,14 +110,15 @@
"increaseFontSize": "Augmenta la mida de la lletra", "increaseFontSize": "Augmenta la mida de la lletra",
"unbindText": "Desvincular el text", "unbindText": "Desvincular el text",
"bindText": "Ajusta el text al contenidor", "bindText": "Ajusta el text al contenidor",
"createContainerFromText": "",
"link": { "link": {
"edit": "Edita l'enllaç", "edit": "Edita l'enllaç",
"create": "Crea un enllaç", "create": "Crea un enllaç",
"label": "Enllaç" "label": "Enllaç"
}, },
"lineEditor": { "lineEditor": {
"edit": "", "edit": "Editar línia",
"exit": "" "exit": "Sortir de l'editor de línia"
}, },
"elementLock": { "elementLock": {
"lock": "Bloca", "lock": "Bloca",
@ -136,8 +137,8 @@
"buttons": { "buttons": {
"clearReset": "Neteja el llenç", "clearReset": "Neteja el llenç",
"exportJSON": "Exporta a un fitxer", "exportJSON": "Exporta a un fitxer",
"exportImage": "", "exportImage": "Exporta la imatge...",
"export": "", "export": "Guardar a...",
"exportToPng": "Exporta a PNG", "exportToPng": "Exporta a PNG",
"exportToSvg": "Exporta a SNG", "exportToSvg": "Exporta a SNG",
"copyToClipboard": "Copia al porta-retalls", "copyToClipboard": "Copia al porta-retalls",
@ -145,7 +146,7 @@
"scale": "Escala", "scale": "Escala",
"save": "Desa al fitxer actual", "save": "Desa al fitxer actual",
"saveAs": "Anomena i desa", "saveAs": "Anomena i desa",
"load": "", "load": "Obrir",
"getShareableLink": "Obté l'enllaç per a compartir", "getShareableLink": "Obté l'enllaç per a compartir",
"close": "Tanca", "close": "Tanca",
"selectLanguage": "Trieu la llengua", "selectLanguage": "Trieu la llengua",
@ -192,7 +193,8 @@
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.", "invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?", "resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?", "removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?",
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada." "invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada.",
"collabOfflineWarning": "Sense connexió a internet disponible.\nEls vostres canvis no seran guardats!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipus de fitxer no suportat.", "unsupportedFileType": "Tipus de fitxer no suportat.",
@ -202,8 +204,23 @@
"invalidSVGString": "SVG no vàlid.", "invalidSVGString": "SVG no vàlid.",
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.", "cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
"importLibraryError": "No s'ha pogut carregar la biblioteca", "importLibraryError": "No s'ha pogut carregar la biblioteca",
"collabSaveFailed": "", "collabSaveFailed": "No s'ha pogut desar a la base de dades de fons. Si els problemes persisteixen, hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "No s'ha pogut desar a la base de dades de fons, sembla que el llenç és massa gran. Hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "Selecció", "selection": "Selecció",
@ -217,10 +234,10 @@
"text": "Text", "text": "Text",
"library": "Biblioteca", "library": "Biblioteca",
"lock": "Mantenir activa l'eina seleccionada desprès de dibuixar", "lock": "Mantenir activa l'eina seleccionada desprès de dibuixar",
"penMode": "", "penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada", "link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador", "eraser": "Esborrador",
"hand": "" "hand": "Mà (eina de desplaçament)"
}, },
"headings": { "headings": {
"canvasActions": "Accions del llenç", "canvasActions": "Accions del llenç",
@ -228,7 +245,7 @@
"shapes": "Formes" "shapes": "Formes"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Per moure el llenç, manteniu premuda la roda del ratolí o la barra espaiadora mentre arrossegueu o utilitzeu l'eina manual",
"linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia", "linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia",
"freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar", "freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar",
"text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció", "text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció",
@ -239,7 +256,7 @@
"resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT", "resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT",
"resizeImage": "Podeu redimensionar lliurement prement MAJÚSCULA;\nper a redimensionar des del centre, premeu ALT", "resizeImage": "Podeu redimensionar lliurement prement MAJÚSCULA;\nper a redimensionar des del centre, premeu ALT",
"rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)", "rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)",
"lineEditor_info": "", "lineEditor_info": "Mantingueu premut Ctrl o Cmd i feu doble clic o premeu Ctrl o Cmd + Retorn per editar els punts",
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el(s) punt(s), CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l", "lineEditor_pointSelected": "Premeu Suprimir per a eliminar el(s) punt(s), CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
"lineEditor_nothingSelected": "Seleccioneu un punt per a editar-lo (premeu SHIFT si voleu\nselecció múltiple), o manteniu Alt i feu clic per a afegir més punts", "lineEditor_nothingSelected": "Seleccioneu un punt per a editar-lo (premeu SHIFT si voleu\nselecció múltiple), o manteniu Alt i feu clic per a afegir més punts",
"placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment", "placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment",
@ -247,7 +264,7 @@
"bindTextToElement": "Premeu enter per a afegir-hi text", "bindTextToElement": "Premeu enter per a afegir-hi text",
"deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament", "deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament",
"eraserRevert": "Mantingueu premuda Alt per a revertir els elements seleccionats per a esborrar", "eraserRevert": "Mantingueu premuda Alt per a revertir els elements seleccionats per a esborrar",
"firefox_clipboard_write": "" "firefox_clipboard_write": "És probable que aquesta funció es pugui activar posant la marca \"dom.events.asyncClipboard.clipboardItem\" a \"true\". Per canviar les marques del navegador al Firefox, visiteu la pàgina \"about:config\"."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "No es pot mostrar la previsualització", "cannotShowPreview": "No es pot mostrar la previsualització",
@ -295,14 +312,15 @@
"blog": "Llegiu el nostre blog", "blog": "Llegiu el nostre blog",
"click": "clic", "click": "clic",
"deepSelect": "Selecció profunda", "deepSelect": "Selecció profunda",
"deepBoxSelect": "", "deepBoxSelect": "Seleccioneu profundament dins del quadre i eviteu arrossegar",
"curvedArrow": "Fletxa corba", "curvedArrow": "Fletxa corba",
"curvedLine": "Línia corba", "curvedLine": "Línia corba",
"documentation": "Documentació", "documentation": "Documentació",
"doubleClick": "doble clic", "doubleClick": "doble clic",
"drag": "arrossega", "drag": "arrossega",
"editor": "Editor", "editor": "Editor",
"editSelectedShape": "Edita la forma seleccionada (text, fletxa o línia)", "editLineArrowPoints": "",
"editText": "",
"github": "Hi heu trobat un problema? Informeu-ne", "github": "Hi heu trobat un problema? Informeu-ne",
"howto": "Seguiu les nostres guies", "howto": "Seguiu les nostres guies",
"or": "o", "or": "o",
@ -316,8 +334,8 @@
"zoomToFit": "Zoom per veure tots els elements", "zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció", "zoomToSelection": "Zoom per veure la selecció",
"toggleElementLock": "Blocar/desblocar la selecció", "toggleElementLock": "Blocar/desblocar la selecció",
"movePageUpDown": "", "movePageUpDown": "Mou la pàgina cap amunt/a baix",
"movePageLeftRight": "" "movePageLeftRight": "Mou la pàgina cap a l'esquerra/dreta"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Neteja el llenç" "title": "Neteja el llenç"
@ -399,7 +417,7 @@
"fileSavedToFilename": "S'ha desat a {filename}", "fileSavedToFilename": "S'ha desat a {filename}",
"canvas": "el llenç", "canvas": "el llenç",
"selection": "la selecció", "selection": "la selecció",
"pasteAsSingleElement": "" "pasteAsSingleElement": "Fer servir {{shortcut}} per enganxar com un sol element,\no enganxeu-lo en un editor de text existent"
}, },
"colors": { "colors": {
"ffffff": "Blanc", "ffffff": "Blanc",
@ -450,15 +468,15 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "Totes les vostres dades es guarden localment al vostre navegador.",
"center_heading_plus": "", "center_heading_plus": "Vols anar a Excalidraw+ en comptes?",
"menuHint": "" "menuHint": "Exportar, preferències, llenguatges..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "Exportar, preferències i més...",
"center_heading": "", "center_heading": "Diagrames. Fer. Simple.",
"toolbarHint": "", "toolbarHint": "Selecciona una eina i comença a dibuixar!",
"helpHint": "" "helpHint": "Dreceres i ajuda"
} }
} }
} }

View File

@ -110,6 +110,7 @@
"increaseFontSize": "Zvětšit písmo", "increaseFontSize": "Zvětšit písmo",
"unbindText": "Zrušit vazbu textu", "unbindText": "Zrušit vazbu textu",
"bindText": "Vázat text s kontejnerem", "bindText": "Vázat text s kontejnerem",
"createContainerFromText": "",
"link": { "link": {
"edit": "Upravit odkaz", "edit": "Upravit odkaz",
"create": "Vytvořit odkaz", "create": "Vytvořit odkaz",
@ -192,7 +193,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "", "cannotResolveCollabServer": "",
"importLibraryError": "", "importLibraryError": "",
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "Výběr", "selection": "Výběr",
@ -302,7 +319,8 @@
"doubleClick": "dvojklik", "doubleClick": "dvojklik",
"drag": "tažení", "drag": "tažení",
"editor": "Editor", "editor": "Editor",
"editSelectedShape": "", "editLineArrowPoints": "",
"editText": "",
"github": "", "github": "",
"howto": "", "howto": "",
"or": "nebo", "or": "nebo",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "", "increaseFontSize": "",
"unbindText": "", "unbindText": "",
"bindText": "", "bindText": "",
"createContainerFromText": "",
"link": { "link": {
"edit": "", "edit": "",
"create": "", "create": "",
@ -192,7 +193,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "", "cannotResolveCollabServer": "",
"importLibraryError": "", "importLibraryError": "",
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "", "selection": "",
@ -302,7 +319,8 @@
"doubleClick": "", "doubleClick": "",
"drag": "", "drag": "",
"editor": "", "editor": "",
"editSelectedShape": "", "editLineArrowPoints": "",
"editText": "",
"github": "", "github": "",
"howto": "", "howto": "",
"or": "", "or": "",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "Schrift vergrößern", "increaseFontSize": "Schrift vergrößern",
"unbindText": "Text lösen", "unbindText": "Text lösen",
"bindText": "Text an Container binden", "bindText": "Text an Container binden",
"createContainerFromText": "Text in Container einbetten",
"link": { "link": {
"edit": "Link bearbeiten", "edit": "Link bearbeiten",
"create": "Link erstellen", "create": "Link erstellen",
@ -192,7 +193,8 @@
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.", "invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?", "resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?", "removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert." "invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert.",
"collabOfflineWarning": "Keine Internetverbindung verfügbar.\nDeine Änderungen werden nicht gespeichert!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Nicht unterstützter Dateityp.", "unsupportedFileType": "Nicht unterstützter Dateityp.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "Konnte keine Verbindung zum Collab-Server herstellen. Bitte lade die Seite neu und versuche es erneut.", "cannotResolveCollabServer": "Konnte keine Verbindung zum Collab-Server herstellen. Bitte lade die Seite neu und versuche es erneut.",
"importLibraryError": "Bibliothek konnte nicht geladen werden", "importLibraryError": "Bibliothek konnte nicht geladen werden",
"collabSaveFailed": "Keine Speicherung in der Backend-Datenbank möglich. Wenn die Probleme weiterhin bestehen, solltest Du Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst.", "collabSaveFailed": "Keine Speicherung in der Backend-Datenbank möglich. Wenn die Probleme weiterhin bestehen, solltest Du Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst.",
"collabSaveFailed_sizeExceeded": "Keine Speicherung in der Backend-Datenbank möglich, die Zeichenfläche scheint zu groß zu sein. Du solltest Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst." "collabSaveFailed_sizeExceeded": "Keine Speicherung in der Backend-Datenbank möglich, die Zeichenfläche scheint zu groß zu sein. Du solltest Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst.",
"brave_measure_text_error": {
"start": "Sieht so aus, als ob du den Brave Browser benutzt mit der",
"aggressive_block_fingerprint": "\"Fingerprinting aggressiv blockieren\"",
"setting_enabled": "Einstellung aktiviert",
"break": "Dies könnte zur inkorrekten Darstellung der",
"text_elements": "Textelemente",
"in_your_drawings": "in deinen Zeichnungen führen",
"strongly_recommend": "Wir empfehlen dringend, diese Einstellung zu deaktivieren. Du kannst",
"steps": "diesen Schritten entsprechend",
"how": "folgen",
"disable_setting": " Wenn die Deaktivierung dieser Einstellung nicht zu einer korrekten Textdarstellung führt, öffne bitte einen",
"issue": "Issue",
"write": "auf GitHub, oder schreibe uns auf",
"discord": "Discord"
}
}, },
"toolBar": { "toolBar": {
"selection": "Auswahl", "selection": "Auswahl",
@ -302,7 +319,8 @@
"doubleClick": "doppelklicken", "doubleClick": "doppelklicken",
"drag": "ziehen", "drag": "ziehen",
"editor": "Editor", "editor": "Editor",
"editSelectedShape": "Ausgewählte Form bearbeiten (Text/Pfeil/Linie)", "editLineArrowPoints": "Linien-/Pfeil-Punkte bearbeiten",
"editText": "Text bearbeiten / Label hinzufügen",
"github": "Ein Problem gefunden? Informiere uns", "github": "Ein Problem gefunden? Informiere uns",
"howto": "Folge unseren Anleitungen", "howto": "Folge unseren Anleitungen",
"or": "oder", "or": "oder",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "Αύξηση μεγέθους γραμματοσειράς", "increaseFontSize": "Αύξηση μεγέθους γραμματοσειράς",
"unbindText": "Αποσύνδεση κειμένου", "unbindText": "Αποσύνδεση κειμένου",
"bindText": "Δέσμευση κειμένου στο δοχείο", "bindText": "Δέσμευση κειμένου στο δοχείο",
"createContainerFromText": "",
"link": { "link": {
"edit": "Επεξεργασία συνδέσμου", "edit": "Επεξεργασία συνδέσμου",
"create": "Δημιουργία συνδέσμου", "create": "Δημιουργία συνδέσμου",
@ -192,7 +193,8 @@
"invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.", "invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;", "resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
"removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;", "removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη." "invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.",
"collabOfflineWarning": "Δεν υπάρχει διαθέσιμη σύνδεση στο internet.\nΟι αλλαγές σας δεν θα αποθηκευτούν!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.", "unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.", "cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.",
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης", "importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης",
"collabSaveFailed": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή. Αν το προβλήματα παραμείνει, θα πρέπει να αποθηκεύσετε το αρχείο σας τοπικά για να βεβαιωθείτε ότι δεν χάνετε την εργασία σας.", "collabSaveFailed": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή. Αν το προβλήματα παραμείνει, θα πρέπει να αποθηκεύσετε το αρχείο σας τοπικά για να βεβαιωθείτε ότι δεν χάνετε την εργασία σας.",
"collabSaveFailed_sizeExceeded": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή, ο καμβάς φαίνεται να είναι πολύ μεγάλος. Θα πρέπει να αποθηκεύσετε το αρχείο τοπικά για να βεβαιωθείτε ότι δεν θα χάσετε την εργασία σας." "collabSaveFailed_sizeExceeded": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή, ο καμβάς φαίνεται να είναι πολύ μεγάλος. Θα πρέπει να αποθηκεύσετε το αρχείο τοπικά για να βεβαιωθείτε ότι δεν θα χάσετε την εργασία σας.",
"brave_measure_text_error": {
"start": "Φαίνεται ότι χρησιμοποιείτε το Brave browser με το",
"aggressive_block_fingerprint": "Αποκλεισμός \"Δακτυλικών Αποτυπωμάτων\"",
"setting_enabled": "ρύθμιση ενεργοποιημένη",
"break": "Αυτό θα μπορούσε να σπάσει το",
"text_elements": "Στοιχεία Κειμένου",
"in_your_drawings": "στα σχέδιά σας",
"strongly_recommend": "Συνιστούμε να απενεργοποιήσετε αυτή τη ρύθμιση. Μπορείτε να ακολουθήσετε",
"steps": "αυτά τα βήματα",
"how": "για το πώς να το κάνετε",
"disable_setting": " Εάν η απενεργοποίηση αυτής της ρύθμισης δεν διορθώνει την εμφάνιση των στοιχείων κειμένου, παρακαλώ ανοίξτε ένα",
"issue": "πρόβλημα",
"write": "στο GitHub, ή γράψτε μας στο",
"discord": "Discord"
}
}, },
"toolBar": { "toolBar": {
"selection": "Επιλογή", "selection": "Επιλογή",
@ -302,7 +319,8 @@
"doubleClick": "διπλό κλικ", "doubleClick": "διπλό κλικ",
"drag": "σύρε", "drag": "σύρε",
"editor": "Επεξεργαστής", "editor": "Επεξεργαστής",
"editSelectedShape": "Επεξεργασία επιλεγμένου σχήματος (κείμενο/βέλος/γραμμή)", "editLineArrowPoints": "",
"editText": "",
"github": "Βρήκατε πρόβλημα; Υποβάλετε το", "github": "Βρήκατε πρόβλημα; Υποβάλετε το",
"howto": "Ακολουθήστε τους οδηγούς μας", "howto": "Ακολουθήστε τους οδηγούς μας",
"or": "ή", "or": "ή",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "Increase font size", "increaseFontSize": "Increase font size",
"unbindText": "Unbind text", "unbindText": "Unbind text",
"bindText": "Bind text to the container", "bindText": "Bind text to the container",
"createContainerFromText": "Wrap text in a container",
"link": { "link": {
"edit": "Edit link", "edit": "Edit link",
"create": "Create link", "create": "Create link",
@ -119,7 +120,6 @@
"edit": "Edit line", "edit": "Edit line",
"exit": "Exit line editor" "exit": "Exit line editor"
}, },
"elementLock": { "elementLock": {
"lock": "Lock", "lock": "Lock",
"unlock": "Unlock", "unlock": "Unlock",
@ -205,7 +205,22 @@
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library", "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": "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": { "toolBar": {
"selection": "Selection", "selection": "Selection",
@ -304,7 +319,8 @@
"doubleClick": "double-click", "doubleClick": "double-click",
"drag": "drag", "drag": "drag",
"editor": "Editor", "editor": "Editor",
"editSelectedShape": "Edit selected shape (text/arrow/line)", "editLineArrowPoints": "Edit line/arrow points",
"editText": "Edit text / add label",
"github": "Found an issue? Submit", "github": "Found an issue? Submit",
"howto": "Follow our guides", "howto": "Follow our guides",
"or": "or", "or": "or",

View File

@ -103,13 +103,14 @@
"share": "Compartir", "share": "Compartir",
"showStroke": "Mostrar selector de color de trazo", "showStroke": "Mostrar selector de color de trazo",
"showBackground": "Mostrar el selector de color de fondo", "showBackground": "Mostrar el selector de color de fondo",
"toggleTheme": "Alternar tema", "toggleTheme": "Cambiar tema",
"personalLib": "Biblioteca personal", "personalLib": "Biblioteca personal",
"excalidrawLib": "Biblioteca Excalidraw", "excalidrawLib": "Biblioteca Excalidraw",
"decreaseFontSize": "Disminuir tamaño de letra", "decreaseFontSize": "Disminuir tamaño de letra",
"increaseFontSize": "Aumentar el tamaño de letra", "increaseFontSize": "Aumentar el tamaño de letra",
"unbindText": "Desvincular texto", "unbindText": "Desvincular texto",
"bindText": "Vincular texto al contenedor", "bindText": "Vincular texto al contenedor",
"createContainerFromText": "Envolver el texto en un contenedor",
"link": { "link": {
"edit": "Editar enlace", "edit": "Editar enlace",
"create": "Crear enlace", "create": "Crear enlace",
@ -192,7 +193,8 @@
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.", "invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?", "resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
"removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?", "removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?",
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada." "invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada.",
"collabOfflineWarning": "No hay conexión a internet disponible.\n¡No se guardarán los cambios!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo de archivo no admitido.", "unsupportedFileType": "Tipo de archivo no admitido.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "No se pudo conectar al servidor colaborador. Por favor, vuelva a cargar la página y vuelva a intentarlo.", "cannotResolveCollabServer": "No se pudo conectar al servidor colaborador. Por favor, vuelva a cargar la página y vuelva a intentarlo.",
"importLibraryError": "No se pudo cargar la librería", "importLibraryError": "No se pudo cargar la librería",
"collabSaveFailed": "No se pudo guardar en la base de datos del backend. Si los problemas persisten, debería guardar su archivo localmente para asegurarse de que no pierde su trabajo.", "collabSaveFailed": "No se pudo guardar en la base de datos del backend. Si los problemas persisten, debería guardar su archivo localmente para asegurarse de que no pierde su trabajo.",
"collabSaveFailed_sizeExceeded": "No se pudo guardar en la base de datos del backend, el lienzo parece ser demasiado grande. Debería guardar el archivo localmente para asegurarse de que no pierde su trabajo." "collabSaveFailed_sizeExceeded": "No se pudo guardar en la base de datos del backend, el lienzo parece ser demasiado grande. Debería guardar el archivo localmente para asegurarse de que no pierde su trabajo.",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "ajuste activado",
"break": "Esto podría resultar en romper los",
"text_elements": "Elementos de texto",
"in_your_drawings": "en tus dibujos",
"strongly_recommend": "Recomendamos desactivar esta configuración. Puedes seguir",
"steps": "estos pasos",
"how": "sobre cómo hacerlo",
"disable_setting": " Si deshabilitar esta opción no arregla la visualización de elementos de texto, por favor abre un",
"issue": "issue",
"write": "en GitHub, o escríbenos en",
"discord": "Discord"
}
}, },
"toolBar": { "toolBar": {
"selection": "Selección", "selection": "Selección",
@ -233,7 +250,7 @@
"freeDraw": "Haz clic y arrastra, suelta al terminar", "freeDraw": "Haz clic y arrastra, suelta al terminar",
"text": "Consejo: también puedes añadir texto haciendo doble clic en cualquier lugar con la herramienta de selección", "text": "Consejo: también puedes añadir texto haciendo doble clic en cualquier lugar con la herramienta de selección",
"text_selected": "Doble clic o pulse ENTER para editar el texto", "text_selected": "Doble clic o pulse ENTER para editar el texto",
"text_editing": "Pulse Escape o CtrlOrCmd+ENTER para terminar de editar", "text_editing": "Pulse Escape o Ctrl/Cmd + ENTER para terminar de editar",
"linearElementMulti": "Haz clic en el último punto o presiona Escape o Enter para finalizar", "linearElementMulti": "Haz clic en el último punto o presiona Escape o Enter para finalizar",
"lockAngle": "Puedes restringir el ángulo manteniendo presionado el botón SHIFT", "lockAngle": "Puedes restringir el ángulo manteniendo presionado el botón SHIFT",
"resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro", "resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro",
@ -302,7 +319,8 @@
"doubleClick": "doble clic", "doubleClick": "doble clic",
"drag": "arrastrar", "drag": "arrastrar",
"editor": "Editor", "editor": "Editor",
"editSelectedShape": "Editar la forma seleccionada (texto/flecha/línea)", "editLineArrowPoints": "",
"editText": "",
"github": "¿Ha encontrado un problema? Envíelo", "github": "¿Ha encontrado un problema? Envíelo",
"howto": "Siga nuestras guías", "howto": "Siga nuestras guías",
"or": "o", "or": "o",
@ -314,7 +332,7 @@
"title": "Ayuda", "title": "Ayuda",
"view": "Vista", "view": "Vista",
"zoomToFit": "Ajustar la vista para mostrar todos los elementos", "zoomToFit": "Ajustar la vista para mostrar todos los elementos",
"zoomToSelection": "Zoom a la selección", "zoomToSelection": "Ampliar selección",
"toggleElementLock": "Bloquear/desbloquear selección", "toggleElementLock": "Bloquear/desbloquear selección",
"movePageUpDown": "Mover página hacia arriba/abajo", "movePageUpDown": "Mover página hacia arriba/abajo",
"movePageLeftRight": "Mover página hacia la izquierda/derecha" "movePageLeftRight": "Mover página hacia la izquierda/derecha"
@ -326,9 +344,9 @@
"title": "Publicar biblioteca", "title": "Publicar biblioteca",
"itemName": "Nombre del artículo", "itemName": "Nombre del artículo",
"authorName": "Nombre del autor", "authorName": "Nombre del autor",
"githubUsername": "Nombre de usuario de Github", "githubUsername": "Nombre de usuario de GitHub",
"twitterUsername": "Nombre de usuario de Twitter", "twitterUsername": "Nombre de usuario de Twitter",
"libraryName": "Nombre de la librería", "libraryName": "Nombre de la biblioteca",
"libraryDesc": "Descripción de la biblioteca", "libraryDesc": "Descripción de la biblioteca",
"website": "Sitio Web", "website": "Sitio Web",
"placeholder": { "placeholder": {
@ -336,7 +354,7 @@
"libraryName": "Nombre de tu biblioteca", "libraryName": "Nombre de tu biblioteca",
"libraryDesc": "Descripción de su biblioteca para ayudar a la gente a entender su uso", "libraryDesc": "Descripción de su biblioteca para ayudar a la gente a entender su uso",
"githubHandle": "Nombre de usuario de GitHub (opcional), así podrá editar la biblioteca una vez enviada para su revisión", "githubHandle": "Nombre de usuario de GitHub (opcional), así podrá editar la biblioteca una vez enviada para su revisión",
"twitterHandle": "Nombre de usuario de Twitter (opcional), así que sabemos a quién acreditar cuando se promociona en Twitter", "twitterHandle": "Nombre de usuario de Twitter (opcional), así sabemos a quién acreditar cuando se promociona en Twitter",
"website": "Enlace a su sitio web personal o en cualquier otro lugar (opcional)" "website": "Enlace a su sitio web personal o en cualquier otro lugar (opcional)"
}, },
"errors": { "errors": {
@ -458,7 +476,7 @@
"menuHint": "Exportar, preferencias y más...", "menuHint": "Exportar, preferencias y más...",
"center_heading": "Diagramas. Hecho. Simplemente.", "center_heading": "Diagramas. Hecho. Simplemente.",
"toolbarHint": "¡Elige una herramienta y empieza a dibujar!", "toolbarHint": "¡Elige una herramienta y empieza a dibujar!",
"helpHint": "Atajos & ayuda" "helpHint": "Atajos y ayuda"
} }
} }
} }

View File

@ -110,6 +110,7 @@
"increaseFontSize": "Handitu letra tamaina", "increaseFontSize": "Handitu letra tamaina",
"unbindText": "Askatu testua", "unbindText": "Askatu testua",
"bindText": "Lotu testua edukiontziari", "bindText": "Lotu testua edukiontziari",
"createContainerFromText": "Bilatu testua edukiontzi batean",
"link": { "link": {
"edit": "Editatu esteka", "edit": "Editatu esteka",
"create": "Sortu esteka", "create": "Sortu esteka",
@ -192,7 +193,8 @@
"invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.", "invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.",
"resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?", "resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?",
"removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?", "removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?",
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago." "invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago.",
"collabOfflineWarning": "Ez dago Interneteko konexiorik.\nZure aldaketak ez dira gordeko!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Onartu gabeko fitxategi mota.", "unsupportedFileType": "Onartu gabeko fitxategi mota.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "Ezin izan da elkarlaneko zerbitzarira konektatu. Mesedez, berriro kargatu orria eta saiatu berriro.", "cannotResolveCollabServer": "Ezin izan da elkarlaneko zerbitzarira konektatu. Mesedez, berriro kargatu orria eta saiatu berriro.",
"importLibraryError": "Ezin izan da liburutegia kargatu", "importLibraryError": "Ezin izan da liburutegia kargatu",
"collabSaveFailed": "Ezin izan da backend datu-basean gorde. Arazoak jarraitzen badu, zure fitxategia lokalean gorde beharko zenuke zure lana ez duzula galtzen ziurtatzeko.", "collabSaveFailed": "Ezin izan da backend datu-basean gorde. Arazoak jarraitzen badu, zure fitxategia lokalean gorde beharko zenuke zure lana ez duzula galtzen ziurtatzeko.",
"collabSaveFailed_sizeExceeded": "Ezin izan da backend datu-basean gorde, ohiala handiegia dela dirudi. Fitxategia lokalean gorde beharko zenuke zure lana galtzen ez duzula ziurtatzeko." "collabSaveFailed_sizeExceeded": "Ezin izan da backend datu-basean gorde, ohiala handiegia dela dirudi. Fitxategia lokalean gorde beharko zenuke zure lana galtzen ez duzula ziurtatzeko.",
"brave_measure_text_error": {
"start": "Brave nabigatzailea erabiltzen ari zarela dirudi",
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
"setting_enabled": "ezarpena gaituta",
"break": "Honek honen haustea eragin dezake",
"text_elements": "Testu-elementuak",
"in_your_drawings": "zure marrazkietan",
"strongly_recommend": "Ezarpen hau desgaitzea gomendatzen dugu. Jarrai dezakezu",
"steps": "urrats hauek",
"how": "jakiteko nola egin",
"disable_setting": " Ezarpen hau desgaitzeak testu-elementuen bistaratzea konpontzen ez badu, ireki",
"issue": "eskaera (issue) bat",
"write": "gure Github-en edo idatz iezaguzu",
"discord": "Discord-en"
}
}, },
"toolBar": { "toolBar": {
"selection": "Hautapena", "selection": "Hautapena",
@ -220,7 +237,7 @@
"penMode": "Luma modua - ukipena saihestu", "penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako", "link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma", "eraser": "Borragoma",
"hand": "" "hand": "Eskua (panoratze tresna)"
}, },
"headings": { "headings": {
"canvasActions": "Canvas ekintzak", "canvasActions": "Canvas ekintzak",
@ -228,7 +245,7 @@
"shapes": "Formak" "shapes": "Formak"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Oihala mugitzeko, eutsi saguaren gurpila edo zuriune-barra arrastatzean, edo erabili esku tresna",
"linearElement": "Egin klik hainbat puntu hasteko, arrastatu lerro bakarrerako", "linearElement": "Egin klik hainbat puntu hasteko, arrastatu lerro bakarrerako",
"freeDraw": "Egin klik eta arrastatu, askatu amaitutakoan", "freeDraw": "Egin klik eta arrastatu, askatu amaitutakoan",
"text": "Aholkua: testua gehitu dezakezu edozein lekutan klik bikoitza eginez hautapen tresnarekin", "text": "Aholkua: testua gehitu dezakezu edozein lekutan klik bikoitza eginez hautapen tresnarekin",
@ -247,7 +264,7 @@
"bindTextToElement": "Sakatu Sartu testua gehitzeko", "bindTextToElement": "Sakatu Sartu testua gehitzeko",
"deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko", "deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko",
"eraserRevert": "Eduki Alt sakatuta ezabatzeko markatutako elementuak leheneratzeko", "eraserRevert": "Eduki Alt sakatuta ezabatzeko markatutako elementuak leheneratzeko",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Ezaugarri hau \"dom.events.asyncClipboard.clipboardItem\" marka \"true\" gisa ezarrita gaitu daiteke. Firefox-en arakatzailearen banderak aldatzeko, bisitatu \"about:config\" orrialdera."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Ezin da oihala aurreikusi", "cannotShowPreview": "Ezin da oihala aurreikusi",
@ -302,7 +319,8 @@
"doubleClick": "klik bikoitza", "doubleClick": "klik bikoitza",
"drag": "arrastatu", "drag": "arrastatu",
"editor": "Editorea", "editor": "Editorea",
"editSelectedShape": "Editatu hautatutako forma (testua/gezia/lerroa)", "editLineArrowPoints": "",
"editText": "",
"github": "Arazorik izan al duzu? Eman horren berri", "github": "Arazorik izan al duzu? Eman horren berri",
"howto": "Jarraitu gure gidak", "howto": "Jarraitu gure gidak",
"or": "edo", "or": "edo",

View File

@ -110,6 +110,7 @@
"increaseFontSize": "افزایش دادن اندازه فونت", "increaseFontSize": "افزایش دادن اندازه فونت",
"unbindText": "بازکردن نوشته", "unbindText": "بازکردن نوشته",
"bindText": "بستن نوشته", "bindText": "بستن نوشته",
"createContainerFromText": "",
"link": { "link": {
"edit": "ویرایش لینک", "edit": "ویرایش لینک",
"create": "ایجاد پیوند", "create": "ایجاد پیوند",
@ -192,7 +193,8 @@
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.", "invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?", "resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?", "removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است." "invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع فایل پشتیبانی نشده.", "unsupportedFileType": "نوع فایل پشتیبانی نشده.",
@ -203,7 +205,22 @@
"cannotResolveCollabServer": "به سرور collab متصل نشد. لطفا صفحه را مجددا بارگذاری کنید و دوباره تلاش کنید.", "cannotResolveCollabServer": "به سرور collab متصل نشد. لطفا صفحه را مجددا بارگذاری کنید و دوباره تلاش کنید.",
"importLibraryError": "داده‌ها بارگذاری نشدند", "importLibraryError": "داده‌ها بارگذاری نشدند",
"collabSaveFailed": "", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "گزینش", "selection": "گزینش",
@ -302,7 +319,8 @@
"doubleClick": "دابل کلیک", "doubleClick": "دابل کلیک",
"drag": "کشیدن", "drag": "کشیدن",
"editor": "ویرایشگر", "editor": "ویرایشگر",
"editSelectedShape": "ویرایش شکل انتخاب شده (متن/فلش/خط)", "editLineArrowPoints": "",
"editText": "",
"github": "اشکالی می بینید؟ گزارش دهید", "github": "اشکالی می بینید؟ گزارش دهید",
"howto": "راهنمای ما را دنبال کنید", "howto": "راهنمای ما را دنبال کنید",
"or": "یا", "or": "یا",

View File

@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "Liitä", "paste": "Liitä",
"pasteAsPlaintext": "", "pasteAsPlaintext": "Liitä pelkkänä tekstinä",
"pasteCharts": "Liitä kaaviot", "pasteCharts": "Liitä kaaviot",
"selectAll": "Valitse kaikki", "selectAll": "Valitse kaikki",
"multiSelect": "Lisää kohde valintaan", "multiSelect": "Lisää kohde valintaan",
@ -72,7 +72,7 @@
"layers": "Tasot", "layers": "Tasot",
"actions": "Toiminnot", "actions": "Toiminnot",
"language": "Kieli", "language": "Kieli",
"liveCollaboration": "", "liveCollaboration": "Live Yhteistyö...",
"duplicateSelection": "Monista", "duplicateSelection": "Monista",
"untitled": "Nimetön", "untitled": "Nimetön",
"name": "Nimi", "name": "Nimi",
@ -110,20 +110,21 @@
"increaseFontSize": "Kasvata kirjasinkokoa", "increaseFontSize": "Kasvata kirjasinkokoa",
"unbindText": "Irroita teksti", "unbindText": "Irroita teksti",
"bindText": "Kiinnitä teksti säiliöön", "bindText": "Kiinnitä teksti säiliöön",
"createContainerFromText": "",
"link": { "link": {
"edit": "Muokkaa linkkiä", "edit": "Muokkaa linkkiä",
"create": "Luo linkki", "create": "Luo linkki",
"label": "Linkki" "label": "Linkki"
}, },
"lineEditor": { "lineEditor": {
"edit": "", "edit": "Muokkaa riviä",
"exit": "" "exit": "Poistu rivieditorista"
}, },
"elementLock": { "elementLock": {
"lock": "", "lock": "Lukitse",
"unlock": "", "unlock": "Poista lukitus",
"lockAll": "", "lockAll": "Lukitse kaikki",
"unlockAll": "" "unlockAll": "Poista lukitus kaikista"
}, },
"statusPublished": "Julkaistu", "statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna" "sidebarLock": "Pidä sivupalkki avoinna"
@ -136,8 +137,8 @@
"buttons": { "buttons": {
"clearReset": "Tyhjennä piirtoalue", "clearReset": "Tyhjennä piirtoalue",
"exportJSON": "Vie tiedostoon", "exportJSON": "Vie tiedostoon",
"exportImage": "", "exportImage": "Vie kuva...",
"export": "", "export": "Tallenna nimellä...",
"exportToPng": "Vie PNG-tiedostona", "exportToPng": "Vie PNG-tiedostona",
"exportToSvg": "Vie SVG-tiedostona", "exportToSvg": "Vie SVG-tiedostona",
"copyToClipboard": "Kopioi leikepöydälle", "copyToClipboard": "Kopioi leikepöydälle",
@ -145,7 +146,7 @@
"scale": "Koko", "scale": "Koko",
"save": "Tallenna nykyiseen tiedostoon", "save": "Tallenna nykyiseen tiedostoon",
"saveAs": "Tallenna nimellä", "saveAs": "Tallenna nimellä",
"load": "", "load": "Avaa",
"getShareableLink": "Hae jaettava linkki", "getShareableLink": "Hae jaettava linkki",
"close": "Sulje", "close": "Sulje",
"selectLanguage": "Valitse kieli", "selectLanguage": "Valitse kieli",
@ -192,7 +193,8 @@
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.", "invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?", "resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?", "removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä." "invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä.",
"collabOfflineWarning": "Internet-yhteyttä ei ole saatavilla.\nMuutoksiasi ei tallenneta!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tiedostotyyppiä ei tueta.", "unsupportedFileType": "Tiedostotyyppiä ei tueta.",
@ -201,9 +203,24 @@
"svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.", "svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.",
"invalidSVGString": "Virheellinen SVG.", "invalidSVGString": "Virheellinen SVG.",
"cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.", "cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.",
"importLibraryError": "", "importLibraryError": "Kokoelman lataaminen epäonnistui",
"collabSaveFailed": "", "collabSaveFailed": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi.",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi.",
"brave_measure_text_error": {
"start": "",
"aggressive_block_fingerprint": "",
"setting_enabled": "",
"break": "",
"text_elements": "",
"in_your_drawings": "",
"strongly_recommend": "",
"steps": "",
"how": "",
"disable_setting": "",
"issue": "",
"write": "",
"discord": ""
}
}, },
"toolBar": { "toolBar": {
"selection": "Valinta", "selection": "Valinta",
@ -217,10 +234,10 @@
"text": "Teksti", "text": "Teksti",
"library": "Kirjasto", "library": "Kirjasto",
"lock": "Pidä valittu työkalu aktiivisena piirron jälkeen", "lock": "Pidä valittu työkalu aktiivisena piirron jälkeen",
"penMode": "", "penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle", "link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu", "eraser": "Poistotyökalu",
"hand": "" "hand": "Käsi (panning-työkalu)"
}, },
"headings": { "headings": {
"canvasActions": "Piirtoalueen toiminnot", "canvasActions": "Piirtoalueen toiminnot",
@ -228,7 +245,7 @@
"shapes": "Muodot" "shapes": "Muodot"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Piirtoalueen liikuttamiseksi pidä hiiren pyörää tai välilyöntiä pohjassa tai käytä käsityökalua",
"linearElement": "Klikkaa piirtääksesi useampi piste, raahaa piirtääksesi yksittäinen viiva", "linearElement": "Klikkaa piirtääksesi useampi piste, raahaa piirtääksesi yksittäinen viiva",
"freeDraw": "Paina ja raahaa, päästä irti kun olet valmis", "freeDraw": "Paina ja raahaa, päästä irti kun olet valmis",
"text": "Vinkki: voit myös lisätä tekstiä kaksoisnapsauttamalla mihin tahansa valintatyökalulla", "text": "Vinkki: voit myös lisätä tekstiä kaksoisnapsauttamalla mihin tahansa valintatyökalulla",
@ -239,7 +256,7 @@
"resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen", "resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen",
"resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri", "resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri",
"rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi", "rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi",
"lineEditor_info": "", "lineEditor_info": "Pidä CtrlOrCmd pohjassa ja kaksoisnapsauta tai paina CtrlOrCmd + Enter muokataksesi pisteitä",
"lineEditor_pointSelected": "Poista piste(et) painamalla delete, monista painamalla CtrlOrCmd+D, tai liikuta raahaamalla", "lineEditor_pointSelected": "Poista piste(et) painamalla delete, monista painamalla CtrlOrCmd+D, tai liikuta raahaamalla",
"lineEditor_nothingSelected": "Valitse muokattava piste (monivalinta pitämällä SHIFT pohjassa), tai paina Alt ja klikkaa lisätäksesi uusia pisteitä", "lineEditor_nothingSelected": "Valitse muokattava piste (monivalinta pitämällä SHIFT pohjassa), tai paina Alt ja klikkaa lisätäksesi uusia pisteitä",
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti", "placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti",
@ -247,7 +264,7 @@
"bindTextToElement": "Lisää tekstiä painamalla enter", "bindTextToElement": "Lisää tekstiä painamalla enter",
"deepBoxSelect": "Käytä syvävalintaa ja estä raahaus painamalla CtrlOrCmd", "deepBoxSelect": "Käytä syvävalintaa ja estä raahaus painamalla CtrlOrCmd",
"eraserRevert": "Pidä Alt alaspainettuna, kumotaksesi merkittyjen elementtien poistamisen", "eraserRevert": "Pidä Alt alaspainettuna, kumotaksesi merkittyjen elementtien poistamisen",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Tämä ominaisuus voidaan todennäköisesti ottaa käyttöön asettamalla \"dom.events.asyncClipboard.clipboardItem\" kohta \"true\":ksi. Vaihtaaksesi selaimen kohdan Firefoxissa, käy \"about:config\" sivulla."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Esikatselua ei voitu näyttää", "cannotShowPreview": "Esikatselua ei voitu näyttää",
@ -302,7 +319,8 @@
"doubleClick": "kaksoisnapsautus", "doubleClick": "kaksoisnapsautus",
"drag": "vedä", "drag": "vedä",
"editor": "Muokkausohjelma", "editor": "Muokkausohjelma",
"editSelectedShape": "Muokkaa valittua muotoa (teksti/nuoli/viiva)", "editLineArrowPoints": "",
"editText": "",
"github": "Löysitkö ongelman? Kerro meille", "github": "Löysitkö ongelman? Kerro meille",
"howto": "Seuraa oppaitamme", "howto": "Seuraa oppaitamme",
"or": "tai", "or": "tai",
@ -315,9 +333,9 @@
"view": "Näkymä", "view": "Näkymä",
"zoomToFit": "Näytä kaikki elementit", "zoomToFit": "Näytä kaikki elementit",
"zoomToSelection": "Näytä valinta", "zoomToSelection": "Näytä valinta",
"toggleElementLock": "", "toggleElementLock": "Lukitse / poista lukitus valinta",
"movePageUpDown": "", "movePageUpDown": "Siirrä sivua ylös/alas",
"movePageLeftRight": "" "movePageLeftRight": "Siirrä sivua vasemmalle/oikealle"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Pyyhi piirtoalue" "title": "Pyyhi piirtoalue"
@ -399,7 +417,7 @@
"fileSavedToFilename": "Tallennettiin kohteeseen {filename}", "fileSavedToFilename": "Tallennettiin kohteeseen {filename}",
"canvas": "piirtoalue", "canvas": "piirtoalue",
"selection": "valinta", "selection": "valinta",
"pasteAsSingleElement": "" "pasteAsSingleElement": "Käytä {{shortcut}} liittääksesi yhtenä elementtinä,\ntai liittääksesi olemassa olevaan tekstieditoriin"
}, },
"colors": { "colors": {
"ffffff": "Valkoinen", "ffffff": "Valkoinen",
@ -450,15 +468,15 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "Kaikki tietosi on tallennettu paikallisesti selaimellesi.",
"center_heading_plus": "", "center_heading_plus": "Haluatko sen sijaan mennä Excalidraw+:aan?",
"menuHint": "" "menuHint": "Vie, asetukset, kielet, ..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "Vie, asetukset ja lisää...",
"center_heading": "", "center_heading": "Kaaviot. Tehty. Yksinkertaiseksi.",
"toolbarHint": "", "toolbarHint": "Valitse työkalu ja aloita piirtäminen!",
"helpHint": "" "helpHint": "Pikanäppäimet & ohje"
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More