Compare commits

...
Author SHA1 Message Date
Aakansha DoshiandGitHub 993294ac08 typo 2023-03-22 16:33:28 +05:30
Aakansha Doshi f584416c9a lint 2023-03-22 16:16:25 +05:30
Aakansha Doshi 87b0c7a679 remove unused method 2023-03-22 15:13:09 +05:30
Aakansha Doshi ee8fff8e8b rename getApproxMinLineWidth -> getApproxMinContainerWidth and getApproxMinLineHeight -> getApproxMinContainerHeight 2023-03-22 14:55:51 +05:30
Aakansha Doshi b799490ece Merge remote-tracking branch 'origin/master' into aakansha-refact 2023-03-22 12:51:23 +05:30
83383977f5 feat: add line height attribute to text element (#6360)
* feat: add line height attribute to text element

* lint

* update line height when redrawing text bounding box

* fix tests

* retain line height when pasting styles

* fix test

* create a util for calculating ling height using old algo

* update line height when resizing multiple text elements

* make line height backward compatible

* udpate line height for older element when font size updated

* remove logs

* Add specs

* lint

* review fixes

* simplify by changing `lineHeight` from px to unitless

* make param non-optional

* update comment

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

* update line height when font family is updated

* lint

* Add spec

* more specs

* rename to getDefaultLineHeight

* fix getting lineHeight for potentially undefined fontFamily

* reduce duplication

* fix fallback

* refactor and comment tweaks

* fix

---------

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

* Update textElement.ts

* Update textElement.ts

* tweak

* fix

* remove unnecessary `Math.sign`

* change check and add doc

* Add a case for negative max width and specs

* fix

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-03-20 17:50:09 +05:30
0726911fa6 fix: containerizing text incorrectly updates arrow bindings (#6369)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-18 15:00:28 +00:00
Aakansha Doshi fd18896293 remove unused function getMinCharWidth 2023-03-15 12:24:53 +05:30
Aakansha Doshi e900cb0b64 move measurements related utils to textMeasurements.ts 2023-03-15 12:20:31 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7e330c8ee1 build(deps-dev): bump webpack from 5.73.0 to 5.76.0 in /src/packages/utils (#6354)
build(deps-dev): bump webpack in /src/packages/utils

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-15 11:03:40 +05:30
Aakansha Doshi 54bf3d9092 fix 2023-03-14 20:43:51 +05:30
Aakansha Doshi 15f19835fe rename 2023-03-14 19:59:58 +05:30
Aakansha Doshi 96c4cff805 Merge remote-tracking branch 'origin/master' into aakansha-refact 2023-03-14 19:46:42 +05:30
Salah Eddine DaciandGitHub fe83e2922d build: move TS and types to devDependencies (#6346) 2023-03-14 12:06:57 +00:00
David LuzarandGitHub 20edddcd4e fix: ensure export preview is centered (#6337) 2023-03-14 13:03:55 +01:00
Aakansha DoshiandGitHub f6e8be399e fix: hide text align for labelled arrows (#6339)
* fix: hide text align for labelled arrows

* lintttt

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

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

* Remove dead code

* remove dead code

* lint

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

* refresh text dimensions when loading from file

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

* Add docs

* Add assets

* tweak message

* fix

* tweak message

* add translations

* lint

* fix

* fix

* lint

* lint

* lint please work now

* tweak doc

* fix

* split error component to new file

* add specs

* tweaks

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

* wrap in div with a width of 30rem

* fix spec

* fix

* Fix typo

---------

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

* fix specs and lint
2023-03-09 13:07:36 +05:30
David LuzarandGitHub 8542c95a7a fix: move utility types out of .d.ts file to fix exported declaration files (#6315) 2023-03-04 19:21:57 +01:00
David LuzarandGitHub cef6094d4c fix: more jotai scopes missing (#6313) 2023-03-03 16:19:02 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3322f0fa6f build(deps): bump @sideway/formula from 3.0.0 to 3.0.1 in /dev-docs (#6309)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-03 14:45:29 +01:00
34a7d48b95 fix: provide HelpButton title prop (#6209)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-03 12:50:18 +00:00
Aakansha DoshiandGitHub 5c0b15ce2b fix: respect text align when wrapping in a container (#6310)
* fix: respect text align when wrapping in a container

* fix
2023-03-03 18:07:26 +05:30
Aakansha DoshiandGitHub 9f9666110e chore: Add debug flag to enable text container bounding box (#6296)
* debug: Add debug flag to enable text container bounding box

* newline

* fix
2023-03-03 18:01:55 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
05ffce62ef build(deps): bump dns-packet from 5.3.1 to 5.4.0 in /src/packages/excalidraw (#6305)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-03 13:20:53 +01:00
Aakansha DoshiandGitHub 0f06fa3851 feat: create bound container from text (#6301)
* feat: create container from text

* fix lint and spec

* fix

* round off dims

* ceil

* review fixes

* fix

* Add specs

* fix

* fix z-index and type

* consider group

* consider linear bindings

* lint
2023-03-03 17:40:42 +05:30
Aakansha DoshiandGitHub 1ce933d2f5 fix: compute bounding box correctly for text element when multiple element resizing (#6307) 2023-03-03 17:34:11 +05:30
David LuzarandGitHub 15655acb5a fix: use jotai scope for editor-specific atoms (#6308) 2023-03-03 11:58:36 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5b264c2d2 build(deps): bump http-cache-semantics from 4.1.0 to 4.1.1 in /dev-docs (#6192)
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-01 14:51:36 +05:30
Aakansha Doshi 8c89fdfa51 lint 2023-03-01 13:41:20 +05:30
Aakansha Doshi 0e54994187 rename to getLineHeight and use the same line height for regular text elements 2023-03-01 13:38:03 +05:30
Aakansha DoshiandGitHub bd4424bbe3 fix: consider arrow for bound text element (#6297)
* fix: consider arrow for bound text element

* add spec
2023-02-28 19:53:30 +05:30
Aakansha Doshi 91f6e87317 Rename to getContainerMaxWidth and getContainerMaxHeight 2023-02-28 13:51:49 +05:30
Aakansha Doshi a05db6864e Add coverage to gitignore 2023-02-28 13:38:40 +05:30
Aakansha Doshi eacee9a158 cleanup getMaxContainerHeight and getMaxContainerWidth and add specs 2023-02-28 13:38:22 +05:30
Aakansha Doshi 7722de4ef2 cleanup 2023-02-27 20:51:43 +05:30
Aakansha Doshi 0a295e523b Merge remote-tracking branch 'origin/master' into aakansha-refact 2023-02-27 17:23:14 +05:30
Aakansha DoshiandGitHub 38fc51b4e3 fix: text never goes beyond max width for unbound text elements (#6288) 2023-02-27 16:54:29 +05:30
Aakansha Doshi 60deddb0e2 fix: use canvas height when editing bound text 2023-02-27 14:19:56 +05:30
David LuzarandGitHub e1dc748aef fix: svg text baseline (#6285
* fix: svg text baseline

* fix for multiline
2023-02-26 12:51:44 +01:00
Aakansha DoshiandGitHub 0e95e2b386 fix: compute container height from bound text correctly (#6273)
* fix: compute container height from bound text correctly

* fix specs

* Add tests
2023-02-23 17:39:02 +05:30
Aakansha DoshiandGitHub 9659254fd6 feat: improve text measurements in bound containers (#6187)
* feat: move to canvas measureText

* calcualte height with better heuristic

* improve heuristic more

* remove vertical offset as its not needed

* lint

* calculate width of individual char and ceil to calculate width and remove adjustment factor

* push the word if equal to max width

* update height when text overflows for vertical alignment top/bottom

* remove the hack of updating height when line mismatch as its not needed

* remove scroll height and calculate the height instead

* remove unused code

* fix

* remove

* use math.ceil for whole width instead of individual chars

* fix tests

* fix

* fix

* redraw text bounding box instead when font loaded to fix alignment as well

* fix

* fix

* fix

* Add a 0.05px extra only for firefox

* Add spec

* stop taking ceil and increase firefox editor width by 0.05px

* Ad 0.05px in safari too

* lint

* lint

* remove baseline from measureFontSizeFromWH

* don't redraw on font load

* lint

* refactor name and signature
2023-02-23 16:33:10 +05:30
39b96cb011 fix: fit mobile toolbar and make scrollable (#6270)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-02-23 11:24:04 +01:00
David LuzarandGitHub 04a8c22f39 fix: rerender i18n in host components on lang change (#6224) 2023-02-22 14:01:23 +00:00
Excalidraw BotandGitHub e4506be3e8 chore: Update translations from Crowdin (#6191) 2023-02-22 11:23:10 +00:00
Hikaru YoshinoandGitHub 1e816e87bf fix: indenting via tab clashing with IME compositor (#6258) 2023-02-22 12:10:29 +01:00
Aakansha DoshiandGitHub 5368ddef74 fix: improve text wrapping inside rhombus and more fixes (#6265)
* fix: improve text wrapping inside rhombus

* Add comments

* specs

* fix: shift resize and multiple element regression for ellipse and rhombus

* use container width for scaling font size

* fix

* fix multiple resize

* lint

* redraw on submit

* redraw only newly pasted elements

* no padding when center

* fix tests

* fix

* dont add padding in rhombus when aligning

* refactor

* fix

* move getMaxContainerHeight and getMaxContainerWidth to textElement.ts

* Add specs
2023-02-22 16:28:12 +05:30
Aakansha DoshiandGitHub 88ff32e9b3 fix: improve text wrapping in ellipse and alignment (#6172)
* fix: improve text wrapping in ellipse

* compute height when font properties updated

* fix alignment

* fix alignment when resizing

* fix

* ad padding

* always compute height when redrawing bounding box and refactor

* lint

* fix specs

* fix

* redraw text bounding box when pasted or refreshed

* fix

* Add specs

* fix

* restore on font load

* add comments
2023-02-21 12:36:43 +05:30
Jan KlassandGitHub 0fcbddda8e docs: Fix outdated link in README.md (#6263) 2023-02-20 09:44:25 +00:00
Aakansha DoshiandGitHub b9ba407f96 feat: Bind text to container if double clicked on filled shape or stroke (#6250)
* feat: bind text to container when clicked on filled shape or element stroke

* Bind if double clicked on stroke as well

* remove

* specs

* remove

* shuffle

* fix

* back to normal
2023-02-16 20:46:51 +05:30
Aakansha DoshiandGitHub 5acb99777a docs: fix typo (#6252) 2023-02-16 19:45:41 +05:30
David LuzarandGitHub b107c9af2a docs: fix next.js example (#6241) 2023-02-15 15:14:15 +01:00
c587b85b4e docs: new readme (#6240)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-02-15 14:45:06 +01:00
Luka HietalaandGitHub 9686141113 docs: Fixed broken codesandbox link in the dev-docs (#6229)
fixed broken link
2023-02-15 05:31:07 +00:00
Aakansha DoshiandGitHub 0d7ee891e0 feat: Make repair and refreshDimensions configurable in restoreElements (#6238)
* fix: don't repair during reconcilation

* Add opts to restoreElement and enable refreshDimensions and repair via config

* remove

* update changelog

* fix tests

* rename to repairBindings
2023-02-15 10:41:11 +05:30
Aakansha DoshiandGitHub 71fb60394a docs: enable Algolia for search (#6230) 2023-02-13 17:39:11 +05:30
Aakansha DoshiandGitHub c9d18ecab6 fix: don't allow blank space in collab name (#6211)
* don't allow blank space in collab name

* add spec

* prevent blank
2023-02-09 15:51:49 +05:30
DanielJGeigerandGitHub 8c1168ef33 refactor: Make the example React app reusable without duplication (#6188) 2023-02-07 12:41:20 +05:30
Dejavu MoeandGitHub c3c45a8c37 fix: docker build architecture:linux/amd64 error occur on linux/arm64 instance (#6197)
fix docker build
when in linux/arm64 use docker buildx plugin to build linux/amd64 image, a build error will occur causing the build to break
2023-02-07 11:44:31 +05:30
Matthieu RossignonandGitHub a8e6028c33 feat: show error message when not connected to internet while collabo… (#6165)
Co-authored-by: dwelle <luzar.david@gmail.com>
Resolves https://github.com/excalidraw/excalidraw/issues/5994
2023-02-04 15:03:39 +01:00
Jang MinandGitHub 11e2f90ca1 feat: shortcut for clearCanvas confirmDialog (#6114)
Co-authored-by: dwelle <luzar.david@gmail.com>
resolve https://github.com/excalidraw/excalidraw/issues/5818
2023-02-04 13:33:40 +01:00
Excalidraw BotandGitHub 4db87a0b6a chore: Update translations from Crowdin (#6150) 2023-02-04 10:04:15 +01:00
4414069617 feat: disable canvas smoothing (antialiasing) for right-angled elements (#6186)Co-authored-by: Ignacio Cuadra <67276174+ignacio-cuadra@users.noreply.github.com>
* feat: disable canvas smoothing for text and other types

* disable smoothing for all right-angled elements

* Update src/renderer/renderElement.ts

Co-authored-by: Ignacio Cuadra <67276174+ignacio-cuadra@users.noreply.github.com>

* Update src/renderer/renderElement.ts

Co-authored-by: Ignacio Cuadra <67276174+ignacio-cuadra@users.noreply.github.com>

* fix lint

* always enable smoothing while zooming

---------

Co-authored-by: Ignacio Cuadra <67276174+ignacio-cuadra@users.noreply.github.com>
2023-02-03 17:07:14 +01:00
a9c5bdb878 fix: sort bound text elements to fix text duplication z-index error (#5130)
* fix: sort bound text elements to fix text duplication z-index error

* improve & sort groups & add tests

* fix backtracking and discontiguous groups

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-02-02 16:23:39 +08:00
Aakansha DoshiandGitHub 5a0334f37f fix: hide welcome screen on mobile once user interacts (#6185)
* fix: hide welcome screen on mobile once started drawing

* Add specs
2023-02-02 12:58:45 +05:30
Aakansha DoshiandGitHub d8a4ca6911 docs: show last updated time and author (#6183)
docs:show last updated time and author
2023-02-01 21:09:23 +05:30
Aakansha DoshiandGitHub eb9eeefc63 fix: edit link in docs (#6182) 2023-02-01 20:27:31 +05:30
161 changed files with 4449 additions and 1865 deletions
+5
View File
@@ -22,3 +22,8 @@ REACT_APP_DEV_ENABLE_SW=
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false
#Debug flags
# To enable bounding box for text containers
REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
+1
View File
@@ -25,3 +25,4 @@ src/packages/excalidraw/types
src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js
coverage
+1 -1
View File
@@ -3,7 +3,7 @@ FROM node:14-alpine AS build
WORKDIR /opt/node_app
COPY package.json yarn.lock ./
RUN yarn --ignore-optional
RUN yarn --ignore-optional --network-timeout 600000
ARG NODE_ENV=production
+111 -29
View File
@@ -1,29 +1,121 @@
<div align="center" style="display:flex;flex-direction:column;"}>
<a href="https://excalidraw.com">
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams."/>
</a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br/>Collaborative and end-to-end encrypted.</h3>
<p>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
<a href="https://discord.gg/UexuTaE">
<img alt="Chat with us on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
</p>
<a href="https://excalidraw.com/" target="_blank" rel="noopener">
<picture>
<source media="(prefers-color-scheme: dark)" alt="Excalidraw" srcset="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/github%2FExcalidraw_Github_cover_dark.png" />
<img alt="Excalidraw" src="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/github%2FExcalidraw_Github_cover.png" />
</picture>
</a>
<h4 align="center">
<a href="https://excalidraw.com">Excalidraw Editor</a> |
<a href="https://blog.excalidraw.com">Blog</a> |
<a href="https://docs.excalidraw.com">Documentation</a> |
<a href="https://plus.excalidraw.com">Excalidraw+</a>
</h4>
<div align="center">
<h2>
An open source virtual hand-drawn style whiteboard. </br>
Collaborative and end-to-end encrypted. </br>
<br />
</h3>
</div>
## Try now
<br />
<p align="center">
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</a>
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
</p>
Visit [excalidraw.com](https://excalidraw.com) to start sketching.
<div align="center">
<figure>
<a href="https://excalidraw.com" target="_blank" rel="noopener">
<img src="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/github%2Fproduct_showcase.png" alt="Product showcase" />
</a>
<figcaption>
<p align="center">
Create beautiful hand-drawn like diagrams, wireframes, or whatever you like.
</p>
</figcaption>
</figure>
</div>
## Community
## Features
For latest updates, follow us on [twitter](https://twitter.com/excalidraw). If you need help or want to chat, join us on [Discord](https://discord.gg/UexuTaE). For releases and deep dives, check out our [blog](https://blog.excalidraw.com). Report bugs on [GitHub](https://github.com/excalidraw/excalidraw/issues).
The Excalidraw editor (npm package) supports:
## Supporting Excalidraw
- 💯&nbsp;Free & open-source.
- 🎨&nbsp;Infinite, canvas-based whiteboard.
- ✍️&nbsp;Hand-drawn like style.
- 🌓&nbsp;Dark mode.
- 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support.
- 👅&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
- ➡️&nbsp;Arrow-binding & labeled arrows.
- 🔙&nbsp;Undo / Redo.
- 🔍&nbsp;Zoom and panning support.
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
## 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/master/src/excalidraw-app) is part of this repository as well, and the app features:
- 📡&nbsp;PWA support (works offline).
- 🤼&nbsp;Real-time collaboration.
- 🔒&nbsp;End-to-end encryption.
- 💾&nbsp;Local-first support (autosaves to the browser).
- 🔗&nbsp;Shareable links (export to a readonly link you can share with others).
We'll be adding these features as drop-in plugins for the npm package in the future.
## Quick start
Install the [Excalidraw npm package](https://www.npmjs.com/package/@excalidraw/excalidraw):
```
npm install react react-dom @excalidraw/excalidraw
```
or via yarn
```
yarn add react react-dom @excalidraw/excalidraw
```
Don't forget to check out our [Documentation](https://docs.excalidraw.com)!
## Contributing
- Missing something or found a bug? [Report here](https://github.com/excalidraw/excalidraw/issues).
- Want to contribute? Check out our [contribution guide](https://docs.excalidraw.com/docs/introduction/contributing) or let us know on [Discord](https://discord.gg/UexuTaE).
- Want to help with translations? See the [translation guide](https://docs.excalidraw.com/docs/introduction/contributing#translating).
## Integrations
- [VScode extension](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor)
- [npm package](https://www.npmjs.com/package/@excalidraw/excalidraw)
## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) • and many others
## Sponsors & support
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw) or use [Excalidraw+](https://plus.excalidraw.com/).
## Thank you for supporting Excalidraw
[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120"/>](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
@@ -32,13 +124,3 @@ If you like the project, you can become a sponsor at [Open Collective](https://o
Last but not least, we're thankful to these companies for offering their services for free:
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## Developers
You can integrate Excalidraw into your app by installing our [npm component](https://npmjs.com/package/@excalidraw/excalidraw).
Visit our documentation on [https://docs.excalidraw.com](https://docs.excalidraw.com).
## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/)
@@ -53,7 +53,7 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
**_Signature_**
<pre>
restoreElements(
restore(
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;
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>
@@ -4,6 +4,34 @@
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
### Turning off Aggressive Anti-Fingerprinting in Brave browser
When *Aggressive Anti-Fingerprinting* is turned on, the `measureText` API breaks which in turn breaks the Text Elements in your drawings. Here is more [info](https://github.com/excalidraw/excalidraw/pull/6336) on the same.
We strongly recommend turning it off. You can follow the steps below on how to do so.
1. Open [excalidraw.com](https://excalidraw.com) in Brave and click on the **Shield** button
![Shield button](../../assets/brave-shield.png)
<div style={{width:'30rem'}}>
2. Once opened, look for **Aggressively Block Fingerprinting**
![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting**
![Block filtering](../../assets/block-fingerprint.png)
4. Thats all. All text elements should be fixed now 🎉
</div>
If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE).
## Need help?
Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw).
@@ -34,14 +34,16 @@ function App() {
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
```jsx showLineNumbers
import { useState, useEffect } from "react";
export default function App() {
const [Comp, setComp] = useState(null);
const [Excalidraw, setExcalidraw] = useState(null);
useEffect(() => {
import("@excalidraw/excalidraw").then((comp) => setComp(comp.default));
import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.Excalidraw));
}, []);
return <>{Comp && <Comp />}</>;
return <>{Excalidraw && <Excalidraw />}</>;
}
```
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

+1 -1
View File
@@ -20,7 +20,7 @@ Pull requests are welcome. For major changes, please [open an issue](https://git
### Option 2 - CodeSandbox
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
1. Go to https://codesandbox.io/p/github/excalidraw/excalidraw
1. Connect your GitHub account
1. Go to Git tab on left side
1. Tap on `Fork Sandbox`
+9 -1
View File
@@ -30,7 +30,10 @@ const config = {
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
editUrl: "https://github.com/excalidraw/docs/tree/master/",
editUrl:
"https://github.com/excalidraw/excalidraw/tree/master/dev-docs/",
showLastUpdateAuthor: true,
showLastUpdateTime: true,
},
theme: {
customCss: [
@@ -129,6 +132,11 @@ const config = {
tableOfContents: {
maxHeadingLevel: 4,
},
algolia: {
appId: "8FEAOD28DI",
apiKey: "4b07cca33ff2d2919bc95ff98f148e9e",
indexName: "excalidraw",
},
}),
themes: ["@docusaurus/theme-live-codeblock"],
plugins: ["docusaurus-plugin-sass"],
+9 -9
View File
@@ -1785,9 +1785,9 @@
"@hapi/hoek" "^9.0.0"
"@sideway/formula@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
version "3.0.1"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
"@sideway/pinpoint@^2.0.0":
version "2.0.0"
@@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1:
entities "^4.3.0"
http-cache-semantics@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
version "4.1.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
http-deceiver@^1.2.7:
version "1.2.7"
@@ -7542,9 +7542,9 @@ webpack-sources@^3.2.2, webpack-sources@^3.2.3:
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.73.0:
version "5.74.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980"
integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==
version "5.76.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c"
integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==
dependencies:
"@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51"
+7 -7
View File
@@ -25,11 +25,6 @@
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1",
"@types/jest": "27.4.0",
"@types/pica": "5.1.3",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
@@ -57,7 +52,6 @@
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"tunnel-rat": "0.1.0",
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
@@ -75,9 +69,14 @@
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/pako": "1.0.3",
"@types/pica": "5.1.3",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/resize-observer-browser": "0.1.7",
"@types/socket.io-client": "1.4.36",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
@@ -88,7 +87,8 @@
"lint-staged": "12.3.7",
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0"
"rewire": "6.0.0",
"typescript": "4.9.4"
},
"engines": {
"node": ">=14.0.0"
+6
View File
@@ -2,6 +2,9 @@ const fs = require("fs");
const THRESSHOLD = 85;
// we're using BCP 47 language tags as keys
// e.g. https://gist.github.com/typpo/b2b828a35e683b9bf8db91b5404f1bd1
const crowdinMap = {
"ar-SA": "en-ar",
"bg-BG": "en-bg",
@@ -52,6 +55,7 @@ const crowdinMap = {
"kk-KZ": "en-kk",
"vi-VN": "en-vi",
"mr-IN": "en-mr",
"th-TH": "en-th",
};
const flags = {
@@ -104,6 +108,7 @@ const flags = {
"eu-ES": "🇪🇦",
"vi-VN": "🇻🇳",
"mr-IN": "🇮🇳",
"th-TH": "🇹🇭",
};
const languages = {
@@ -156,6 +161,7 @@ const languages = {
"zh-TW": "繁體中文",
"vi-VN": "Tiếng Việt",
"mr-IN": "मराठी",
"th-TH": "ภาษาไทย",
};
const percentages = fs.readFileSync(
+159 -15
View File
@@ -1,11 +1,12 @@
import { VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement } from "../element";
import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
computeContainerDimensionForBoundText,
getBoundTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import { measureText } from "../element/textMeasurements";
import {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
@@ -13,8 +14,11 @@ import {
import {
hasBoundTextElement,
isTextBindableContainer,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
@@ -38,9 +42,10 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
const { width, height } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
@@ -51,7 +56,6 @@ export const actionUnbindText = register({
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
@@ -130,19 +134,159 @@ export const actionBindText = register({
}),
});
redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return {
elements: updatedElements,
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
};
},
});
const pushTextAboveContainer = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return updatedElements;
};
const pushContainerBelowText = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex, 1);
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
return updatedElements;
};
export const actionCreateContainerFromText = register({
name: "createContainerFromText",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1 && isTextElement(selectedElements[0]);
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = elements.slice();
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
const textElement = selectedElements[0];
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 });
}
});
}
mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
});
redrawTextBoundingBox(textElement, container);
return {
elements: pushContainerBelowText(
[...elements, container],
container,
textElement,
),
appState: {
...appState,
selectedElementIds: {
[container.id]: true,
[textElement.id]: false,
},
},
commitToHistory: true,
};
}
return {
elements: updatedElements,
appState,
commitToHistory: true,
};
},
});
+3 -1
View File
@@ -154,7 +154,9 @@ export const actionDeleteSelected = register({
};
},
contextItemLabel: "labels.delete",
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
+99 -20
View File
@@ -16,8 +16,12 @@ import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import { bindTextToShapeAfterDuplication } from "../element/textElement";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
} from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
export const actionDuplicateSelection = register({
@@ -64,6 +68,11 @@ const duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<ActionResult> => {
// ---------------------------------------------------------------------------
// step (1)
const sortedElements = normalizeElementOrder(elements);
const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
@@ -85,42 +94,112 @@ const duplicateElements = (
return newElement;
};
const finalElements: ExcalidrawElement[] = [];
let index = 0;
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, true),
getSelectedElements(sortedElements, appState, true),
);
while (index < elements.length) {
const element = elements[index];
// Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const markAsProcessed = (elements: ExcalidrawElement[]) => {
for (const element of elements) {
processedIds.set(element.id, true);
}
return elements;
};
const elementsWithClones: ExcalidrawElement[] = [];
let index = -1;
while (++index < sortedElements.length) {
const element = sortedElements[index];
if (processedIds.get(element.id)) {
continue;
}
const boundTextElement = getBoundTextElement(element);
if (selectedElementIds.get(element.id)) {
if (element.groupIds.length) {
// if a group or a container/bound-text, duplicate atomically
if (element.groupIds.length || boundTextElement) {
const groupId = getSelectedGroupForElement(appState, element);
// if group selected, duplicate it atomically
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId);
finalElements.push(
...groupElements,
...groupElements.map((element) =>
const groupElements = getElementsInGroup(sortedElements, groupId);
elementsWithClones.push(
...markAsProcessed([
...groupElements,
...groupElements.map((element) =>
duplicateAndOffsetElement(element),
),
]),
);
continue;
}
if (boundTextElement) {
elementsWithClones.push(
...markAsProcessed([
element,
boundTextElement,
duplicateAndOffsetElement(element),
),
duplicateAndOffsetElement(boundTextElement),
]),
);
index = index + groupElements.length;
continue;
}
}
finalElements.push(element, duplicateAndOffsetElement(element));
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} else {
finalElements.push(element);
elementsWithClones.push(...markAsProcessed([element]));
}
index++;
}
// step (2)
// second pass to remove duplicates. We loop from the end as it's likelier
// that the last elements are in the correct order (contiguous or otherwise).
// Thus we need to reverse as the last step (3).
const finalElementsReversed: ExcalidrawElement[] = [];
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
index = elementsWithClones.length;
while (--index >= 0) {
const element = elementsWithClones[index];
if (!finalElementIds.get(element.id)) {
finalElementIds.set(element.id, true);
finalElementsReversed.push(element);
}
}
// step (3)
const finalElements = finalElementsReversed.reverse();
// ---------------------------------------------------------------------------
bindTextToShapeAfterDuplication(
finalElements,
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return {
elements: finalElements,
+5
View File
@@ -55,6 +55,7 @@ import {
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import { getDefaultLineHeight } from "../element/textMeasurements";
import {
isBoundToContainer,
isLinearElement,
@@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
oldElement,
{
fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
@@ -745,16 +747,19 @@ export const actionChangeTextAlign = register({
value: "left",
text: t("labels.left"),
icon: TextAlignLeftIcon,
testId: "align-left",
},
{
value: "center",
text: t("labels.center"),
icon: TextAlignCenterIcon,
testId: "align-horizontal-center",
},
{
value: "right",
text: t("labels.right"),
icon: TextAlignRightIcon,
testId: "align-right",
},
]}
value={getFormValue(
+10 -3
View File
@@ -19,6 +19,7 @@ import {
getDefaultRoundnessTypeForElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { getDefaultLineHeight } from "../element/textMeasurements";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -92,12 +93,18 @@ export const actionPasteStyles = register({
});
if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
fontFamily:
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
fontSize,
fontFamily,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
getDefaultLineHeight(fontFamily),
});
let container = null;
if (newElement.containerId) {
+3
View File
@@ -1,5 +1,6 @@
import { isDarwin } from "../constants";
import { t } from "../i18n";
import { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
@@ -8,6 +9,7 @@ export type ShortcutName =
ActionName,
| "toggleTheme"
| "loadScene"
| "clearCanvas"
| "cut"
| "copy"
| "paste"
@@ -41,6 +43,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")],
loadScene: [getShortcutKey("CtrlOrCmd+O")],
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
+3 -1
View File
@@ -6,6 +6,7 @@ import {
ExcalidrawProps,
BinaryFiles,
} from "../types";
import { MarkOptional } from "../utility-types";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
@@ -113,7 +114,8 @@ export type ActionName =
| "toggleLock"
| "toggleLinearEditor"
| "toggleEraserTool"
| "toggleHandTool";
| "toggleHandTool"
| "createContainerFromText";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
+1 -1
View File
@@ -21,7 +21,7 @@ export const getClientColors = (clientId: string, appState: AppState) => {
};
export const getClientInitials = (userName?: string | null) => {
if (!userName) {
if (!userName?.trim()) {
return "?";
}
return userName.trim()[0].toUpperCase();
+6 -2
View File
@@ -30,7 +30,10 @@ import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
import { shouldAllowVerticalAlign } from "../element/textElement";
import {
shouldAllowVerticalAlign,
suppportsHorizontalAlign,
} from "../element/textElement";
export const SelectedShapeActions = ({
appState,
@@ -122,7 +125,8 @@ export const SelectedShapeActions = ({
{renderAction("changeFontFamily")}
{renderAction("changeTextAlign")}
{suppportsHorizontalAlign(targetElements) &&
renderAction("changeTextAlign")}
</>
)}
+2
View File
@@ -1,6 +1,7 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions";
import { t } from "../i18n";
import { jotaiScope } from "../jotai";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
@@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager();
+45
View File
@@ -0,0 +1,45 @@
import ReactDOM from "react-dom";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { render, queryByTestId } from "../tests/test-utils";
import ExcalidrawApp from "../excalidraw-app";
const renderScene = jest.spyOn(Renderer, "renderScene");
describe("Test <App/>", () => {
beforeEach(async () => {
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear();
renderScene.mockClear();
reseed(7);
});
it("should show error modal when using brave and measureText API is not working", async () => {
(global.navigator as any).brave = {
isBrave: {
name: "isBrave",
},
};
const originalContext = global.HTMLCanvasElement.prototype.getContext("2d");
//@ts-ignore
global.HTMLCanvasElement.prototype.getContext = (contextId) => {
return {
...originalContext,
measureText: () => ({
width: 0,
}),
};
};
await render(<ExcalidrawApp />);
expect(
queryByTestId(
document.querySelector(".excalidraw-modal-container")!,
"brave-measure-text-error",
),
).toMatchSnapshot();
});
});
+73 -31
View File
@@ -62,6 +62,7 @@ import {
GRID_SIZE,
IMAGE_RENDER_TIMEOUT,
isAndroid,
isBrave,
LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES,
@@ -108,6 +109,7 @@ import {
textWysiwyg,
transformElements,
updateTextElement,
redrawTextBoundingBox,
} from "../element";
import {
bindOrUnbindLinearElement,
@@ -226,6 +228,7 @@ import {
setEraserCursor,
updateActiveTool,
getShortcutKey,
isTransparent,
} from "../utils";
import {
ContextMenu,
@@ -257,15 +260,20 @@ import throttle from "lodash.throttle";
import { fileOpen, FileSystemHandle } from "../data/filesystem";
import {
bindTextToShapeAfterDuplication,
getApproxLineHeight,
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getContainerCenter,
getContainerDims,
getContainerElement,
getTextBindableContainerAtPosition,
isValidTextContainer,
} from "../element/textElement";
import {
getApproxMinContainerHeight,
getApproxMinContainerWidth,
isMeasureTextSupported,
getLineHeightInPx,
getDefaultLineHeight,
} from "../element/textMeasurements";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import {
normalizeLink,
@@ -279,6 +287,10 @@ import { shouldShowBoundingBox } from "../element/transformHandles";
import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard";
import { actionToggleHandTool } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionCreateContainerFromText } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
const deviceContextInitialValue = {
isSmScreen: false,
@@ -423,7 +435,6 @@ class App extends React.Component<AppProps, AppState> {
};
this.id = nanoid();
this.library = new Library(this);
if (excalidrawRef) {
const readyPromise =
@@ -705,6 +716,8 @@ class App extends React.Component<AppProps, AppState> {
const theme =
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
let name = actionResult?.appState?.name ?? this.state.name;
const errorMessage =
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
}
@@ -720,7 +733,6 @@ class App extends React.Component<AppProps, AppState> {
if (typeof this.props.name !== "undefined") {
name = this.props.name;
}
this.setState(
(state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
@@ -738,6 +750,7 @@ class App extends React.Component<AppProps, AppState> {
gridSize,
theme,
name,
errorMessage,
});
},
() => {
@@ -834,7 +847,7 @@ class App extends React.Component<AppProps, AppState> {
},
};
}
const scene = restore(initialData, null, null);
const scene = restore(initialData, null, null, { repairBindings: true });
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
@@ -866,7 +879,6 @@ class App extends React.Component<AppProps, AppState> {
),
};
}
// FontFaceSet loadingdone event we listen on may not always fire
// (looking at you Safari), so on init we manually load fonts for current
// text elements on canvas, and rerender them once done. This also
@@ -994,6 +1006,13 @@ class App extends React.Component<AppProps, AppState> {
} else {
this.updateDOMRect(this.initializeScene);
}
// note that this check seems to always pass in localhost
if (isBrave() && !isMeasureTextSupported()) {
this.setState({
errorMessage: <BraveMeasureTextError />,
});
}
}
public componentWillUnmount() {
@@ -1622,6 +1641,7 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId.set(element.id, newElement.id);
return newElement;
});
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
const nextElements = [
...this.scene.getElementsIncludingDeleted(),
@@ -1634,6 +1654,14 @@ class App extends React.Component<AppProps, AppState> {
}
this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement(newElement);
redrawTextBoundingBox(newElement, container);
}
});
this.history.resumeRecording();
this.setState(
@@ -1706,12 +1734,14 @@ class App extends React.Component<AppProps, AppState> {
(acc: ExcalidrawTextElement[], line, idx) => {
const text = line.trim();
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) {
const element = newTextElement({
...textElementProps,
x,
y: currentY,
text,
lineHeight,
});
acc.push(element);
currentY += element.height + LINE_GAP;
@@ -1720,14 +1750,9 @@ class App extends React.Component<AppProps, AppState> {
// add paragraph only if previous line was not empty, IOW don't add
// more than one empty line
if (prevLine) {
const defaultLineHeight = getApproxLineHeight(
getFontString({
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
}),
);
currentY += defaultLineHeight + LINE_GAP;
currentY +=
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
LINE_GAP;
}
}
@@ -1952,7 +1977,6 @@ class App extends React.Component<AppProps, AppState> {
);
// Input handling
private onKeyDown = withBatchedUpdates(
(event: React.KeyboardEvent | KeyboardEvent) => {
// normalize `event.key` when CapsLock is pressed #2372
@@ -2194,6 +2218,13 @@ class App extends React.Component<AppProps, AppState> {
event.stopPropagation();
}
}
if (
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
}
},
);
@@ -2576,6 +2607,13 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if (
!existingTextElement &&
shouldBindToContainer &&
@@ -2583,11 +2621,14 @@ class App extends React.Component<AppProps, AppState> {
!isArrowElement(container)
) {
const fontString = {
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
fontSize,
fontFamily,
};
const minWidth = getApproxMinLineWidth(getFontString(fontString));
const minHeight = getApproxMinLineHeight(getFontString(fontString));
const minWidth = getApproxMinContainerWidth(
getFontString(fontString),
lineHeight,
);
const minHeight = getApproxMinContainerHeight(fontSize, lineHeight);
const containerDims = getContainerDims(container);
const newHeight = Math.max(containerDims.height, minHeight);
const newWidth = Math.max(containerDims.width, minWidth);
@@ -2621,8 +2662,8 @@ class App extends React.Component<AppProps, AppState> {
opacity: this.state.currentItemOpacity,
roundness: null,
text: "",
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
fontSize,
fontFamily,
textAlign: parentCenterPosition
? "center"
: this.state.currentItemTextAlign,
@@ -2632,6 +2673,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [],
locked: false,
lineHeight,
});
if (!existingTextElement && shouldBindToContainer && container) {
@@ -2654,14 +2696,6 @@ class App extends React.Component<AppProps, AppState> {
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({
@@ -2754,7 +2788,14 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
);
if (container) {
if (isArrowElement(container) || hasBoundTextElement(container)) {
if (
hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) ||
isHittingElementNotConsideringBoundingBox(container, this.state, [
sceneX,
sceneY,
])
) {
const midPoint = getContainerCenter(container, this.state);
sceneX = midPoint.x;
@@ -6218,6 +6259,7 @@ class App extends React.Component<AppProps, AppState> {
actionGroup,
actionUnbindText,
actionBindText,
actionCreateContainerFromText,
actionUngroup,
CONTEXT_MENU_SEPARATOR,
actionAddToLibrary,
+42
View File
@@ -0,0 +1,42 @@
import { t } from "../i18n";
const BraveMeasureTextError = () => {
return (
<div data-testid="brave-measure-text-error">
<p>
{t("errors.brave_measure_text_error.start")} &nbsp;
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
</span>{" "}
{t("errors.brave_measure_text_error.setting_enabled")}.
<br />
<br />
{t("errors.brave_measure_text_error.break")}{" "}
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.text_elements")}
</span>{" "}
{t("errors.brave_measure_text_error.in_your_drawings")}.
</p>
<p>
{t("errors.brave_measure_text_error.strongly_recommend")}{" "}
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
{" "}
{t("errors.brave_measure_text_error.steps")}
</a>{" "}
{t("errors.brave_measure_text_error.how")}.
</p>
<p>
{t("errors.brave_measure_text_error.disable_setting")}{" "}
<a href="https://github.com/excalidraw/excalidraw/issues/new">
{t("errors.brave_measure_text_error.issue")}
</a>{" "}
{t("errors.brave_measure_text_error.write")}{" "}
<a href="https://discord.gg/UexuTaE">
{t("errors.brave_measure_text_error.discord")}
</a>
.
</p>
</div>
);
};
export default BraveMeasureTextError;
+2 -1
View File
@@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
@@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => {
...rest
} = props;
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
return (
<Dialog
+2 -1
View File
@@ -16,6 +16,7 @@ import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { jotaiScope } from "../jotai";
export interface DialogProps {
children: React.ReactNode;
@@ -72,7 +73,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const onClose = () => {
setAppState({ openMenu: null });
+4 -4
View File
@@ -5,13 +5,13 @@ import { Dialog } from "./Dialog";
import { useExcalidrawContainer } from "./App";
export const ErrorDialog = ({
message,
children,
onClose,
}: {
message: string;
children?: React.ReactNode;
onClose?: () => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(!!message);
const [modalIsShown, setModalIsShown] = useState(!!children);
const { container: excalidrawContainer } = useExcalidrawContainer();
const handleClose = React.useCallback(() => {
@@ -32,7 +32,7 @@ export const ErrorDialog = ({
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
<div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
</Dialog>
)}
</>
+4
View File
@@ -9,6 +9,10 @@
text-align: center;
padding: var(--preview-padding);
margin-bottom: calc(var(--space-factor) * 3);
display: flex;
justify-content: center;
align-items: center;
}
.ExportDialog__preview canvas {
+3 -3
View File
@@ -1,7 +1,7 @@
import { t } from "../i18n";
import { HelpIcon } from "./icons";
type HelpButtonProps = {
title?: string;
name?: string;
id?: string;
onClick?(): void;
@@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => (
className="help-icon"
onClick={props.onClick}
type="button"
title={`${props.title} — ?`}
aria-label={props.title}
title={`${t("helpDialog.title")} — ?`}
aria-label={t("helpDialog.title")}
>
{HelpIcon}
</button>
+24 -20
View File
@@ -273,22 +273,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--editor"
caption={t("helpDialog.editor")}
>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepBoxSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
@@ -297,6 +281,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]}
isOr={true}
/>
<Shortcut
label={t("buttons.clearReset")}
shortcuts={[getShortcutKey("CtrlOrCmd+Delete")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Delete")]}
/>
<Shortcut
label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
@@ -313,6 +305,22 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepBoxSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]}
/>
{/* firefox supports clipboard API under a flag, so we'll
show users what they can do in the error message */}
{(probablySupportsClipboardBlob || isFirefox) && (
@@ -329,10 +337,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.pasteStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Delete")]}
/>
<Shortcut
label={t("labels.sendToBack")}
shortcuts={[
+4 -5
View File
@@ -124,7 +124,6 @@ const LayerUI = ({
children,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
const renderJSONExportDialog = () => {
@@ -365,10 +364,9 @@ const LayerUI = ({
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
onClose={() => setAppState({ errorMessage: null })}
/>
<ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
{appState.errorMessage}
</ErrorDialog>
)}
{appState.openDialog === "help" && (
<HelpDialog
@@ -409,6 +407,7 @@ const LayerUI = ({
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen}
/>
)}
@@ -48,6 +48,7 @@ export const LibraryMenuHeader: React.FC<{
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
jotaiScope,
);
const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length
+3 -1
View File
@@ -41,6 +41,7 @@ type MobileMenuProps = {
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean;
};
export const MobileMenu = ({
@@ -57,12 +58,13 @@ export const MobileMenu = ({
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
}: MobileMenuProps) => {
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
<welcomeScreenCenterTunnel.Out />
{renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
<Section heading="shapes">
{(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center">
@@ -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>
`;
+16 -23
View File
@@ -1,5 +1,5 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import { useI18n } from "../../i18n";
import {
useExcalidrawAppState,
useExcalidrawSetAppState,
@@ -31,11 +31,10 @@ import "./DefaultItems.scss";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionLoadScene)) {
@@ -57,9 +56,7 @@ export const LoadScene = () => {
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
@@ -80,9 +77,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const { t } = useI18n();
return (
<DropdownMenuItem
icon={ExportImageIcon}
@@ -98,9 +93,7 @@ export const SaveAsImage = () => {
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@@ -119,10 +112,12 @@ export const Help = () => {
Help.displayName = "Help";
export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const { t } = useI18n();
const setActiveConfirmDialog = useSetAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) {
@@ -143,6 +138,7 @@ export const ClearCanvas = () => {
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
const { t } = useI18n();
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
@@ -175,6 +171,7 @@ export const ToggleTheme = () => {
ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => {
const { t } = useI18n();
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
@@ -195,9 +192,7 @@ export const ChangeCanvasBackground = () => {
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const { t } = useI18n();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItem
@@ -248,9 +243,7 @@ export const LiveCollaborationTrigger = ({
onSelect: () => void;
isCollaborating: boolean;
}) => {
// FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const { t } = useI18n();
return (
<DropdownMenuItem
data-testid="collab-button"
@@ -1,6 +1,6 @@
import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import { t, useI18n } from "../../i18n";
import {
useDevice,
useExcalidrawActionManager,
@@ -172,10 +172,7 @@ const MenuItemLiveCollaborationTrigger = ({
}: {
onSelect: () => any;
}) => {
// FIXME when we tie t() to lang state
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const appState = useExcalidrawAppState();
const { t } = useI18n();
return (
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
{t("labels.liveCollaboration")}
+6
View File
@@ -9,6 +9,12 @@ export const isFirefox =
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const APP_NAME = "Excalidraw";
+3 -2
View File
@@ -530,6 +530,7 @@
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 3px;
height: 3px;
}
::-webkit-scrollbar-thumb {
@@ -567,8 +568,8 @@
}
.App-toolbar--mobile {
overflow-x: hidden;
max-width: 100vw;
overflow-x: auto;
max-width: 90vw;
.ToolIcon__keybinding {
display: none;
+5
View File
@@ -95,6 +95,9 @@
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-warning: #fceeca;
--color-text-warning: var(--text-primary-color);
--color-danger: #db6965;
--color-promo: #e70078;
@@ -163,6 +166,8 @@
--color-primary-darkest: #beb9ff;
--color-primary-light: #4f4d6f;
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;
--color-promo: #d297ff;
}
+2
View File
@@ -7,6 +7,7 @@ import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
import { ValueOf } from "../utility-types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
@@ -156,6 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
},
localAppState,
localElements,
{ repairBindings: true, refreshDimensions: true },
),
};
} else if (isValidLibrary(data)) {
+3 -1
View File
@@ -89,7 +89,9 @@ export const exportCanvas = async (
return await fileSave(blob, {
description: "Export to PNG",
name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
// FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle,
});
} else if (type === "clipboard") {
+31 -7
View File
@@ -34,6 +34,11 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
} from "../element/textMeasurements";
type RestoredAppState = Omit<
AppState,
@@ -164,18 +169,32 @@ const restoreElement = (
const [fontPx, _fontFamily]: [string, string] = (
element as any
).font.split(" ");
fontSize = parseInt(fontPx, 10);
fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily);
}
const text = element.text ?? "";
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
text: element.text ?? "",
baseline: element.baseline,
text,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null,
originalText: element.originalText || element.text,
originalText: element.originalText || text,
// 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.
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)),
});
if (refreshDimensions) {
@@ -339,7 +358,7 @@ export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
refreshDimensions = false,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): ExcalidrawElement[] => {
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => {
@@ -348,7 +367,7 @@ export const restoreElements = (
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(
element,
refreshDimensions,
opts?.refreshDimensions,
);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
@@ -361,6 +380,10 @@ export const restoreElements = (
return elements;
}, [] as ExcalidrawElement[]);
if (!opts?.repairBindings) {
return restoredElements;
}
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
@@ -497,9 +520,10 @@ export const restore = (
*/
localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements),
elements: restoreElements(data?.elements, localElements, elementsConfig),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};
+1
View File
@@ -23,6 +23,7 @@ import {
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
+7 -1
View File
@@ -38,6 +38,7 @@ import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement";
import { Mutable } from "../utility-types";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -785,7 +786,12 @@ export const findFocusPointForEllipse = (
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
squares;
const n = (-m * px - 1) / py;
let n = (-m * px - 1) / py;
if (n === 0) {
// if zero {-0, 0}, fall back to a same-sign value in the similar range
n = (Object.is(n, -0) ? -1 : 1) * 0.01;
}
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
return GA.point(x, (-m * x - 1) / n);
+1
View File
@@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
const editorMidPointsCache: {
version: number | null;
+1
View File
@@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
+20 -51
View File
@@ -22,16 +22,20 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getBoundTextElement,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureText,
normalizeText,
wrapText,
getBoundTextMaxWidth,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
import { MarkOptional, Merge, Mutable } from "../utility-types";
import {
measureText,
wrapText,
getDefaultLineHeight,
} from "./textMeasurements";
type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -136,10 +140,12 @@ export const newTextElement = (
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts));
const metrics = measureText(text, getFontString(opts), lineHeight);
const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith(
{
@@ -153,9 +159,9 @@ export const newTextElement = (
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
lineHeight,
},
{},
);
@@ -170,18 +176,14 @@ const getAdjustedDimensions = (
y: number;
width: number;
height: number;
baseline: number;
} => {
let maxWidth = null;
const container = getContainerElement(element);
if (container) {
maxWidth = getMaxContainerWidth(container);
}
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), maxWidth);
const { width: nextWidth, height: nextHeight } = measureText(
nextText,
getFontString(element),
element.lineHeight,
);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -193,7 +195,7 @@ const getAdjustedDimensions = (
const prevMetrics = measureText(
element.text,
getFontString(element),
maxWidth,
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
@@ -258,7 +260,6 @@ const getAdjustedDimensions = (
height: nextHeight,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
baseline: nextBaseline,
};
};
@@ -271,45 +272,13 @@ export const refreshTextDimensions = (
text = wrapText(
text,
getFontString(textElement),
getMaxContainerWidth(container),
getBoundTextMaxWidth(container),
);
}
const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions };
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = (
textElement: ExcalidrawTextElement,
{
+50 -53
View File
@@ -39,17 +39,16 @@ import {
import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
measureText,
getBoundTextMaxWidth,
} from "./textElement";
import { getMaxContainerWidth } from "./newElement";
import {
getApproxMinContainerHeight,
getApproxMinContainerWidth,
} from "./textMeasurements";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI;
@@ -192,11 +191,10 @@ const rescalePointsInElement = (
const MIN_FONT_SIZE = 1;
const measureFontSizeFromWH = (
const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number,
nextHeight: number,
): { size: number; baseline: number } | null => {
): number | null => {
// We only use width to scale font on resize
let width = element.width;
@@ -204,22 +202,15 @@ const measureFontSizeFromWH = (
if (hasContainer) {
const container = getContainerElement(element);
if (container) {
width = getMaxContainerWidth(container);
width = getBoundTextMaxWidth(container);
}
}
const nextFontSize = element.fontSize * (nextWidth / width);
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.containerId ? width : null,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
return nextFontSize;
};
const getSidesForTransformHandle = (
@@ -290,8 +281,8 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
if (nextFont === null) {
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
if (nextFontSize === null) {
return;
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@@ -315,10 +306,9 @@ const resizeSingleTextElement = (
deltaY2,
);
mutateElement(element, {
fontSize: nextFont.size,
fontSize: nextFontSize,
width: nextWidth,
height: nextHeight,
baseline: nextFont.baseline,
x: nextElementX,
y: nextElementY,
});
@@ -371,7 +361,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number; baseline?: number } = {};
let boundTextFontSize: number | null = null;
const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) {
@@ -421,29 +411,32 @@ export const resizeSingleElement = (
boundTextElement.id,
) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) {
boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
}
if (shouldMaintainAspectRatio) {
const boundTextElementPadding =
getBoundTextElementOffset(boundTextElement);
const nextFont = measureFontSizeFromWH(
const updatedElement = {
...element,
width: eleNewWidth,
height: eleNewHeight,
};
const nextFontSize = measureFontSizeFromWidth(
boundTextElement,
eleNewWidth - boundTextElementPadding * 2,
eleNewHeight - boundTextElementPadding * 2,
getBoundTextMaxWidth(updatedElement),
);
if (nextFont === null) {
if (nextFontSize === null) {
return;
}
boundTextFont = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
};
boundTextFontSize = nextFontSize;
} else {
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
const minWidth = getApproxMinContainerWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinContainerHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
}
@@ -576,8 +569,10 @@ export const resizeSingleElement = (
});
mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
if (boundTextElement && boundTextFontSize != null) {
mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
});
}
handleBindTextResize(element, transformHandleDirection);
}
@@ -683,7 +678,6 @@ const resizeMultipleElements = (
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
@@ -692,31 +686,34 @@ const resizeMultipleElements = (
...rescaledPoints,
};
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
let boundTextUpdates: { fontSize: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
const textMeasurements = measureFontSizeFromWH(
const updatedElement = {
...element.latest,
width,
height,
};
const fontSize = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
height - optionalPadding,
boundTextElement
? getBoundTextMaxWidth(updatedElement)
: updatedElement.width,
);
if (!textMeasurements) {
if (!fontSize) {
return;
}
if (isTextElement(element.orig)) {
update.fontSize = textMeasurements.size;
update.baseline = textMeasurements.baseline;
update.fontSize = fontSize;
}
if (boundTextElement) {
boundTextUpdates = {
fontSize: textMeasurements.size,
baseline: textMeasurements.baseline,
fontSize,
};
}
}
+402
View File
@@ -0,0 +1,402 @@
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { normalizeElementOrder } from "./sortElements";
import { ExcalidrawElement } from "./types";
const assertOrder = (
elements: readonly ExcalidrawElement[],
expectedOrder: string[],
) => {
const actualOrder = elements.map((element) => element.id);
expect(actualOrder).toEqual(expectedOrder);
};
describe("normalizeElementsOrder", () => {
it("sort bound-text elements", () => {
const container = API.createElement({
id: "container",
type: "rectangle",
});
const boundText = API.createElement({
id: "boundText",
type: "text",
containerId: container.id,
});
const otherElement = API.createElement({
id: "otherElement",
type: "rectangle",
boundElements: [],
});
const otherElement2 = API.createElement({
id: "otherElement2",
type: "rectangle",
boundElements: [],
});
mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
assertOrder(normalizeElementOrder([container, boundText]), [
"container",
"boundText",
]);
assertOrder(normalizeElementOrder([boundText, container]), [
"container",
"boundText",
]);
assertOrder(
normalizeElementOrder([
boundText,
container,
otherElement,
otherElement2,
]),
["container", "boundText", "otherElement", "otherElement2"],
);
assertOrder(normalizeElementOrder([container, otherElement, boundText]), [
"container",
"boundText",
"otherElement",
]);
assertOrder(
normalizeElementOrder([
container,
otherElement,
otherElement2,
boundText,
]),
["container", "boundText", "otherElement", "otherElement2"],
);
assertOrder(
normalizeElementOrder([
boundText,
otherElement,
container,
otherElement2,
]),
["otherElement", "container", "boundText", "otherElement2"],
);
// noop
assertOrder(
normalizeElementOrder([
otherElement,
container,
boundText,
otherElement2,
]),
["otherElement", "container", "boundText", "otherElement2"],
);
// text has existing containerId, but container doesn't list is
// as a boundElement
assertOrder(
normalizeElementOrder([
API.createElement({
id: "boundText",
type: "text",
containerId: "container",
}),
API.createElement({
id: "container",
type: "rectangle",
}),
]),
["boundText", "container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "boundText",
type: "text",
containerId: "container",
}),
]),
["boundText"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "container",
type: "rectangle",
boundElements: [],
}),
]),
["container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "container",
type: "rectangle",
boundElements: [{ id: "x", type: "text" }],
}),
]),
["container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "arrow",
type: "arrow",
}),
API.createElement({
id: "container",
type: "rectangle",
boundElements: [{ id: "arrow", type: "arrow" }],
}),
]),
["arrow", "container"],
);
});
it("normalize group order", () => {
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect2",
type: "rectangle",
}),
API.createElement({
id: "rect3",
type: "rectangle",
}),
API.createElement({
id: "A_rect4",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "A_rect5",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect6",
type: "rectangle",
}),
API.createElement({
id: "A_rect7",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect2",
type: "rectangle",
}),
API.createElement({
id: "B_rect3",
type: "rectangle",
groupIds: ["B"],
}),
API.createElement({
id: "A_rect4",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "B_rect5",
type: "rectangle",
groupIds: ["B"],
}),
API.createElement({
id: "rect6",
type: "rectangle",
}),
API.createElement({
id: "A_rect7",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"],
);
// nested groups
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "BA_rect2",
type: "rectangle",
groupIds: ["B", "A"],
}),
]),
["A_rect1", "BA_rect2"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "BA_rect1",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "A_rect2",
type: "rectangle",
groupIds: ["A"],
}),
]),
["BA_rect1", "A_rect2"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "BA_rect1",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "A_rect2",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect3",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "rect4",
type: "rectangle",
}),
API.createElement({
id: "A_rect5",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "BA_rect5",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "BA_rect6",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "CBA_rect7",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "X_rect8",
type: "rectangle",
groupIds: ["X"],
}),
API.createElement({
id: "rect9",
type: "rectangle",
}),
API.createElement({
id: "YX_rect10",
type: "rectangle",
groupIds: ["Y", "X"],
}),
API.createElement({
id: "X_rect11",
type: "rectangle",
groupIds: ["X"],
}),
]),
[
"BA_rect1",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"X_rect11",
"YX_rect10",
"rect9",
],
);
});
// TODO
it.skip("normalize boundElements array", () => {
const container = API.createElement({
id: "container",
type: "rectangle",
boundElements: [],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
containerId: container.id,
});
mutateElement(container, {
boundElements: [
{ type: "text", id: boundText.id },
{ type: "text", id: "xxx" },
],
});
expect(normalizeElementOrder([container, boundText])).toEqual([
expect.objectContaining({
id: container.id,
}),
expect.objectContaining({ id: boundText.id }),
]);
});
// should take around <100ms for 10K iterations (@dwelle's PC 22-05-25)
it.skip("normalizeElementsOrder() perf", () => {
const makeElements = (iterations: number) => {
const elements: ExcalidrawElement[] = [];
while (iterations--) {
const container = API.createElement({
type: "rectangle",
boundElements: [],
groupIds: ["B", "A"],
});
const boundText = API.createElement({
type: "text",
containerId: container.id,
groupIds: ["A"],
});
const otherElement = API.createElement({
type: "rectangle",
boundElements: [],
groupIds: ["C", "A"],
});
mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
elements.push(boundText, otherElement, container);
}
return elements;
};
const elements = makeElements(10000);
const t0 = Date.now();
normalizeElementOrder(elements);
console.info(`${Date.now() - t0}ms`);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { arrayToMapWithIndex } from "../utils";
import { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
}
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
};
const groupHandledElements = new Map<string, true>();
origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements;
}
return [...sortedElements];
};
/**
* In theory, when we have text elements bound to a container, they
* should be right after the container element in the elements array.
* However, this is not guaranteed due to old and potential future bugs.
*
* This function sorts containers and their bound texts together. It prefers
* original z-index of container (i.e. it moves bound text elements after
* containers).
*/
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
origElements.forEach((element, idx) => {
if (!element) {
return;
}
if (element.boundElements?.length) {
sortedElements.add(element);
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child[0]);
origElements[child[1]] = null;
}
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
}
} else {
sortedElements.add(element);
origElements[idx] = null;
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error(
"normalizeBoundElementsOrder: lost some elements... bailing!",
);
return elements;
}
return [...sortedElements];
};
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
// console.time();
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
// console.timeEnd();
return ret;
};
+164 -187
View File
@@ -1,196 +1,173 @@
import { BOUND_TEXT_PADDING } from "../constants";
import { measureText, wrapText } from "./textElement";
import { FontString } from "./types";
describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello whats up ");
});
it("should work with emojis", () => {
const text = "😀";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀");
});
it("should show the text correctly when min width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
{
desc: "break all words when width of each word is less than container width",
width: 90,
res: `Hello
whats
up`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
},
{
desc: "break words as per the width",
width: 150,
res: `Hello whats
up`,
},
{
desc: "fit the container",
width: 250,
res: "Hello whats up",
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
describe("When text contain new lines", () => {
const text = `Hello
whats up`;
[
{
desc: "break all words when width of each word is less than container width",
width: 90,
res: `Hello
whats
up`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
},
{
desc: "break words as per the width",
width: 150,
res: `Hello
whats up`,
},
{
desc: "fit the container",
width: 250,
res: `Hello
whats up`,
},
].forEach((data) => {
it(`should respect new lines and ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 170,
res: `hellolongtextth
isiswhatsupwith
youIamtypingggg
gandtypinggg
break it now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 130,
res: `hellolongte
xtthisiswha
tsupwithyou
Iamtypinggg
ggandtyping
gg break it
now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg
break it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
});
import { API } from "../tests/helpers/api";
import {
computeContainerDimensionForBoundText,
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
} from "./textElement";
import { ExcalidrawTextElementWithContainer } from "./types";
describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
const text = "Hello World";
describe("Test getContainerCoords", () => {
const params = { width: 200, height: 100, x: 10, y: 20 };
it("should add correct attributes when maxWidth is passed", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = measureText(text, font, maxWidth);
it("should compute coords correctly when ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 44.2893218813452455,
y: 39.64466094067262,
});
});
expect(res.container).toMatchInlineSnapshot(`
<div
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;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
it("should compute coords correctly when rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 15,
y: 25,
});
});
it("should compute coords correctly when diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 65,
y: 50,
});
});
});
it("should add correct attributes when maxWidth is not passed", () => {
const res = measureText(text, font);
describe("Test computeContainerDimensionForBoundText", () => {
const params = {
width: 178,
height: 194,
};
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
it("should compute container height correctly for rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
160,
);
});
it("should compute container height correctly for ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
226,
);
});
it("should compute container height correctly for diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
320,
);
});
});
describe("Test getBoundTextMaxWidth", () => {
const params = {
width: 178,
height: 194,
};
it("should return max width when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getBoundTextMaxWidth(container)).toBe(168);
});
it("should return max width when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getBoundTextMaxWidth(container)).toBe(116);
});
it("should return max width when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getBoundTextMaxWidth(container)).toBe(79);
});
it("should return max width when container is arrow", () => {
const container = API.createElement({
type: "arrow",
...params,
});
expect(getBoundTextMaxWidth(container)).toBe(220);
});
});
describe("Test getBoundTextMaxHeight", () => {
const params = {
width: 178,
height: 194,
id: "container-id",
};
const boundTextElement = API.createElement({
type: "text",
id: "text-id",
x: 560.51171875,
y: 202.033203125,
width: 154,
height: 175,
fontSize: 20,
fontFamily: 1,
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
textAlign: "center",
verticalAlign: "middle",
containerId: params.id,
}) as ExcalidrawTextElementWithContainer;
it("should return max height when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
});
it("should return max height when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
});
it("should return max height when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
});
it("should return max height when container is arrow", () => {
const container = API.createElement({
type: "arrow",
...params,
});
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
});
it("should return max height when container is arrow and height is less than threshold", () => {
const container = API.createElement({
type: "arrow",
...params,
height: 70,
boundElements: [{ type: "text", id: "text-id" }],
});
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
boundTextElement.height,
);
});
});
});
+211 -399
View File
@@ -1,10 +1,9 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import { getFontString, arrayToMap } from "../utils";
import {
ExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
NonDeletedExcalidrawElement,
} from "./types";
import { mutateElement } from "./mutateElement";
@@ -12,12 +11,7 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import {
isBoundToContainer,
isImageElement,
isArrowElement,
} from "./typeChecks";
import { isBoundToContainer, isArrowElement } from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks";
@@ -28,6 +22,8 @@ import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./textWysiwyg";
import { ExtractSetType } from "../utility-types";
import { measureText, wrapText } from "./textMeasurements";
export const normalizeText = (text: string) => {
return (
@@ -44,68 +40,59 @@ export const redrawTextBoundingBox = (
container: ExcalidrawElement | null,
) => {
let maxWidth = undefined;
let text = textElement.text;
const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
};
if (container) {
maxWidth = getMaxContainerWidth(container);
text = wrapText(
maxWidth = getBoundTextMaxWidth(container);
boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
}
const metrics = measureText(text, getFontString(textElement), maxWidth);
let coordY = textElement.y;
let coordX = textElement.x;
// Resize container and vertically center align the text
const metrics = measureText(
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
);
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
if (container) {
if (!isArrowElement(container)) {
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
BOUND_TEXT_PADDING;
} else {
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + BOUND_TEXT_PADDING;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x +
containerDims.width -
metrics.width -
BOUND_TEXT_PADDING;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
const containerDims = getContainerDims(container);
const maxContainerHeight = getBoundTextMaxHeight(
container,
textElement as ExcalidrawTextElementWithContainer,
);
let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerDimensionForBoundText(
metrics.height,
container.type,
);
mutateElement(container, { height: nextHeight });
} else {
const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2;
updateOriginalContainerCache(container.id, nextHeight);
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
}
mutateElement(textElement, {
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
y: coordY,
x: coordX,
text,
});
mutateElement(textElement, boundTextUpdates);
};
export const bindTextToShapeAfterDuplication = (
@@ -127,10 +114,16 @@ export const bindTextToShapeAfterDuplication = (
const newContainer = sceneElementMap.get(newElementId);
if (newContainer) {
mutateElement(newContainer, {
boundElements: (newContainer.boundElements || []).concat({
type: "text",
id: newTextElementId,
}),
boundElements: (element.boundElements || [])
.filter(
(boundElement) =>
boundElement.id !== newTextElementId &&
boundElement.id !== boundTextElementId,
)
.concat({
type: "text",
id: newTextElementId,
}),
});
}
const newTextElement = sceneElementMap.get(newTextElementId);
@@ -168,10 +161,12 @@ export const handleBindTextResize = (
let nextHeight = textElement.height;
let nextWidth = textElement.width;
const containerDims = getContainerDims(container);
const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container);
const maxWidth = getBoundTextMaxWidth(container);
const maxHeight = getBoundTextMaxHeight(
container,
textElement as ExcalidrawTextElementWithContainer,
);
let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapText(
@@ -183,15 +178,18 @@ export const handleBindTextResize = (
const dimensions = measureText(
text,
getFontString(textElement),
maxWidth,
textElement.lineHeight,
);
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
containerHeight = computeContainerDimensionForBoundText(
nextHeight,
container.type,
);
const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
@@ -211,325 +209,54 @@ export const handleBindTextResize = (
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
updateBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
mutateElement(
textElement,
computeBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
),
);
}
}
};
const updateBoundTextPosition = (
export const computeBoundTextPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const containerDims = getContainerDims(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
const containerCoords = getContainerCoords(container);
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
const maxContainerWidth = getBoundTextMaxWidth(container);
let x;
let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y =
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
} else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
}
const x =
boundTextElement.textAlign === TEXT_ALIGN.LEFT
? container.x + boundTextElementPadding
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
? container.x +
containerDims.width -
boundTextElement.width -
boundTextElementPadding
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
mutateElement(boundTextElement, { x, y });
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const container = document.createElement("div");
container.style.position = "absolute";
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
// since we are adding a span of width 1px later
container.style.maxWidth = `${maxWidth + 1}px`;
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
container.style.whiteSpace = "pre-wrap";
}
document.body.appendChild(container);
container.innerText = text;
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.overflow = "hidden";
span.style.width = "1px";
span.style.height = "1px";
container.appendChild(span);
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
// since we are adding a span of width 1px
const width = container.offsetWidth + 1;
const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) {
return { width, height, baseline, container };
}
return { width, height, baseline };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
const cacheApproxLineHeight: { [key: FontString]: number } = {};
export const getApproxLineHeight = (font: FontString) => {
if (cacheApproxLineHeight[font]) {
return cacheApproxLineHeight[font];
}
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
return cacheApproxLineHeight[font];
};
let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const metrics = canvas2dContext.measureText(text);
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return metrics.width * 10;
}
// Since measureText behaves differently in different browsers
// OS so considering a adjustment factor of 0.2
const adjustmentFactor = 0.2;
return metrics.width + adjustmentFactor;
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n");
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font);
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
originalLines.forEach((originalLine) => {
const words = originalLine.split(" ");
// This means its newline so push it
if (words.length === 1 && words[0] === "") {
lines.push(words[0]);
return; // continue
}
let currentLine = "";
let currentLineWidthTillNow = 0;
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
// Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
);
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
} else {
currentLine += currentChar;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
} else {
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
break;
}
index++;
currentLine += `${word} `;
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1);
push(word);
currentLine = "";
currentLineWidthTillNow = 0;
break;
}
}
if (currentLineWidthTillNow === maxWidth) {
currentLine = "";
currentLineWidthTillNow = 0;
}
}
}
if (currentLine) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
}
});
return lines.join("\n");
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][ascii];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
return {
calculate,
getCache,
};
})();
export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
BOUND_TEXT_PADDING * 2
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getApproxMinLineHeight = (font: FontString) => {
return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = containerCoords.y;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
} else {
y =
containerCoords.y +
(maxContainerHeight / 2 - boundTextElement.height / 2);
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
x = containerCoords.x;
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
} else {
x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
// Generally lower case is used so converting to lower case
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
const batchLength = 6;
let index = 0;
let widthTillNow = 0;
let str = "";
while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength);
str += batch;
widthTillNow += getLineWidth(str, font);
if (index === dummyText.length - 1) {
index = 0;
}
index = index + batchLength;
}
while (widthTillNow > width) {
str = str.substr(0, str.length - 1);
widthTillNow = getLineWidth(str, font);
}
return str.length;
return { x, y };
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
@@ -615,6 +342,26 @@ export const getContainerCenter = (
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
};
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
}
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
if (container.type === "diamond") {
offsetX += container.width / 4;
offsetY += container.height / 4;
}
return {
x: container.x + offsetX,
y: container.y + offsetY,
};
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) {
@@ -627,25 +374,14 @@ export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null,
) => {
const container = getContainerElement(boundTextElement);
if (!container) {
if (!container || !boundTextElement) {
return 0;
}
if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8;
}
return BOUND_TEXT_PADDING;
};
export const getBoundTextElementPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
);
}
return BOUND_TEXT_PADDING;
};
export const shouldAllowVerticalAlign = (
@@ -660,14 +396,24 @@ export const shouldAllowVerticalAlign = (
}
return true;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
if (isArrowElement(element)) {
return false;
});
};
export const suppportsHorizontalAlign = (
selectedElements: NonDeletedExcalidrawElement[],
) => {
return selectedElements.some((element) => {
const hasBoundContainer = isBoundToContainer(element);
if (hasBoundContainer) {
const container = getContainerElement(element);
if (isTextElement(element) && isArrowElement(container)) {
return false;
}
return true;
}
return false;
return isTextElement(element);
});
};
@@ -708,12 +454,78 @@ export const getTextBindableContainerAtPosition = (
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};
export const isValidTextContainer = (element: ExcalidrawElement) => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond" ||
isImageElement(element) ||
isArrowElement(element)
);
const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"ellipse",
"diamond",
"image",
"arrow",
]);
export const isValidTextContainer = (element: ExcalidrawElement) =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
containerType: ExtractSetType<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 getBoundTextMaxWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
return width - BOUND_TEXT_PADDING * 8 * 2;
}
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 getBoundTextMaxHeight = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
return boundTextElement.height;
}
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;
};
+213
View File
@@ -0,0 +1,213 @@
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { API } from "../tests/helpers/api";
import {
detectLineHeight,
getDefaultLineHeight,
getLineHeightInPx,
wrapText,
} from "./textMeasurements";
import { FontString } from "./types";
describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should work with emojis", () => {
const text = "😀";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀");
});
it("should show the text correctly when max width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello \nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
},
{
desc: "break words as per the width",
width: 140,
res: `Hello whats \nup`,
},
{
desc: "fit the container",
width: 250,
res: "Hello whats up",
},
{
desc: "should push the word if its equal to max width",
width: 60,
res: `Hello
whats
up`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
describe("When text contain new lines", () => {
const text = `Hello
whats up`;
[
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello\nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
},
{
desc: "break words as per the width",
width: 150,
res: `Hello
whats up`,
},
{
desc: "fit the container",
width: 250,
res: `Hello
whats up`,
},
].forEach((data) => {
it(`should respect new lines and ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 170,
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 130,
res: `hellolongte
xtthisiswha
tsupwithyou
Iamtypinggg
ggandtyping
gg break it
now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
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);
});
});
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 correct line height", () => {
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});
+339
View File
@@ -0,0 +1,339 @@
import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
} from "../constants";
import { getFontString, isTestEnv } from "../utils";
import { normalizeText } from "./textElement";
import { ExcalidrawTextElement, FontFamilyValues, FontString } from "./types";
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
let canvas: HTMLCanvasElement | undefined;
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letter is 10px
const DUMMY_CHAR_WIDTH = 10;
const getLineWidth = (text: string, font: FontString) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const width = canvas2dContext.measureText(text).width;
/* istanbul ignore else */
if (isTestEnv()) {
return width * DUMMY_CHAR_WIDTH;
}
/* istanbul ignore next */
return width;
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
return { width, height };
};
export const getApproxMinContainerWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getApproxMinContainerHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][ascii];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
return {
calculate,
getCache,
};
})();
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};
/** this is not used currently but might be useful
* in future hence keeping it
*/
/* istanbul ignore next */
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
// Generally lower case is used so converting to lower case
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
const batchLength = 6;
let index = 0;
let widthTillNow = 0;
let str = "";
while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength);
str += batch;
widthTillNow += getLineWidth(str, font);
if (index === dummyText.length - 1) {
index = 0;
}
index = index + batchLength;
}
while (widthTillNow > width) {
str = str.substr(0, str.length - 1);
widthTillNow = getLineWidth(str, font);
}
return str.length;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font);
let currentLine = "";
let currentLineWidthTillNow = 0;
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const resetParams = () => {
currentLine = "";
currentLineWidthTillNow = 0;
};
originalLines.forEach((originalLine) => {
const currentLineWidth = getTextWidth(originalLine, font);
//Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
return; // continue
}
const words = originalLine.split(" ");
resetParams();
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
push(words[index]);
index++;
}
// Start breaking longer words exceeding max width
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
resetParams();
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
);
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
} else {
currentLine += currentChar;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
resetParams();
} else {
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
resetParams();
break;
}
index++;
currentLine += `${word} `;
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1);
push(word);
resetParams();
break;
}
}
}
}
if (currentLine.slice(-1) === " ") {
// only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1);
push(currentLine);
}
});
return lines.join("\n");
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
/**
* 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"];
};
/**
* 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 Helvetica in WebKit and Blink.
// Gecko if all over the place.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily) {
return DEFAULT_LINE_HEIGHT[fontFamily];
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};
+291 -150
View File
@@ -3,19 +3,23 @@ import ExcalidrawApp from "../excalidraw-app";
import { GlobalTestState, render, screen } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { CODES, KEYS } from "../keys";
import { fireEvent } from "../tests/test-utils";
import {
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
} from "../tests/test-utils";
import { queryByText } from "@testing-library/react";
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
} from "./types";
import * as textElementUtils from "./textElement";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -222,11 +226,19 @@ describe("textWysiwyg", () => {
describe("Test container-unbound text", () => {
const { h } = window;
const dimensions = { height: 400, width: 800 };
let textarea: HTMLTextAreaElement;
let textElement: ExcalidrawTextElement;
beforeAll(() => {
mockBoundingClientRect(dimensions);
});
beforeEach(async () => {
await render(<ExcalidrawApp />);
//@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
textElement = UI.createElement("text");
@@ -236,6 +248,10 @@ describe("textWysiwyg", () => {
)!;
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should add a tab at the start of the first line", () => {
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
textarea.value = "Line#1\nLine#2";
@@ -434,23 +450,33 @@ describe("textWysiwyg", () => {
);
expect(h.state.zoom.value).toBe(1);
});
it("text should never go beyond max width", async () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
textarea = document.querySelector(
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.change(textarea, {
target: {
value:
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
},
});
textarea.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
textarea.blur();
expect(textarea.style.width).toBe("792px");
expect(h.elements[0].width).toBe(1000);
});
});
describe("Test container-bound text", () => {
let rectangle: any;
const { h } = window;
const DUMMY_HEIGHT = 240;
const DUMMY_WIDTH = 160;
const APPROX_LINE_HEIGHT = 25;
const INITIAL_WIDTH = 10;
beforeAll(() => {
jest
.spyOn(textElementUtils, "getApproxLineHeight")
.mockReturnValue(APPROX_LINE_HEIGHT);
});
beforeEach(async () => {
await render(<ExcalidrawApp />);
h.elements = [];
@@ -463,14 +489,21 @@ describe("textWysiwyg", () => {
});
});
it("should bind text to container when double clicked on center of filled container", async () => {
it("should bind text to container when double clicked inside filled container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "red",
});
h.elements = [rectangle];
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
@@ -504,24 +537,37 @@ describe("textWysiwyg", () => {
});
h.elements = [rectangle];
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
expect(h.elements.length).toBe(2);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
editor.blur();
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(2);
expect(h.elements.length).toBe(3);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = document.querySelector(
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -551,6 +597,43 @@ describe("textWysiwyg", () => {
]);
});
it("should bind text to container when double clicked on container stroke", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
strokeWidth: 4,
});
h.elements = [rectangle];
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
mouse.doubleClickAt(rectangle.x + 2, rectangle.y + 2);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("shouldn't bind to non-text-bindable containers", async () => {
const freedraw = API.createElement({
type: "freedraw",
@@ -586,11 +669,11 @@ describe("textWysiwyg", () => {
["freedraw", "line"].forEach((type: any) => {
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
h.elements = [];
const elemnet = UI.createElement(type, {
const element = UI.createElement(type, {
width: 100,
height: 50,
});
API.setSelectedElements([elemnet]);
API.setSelectedElements([element]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(1);
});
@@ -675,39 +758,6 @@ describe("textWysiwyg", () => {
});
it("should wrap text and vertcially center align once text submitted", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
if (text === "Hello \nWorld!") {
height = APPROX_LINE_HEIGHT * 2;
}
if (maxWidth) {
width = maxWidth;
// To capture cases where maxWidth passed is initial width
// due to which the text is not wrapped correctly
if (maxWidth === INITIAL_WIDTH) {
height = DUMMY_HEIGHT;
}
}
return {
width,
height,
baseline,
};
});
expect(h.elements.length).toBe(1);
Keyboard.keyDown(KEYS.ENTER);
@@ -716,11 +766,6 @@ describe("textWysiwyg", () => {
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
fireEvent.change(editor, {
target: {
value: "Hello World!",
@@ -735,11 +780,11 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2,
rectangle.y + h.elements[0].height / 2 - text.height / 2,
);
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
expect(text.x).toBe(25);
expect(text.height).toBe(50);
expect(text.width).toBe(60);
// Edit and text by removing second line and it should
// still vertically align correctly
@@ -756,11 +801,6 @@ describe("textWysiwyg", () => {
},
});
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT);
editor.style.height = "25px";
editor.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 0));
@@ -770,12 +810,12 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello");
expect(text.height).toBe(25);
expect(text.width).toBe(50);
expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2,
rectangle.y + h.elements[0].height / 2 - text.height / 2,
);
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
expect(text.height).toBe(APPROX_LINE_HEIGHT);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
expect(text.x).toBe(30);
});
it("should unbind bound text when unbind action from context menu is triggered", async () => {
@@ -862,8 +902,8 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
109.5,
17,
85,
4.5,
]
`);
@@ -877,6 +917,8 @@ describe("textWysiwyg", () => {
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0));
@@ -887,7 +929,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
90,
65,
]
`);
@@ -910,7 +952,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
424,
375,
-539,
]
`);
@@ -1025,9 +1067,9 @@ describe("textWysiwyg", () => {
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85);
expect(text.x).toBe(89.5);
expect(text.y).toBe(90);
expect(rectangle.y).toBe(-40);
expect(text.x).toBe(85);
expect(text.y).toBe(-35);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
@@ -1057,29 +1099,6 @@ describe("textWysiwyg", () => {
});
it("should restore original container height and clear cache once text is unbind", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
});
const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight);
@@ -1093,7 +1112,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" },
});
editor.blur();
expect(rectangle.height).toBe(135);
expect(rectangle.height).toBe(185);
mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
@@ -1119,7 +1138,7 @@ describe("textWysiwyg", () => {
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215);
expect(rectangle.height).toBe(156);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
@@ -1131,13 +1150,12 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBe(215);
expect(rectangle.height).toBe(156);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
});
//@todo fix this test later once measureText is mocked correctly
it.skip("should reset the container height cache when font properties updated", async () => {
it("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
@@ -1163,7 +1181,44 @@ describe("textWysiwyg", () => {
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
96.39999999999999,
);
});
it("should update line height when font family updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.25);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.2);
fireEvent.click(screen.getByTitle(/normal/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Helvetica);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.15);
});
describe("should align correctly", () => {
@@ -1191,7 +1246,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
20,
25,
]
`);
});
@@ -1201,8 +1256,8 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
94.5,
20,
30,
25,
]
`);
});
@@ -1212,22 +1267,22 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
174,
20,
]
`);
Array [
45,
25,
]
`);
});
it("when center left", async () => {
fireEvent.click(screen.getByTitle("Center vertically"));
fireEvent.click(screen.getByTitle("Left"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
25,
]
`);
Array [
15,
45,
]
`);
});
it("when center center", async () => {
@@ -1235,11 +1290,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
-25,
25,
]
`);
Array [
30,
45,
]
`);
});
it("when center right", async () => {
@@ -1247,11 +1302,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
174,
25,
]
`);
Array [
45,
45,
]
`);
});
it("when bottom left", async () => {
@@ -1259,34 +1314,120 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
25,
]
`);
Array [
15,
65,
]
`);
});
it("when bottom center", async () => {
fireEvent.click(screen.getByTitle("Center"));
fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
94.5,
25,
]
`);
Array [
30,
65,
]
`);
});
it("when bottom right", async () => {
fireEvent.click(screen.getByTitle("Right"));
fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
174,
25,
]
`);
Array [
45,
65,
]
`);
});
});
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Excalidraw is an opensource virtual collaborative whiteboard",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600);
expect(textElement.height).toBe(25);
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
expect((textElement as ExcalidrawTextElement).text).toBe(
"Excalidraw is an opensource virtual collaborative whiteboard",
);
API.setSelectedElements([textElement]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Wrap text in a container")!,
);
expect(h.elements.length).toBe(3);
expect(h.elements[1]).toEqual(
expect.objectContaining({
angle: 0,
backgroundColor: "transparent",
boundElements: [
{
id: h.elements[2].id,
type: "text",
},
],
fillStyle: "hachure",
groupIds: [],
height: 35,
isDeleted: false,
link: null,
locked: false,
opacity: 100,
roughness: 1,
roundness: {
type: 3,
},
strokeColor: "#000000",
strokeStyle: "solid",
strokeWidth: 1,
type: "rectangle",
updated: 1,
version: 1,
width: 610,
x: 15,
y: 25,
}),
);
expect(h.elements[2] as ExcalidrawTextElement).toEqual(
expect.objectContaining({
text: "Excalidraw is an opensource virtual collaborative whiteboard",
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.LEFT,
boundElements: null,
}),
);
});
});
});
+51 -94
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants";
import { CLASSES } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -22,15 +22,15 @@ import {
import { AppState } from "../types";
import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
getTextElementAngle,
getTextWidth,
normalizeText,
wrapText,
redrawTextBoundingBox,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement";
import {
actionDecreaseFontSize,
@@ -38,9 +38,14 @@ import {
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
import {
getTextWidth,
measureText,
wrapText,
getTextHeight,
} from "./textMeasurements";
const getTransform = (
width: number,
@@ -147,9 +152,7 @@ export const textWysiwyg = ({
return;
}
const { textAlign, verticalAlign } = updatedTextElement;
const approxLineHeight = getApproxLineHeight(
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y;
@@ -157,7 +160,7 @@ export const textWysiwyg = ({
let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height;
const width = updatedTextElement.width;
let textElementWidth = updatedTextElement.width;
// Set to element height by default since that's
// what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height;
@@ -177,15 +180,12 @@ export const textWysiwyg = ({
editable,
);
const containerDims = getContainerDims(container);
// using editor.style.height to get the accurate height of text editor
const editorHeight = Number(editable.style.height.slice(0, -2));
if (editorHeight > 0) {
textElementHeight = editorHeight;
}
if (propertiesUpdated) {
// update height of the editor after properties updated
textElementHeight = updatedTextElement.height;
}
textElementHeight = getTextHeight(
updatedTextElement.text,
updatedTextElement.fontSize,
updatedTextElement.lineHeight,
);
let originalContainerData;
if (propertiesUpdated) {
@@ -203,14 +203,17 @@ export const textWysiwyg = ({
}
}
maxWidth = getMaxContainerWidth(container);
maxHeight = getMaxContainerHeight(container);
maxWidth = getBoundTextMaxWidth(container);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
// autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min(
textElementHeight - maxHeight,
approxLineHeight,
element.lineHeight,
);
mutateElement(container, { height: containerDims.height + diff });
return;
@@ -223,27 +226,15 @@ export const textWysiwyg = ({
) {
const diff = Math.min(
maxHeight - textElementHeight,
approxLineHeight,
element.lineHeight,
);
mutateElement(container, { height: containerDims.height - diff });
}
// Start pushing text upward until a diff of 30px (padding)
// is reached
else {
// vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) {
coordY =
container.y + containerDims.height / 2 - textElementHeight / 2;
}
}
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
}
} else {
const { y } = computeBoundTextPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
coordY = y;
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
@@ -265,27 +256,25 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff;
}
const lines = updatedTextElement.originalText.split("\n");
const lineHeight = updatedTextElement.containerId
? approxLineHeight
: updatedTextElement.height / lines.length;
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
}
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
width: `${Math.min(width, maxWidth)}px`,
lineHeight: element.lineHeight,
width: `${textElementWidth}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transform: getTransform(
width,
textElementWidth,
textElementHeight,
getTextElementAngle(updatedTextElement),
appState,
@@ -366,7 +355,7 @@ export const textWysiwyg = ({
const wrappedText = wrapText(
`${editable.value}${data}`,
font,
getMaxContainerWidth(container),
getBoundTextMaxWidth(container),
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
@@ -378,55 +367,20 @@ export const textWysiwyg = ({
id,
) as ExcalidrawTextElement;
const font = getFontString(updatedTextElement);
// using scrollHeight here since we need to calculate
// number of lines so cannot use editable.style.height
// as that gets updated below
// Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
// auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for
// first line as well as setting height to "auto"
// doubles the height as soon as user starts typing
if (isBoundToContainer(element) && lines > 1) {
if (isBoundToContainer(element)) {
const container = getContainerElement(element);
let height = "auto";
editable.style.height = "0px";
let heightSet = false;
if (lines === 2) {
const actualLineCount = wrapText(
editable.value,
font,
getMaxContainerWidth(container!),
).split("\n").length;
// This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well
// hence reducing the height by half if actual line count is 1
// so single line aligns vertically when deleting
if (actualLineCount === 1) {
height = `${editable.scrollHeight / 2}px`;
editable.style.height = height;
heightSet = true;
}
}
const wrappedText = wrapText(
normalizeText(editable.value),
font,
getMaxContainerWidth(container!),
getBoundTextMaxWidth(container!),
);
const { width, height } = measureText(
wrappedText,
font,
updatedTextElement.lineHeight,
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
if (!heightSet) {
editable.style.height = `${editable.scrollHeight}px`;
}
editable.style.height = `${height}px`;
}
onChange(normalizeText(editable.value));
};
@@ -463,7 +417,9 @@ export const textWysiwyg = ({
event.code === CODES.BRACKET_RIGHT))
) {
event.preventDefault();
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
if (event.isComposing) {
return;
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
outdent();
} else {
indent();
@@ -612,6 +568,7 @@ export const textWysiwyg = ({
),
});
}
redrawTextBoundingBox(updateElement, container);
}
onSubmit({
+66
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();
});
});
});
+3 -2
View File
@@ -1,5 +1,6 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import { MarkNonNullable } from "../utility-types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -137,9 +138,9 @@ export const isExcalidrawElement = (element: any): boolean => {
export const hasBoundTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
return (
isBindableElement(element) &&
isTextBindableContainer(element) &&
!!element.boundElements?.some(({ type }) => type === "text")
);
};
+6 -1
View File
@@ -6,6 +6,7 @@ import {
THEME,
VERTICAL_ALIGN,
} from "../constants";
import { MarkNonNullable, ValueOf } from "../utility-types";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid";
@@ -130,11 +131,15 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number;
fontFamily: FontFamilyValues;
text: string;
baseline: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;
originalText: string;
/**
* Unitless line height (aligned to W3C). To get line height in px, multiply
* with font size (using `getLineHeightInPx` helper).
*/
lineHeight: number & { _brand: "unitlessLineHeight" };
}>;
export type ExcalidrawBindableElement =
+3
View File
@@ -0,0 +1,3 @@
import { unstable_createStore } from "jotai";
export const appJotaiStore = unstable_createStore();
+19 -10
View File
@@ -70,11 +70,12 @@ import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai";
import { jotaiStore } from "../../jotai";
import { appJotaiStore } from "../app-jotai";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false);
interface CollabState {
errorMessage: string;
@@ -152,6 +153,8 @@ class Collab extends PureComponent<Props, CollabState> {
componentDidMount() {
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.addEventListener("online", this.onOfflineStatusToggle);
window.addEventListener("offline", this.onOfflineStatusToggle);
window.addEventListener(EVENT.UNLOAD, this.onUnload);
const collabAPI: CollabAPI = {
@@ -164,7 +167,8 @@ class Collab extends PureComponent<Props, CollabState> {
setUsername: this.setUsername,
};
jotaiStore.set(collabAPIAtom, collabAPI);
appJotaiStore.set(collabAPIAtom, collabAPI);
this.onOfflineStatusToggle();
if (
process.env.NODE_ENV === ENV.TEST ||
@@ -180,7 +184,13 @@ class Collab extends PureComponent<Props, CollabState> {
}
}
onOfflineStatusToggle = () => {
appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
};
componentWillUnmount() {
window.removeEventListener("online", this.onOfflineStatusToggle);
window.removeEventListener("offline", this.onOfflineStatusToggle);
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
@@ -198,10 +208,10 @@ class Collab extends PureComponent<Props, CollabState> {
}
}
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
private setIsCollaborating = (isCollaborating: boolean) => {
jotaiStore.set(isCollaboratingAtom, isCollaborating);
appJotaiStore.set(isCollaboratingAtom, isCollaborating);
};
private onUnload = () => {
@@ -600,7 +610,7 @@ class Collab extends PureComponent<Props, CollabState> {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
remoteElements = restoreElements(remoteElements, null, false);
remoteElements = restoreElements(remoteElements, null);
const reconciledElements = _reconcileElements(
localElements,
@@ -794,7 +804,7 @@ class Collab extends PureComponent<Props, CollabState> {
);
handleClose = () => {
jotaiStore.set(collabDialogShownAtom, false);
appJotaiStore.set(collabDialogShownAtom, false);
};
setUsername = (username: string) => {
@@ -828,10 +838,9 @@ class Collab extends PureComponent<Props, CollabState> {
/>
)}
{errorMessage && (
<ErrorDialog
message={errorMessage}
onClose={() => this.setState({ errorMessage: "" })}
/>
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
{errorMessage}
</ErrorDialog>
)}
</>
);
+3 -2
View File
@@ -10,13 +10,13 @@ import {
shareWindows,
} from "../../components/icons";
import { ToolButton } from "../../components/ToolButton";
import { t } from "../../i18n";
import "./RoomDialog.scss";
import Stack from "../../components/Stack";
import { AppState } from "../../types";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
import DialogActionButton from "../../components/DialogActionButton";
import { useI18n } from "../../i18n";
const getShareIcon = () => {
const navigator = window.navigator as any;
@@ -51,6 +51,7 @@ const RoomDialog = ({
setErrorMessage: (message: string) => void;
theme: AppState["theme"];
}) => {
const { t } = useI18n();
const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
@@ -144,7 +145,7 @@ const RoomDialog = ({
<input
type="text"
id="username"
value={username || ""}
value={username.trim() || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
+18 -34
View File
@@ -1,6 +1,7 @@
import { PRECEDING_ELEMENT_KEY } from "../../constants";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { arrayToMapWithIndex } from "../../utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
@@ -33,30 +34,13 @@ const shouldDiscardRemoteElement = (
return false;
};
const getElementsMapWithIndex = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.reduce(
(
acc: {
[key: string]: [element: T, index: number] | undefined;
},
element: T,
idx,
) => {
acc[element.id] = [element, idx];
return acc;
},
{},
);
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
getElementsMapWithIndex<ExcalidrawElement>(localElements);
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
@@ -69,7 +53,7 @@ export const reconcileElements = (
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData[remoteElement.id];
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
@@ -105,21 +89,21 @@ export const reconcileElements = (
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
];
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
];
]);
cursor++;
}
} else {
let idx = localElementsData[parent]
? localElementsData[parent]![1]
let idx = localElementsData.has(parent)
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
@@ -127,38 +111,38 @@ export const reconcileElements = (
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
];
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
];
]);
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
];
]);
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData[remoteElement.id] = [remoteElement, local[1]];
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
];
]);
}
}
@@ -1,12 +1,13 @@
import React from "react";
import { PlusPromoIcon } from "../../components/icons";
import { t } from "../../i18n";
import { useI18n } from "../../i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any;
}> = React.memo((props) => {
const { t } = useI18n();
let headingContent;
if (isExcalidrawPlusSignedUser) {
+18 -14
View File
@@ -1,17 +1,21 @@
import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip";
import { t } from "../../i18n";
import { useI18n } from "../../i18n";
export const EncryptedIcon = () => (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>
{shield}
</Tooltip>
</a>
);
export const EncryptedIcon = () => {
const { t } = useI18n();
return (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>
{shield}
</Tooltip>
</a>
);
};
@@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { nanoid } from "nanoid";
import { t } from "../../i18n";
import { useI18n } from "../../i18n";
import { excalidrawPlusIcon } from "./icons";
import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks";
@@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{
files: BinaryFiles;
onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => {
const { t } = useI18n();
return (
<Card color="primary">
<div className="Card-icon">{excalidrawPlusIcon}</div>
@@ -1,22 +1,23 @@
import { useAtom } from "jotai";
import { useSetAtom } from "jotai";
import React from "react";
import { langCodeAtom } from "..";
import * as i18n from "../../i18n";
import { appLangCodeAtom } from "..";
import { defaultLang, useI18n } from "../../i18n";
import { languages } from "../../i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const [langCode, setLangCode] = useAtom(langCodeAtom);
const { t, langCode } = useI18n();
const setLangCode = useSetAtom(appLangCodeAtom);
return (
<select
className="dropdown-select dropdown-select__language"
onChange={({ target }) => setLangCode(target.value)}
value={langCode}
aria-label={i18n.t("buttons.selectLanguage")}
aria-label={t("buttons.selectLanguage")}
style={style}
>
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
{i18n.defaultLang.label}
<option key={defaultLang.code} value={defaultLang.code}>
{defaultLang.label}
</option>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
+1
View File
@@ -14,6 +14,7 @@ import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../utility-types";
// private
// -----------------------------------------------------------------------------
+4 -1
View File
@@ -263,9 +263,12 @@ export const loadScene = async (
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{ repairBindings: true, refreshDimensions: true },
);
} else {
data = restore(localDataState || null, null, null);
data = restore(localDataState || null, null, null, {
repairBindings: true,
});
}
return {
+17
View File
@@ -45,6 +45,23 @@
}
}
}
.collab-offline-warning {
pointer-events: none;
position: absolute;
top: 6.5rem;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem 1rem;
font-size: 0.875rem;
text-align: center;
line-height: 1.5;
border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6;
white-space: pre;
}
}
.excalidraw-app.is-collaborating {
+24 -16
View File
@@ -52,6 +52,7 @@ import Collab, {
collabAPIAtom,
collabDialogShownAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
@@ -66,10 +67,7 @@ import {
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
@@ -77,13 +75,17 @@ import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { atom, Provider, useAtom } from "jotai";
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
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 { ResolutionType } from "../utility-types";
polyfill();
@@ -226,15 +228,15 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const currentLangCode = languageDetector.detect() || defaultLang.code;
export const langCodeAtom = atom(
Array.isArray(currentLangCode) ? currentLangCode[0] : currentLangCode,
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(langCodeAtom);
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
// initial state
// ---------------------------------------------------------------------------
@@ -362,7 +364,7 @@ const ExcalidrawWrapper = () => {
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null),
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
});
}
@@ -599,6 +601,8 @@ const ExcalidrawWrapper = () => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const isOffline = useAtomValue(isOfflineAtom);
return (
<div
style={{ height: "100%" }}
@@ -661,13 +665,17 @@ const ExcalidrawWrapper = () => {
/>
<AppWelcomeScreen setCollabDialogShown={setCollabDialogShown} />
<AppFooter />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
</Excalidraw>
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
<ErrorDialog
message={errorMessage}
onClose={() => setErrorMessage("")}
/>
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</div>
);
@@ -676,7 +684,7 @@ const ExcalidrawWrapper = () => {
const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => jotaiStore}>
<Provider unstable_createStore={() => appJotaiStore}>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
-47
View File
@@ -50,36 +50,6 @@ interface Clipboard extends EventTarget {
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
// -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
@@ -101,23 +71,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 {
handle?: import("browser-fs-acces").FileSystemHandle;
name?: string;
+1
View File
@@ -2,6 +2,7 @@ import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
+16
View File
@@ -1,6 +1,8 @@
import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json";
import { ENV } from "./constants";
import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai";
const COMPLETION_THRESHOLD = 85;
@@ -99,6 +101,8 @@ export const setLanguage = async (lang: Language) => {
currentLangData = fallbackLangData;
}
}
jotaiStore.set(editorLangCodeAtom, lang.code);
};
export const getLanguage = () => currentLang;
@@ -143,3 +147,15 @@ export const t = (
}
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 };
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { unstable_createStore, useAtom, WritableAtom } from "jotai";
import { PrimitiveAtom, unstable_createStore, useAtom } from "jotai";
import { useLayoutEffect } from "react";
export const jotaiScope = Symbol();
@@ -6,7 +6,7 @@ export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = <
T extends unknown,
A extends WritableAtom<T, T>,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
+29 -26
View File
@@ -109,35 +109,35 @@
"decreaseFontSize": "تصغير حجم الخط",
"increaseFontSize": "تكبير حجم الخط",
"unbindText": "فك ربط النص",
"bindText": "",
"bindText": "ربط النص بالحاوية",
"link": {
"edit": "تعديل الرابط",
"create": "إنشاء رابط",
"label": "رابط"
},
"lineEditor": {
"edit": "",
"exit": ""
"edit": "تحرير السطر",
"exit": "الخروج من المُحرر"
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
"lock": "قفل",
"unlock": "فتح",
"lockAll": "قفل الكل",
"unlockAll": "فتح الكل"
},
"statusPublished": "",
"sidebarLock": ""
"statusPublished": "نُشر",
"sidebarLock": "إبقاء الشريط الجانبي مفتوح"
},
"library": {
"noItems": "",
"hint_emptyLibrary": "",
"hint_emptyPrivateLibrary": ""
"noItems": "لا توجد عناصر أضيفت بعد...",
"hint_emptyLibrary": "حدد عنصر على القماش لإضافته هنا، أو تثبيت مكتبة من المستودع العام أدناه.",
"hint_emptyPrivateLibrary": "حدد عنصر على القماش لإضافته هنا."
},
"buttons": {
"clearReset": "إعادة تعيين اللوحة",
"exportJSON": "صدر الملف",
"exportImage": "",
"export": "",
"exportImage": "تصدير الصورة...",
"export": "حفظ إلى...",
"exportToPng": "تصدير بصيغة PNG",
"exportToSvg": "تصدير بصيغة SVG",
"copyToClipboard": "نسخ إلى الحافظة",
@@ -179,7 +179,7 @@
"couldNotLoadInvalidFile": "تعذر التحميل، الملف غير صالح",
"importBackendFailed": "فشل الاستيراد من الخادوم.",
"cannotExportEmptyCanvas": "لا يمكن تصدير لوحة فارغة.",
"couldNotCopyToClipboard": "",
"couldNotCopyToClipboard": "تعذر النسخ إلى الحافظة.",
"decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
@@ -192,7 +192,8 @@
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "نوع الملف غير مدعوم.",
@@ -200,10 +201,10 @@
"fileTooBig": "الملف كبير جداً. الحد الأقصى المسموح به للحجم هو {{maxSize}}.",
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
"invalidSVGString": "SVG غير صالح.",
"cannotResolveCollabServer": "",
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
"cannotResolveCollabServer": "تعذر الاتصال بخادم التعاون. الرجاء إعادة تحميل الصفحة والمحاولة مرة أخرى.",
"importLibraryError": "تعذر تحميل المكتبة",
"collabSaveFailed": "تعذر الحفظ في قاعدة البيانات. إذا استمرت المشاكل، يفضل أن تحفظ ملفك محليا كي لا تفقد عملك.",
"collabSaveFailed_sizeExceeded": "تعذر الحفظ في قاعدة البيانات، يبدو أن القماش كبير للغاية، يفضّل حفظ الملف محليا كي لا تفقد عملك."
},
"toolBar": {
"selection": "تحديد",
@@ -217,9 +218,10 @@
"text": "نص",
"library": "مكتبة",
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم",
"penMode": "",
"link": "",
"eraser": "ممحاة"
"penMode": "وضع القلم - امنع اللمس",
"link": "إضافة/تحديث الرابط للشكل المحدد",
"eraser": "ممحاة",
"hand": ""
},
"headings": {
"canvasActions": "إجراءات اللوحة",
@@ -227,7 +229,7 @@
"shapes": "الأشكال"
},
"hints": {
"canvasPanning": "لتحريك لوحة الرسم ، استمر في الضغط على عجلة الماوس أو مفتاح المسافة أثناء السحب",
"canvasPanning": "لتحريك القماش، اضغط على عجلة الفأرة أو مفتاح المسافة أثناء السحب، أو استخدم أداة اليد",
"linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد",
"freeDraw": "انقر واسحب، افرج عند الانتهاء",
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
@@ -238,14 +240,15 @@
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
"resizeImage": "يمكنك تغيير الحجم بحرية بالضغط بأستمرار على SHIFT،\nاضغط بأستمرار على ALT أيضا لتغيير الحجم من المركز",
"rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران",
"lineEditor_info": "",
"lineEditor_info": "اضغط على مفتاح (Ctrl أو Cmd) و انقر بشكل مزدوج، أو اضغط على مفتاحي (Ctrl أو Cmd) و (Enter) لتعديل النقاط",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": "نشر مكتبتك",
"bindTextToElement": "",
"deepBoxSelect": "",
"eraserRevert": ""
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "تعذر عرض المعاينة",
+6 -3
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
"invalidEncryptionKey": "",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "Този файлов формат не се поддържа.",
@@ -219,7 +220,8 @@
"lock": "Поддържайте избрания инструмент активен след рисуване",
"penMode": "",
"link": "",
"eraser": ""
"eraser": "",
"hand": ""
},
"headings": {
"canvasActions": "Действия по платното",
@@ -245,7 +247,8 @@
"publishLibrary": "",
"bindTextToElement": "Натиснете Enter, за да добавите",
"deepBoxSelect": "",
"eraserRevert": ""
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "Невъзможност за показване на preview",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷",
"resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?",
"removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?",
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।"
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "অসমর্থিত ফাইল।",
@@ -219,7 +220,8 @@
"lock": "আঁকার পরে নির্বাচিত টুল সক্রিয় রাখুন",
"penMode": "",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন"
"eraser": "ঝাড়ন",
"hand": ""
},
"headings": {
"canvasActions": "ক্যানভাস কার্যকলাপ",
@@ -227,7 +229,7 @@
"shapes": "আকার(গুলি)"
},
"hints": {
"canvasPanning": "ক্যানভাস সরানোর জন্য মাউস হুইল বা স্পেসবার ধরে টানুন",
"canvasPanning": "",
"linearElement": "একাধিক বিন্দু শুরু করতে ক্লিক করুন, একক লাইনের জন্য টেনে আনুন",
"freeDraw": "ক্লিক করুন এবং টেনে আনুন, আপনার কাজ শেষ হলে ছেড়ে দিন",
"text": "বিশেষ্য: আপনি নির্বাচন টুলের সাথে যে কোনো জায়গায় ডাবল-ক্লিক করে পাঠ্য যোগ করতে পারেন",
@@ -245,7 +247,8 @@
"publishLibrary": "আপনার নিজস্ব সংগ্রহ প্রকাশ করুন",
"bindTextToElement": "লেখা যোগ করতে এন্টার টিপুন",
"deepBoxSelect": "",
"eraserRevert": "মুছে ফেলার জন্য চিহ্নিত উপাদানগুলিকে ফিরিয়ে আনতে অল্ট ধরে রাখুন"
"eraserRevert": "মুছে ফেলার জন্য চিহ্নিত উপাদানগুলিকে ফিরিয়ে আনতে অল্ট ধরে রাখুন",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "প্রিভিউ দেখাতে অপারগ",
+29 -26
View File
@@ -1,7 +1,7 @@
{
"labels": {
"paste": "Enganxa",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "Enganxar com a text pla",
"pasteCharts": "Enganxa els diagrames",
"selectAll": "Selecciona-ho tot",
"multiSelect": "Afegeix un element a la selecció",
@@ -72,7 +72,7 @@
"layers": "Capes",
"actions": "Accions",
"language": "Llengua",
"liveCollaboration": "",
"liveCollaboration": "Col·laboració en directe...",
"duplicateSelection": "Duplica",
"untitled": "Sense títol",
"name": "Nom",
@@ -116,8 +116,8 @@
"label": "Enllaç"
},
"lineEditor": {
"edit": "",
"exit": ""
"edit": "Editar línia",
"exit": "Sortir de l'editor de línia"
},
"elementLock": {
"lock": "Bloca",
@@ -136,8 +136,8 @@
"buttons": {
"clearReset": "Neteja el llenç",
"exportJSON": "Exporta a un fitxer",
"exportImage": "",
"export": "",
"exportImage": "Exporta la imatge...",
"export": "Guardar a...",
"exportToPng": "Exporta a PNG",
"exportToSvg": "Exporta a SNG",
"copyToClipboard": "Copia al porta-retalls",
@@ -145,7 +145,7 @@
"scale": "Escala",
"save": "Desa al fitxer actual",
"saveAs": "Anomena i desa",
"load": "",
"load": "Obrir",
"getShareableLink": "Obté l'enllaç per a compartir",
"close": "Tanca",
"selectLanguage": "Trieu la llengua",
@@ -192,7 +192,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.",
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"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": {
"unsupportedFileType": "Tipus de fitxer no suportat.",
@@ -202,8 +203,8 @@
"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.",
"importLibraryError": "No s'ha pogut carregar la biblioteca",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
"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": "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."
},
"toolBar": {
"selection": "Selecció",
@@ -217,9 +218,10 @@
"text": "Text",
"library": "Biblioteca",
"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",
"eraser": "Esborrador"
"eraser": "Esborrador",
"hand": "Mà (eina de desplaçament)"
},
"headings": {
"canvasActions": "Accions del llenç",
@@ -227,7 +229,7 @@
"shapes": "Formes"
},
"hints": {
"canvasPanning": "Per a moure el llenç, mantingueu premuda la roda del ratolí o la tecla espai mentre l'arrossegueu",
"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",
"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ó",
@@ -238,14 +240,15 @@
"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",
"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_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",
"publishLibrary": "Publiqueu la vostra pròpia llibreria",
"bindTextToElement": "Premeu enter per a afegir-hi text",
"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": "É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": {
"cannotShowPreview": "No es pot mostrar la previsualització",
@@ -293,7 +296,7 @@
"blog": "Llegiu el nostre blog",
"click": "clic",
"deepSelect": "Selecció profunda",
"deepBoxSelect": "",
"deepBoxSelect": "Seleccioneu profundament dins del quadre i eviteu arrossegar",
"curvedArrow": "Fletxa corba",
"curvedLine": "Línia corba",
"documentation": "Documentació",
@@ -314,8 +317,8 @@
"zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció",
"toggleElementLock": "Blocar/desblocar la selecció",
"movePageUpDown": "",
"movePageLeftRight": ""
"movePageUpDown": "Mou la pàgina cap amunt/a baix",
"movePageLeftRight": "Mou la pàgina cap a l'esquerra/dreta"
},
"clearCanvasDialog": {
"title": "Neteja el llenç"
@@ -397,7 +400,7 @@
"fileSavedToFilename": "S'ha desat a {filename}",
"canvas": "el llenç",
"selection": "la selecció",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "Fer servir {{shortcut}} per enganxar com un sol element,\no enganxeu-lo en un editor de text existent"
},
"colors": {
"ffffff": "Blanc",
@@ -448,15 +451,15 @@
},
"welcomeScreen": {
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
"center_heading": "Totes les vostres dades es guarden localment al vostre navegador.",
"center_heading_plus": "Vols anar a Excalidraw+ en comptes?",
"menuHint": "Exportar, preferències, llenguatges..."
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
"menuHint": "Exportar, preferències i més...",
"center_heading": "Diagrames. Fer. Simple.",
"toolbarHint": "Selecciona una eina i comença a dibuixar!",
"helpHint": "Dreceres i ajuda"
}
}
}
+6 -3
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
"invalidEncryptionKey": "",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "",
@@ -219,7 +220,8 @@
"lock": "",
"penMode": "",
"link": "",
"eraser": "Guma"
"eraser": "Guma",
"hand": ""
},
"headings": {
"canvasActions": "",
@@ -245,7 +247,8 @@
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": "",
"eraserRevert": ""
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "",
+6 -3
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
"invalidEncryptionKey": "",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "",
@@ -219,7 +220,8 @@
"lock": "",
"penMode": "",
"link": "",
"eraser": ""
"eraser": "",
"hand": ""
},
"headings": {
"canvasActions": "",
@@ -245,7 +247,8 @@
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": "",
"eraserRevert": ""
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "",
+7 -4
View File
@@ -192,7 +192,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.",
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
"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": {
"unsupportedFileType": "Nicht unterstützter Dateityp.",
@@ -219,7 +220,8 @@
"lock": "Ausgewähltes Werkzeug nach Zeichnen aktiv lassen",
"penMode": "Stift-Modus - Berührung verhindern",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer"
"eraser": "Radierer",
"hand": "Hand (Schwenkwerkzeug)"
},
"headings": {
"canvasActions": "Aktionen für Zeichenfläche",
@@ -227,7 +229,7 @@
"shapes": "Formen"
},
"hints": {
"canvasPanning": "Um die Zeichenfläche zu verschieben, halte das Mausrad oder die Leertaste während des Ziehens",
"canvasPanning": "Um die Zeichenfläche zu verschieben, halte das Mausrad oder die Leertaste während des Ziehens, oder verwende das Hand-Werkzeug",
"linearElement": "Klicken für Linie mit mehreren Punkten, Ziehen für einzelne Linie",
"freeDraw": "Klicke und ziehe. Lass los, wenn du fertig bist",
"text": "Tipp: Du kannst auch Text hinzufügen, indem du mit dem Auswahlwerkzeug auf eine beliebige Stelle doppelklickst",
@@ -245,7 +247,8 @@
"publishLibrary": "Veröffentliche deine eigene Bibliothek",
"bindTextToElement": "Zum Hinzufügen Eingabetaste drücken",
"deepBoxSelect": "Halte CtrlOrCmd gedrückt, um innerhalb der Gruppe auszuwählen, und um Ziehen zu vermeiden",
"eraserRevert": "Halte Alt gedrückt, um die zum Löschen markierten Elemente zurückzusetzen"
"eraserRevert": "Halte Alt gedrückt, um die zum Löschen markierten Elemente zurückzusetzen",
"firefox_clipboard_write": "Diese Funktion kann wahrscheinlich aktiviert werden, indem die Einstellung \"dom.events.asyncClipboard.clipboardItem\" auf \"true\" gesetzt wird. Um die Browsereinstellungen in Firefox zu ändern, besuche die Seite \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "Vorschau kann nicht angezeigt werden",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
"removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.",
"collabOfflineWarning": "Δεν υπάρχει διαθέσιμη σύνδεση στο internet.\nΟι αλλαγές σας δεν θα αποθηκευτούν!"
},
"errors": {
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",
@@ -219,7 +220,8 @@
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "Γόμα"
"eraser": "Γόμα",
"hand": ""
},
"headings": {
"canvasActions": "Ενέργειες καμβά",
@@ -227,7 +229,7 @@
"shapes": "Σχήματα"
},
"hints": {
"canvasPanning": "Για να μετακινήσετε καμβά, κρατήστε πατημένο τον τροχό του ποντικιού ή το πλήκτρο διαστήματος ενώ σύρετε",
"canvasPanning": "",
"linearElement": "Κάνε κλικ για να ξεκινήσεις πολλαπλά σημεία, σύρε για μια γραμμή",
"freeDraw": "Κάντε κλικ και σύρτε, απελευθερώσατε όταν έχετε τελειώσει",
"text": "Tip: μπορείτε επίσης να προσθέστε κείμενο με διπλό-κλικ οπουδήποτε με το εργαλείο επιλογών",
@@ -245,7 +247,8 @@
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη",
"bindTextToElement": "Πατήστε Enter για προσθήκη κειμένου",
"deepBoxSelect": "Κρατήστε πατημένο το CtrlOrCmd για να επιλέξετε βαθιά, και να αποτρέψετε τη μεταφορά",
"eraserRevert": "Κρατήστε πατημένο το Alt για να επαναφέρετε τα στοιχεία που σημειώθηκαν για διαγραφή"
"eraserRevert": "Κρατήστε πατημένο το Alt για να επαναφέρετε τα στοιχεία που σημειώθηκαν για διαγραφή",
"firefox_clipboard_write": "Αυτή η επιλογή μπορεί πιθανώς να ενεργοποιηθεί αλλάζοντας την ρύθμιση \"dom.events.asyncClipboard.clipboardItem\" σε \"true\". Για να αλλάξετε τις ρυθμίσεις του προγράμματος περιήγησης στο Firefox, επισκεφθείτε τη σελίδα \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
+19 -3
View File
@@ -110,6 +110,7 @@
"increaseFontSize": "Increase font size",
"unbindText": "Unbind text",
"bindText": "Bind text to the container",
"createContainerFromText": "Wrap text in a container",
"link": {
"edit": "Edit link",
"create": "Create link",
@@ -119,7 +120,6 @@
"edit": "Edit line",
"exit": "Exit line editor"
},
"elementLock": {
"lock": "Lock",
"unlock": "Unlock",
@@ -193,7 +193,8 @@
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
"resetLibrary": "This will clear your library. Are you sure?",
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!"
},
"errors": {
"unsupportedFileType": "Unsupported file type.",
@@ -204,7 +205,22 @@
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library",
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work."
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
"brave_measure_text_error": {
"start": "Looks like you are using Brave browser with the",
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
"setting_enabled": "setting enabled",
"break": "This could result in breaking the",
"text_elements": "Text Elements",
"in_your_drawings": "in your drawings",
"strongly_recommend": "We strongly recommend disabling this setting. You can follow",
"steps": "these steps",
"how": "on how to do so",
"disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
"issue": "issue",
"write": "on our GitHub, or write us on",
"discord": "Discord"
}
},
"toolBar": {
"selection": "Selection",
+14 -11
View File
@@ -103,7 +103,7 @@
"share": "Compartir",
"showStroke": "Mostrar selector de color de trazo",
"showBackground": "Mostrar el selector de color de fondo",
"toggleTheme": "Alternar tema",
"toggleTheme": "Cambiar tema",
"personalLib": "Biblioteca personal",
"excalidrawLib": "Biblioteca Excalidraw",
"decreaseFontSize": "Disminuir tamaño de letra",
@@ -192,7 +192,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.",
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
"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": {
"unsupportedFileType": "Tipo de archivo no admitido.",
@@ -219,7 +220,8 @@
"lock": "Mantener la herramienta seleccionada activa después de dibujar",
"penMode": "Modo Lápiz - previene toque",
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar"
"eraser": "Borrar",
"hand": "Mano (herramienta de panoramización)"
},
"headings": {
"canvasActions": "Acciones del lienzo",
@@ -227,12 +229,12 @@
"shapes": "Formas"
},
"hints": {
"canvasPanning": "Para mover el lienzo, mantenga la rueda del ratón o la barra de espacio mientras arrastra",
"canvasPanning": "Para mover el lienzo, mantenga la rueda del ratón o la barra espaciadora mientras arrastra o utilice la herramienta de mano",
"linearElement": "Haz clic para dibujar múltiples puntos, arrastrar para solo una línea",
"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_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",
"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",
@@ -245,7 +247,8 @@
"publishLibrary": "Publica tu propia biblioteca",
"bindTextToElement": "Presione Entrar para agregar",
"deepBoxSelect": "Mantén CtrlOrCmd para seleccionar en profundidad, y para evitar arrastrar",
"eraserRevert": "Mantenga pulsado Alt para revertir los elementos marcados para su eliminación"
"eraserRevert": "Mantenga pulsado Alt para revertir los elementos marcados para su eliminación",
"firefox_clipboard_write": "Esta característica puede ser habilitada estableciendo la bandera \"dom.events.asyncClipboard.clipboardItem\" a \"true\". Para cambiar las banderas del navegador en Firefox, visite la página \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "No se puede mostrar la vista previa",
@@ -312,7 +315,7 @@
"title": "Ayuda",
"view": "Vista",
"zoomToFit": "Ajustar la vista para mostrar todos los elementos",
"zoomToSelection": "Zoom a la selección",
"zoomToSelection": "Ampliar selección",
"toggleElementLock": "Bloquear/desbloquear selección",
"movePageUpDown": "Mover página hacia arriba/abajo",
"movePageLeftRight": "Mover página hacia la izquierda/derecha"
@@ -324,9 +327,9 @@
"title": "Publicar biblioteca",
"itemName": "Nombre del artículo",
"authorName": "Nombre del autor",
"githubUsername": "Nombre de usuario de Github",
"githubUsername": "Nombre de usuario de GitHub",
"twitterUsername": "Nombre de usuario de Twitter",
"libraryName": "Nombre de la librería",
"libraryName": "Nombre de la biblioteca",
"libraryDesc": "Descripción de la biblioteca",
"website": "Sitio Web",
"placeholder": {
@@ -334,7 +337,7 @@
"libraryName": "Nombre de tu biblioteca",
"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",
"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)"
},
"errors": {
@@ -456,7 +459,7 @@
"menuHint": "Exportar, preferencias y más...",
"center_heading": "Diagramas. Hecho. Simplemente.",
"toolbarHint": "¡Elige una herramienta y empieza a dibujar!",
"helpHint": "Atajos & ayuda"
"helpHint": "Atajos y ayuda"
}
}
}
+7 -4
View File
@@ -192,7 +192,8 @@
"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?",
"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": {
"unsupportedFileType": "Onartu gabeko fitxategi mota.",
@@ -219,7 +220,8 @@
"lock": "Mantendu aktibo hautatutako tresna marraztu ondoren",
"penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma"
"eraser": "Borragoma",
"hand": "Eskua (panoratze tresna)"
},
"headings": {
"canvasActions": "Canvas ekintzak",
@@ -227,7 +229,7 @@
"shapes": "Formak"
},
"hints": {
"canvasPanning": "Oihala mugitzeko, sakatu saguaren gurpila edo zuriune-barra arrastatzean",
"canvasPanning": "Oihala mugitzeko, eutsi saguaren gurpila edo zuriune-barra arrastatzean, edo erabili esku tresna",
"linearElement": "Egin klik hainbat puntu hasteko, arrastatu lerro bakarrerako",
"freeDraw": "Egin klik eta arrastatu, askatu amaitutakoan",
"text": "Aholkua: testua gehitu dezakezu edozein lekutan klik bikoitza eginez hautapen tresnarekin",
@@ -245,7 +247,8 @@
"publishLibrary": "Argitaratu zure liburutegia",
"bindTextToElement": "Sakatu Sartu testua gehitzeko",
"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": "Ezaugarri hau \"dom.events.asyncClipboard.clipboardItem\" marka \"true\" gisa ezarrita gaitu daiteke. Firefox-en arakatzailearen banderak aldatzeko, bisitatu \"about:config\" orrialdera."
},
"canvasError": {
"cannotShowPreview": "Ezin da oihala aurreikusi",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است."
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "نوع فایل پشتیبانی نشده.",
@@ -219,7 +220,8 @@
"lock": "ابزار انتخاب شده را بعد از کشیدن نگه دار",
"penMode": "حالت قلم - جلوگیری از تماس",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن"
"eraser": "پاک کن",
"hand": ""
},
"headings": {
"canvasActions": "عملیات روی بوم",
@@ -227,7 +229,7 @@
"shapes": "شکل‌ها"
},
"hints": {
"canvasPanning": "برای حرکت دادن بوم، چرخ ماوس یا فاصله را در حین کشیدن نگه دارید",
"canvasPanning": "",
"linearElement": "برای چند نقطه کلیک و برای یک خط بکشید",
"freeDraw": "کلیک کنید و بکشید و وقتی کار تمام شد رها کنید",
"text": "نکته: با برنامه انتخاب شده شما میتوانید با دوبار کلیک کردن هرکجا میخواید متن اظاف کنید",
@@ -245,7 +247,8 @@
"publishLibrary": "کتابخانه خود را منتشر کنید",
"bindTextToElement": "برای افزودن اینتر را بزنید",
"deepBoxSelect": "CtrlOrCmd را برای انتخاب عمیق و جلوگیری از کشیدن نگه دارید",
"eraserRevert": "Alt را نگه دارید تا عناصر علامت گذاری شده برای حذف برگردند"
"eraserRevert": "Alt را نگه دارید تا عناصر علامت گذاری شده برای حذف برگردند",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "پیش نمایش نشان داده نمی شود",
+34 -31
View File
@@ -1,7 +1,7 @@
{
"labels": {
"paste": "Liitä",
"pasteAsPlaintext": "",
"pasteAsPlaintext": "Liitä pelkkänä tekstinä",
"pasteCharts": "Liitä kaaviot",
"selectAll": "Valitse kaikki",
"multiSelect": "Lisää kohde valintaan",
@@ -72,7 +72,7 @@
"layers": "Tasot",
"actions": "Toiminnot",
"language": "Kieli",
"liveCollaboration": "",
"liveCollaboration": "Live Yhteistyö...",
"duplicateSelection": "Monista",
"untitled": "Nimetön",
"name": "Nimi",
@@ -116,14 +116,14 @@
"label": "Linkki"
},
"lineEditor": {
"edit": "",
"exit": ""
"edit": "Muokkaa riviä",
"exit": "Poistu rivieditorista"
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
"lock": "Lukitse",
"unlock": "Poista lukitus",
"lockAll": "Lukitse kaikki",
"unlockAll": "Poista lukitus kaikista"
},
"statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna"
@@ -136,8 +136,8 @@
"buttons": {
"clearReset": "Tyhjennä piirtoalue",
"exportJSON": "Vie tiedostoon",
"exportImage": "",
"export": "",
"exportImage": "Vie kuva...",
"export": "Tallenna nimellä...",
"exportToPng": "Vie PNG-tiedostona",
"exportToSvg": "Vie SVG-tiedostona",
"copyToClipboard": "Kopioi leikepöydälle",
@@ -145,7 +145,7 @@
"scale": "Koko",
"save": "Tallenna nykyiseen tiedostoon",
"saveAs": "Tallenna nimellä",
"load": "",
"load": "Avaa",
"getShareableLink": "Hae jaettava linkki",
"close": "Sulje",
"selectLanguage": "Valitse kieli",
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
"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": {
"unsupportedFileType": "Tiedostotyyppiä ei tueta.",
@@ -201,9 +202,9 @@
"svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.",
"invalidSVGString": "Virheellinen SVG.",
"cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.",
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": ""
"importLibraryError": "Kokoelman lataaminen epäonnistui",
"collabSaveFailed": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi.",
"collabSaveFailed_sizeExceeded": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi."
},
"toolBar": {
"selection": "Valinta",
@@ -217,9 +218,10 @@
"text": "Teksti",
"library": "Kirjasto",
"lock": "Pidä valittu työkalu aktiivisena piirron jälkeen",
"penMode": "",
"penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu"
"eraser": "Poistotyökalu",
"hand": "Käsi (panning-työkalu)"
},
"headings": {
"canvasActions": "Piirtoalueen toiminnot",
@@ -227,7 +229,7 @@
"shapes": "Muodot"
},
"hints": {
"canvasPanning": "Liikuttaaksesi piirtoaluetta, raahaa hiiren vieritysrulla tai välilyöntinäppäin alaspainettuna",
"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",
"freeDraw": "Paina ja raahaa, päästä irti kun olet valmis",
"text": "Vinkki: voit myös lisätä tekstiä kaksoisnapsauttamalla mihin tahansa valintatyökalulla",
@@ -238,14 +240,15 @@
"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",
"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_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",
"publishLibrary": "Julkaise oma kirjasto",
"bindTextToElement": "Lisää tekstiä painamalla enter",
"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": "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": {
"cannotShowPreview": "Esikatselua ei voitu näyttää",
@@ -313,9 +316,9 @@
"view": "Näkymä",
"zoomToFit": "Näytä kaikki elementit",
"zoomToSelection": "Näytä valinta",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Lukitse / poista lukitus valinta",
"movePageUpDown": "Siirrä sivua ylös/alas",
"movePageLeftRight": "Siirrä sivua vasemmalle/oikealle"
},
"clearCanvasDialog": {
"title": "Pyyhi piirtoalue"
@@ -397,7 +400,7 @@
"fileSavedToFilename": "Tallennettiin kohteeseen {filename}",
"canvas": "piirtoalue",
"selection": "valinta",
"pasteAsSingleElement": ""
"pasteAsSingleElement": "Käytä {{shortcut}} liittääksesi yhtenä elementtinä,\ntai liittääksesi olemassa olevaan tekstieditoriin"
},
"colors": {
"ffffff": "Valkoinen",
@@ -448,15 +451,15 @@
},
"welcomeScreen": {
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
"center_heading": "Kaikki tietosi on tallennettu paikallisesti selaimellesi.",
"center_heading_plus": "Haluatko sen sijaan mennä Excalidraw+:aan?",
"menuHint": "Vie, asetukset, kielet, ..."
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
"menuHint": "Vie, asetukset ja lisää...",
"center_heading": "Kaaviot. Tehty. Yksinkertaiseksi.",
"toolbarHint": "Valitse työkalu ja aloita piirtäminen!",
"helpHint": "Pikanäppäimet & ohje"
}
}
}
+8 -5
View File
@@ -45,7 +45,7 @@
"exportEmbedScene": "Intégrer la scène",
"exportEmbedScene_details": "Les données de scène seront enregistrées dans le fichier PNG/SVG exporté, afin que la scène puisse être restaurée à partir de celui-ci.\nCela augmentera la taille du fichier exporté.",
"addWatermark": "Ajouter \"Réalisé avec Excalidraw\"",
"handDrawn": "Manuscrit",
"handDrawn": "À main levée",
"normal": "Normale",
"code": "Code",
"small": "Petite",
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.",
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?",
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée."
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée.",
"collabOfflineWarning": "Aucune connexion internet disponible.\nVos modifications ne seront pas enregistrées !"
},
"errors": {
"unsupportedFileType": "Type de fichier non supporté.",
@@ -219,7 +220,8 @@
"lock": "Garder l'outil sélectionné actif après le dessin",
"penMode": "Mode stylo - évite le toucher",
"link": "Ajouter/mettre à jour le lien pour une forme sélectionnée",
"eraser": "Gomme"
"eraser": "Gomme",
"hand": "Mains (outil de déplacement de la vue)"
},
"headings": {
"canvasActions": "Actions du canevas",
@@ -227,7 +229,7 @@
"shapes": "Formes"
},
"hints": {
"canvasPanning": "Pour déplacer la zone de dessin, maintenez la molette de la souris enfoncée ou la barre d'espace tout en faisant glisser",
"canvasPanning": "Pour déplacer la zone de dessin, maintenez la molette de la souris enfoncée ou la barre d'espace tout en faisant glisser, ou utiliser l'outil main.",
"linearElement": "Cliquez pour démarrer plusieurs points, faites glisser pour une seule ligne",
"freeDraw": "Cliquez et faites glissez, relâchez quand vous avez terminé",
"text": "Astuce : vous pouvez aussi ajouter du texte en double-cliquant n'importe où avec l'outil de sélection",
@@ -245,7 +247,8 @@
"publishLibrary": "Publier votre propre bibliothèque",
"bindTextToElement": "Appuyer sur Entrée pour ajouter du texte",
"deepBoxSelect": "Maintenir Ctrl ou Cmd pour sélectionner dans les groupes et empêcher le déplacement",
"eraserRevert": "Maintenez Alt enfoncé pour annuler les éléments marqués pour suppression"
"eraserRevert": "Maintenez Alt enfoncé pour annuler les éléments marqués pour suppression",
"firefox_clipboard_write": "Cette fonctionnalité devrait pouvoir être activée en définissant l'option \"dom.events.asyncClipboard.clipboard.clipboardItem\" à \"true\". Pour modifier les paramètres du navigateur dans Firefox, visitez la page \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "Impossible dafficher laperçu",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Non se puido importar a escena dende a URL proporcionada. Ou ben está malformada ou non contén un JSON con información válida para Excalidraw.",
"resetLibrary": "Isto limpará a súa biblioteca. Está seguro?",
"removeItemsFromsLibrary": "Eliminar {{count}} elemento(s) da biblioteca?",
"invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada."
"invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada.",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "Tipo de ficheiro non soportado.",
@@ -219,7 +220,8 @@
"lock": "Manter a ferramenta seleccionada activa despois de debuxar",
"penMode": "Modo lapis - evitar o contacto",
"link": "Engadir/ Actualizar ligazón para a forma seleccionada",
"eraser": "Goma de borrar"
"eraser": "Goma de borrar",
"hand": "Man (ferramenta de desprazamento)"
},
"headings": {
"canvasActions": "Accións do lenzo",
@@ -227,7 +229,7 @@
"shapes": "Formas"
},
"hints": {
"canvasPanning": "Para mover o lenzo, manteña a roda do rato ou a barra de espazo mentres arrastra",
"canvasPanning": "Para mover o lenzo, manteña pulsada a roda do rato ou a barra de espazo mentres arrastra, ou utilice a ferramenta da man",
"linearElement": "Faga clic para iniciar varios puntos, arrastre para unha sola liña",
"freeDraw": "Fai clic e arrastra, solta cando acabes",
"text": "Consello: tamén podes engadir texto facendo dobre-clic en calquera lugar coa ferramenta de selección",
@@ -245,7 +247,8 @@
"publishLibrary": "Publica a túa propia biblioteca",
"bindTextToElement": "Prema a tecla enter para engadir texto",
"deepBoxSelect": "Manteña pulsado CtrlOrCmd para seleccionar en profundidade e evitar o arrastre",
"eraserRevert": "Manteña pulsado Alt para reverter os elementos marcados para a súa eliminación"
"eraserRevert": "Manteña pulsado Alt para reverter os elementos marcados para a súa eliminación",
"firefox_clipboard_write": "Esta función pódese activar establecendo a opción \"dom.events.asyncClipboard.clipboardItem\" a \"true\". Para cambiar as opcións do navegador en Firefox, visita a páxina \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "Non se pode mostrar a vista previa",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
"removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?",
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל."
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל.",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "סוג הקובץ אינו נתמך.",
@@ -219,7 +220,8 @@
"lock": "השאר את הכלי הנבחר פעיל גם לאחר סיום הציור",
"penMode": "",
"link": "הוספה/עדכון של קישור עבור הצורה הנבחרת",
"eraser": "מחק"
"eraser": "מחק",
"hand": ""
},
"headings": {
"canvasActions": "פעולות הלוח",
@@ -227,7 +229,7 @@
"shapes": "צורות"
},
"hints": {
"canvasPanning": "כדי להזיז את הקנבס לחצו על גלגל העכבר או על מקש הרווח תוך כדי גרירה",
"canvasPanning": "",
"linearElement": "הקלק בשביל לבחור נקודות מרובות, גרור בשביל קו בודד",
"freeDraw": "לחץ וגרור, שחרר כשסיימת",
"text": "טיפ: אפשר להוסיף טקסט על ידי לחיצה כפולה בכל מקום עם כלי הבחירה",
@@ -245,7 +247,8 @@
"publishLibrary": "פירסום ספריה אישית",
"bindTextToElement": "יש להקיש Enter כדי להוסיף טקסט",
"deepBoxSelect": "",
"eraserRevert": ""
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "לא הצלחנו להציג את התצוגה המקדימה",
+14 -11
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "दिये गये युआरेल से दृश्य आयात नहीं किया जा सका. यह या तो अनुचित है, या इसमें उचित Excalidraw JSON डेटा नहीं है।",
"resetLibrary": "यह पूरा संग्रह रिक्त करेगा. क्या आपको यक़ीन हैं?",
"removeItemsFromsLibrary": "{{count}} वस्तु(यें) संग्रह से हटायें?",
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं"
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं",
"collabOfflineWarning": "कोई इंटरनेट कनेक्शन उपलब्ध नहीं है।\nआपके बदलाव सहेजे नहीं जाएंगे!"
},
"errors": {
"unsupportedFileType": "असमर्थित फाइल प्रकार",
@@ -219,7 +220,8 @@
"lock": "ड्राइंग के बाद चयनित टूल को सक्रिय रखें",
"penMode": "पेन का मोड - स्पर्श टाले",
"link": "",
"eraser": "रबड़"
"eraser": "रबड़",
"hand": "हाथ ( खिसकाने का औज़ार)"
},
"headings": {
"canvasActions": "कैनवास क्रिया",
@@ -227,7 +229,7 @@
"shapes": "आकृतियाँ"
},
"hints": {
"canvasPanning": "",
"canvasPanning": "कैनवास को सरकाने के लिए, ड्रैग करते समय माउस व्हील को पकड़े रखे या स्पेसबार को दबाए रखे, अथवा हाथ वाले औज़ार का उपयोग करें",
"linearElement": "कई बिंदुओं को शुरू करने के लिए क्लिक करें, सिंगल लाइन के लिए खींचें",
"freeDraw": "क्लिक करें और खींचें। समाप्त करने के लिए, छोड़ो",
"text": "आप चयन टूल से कहीं भी डबल-क्लिक करके टेक्स्ट जोड़ सकते हैं",
@@ -245,7 +247,8 @@
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": "",
"eraserRevert": "मिटाने के लिए चुने हुए चीजों को ना चुनने के लिए Alt साथ में दबाए"
"eraserRevert": "मिटाने के लिए चुने हुए चीजों को ना चुनने के लिए Alt साथ में दबाए",
"firefox_clipboard_write": "\"dom.events.asyncClipboard.clipboardItem\" फ़्लैग को \"true\" पर सेट करके इस सुविधा को संभवतः सक्षम किया जा सकता है। Firefox में ब्राउज़र फ़्लैग बदलने के लिए, \"about:config\" पृष्ठ पर जाएँ।"
},
"canvasError": {
"cannotShowPreview": "पूर्वावलोकन नहीं दिखा सकते हैं",
@@ -448,15 +451,15 @@
},
"welcomeScreen": {
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
"center_heading": "आपका सर्व डेटा ब्राउज़र के भीतर स्थानिक जगह पे सुरक्षित किया गया.",
"center_heading_plus": "बजाय आपको Excalidraw+ पर जाना है?",
"menuHint": "निर्यात, पसंद, भाषायें, ..."
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
"menuHint": "निर्यात, पसंद, और भी...",
"center_heading": "चित्रांकन। बनाया गया। सरल।",
"toolbarHint": "एक औजार चुने और चित्रकारी प्रारंभ करे!",
"helpHint": "शॉर्ट्कट और सहाय्य"
}
}
}
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.",
"resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?",
"removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?",
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva."
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva.",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "Nem támogatott fájltípus.",
@@ -219,7 +220,8 @@
"lock": "Rajzolás után az aktív eszközt tartsa kijelölve",
"penMode": "",
"link": "Hivatkozás hozzáadása/frissítése a kiválasztott alakzathoz",
"eraser": ""
"eraser": "",
"hand": ""
},
"headings": {
"canvasActions": "Vászon műveletek",
@@ -227,7 +229,7 @@
"shapes": "Alakzatok"
},
"hints": {
"canvasPanning": "A vászon mozgatásához tartsd lenyomva az egér görgőjét vagy a szóköz billentyűt húzás közben",
"canvasPanning": "",
"linearElement": "Kattintással görbe, az eger húzásával pedig egyenes nyilat rajzolhatsz",
"freeDraw": "Kattints és húzd, majd engedd el, amikor végeztél",
"text": "Tipp: A kijelölés eszközzel a dupla kattintás új szöveget hoz létre",
@@ -245,7 +247,8 @@
"publishLibrary": "Tedd közzé saját könyvtáradat",
"bindTextToElement": "Nyomd meg az Entert szöveg hozzáadáshoz",
"deepBoxSelect": "Tartsd lenyomva a Ctrl/Cmd billentyűt a mély kijelöléshez és a húzás megakadályozásához",
"eraserRevert": ""
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "Előnézet nem jeleníthető meg",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan.",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "Tipe file tidak didukung.",
@@ -219,7 +220,8 @@
"lock": "Biarkan alat yang dipilih aktif setelah menggambar",
"penMode": "Mode pena - mencegah sentuhan",
"link": "Tambah/Perbarui tautan untuk bentuk yang dipilih",
"eraser": "Penghapus"
"eraser": "Penghapus",
"hand": ""
},
"headings": {
"canvasActions": "Opsi Kanvas",
@@ -227,7 +229,7 @@
"shapes": "Bentuk"
},
"hints": {
"canvasPanning": "Untuk memindahkan kanvas, tekan roda mouse atau spasi ketika menarik",
"canvasPanning": "",
"linearElement": "Klik untuk memulai banyak poin, seret untuk satu baris",
"freeDraw": "Klik dan seret, lepaskan jika Anda selesai",
"text": "Tip: Anda juga dapat menambahkan teks dengan klik ganda di mana saja dengan alat pemilihan",
@@ -245,7 +247,8 @@
"publishLibrary": "Terbitkan pustaka Anda",
"bindTextToElement": "Tekan enter untuk tambahkan teks",
"deepBoxSelect": "Tekan Ctrl atau Cmd untuk memilih yang di dalam, dan mencegah penggeseran",
"eraserRevert": "Tahan Alt untuk mengembalikan elemen yang ditandai untuk dihapus"
"eraserRevert": "Tahan Alt untuk mengembalikan elemen yang ditandai untuk dihapus",
"firefox_clipboard_write": "Fitur ini dapat diaktifkan melalui pengaturan flag \"dom.events.asyncClipboard.clipboardItem\" ke \"true\". Untuk mengganti flag di Firefox, pergi ke laman \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "Tidak dapat menampilkan pratinjau",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.",
"resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
"removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?",
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata."
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata.",
"collabOfflineWarning": "Nessuna connessione internet disponibile.\nLe tue modifiche non verranno salvate!"
},
"errors": {
"unsupportedFileType": "Tipo di file non supportato.",
@@ -219,7 +220,8 @@
"lock": "Mantieni lo strumento selezionato attivo dopo aver disegnato",
"penMode": "Modalità penna - previene il tocco",
"link": "Aggiungi/ aggiorna il link per una forma selezionata",
"eraser": "Gomma"
"eraser": "Gomma",
"hand": "Mano (strumento di panoramica)"
},
"headings": {
"canvasActions": "Azioni sulla Tela",
@@ -227,7 +229,7 @@
"shapes": "Forme"
},
"hints": {
"canvasPanning": "Per spostare la tela, tieni premuta la rotella del mouse o la barra spaziatrice mentre la trascini",
"canvasPanning": "Per spostare la tela, tieni premuta la rotellina del mouse o la barra spaziatrice mentre trascini oppure usa lo strumento mano",
"linearElement": "Clicca per iniziare una linea in più punti, trascina per singola linea",
"freeDraw": "Clicca e trascina, rilascia quando avrai finito",
"text": "Suggerimento: puoi anche aggiungere del testo facendo doppio clic ovunque con lo strumento di selezione",
@@ -245,7 +247,8 @@
"publishLibrary": "Pubblica la tua libreria",
"bindTextToElement": "Premi invio per aggiungere il testo",
"deepBoxSelect": "Tieni premuto CtrlOCmd per selezionare in profondità e per impedire il trascinamento",
"eraserRevert": "Tieni premuto Alt per ripristinare gli elementi contrassegnati per l'eliminazione"
"eraserRevert": "Tieni premuto Alt per ripristinare gli elementi contrassegnati per l'eliminazione",
"firefox_clipboard_write": "Questa funzione può essere abilitata impostando il flag \"dom.events.asyncClipboard.clipboardItem\" su \"true\". Per modificare i flag del browser in Firefox, visitare la pagina \"about:config\"."
},
"canvasError": {
"cannotShowPreview": "Impossibile visualizzare l'anteprima",
+7 -4
View File
@@ -192,7 +192,8 @@
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
"resetLibrary": "ライブラリを消去します。本当によろしいですか?",
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。",
"collabOfflineWarning": "インターネットに接続されていません。\n変更は保存されません!"
},
"errors": {
"unsupportedFileType": "サポートされていないファイル形式です。",
@@ -219,7 +220,8 @@
"lock": "描画後も使用中のツールを選択したままにする",
"penMode": "ペンモード - タッチ防止",
"link": "選択した図形のリンクを追加/更新",
"eraser": "消しゴム"
"eraser": "消しゴム",
"hand": "手 (パンニングツール)"
},
"headings": {
"canvasActions": "キャンバス操作",
@@ -227,7 +229,7 @@
"shapes": "図形"
},
"hints": {
"canvasPanning": "キャンバスを移動するには、マウスホイールまたはスペースバーを押しながらドラッグします",
"canvasPanning": "キャンバスを移動するには、マウスホイールまたはスペースバーを押しながらドラッグするか、手ツールを使用します",
"linearElement": "クリックすると複数の頂点からなる曲線を開始、ドラッグすると直線",
"freeDraw": "クリックしてドラッグします。離すと終了します",
"text": "ヒント: 選択ツールを使用して任意の場所をダブルクリックしてテキストを追加することもできます",
@@ -245,7 +247,8 @@
"publishLibrary": "自分のライブラリを公開",
"bindTextToElement": "Enterを押してテキストを追加",
"deepBoxSelect": "CtrlOrCmd を押し続けることでドラッグを抑止し、深い選択を行います",
"eraserRevert": "Alt を押し続けることで削除マークされた要素を元に戻す"
"eraserRevert": "Alt を押し続けることで削除マークされた要素を元に戻す",
"firefox_clipboard_write": "この機能は、\"dom.events.asyncClipboard.clipboardItem\" フラグを \"true\" に設定することで有効になる可能性があります。Firefox でブラウザーの設定を変更するには、\"about:config\" ページを参照してください。"
},
"canvasError": {
"cannotShowPreview": "プレビューを表示できません",

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