Compare commits

...

91 Commits

Author SHA1 Message Date
Aakansha Doshi ef8bcbe1f8 cache the visible point indexes 2022-09-16 16:57:41 +05:30
Aakansha Doshi 02d5cc4174 Merge remote-tracking branch 'origin/master' into aakansha-hide-close-linear-element-points 2022-09-16 16:53:50 +05:30
Seunghyun oh ec4b3d913e fix: remove no longer used code related to collab room loading (#5699)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-09-15 19:58:07 +00:00
Aakansha Doshi 5390617c01 test: add more specs for line editor segment midpoints (#5698)
* tests: add more specs for line editor segment midpoints

* use API to create elements

* Add specs for checking midpoint hidden when points too close
2022-09-15 19:31:55 +05:30
Aakansha Doshi 0d1058a596 feat: support segment midpoints in line editor (#5641)
* feat: support segment midpoints in line editor

* fix tests

* midpoints working in bezier curve

* midpoint working with non zero roughness

* calculate beizer curve control points for points >2

* unnecessary rerender

* don't show phantom points inside editor for short segments

* don't show phantom points for small curves

* improve the algo for plotting midpoints on bezier curve by taking arc lengths and doing binary search

* fix tests finally

* fix naming

* cache editor midpoints

* clear midpoint cache when undo

* fix caching

* calculate index properly when not all segments have midpoints

* make sure correct element version is fetched from cache

* chore

* fix

* direct comparison for equal points

* create arePointsEqual util

* upate name

* don't update cache except inside getter

* don't compute midpoints outside editor unless 2pointer lines

* update cache to object and burst when Zoom updated as well

* early return if midpoints not present outside editor

* don't early return

* cleanup

* Add specs

* fix
2022-09-14 19:55:54 +05:30
Seunghyun oh c5869979c8 chore: fix typo in clipboard alert (#5693)
chore: fix typo
2022-09-14 12:15:35 +05:30
David Luzar 6a6b9c90a7 fix: revert webpack deduping to fix @next runtime (#5695)
Revert "chore: Dedupe webpack configs. (#5449)"

This reverts commit da4fa91ffc.
2022-09-13 21:19:57 +02:00
Aakansha Doshi 5c17751662 fix: Move to release notes for v0.9.0 and after (#5686)
* fix: Move to release notes for v0.9.0 and after

* fix
2022-09-13 16:29:56 +05:30
Ryan Di 898789b979 chore: update lib menu click outside callback comment (#5687) 2022-09-12 11:19:22 +05:30
Ikko Ashimine 7922ce129e chore: fix typo in blob.ts (#5664)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-09-11 21:50:51 +00:00
David Luzar 59ec1c6cee fix: zen-mode exit button not working (#5682) 2022-09-09 13:53:38 +02:00
Igor Berlenko 933c6a2237 build: add missing dependencies: pica, lodash (#5656)
* add missing dependencies: pica, lodash

* remove lodash & fix yarn.lock

* first

* second

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-09-07 16:08:04 +05:30
zsviczian cd61f31116 fix: buttons jump around on the mobile menu (#5658)
Update MobileMenu.tsx
2022-09-05 16:00:47 +05:30
Aakansha Doshi 2f4d051ea1 fix specs 2022-09-01 18:26:43 +05:30
Aakansha Doshi 0b0846bd8e fix index 2022-09-01 18:03:49 +05:30
Aakansha Doshi 330b3a0530 hide n-1 point if distance is less than threshold 2022-09-01 17:51:50 +05:30
Aakansha Doshi cd195374bc don't allow dragging hidden points 2022-09-01 17:30:31 +05:30
Aakansha Doshi 8971d06655 Merge remote-tracking branch 'origin/master' into aakansha-hide-close-linear-element-points 2022-09-01 16:18:52 +05:30
Abdullah Adeel b3052f0178 fix: #5622 - prevent session theme reset during collaboration (#5640)
*  fixed #5622 - prevent session theme reset

 -  prevent newly initialized state to override theme preferences.
 - 🔧 fix for #5622

* refactor

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-09-01 15:41:44 +05:30
Excalidraw Bot a271e42af1 chore: Update translations from Crowdin (#5596)
* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Lithuanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Lithuanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Bengali)

* Auto commit: Calculate translation coverage

* New translations en.json (Bengali)

* Auto commit: Calculate translation coverage
2022-09-01 13:36:54 +05:30
zsviczian 836120c14b feat: added exportPadding to PNG (blob) export in @excalidraw/utils (#5626)
* added exportPadding

* Update README.md

* Update CHANGELOG.md
2022-08-30 12:48:24 +05:30
DanielJGeiger da4fa91ffc chore: Dedupe webpack configs. (#5449)
* chore: Dedupe package dependencies and webpack configs.

* Fully dedupe `src/packages` via symlinks

* Merge https://github.com/excalidraw/excalidraw into dedupe-package-deps-configs

* fix: Link `tsc` so `build:example` works in @excalidraw/excalidraw

* @excalidraw/plugins: Revert the `yarn.lock` deduping.

* Drop yarn commands from the root `package.json`.

* Remove more unneeded `package.json` additions.

* One more change to drop in `package.json` files.

* Deduping: Move even more into common webpack configs.

* renaming

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-08-30 12:37:18 +05:30
Aakansha Doshi 553b493f37 fix: library actions inside the sidebar (#5638) 2022-08-29 19:26:03 +05:30
Ives van Hoorne 59a1d192d2 chore: update CodeSandbox links and add a config (#5624)
* chore: update CodeSandbox links and add a config

* Update tasks.json
2022-08-29 18:52:04 +05:30
Aakansha Doshi 8b7302e89e fix: don't render library menu twice for mobile (#5636) 2022-08-29 15:44:10 +05:30
David Luzar f9b7cfd8aa fix: reintroduce help dialog button (#5631) 2022-08-27 23:02:17 +02:00
Aakansha Doshi 1e4e37b87f empty 2022-08-26 21:16:51 +05:30
Aakansha Doshi 189a557ed6 update visible point indexes when zooming 2022-08-26 20:49:50 +05:30
Aakansha Doshi bff2f9178d update visiblePointIndexes when points added 2022-08-26 18:53:15 +05:30
Aakansha Doshi 9b5715623a don't update points unitil deselected 2022-08-26 14:14:01 +05:30
Aakansha Doshi 2b4462c941 refactor: reuse common ui dialogs and message for mobile and LayerUI (#5611)
* refactor: Move common UI dialogs to component

* refactor

* fix
2022-08-26 11:46:34 +05:30
Aakansha Doshi 43b13d8e3a fix: Add display name to components so it doesn't show as anonymous (#5616) 2022-08-26 11:46:19 +05:30
Aakansha Doshi 3487f0ab26 Always show extreme points 2022-08-24 16:48:57 +05:30
Aakansha Doshi 76910828c2 check for 2 pointer arrows 2022-08-24 16:04:17 +05:30
Aakansha Doshi fde521ef4d fix 2022-08-24 15:58:43 +05:30
Aakansha Doshi bcb45f7cf6 fix: hide points outside linear element editor when close enough 2022-08-24 15:49:04 +05:30
Ryan Di 720f468f39 fix: improve solveQuadratic when a = 0 (#5618) 2022-08-24 14:44:59 +08:00
Ryan Di 33300d19f6 fix: add random tiny offsets to avoid linear elements from being clipped (#5615)
Co-authored-by: Ryan Di <ryandi@Ryans-MacBook-Pro.local>
2022-08-23 15:52:15 +02:00
Aakansha Doshi 5aed159991 docs: fix refs table (#5614)
* docs: fix refs table

* fix

* fix

* fix

* fix
2022-08-23 13:55:43 +05:30
Aakansha Doshi de1d221d1c docs: add PR link (#5613)
docs:add PR link
2022-08-23 11:51:45 +05:30
Aakansha Doshi 9a68dbffe2 docs: update docs for param defaultStatus in loadLibraryFromBlob (#5612) 2022-08-23 11:32:53 +05:30
Aakansha Doshi 32d82219b1 refactor: Stats component (#5610)
refactor: stats component
2022-08-22 17:18:25 +05:30
Aakansha Doshi ba2c86fe1b refactor: Move footer to its own component (#5609) 2022-08-22 16:09:24 +05:30
zsviczian f1ae37c84b fix: Crash when adding a new point in the line editor #5602 (#5606)
Update linearElementEditor.ts
2022-08-22 10:39:27 +05:30
Aakansha Doshi ec350ba8b2 feat: Introduce ExcalidrawElements and ExcalidrawAppState provider (#5463)
* feat: Introduce ExcalidrawData provider so that app state and elements need not be passed to children

* fix

* fix zen mode

* Separate providers for data and elements

* pass appState and elements to layerUI

* pass appState and elements to selectedShapeActions

* pass appState and elements to MobileMenu

* pass appState to librarymenu

* rename

* rename to ExcalidrawAppState
2022-08-20 22:49:44 +05:30
Aakansha Doshi 46a61ad4df feat: enable midpoint inside linear element editor (#5564)
* feat: enable midpoint inside linear element editor

* fix

* fix

* hack to set pointerDownState.hit.hasHitElementInside when mid point added

* remove hacks as not needed :)

* remove newline

* fix

* add doc
2022-08-18 19:56:26 +05:30
Excalidraw Bot f4b1a30bef chore: Update translations from Crowdin (#5552) 2022-08-18 15:42:40 +02:00
Aakansha Doshi 32aa79164b refactor: remove unused attribute hasHitElementInside from pointerDownState (#5591) 2022-08-18 19:11:18 +05:30
Aakansha Doshi b5fd904808 fix: allow box selection of points when inside editor (#5594) 2022-08-18 19:07:14 +05:30
David Luzar 8f8dd1105f docs: changelog fixes (#5593) 2022-08-18 14:16:06 +02:00
David Luzar b914ad41fc feat: support ExcalidrawElement.customData (#5592)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-08-18 17:32:46 +05:30
Aakansha Doshi 551c38f60b fix: remove unnecessary conditions in pointerup for linear elements (#5575)
* fix: remove unnecessary conditions in pointerup for linear elements

* reset editingLinearElement when clicked inside bounding box
2022-08-18 13:58:46 +05:30
Aakansha Doshi 38e8ae46c9 fix: check if hitting link in handleSelectionOnPointerDown (#5589)
fix: check if hitting link in handleSelectionOnPoiinterDown
2022-08-18 13:40:26 +05:30
David Luzar ad0c4c4c78 fix: points not being normalized on single-elem resize (#5581) 2022-08-16 21:51:43 +02:00
Aakansha Doshi 27cf5ed17e fix: deselect linear element when clicked inside bounding box outside editor (#5579) 2022-08-16 23:05:38 +05:30
Aakansha Doshi fd946adbae refactor: cleanup renderScene (#5573)
* refactor: cleanup renderScene

* pass object instead of individual params
2022-08-16 16:09:53 +05:30
Caleb OLeary c37977af4b docs: correct readme type typo (#5574) 2022-08-16 13:55:55 +05:30
Alex Kim a0d413ab4e fix: resize multiple elements from center (#5560)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-08-13 19:53:10 +02:00
Aakansha Doshi b67a2b4f65 fix: call static methods via class instead of instance in linearElementEditor (#5561)
* fix: call getMidPoint via class instead of instance

* fix

* fix
2022-08-11 20:33:07 +05:30
Aakansha Doshi 5a8dbe8030 feat: show a mid point for linear elements (#5534)
* feat: Add a mid point for linear elements

* fix tests

* show mid point only on hover

* hacky fix :(

* don't add mid points if present and only add outside editor

* improve styling and always show phantom point instead of just on hover

* fix tests

* fix

* only add polyfill for test

* add hover state for phantom point

* fix tests

* fix

* Add Array.at polyfill

* reuse `centerPoint()` helper

* reuse `distance2d`

* use `Point` type

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-08-11 20:16:25 +05:30
Aakansha Doshi 731093f631 fix: show bounding box for 3 or more linear point elements (#5554)
* fix: show bounding box for 3+ linear point elements

* refactor

* show bounding box for 3 points as well

* fix dragging bounding box for linear elements

* Increase margin/padding for linear elements

* fix cursor and keep bounding box same but offset resize handles

* introduce slight padding for selection border

* better

* add constant for spacing
2022-08-10 21:42:28 +05:30
Aakansha Doshi fe56975f19 fix: cleanup the condition for dragging elements (#5555) 2022-08-10 15:32:40 +05:30
David Luzar 2d800feeeb fix: shareable links being merged with current scene data (#5547) 2022-08-08 17:51:19 +02:00
David Luzar 93cccd596a fix: Scene lookup failing when looking up by id (#5542) 2022-08-08 17:01:17 +02:00
David Luzar 45b592227d fix: remove rounding to fix jitter when shift-editing (#5543)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2022-08-05 20:22:46 +05:30
Ryan Di b818df1098 feat: lock angle when editing linear elements with shift pressed (#5527)
Co-authored-by: Ryan <diweihao@bytedance.com>
2022-08-04 22:42:31 +00:00
David Luzar 4359e2935d fix: line deselected when shift-dragging point outside editor (#5540) 2022-08-05 00:01:56 +05:30
Aakansha Doshi 3d9d398378 fix: flip linear elements after redesign (#5538) 2022-08-04 18:41:31 +05:30
Aakansha Doshi 0a5da0269f docs: migrate the example to React 18 (#5533) 2022-08-04 12:24:13 +05:30
Aakansha Doshi 08ce7c7fc3 feat: redesign linear elements 🎉 (#5501)
* feat: redesign arrows and lines

* set selectedLinearElement on pointerup

* fix tests

* fix lint

* set selectionLinearElement to null when element is not selected

* fix

* don't set selectedElementIds to empty object when linear element selected

* don't move arrows when clicked on bounding box

* don't consider bounding box when linear element selected

* better hitbox

* show pointer when over the points in linear elements

* highlight points when hovered

* tweak design whene editing linear element points

* tweak

* fix test

* fix multi point editing

* cleanup

* fix

* fix

* remove stroke when hovered

* account for zoom when hover

* review fix

* set selectedLinearElement to null when selectedElementIds doesn't contain the linear element

* remove hover affect when moved away from linear element

* don't set selectedLinearAElement if already set

* fix selection

* render reduced in test :p

* fix box selection for single linear element

* set selectedLinearElement when deselecting selected elements and linear element is selected

* don't show linear element handles when element locked

* selected linear element when only linear present and selected with selectAll

* don't set selectedLinearElement if already set

* store selectedLinearElement in browser to persist

* remove redundant checks

* test fix

* select linear element handles when user has finished multipoint editing

* fix snap

* add comments

* show bounding box for locked linear elements

* add stroke param to fillCircle and remove stroke when linear element point hovered

* set selectedLinearElement when thats the only element left when deselcting others

* skip tests instead of removing for rotation

* (un)bind on pointerUp when moving linear element points outside editor

* render bounding box for linear elements as a fallback on state mismatch

* simplify and remove type assertion

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-08-03 20:58:17 +05:30
Excalidraw Bot fe7fbff7f6 chore: Update translations from Crowdin (#5507)
* New translations en.json (Czech)

* Auto commit: Calculate translation coverage

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage
2022-08-03 11:43:00 +05:30
David Luzar 501397cb61 fix: disable locking aspect ratio for box-selection (#5525) 2022-08-02 19:10:17 +05:30
Ryan Di 865d29388c feat: cursor alignment when creating linear elements with shift pressed (#5518)
* feat: cursor alignment when creating linear elements

* feat: apply cursor alignment to multi-point linear elements

* refactor: rename size helper function
2022-08-02 15:43:19 +05:30
José dBruxelles 54c7ec416a fix: Add title attribute to the modal close button (#5521)
Add `title` attribute to the modal close button
2022-08-02 11:42:47 +05:30
zsviczian aca284057d fix: Context menu positioning when component has offsets (#5520)
Update Popover.tsx
2022-08-02 11:38:55 +05:30
Ryan Di 2820cd112e feat: shift-clamp when creating multi-point lines/arrows (#5500)
Co-authored-by: Ryan <diweihao@bytedance.com>
2022-08-01 15:41:50 +02:00
Ryan Di 426b5d9537 feat: cursor alignment when creating generic elements (#5516)
Co-authored-by: Ryan <diweihao@bytedance.com>
2022-08-01 13:24:46 +02:00
David Luzar e7d34677c6 fix: resolve paths in prebuild.js script (#5498) 2022-07-30 21:56:46 +02:00
Aakansha Doshi 3d5356cb8e fix: use flushSync when moving line editor since we need to read previous value after setting state (#5508)
* fix: use flushSync when moving line editor since we need to read previous value after setting state

* add comment
2022-07-29 19:27:37 +05:30
Aakansha Doshi 46f5ce5ce0 fix: useLayout effect cleanup in dev mode for charts (#5505) 2022-07-29 17:25:26 +05:30
Excalidraw Bot b00bd3d6c0 chore: Update translations from Crowdin (#5476)
* New translations en.json (French)

* New translations en.json (French)

* New translations en.json (French)

* New translations en.json (Basque)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* New translations en.json (Tamil)

* New translations en.json (Swedish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Bengali)

* New translations en.json (Slovak)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Slovenian)

* New translations en.json (Russian)

* New translations en.json (Turkish)

* New translations en.json (German)

* New translations en.json (Marathi)

* New translations en.json (Basque)

* New translations en.json (Romanian)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (Greek)

* New translations en.json (Portuguese)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-07-29 11:14:09 +05:30
Aakansha Doshi 91fc22182c fix: revert browser toast for high/low zoom (#5495) 2022-07-27 20:49:29 +05:30
Aakansha Doshi 966ca2ffa6 refactor: rename docs to dev-docs (#5487) 2022-07-26 16:55:25 +05:30
Aakansha Doshi 2b049b4a65 docs: Integrate docusaraus for docs (#5482)
* feat:Integrate docusaraus for docs

* Update docs for Excalidraw

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

* remove blogs

* remove blog authors

* get started docs

* typo

* add static assets

* change port number

* Add script to build docs only if docs updated

* dummy

* update script to be compatible with ignoreBuild in vercel

* remove script and dummy log

Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-07-26 16:34:12 +05:30
Aakansha Doshi 339212e563 refactor: remove unnecessary if condition for linear element onKeyDown (#5486)
* refactor: remove unnecessary if condition for linear element onKeyDown

* fix
2022-07-26 16:33:13 +05:30
DanielJGeiger f8b4bb66b4 chore: Update peer dependencies to React 18 in @excalidraw/excalidraw (#5483)
* chore: Update peer dependencies to React 18 in `@excalidraw/excalidraw`

* Update src/packages/excalidraw/package.json

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

Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-07-26 16:23:30 +05:30
Andrew f4312bba5e fix: Fixing push to DockerHub (#5468)
* fix: Fixing push to DockerHub

* Update .github/workflows/publish-docker.yml

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-07-26 16:13:09 +05:30
David Luzar ac66665b64 fix: incorrectly rendering freedraw elements (#5481) 2022-07-22 16:18:41 +02:00
Aakansha Doshi 2b71a1f0bd fix: generate types when building example (#5480) 2022-07-22 18:53:21 +05:30
Aakansha Doshi 58845e450a fix: Use React.FC as react-dom is not able to infer types of Modal (#5479) 2022-07-22 13:09:15 +05:30
Aakansha Doshi 15d79f8fee chore: upgrade to React 18 (#5450)
* chore: upgrade to React 18

* better type

* use React.FC to fix type

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-07-22 11:20:36 +05:30
156 changed files with 12070 additions and 2715 deletions
+43
View File
@@ -0,0 +1,43 @@
{
// These tasks will run in order when initializing your CodeSandbox project.
"setupTasks": [
{
"name": "Install Dependencies",
"command": "yarn install"
}
],
// These tasks can be run from CodeSandbox. Running one will open a log in the app.
"tasks": {
"build": {
"name": "Build",
"command": "yarn build",
"runAtStart": false
},
"fix": {
"name": "Fix",
"command": "yarn fix",
"runAtStart": false
},
"prettier": {
"name": "Prettify",
"command": "yarn prettier",
"runAtStart": false
},
"start": {
"name": "Start Excalidraw",
"command": "yarn start",
"runAtStart": true
},
"test": {
"name": "Run Tests",
"command": "yarn test",
"runAtStart": false
},
"install-deps": {
"name": "Install Dependencies",
"command": "yarn install",
"restartOn": { "files": ["yarn.lock"] }
}
}
}
+10 -5
View File
@@ -10,11 +10,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - name: Checkout repository
- uses: docker/build-push-action@v2 uses: actions/checkout@v3
- name: Login to DockerHub
uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
repository: excalidraw/excalidraw - name: Build and push
tag_with_ref: true uses: docker/build-push-action@v3
tag_with_sha: true with:
context: .
push: true
tags: excalidraw/excalidraw:latest
-1
View File
@@ -19,7 +19,6 @@ logs
node_modules node_modules
npm-debug.log* npm-debug.log*
package-lock.json package-lock.json
static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
src/packages/excalidraw/types src/packages/excalidraw/types
+1 -1
View File
@@ -88,7 +88,7 @@ Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/exc
### Code Sandbox ### Code Sandbox
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw - Go to https://codesandbox.io/p/github/excalidraw/excalidraw
- You may need to sign in with GitHub and reload the page - You may need to sign in with GitHub and reload the page
- You can start coding instantly, and even send PRs from there! - You can start coding instantly, and even send PRs from there!
+20
View File
@@ -0,0 +1,20 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+41
View File
@@ -0,0 +1,41 @@
# Website
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
Using SSH:
```
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
};
+6
View File
@@ -0,0 +1,6 @@
---
sidebar_position: 1
title: Overview
---
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).
+8
View File
@@ -0,0 +1,8 @@
---
sidebar_position: 1
title: Introduction
---
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).
+6
View File
@@ -0,0 +1,6 @@
---
sidebar_position: 1
title: Overview
---
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).
+121
View File
@@ -0,0 +1,121 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Excalidraw developer docs",
tagline:
"For Excalidraw contributors or those integrating the Excalidraw editor",
url: "https://docs.excalidraw.com.com",
baseUrl: "/",
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
favicon: "img/favicon.ico",
organizationName: "Excalidraw", // Usually your GitHub org/user name.
projectName: "excalidraw", // Usually your repo name.
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
// to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
editUrl: "https://github.com/excalidraw/docs/tree/master/",
},
theme: {
customCss: require.resolve("./src/css/custom.css"),
},
}),
],
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
navbar: {
title: "Excalidraw Docs",
logo: {
alt: "Excalidraw Logo",
src: "img/logo.svg",
},
items: [
{
type: "doc",
docId: "get-started",
position: "left",
label: "Get started",
},
{
to: "https://blog.excalidraw.com",
label: "Blog",
position: "left",
},
{
to: "https://github.com/excalidraw/excalidraw",
label: "GitHub",
position: "right",
},
],
},
footer: {
style: "dark",
links: [
{
title: "Docs",
items: [
{
label: "Get Started",
to: "/docs/get-started",
},
],
},
{
title: "Community",
items: [
{
label: "Discord",
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
},
],
},
{
title: "More",
items: [
{
label: "Blog",
to: "https://blog.excalidraw.com",
},
{
label: "GitHub",
to: "https://github.com/excalidraw/excalidraw",
},
],
},
],
copyright: `Made with ❤️ Built with Docusaurus`,
},
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
},
}),
};
module.exports = config;
+46
View File
@@ -0,0 +1,46 @@
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start --port 3003",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "2.0.0-rc.1",
"@docusaurus/preset-classic": "2.0.0-rc.1",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
"@tsconfig/docusaurus": "^1.0.5",
"typescript": "^4.7.4"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=16.14"
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
// But you can create a sidebar manually
/*
tutorialSidebar: [
{
type: 'category',
label: 'Tutorial',
items: ['hello'],
},
],
*/
};
module.exports = sidebars;
+62
View File
@@ -0,0 +1,62 @@
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
const FeatureList = [
{
title: "Learn how Excalidraw works",
Svg: require("@site/static/img/undraw_innovative.svg").default,
description: (
<>Want to contribute to Excalidraw but got lost in the codebase?</>
),
},
{
title: "Integrate Excalidraw",
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
description: (
<>
Want to build your own app powered by Excalidraw by don't know where to
start?
</>
),
},
{
title: "Help us improve",
Svg: require("@site/static/img/undraw_add_files.svg").default,
description: (
<>
Are the docs missing something? Anything you had trouble understanding
or needs an explanation? Come contribute to the docs to make them even
better!
</>
),
},
];
function Feature({ Svg, title, description }) {
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
export default function HomepageFeatures() {
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,70 @@
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
type FeatureItem = {
title: string;
Svg: React.ComponentType<React.ComponentProps<"svg">>;
description: JSX.Element;
};
const FeatureList: FeatureItem[] = [
{
title: "Easy to Use",
Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
description: (
<>
Docusaurus was designed from the ground up to be easily installed and
used to get your website up and running quickly.
</>
),
},
{
title: "Focus on What Matters",
Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
description: (
<>
Docusaurus lets you focus on your docs, and we&apos;ll do the chores. Go
ahead and move your docs into the <code>docs</code> directory.
</>
),
},
{
title: "Powered by React",
Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
description: (
<>
Extend or customize your website layout by reusing React. Docusaurus can
be extended while reusing the same header and footer.
</>
),
},
];
function Feature({ title, Svg, description }: FeatureItem) {
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
export default function HomepageFeatures(): JSX.Element {
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,11 @@
.features {
display: flex;
align-items: center;
padding: 2rem 0;
width: 100%;
}
.featureSvg {
height: 200px;
width: 200px;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Any CSS included here will be global. The classic template
* bundles Infima by default. Infima is a CSS framework designed to
* work well for content-centric websites.
*/
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #6965db;
--ifm-color-primary-dark: #5b57d1;
--ifm-color-primary-darker: #5b57d1;
--ifm-color-primary-darkest: #4a47b1;
--ifm-color-primary-light: #5b57d1;
--ifm-color-primary-lighter: #5b57d1;
--ifm-color-primary-lightest: #5b57d1;
--ifm-code-font-size: 95%;
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme="dark"] {
--ifm-color-primary: #5650f0;
--ifm-color-primary-dark: #4b46d8;
--ifm-color-primary-darker: #4b46d8;
--ifm-color-primary-darkest: #3e39be;
--ifm-color-primary-light: #3f3d64;
--ifm-color-primary-lighter: #3f3d64;
--ifm-color-primary-lightest: #3f3d64;
}
.docusaurus-highlight-code-line {
background-color: rgba(0, 0, 0, 0.1);
display: block;
margin: 0 calc(-1 * var(--ifm-pre-padding));
padding: 0 var(--ifm-pre-padding);
}
[data-theme="dark"] .docusaurus-highlight-code-line {
background-color: rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .navbar__logo {
filter: invert(93%) hue-rotate(180deg);
}
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx("hero hero--primary", styles.heroBanner)}>
<div className="container">
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs/get-started"
>
Get started
</Link>
</div>
</div>
</header>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
</main>
</Layout>
);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* CSS files with the .module.css suffix will be treated as CSS modules
* and scoped locally.
*/
.heroBanner {
padding: 4rem 0;
text-align: center;
position: relative;
overflow: hidden;
}
[data-theme="dark"] .heroBanner {
color: #fff;
}
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2rem;
}
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
}
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx("hero hero--primary", styles.heroBanner)}>
<div className="container">
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs/get-started"
>
Get started
</Link>
</div>
</div>
</header>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
</main>
</Layout>
);
}
+7
View File
@@ -0,0 +1,7 @@
---
title: Markdown page example
---
# Markdown page example
You don't need React to write simple standalone pages.
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

+7
View File
@@ -0,0 +1,7 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@tsconfig/docusaurus/tsconfig.json",
"compilerOptions": {
"baseUrl": "."
}
}
+7489
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -26,8 +26,8 @@
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.7.1",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "17.0.39", "@types/react": "18.0.15",
"@types/react-dom": "17.0.11", "@types/react-dom": "18.0.6",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
@@ -42,13 +42,14 @@
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.16", "perfect-freehand": "1.0.16",
"pica": "7.1.1",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0", "points-on-curve": "0.2.0",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "17.0.2", "react": "18.2.0",
"react-dom": "17.0.2", "react-dom": "18.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
+21
View File
@@ -0,0 +1,21 @@
const { exec } = require("child_process");
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const docFiles = changedFiles.filter((file) => {
return file.indexOf("docs") >= 0;
});
if (!docFiles.length) {
console.info("Skipping building docs as no valid diff found");
process.exit(0);
}
// Exit code 1 to build the docs in ignoredBuildStep
process.exit(1);
});
+3 -2
View File
@@ -1,11 +1,12 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path");
// for development purposes we want to have the service-worker.js file // for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it // accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder. // and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => { const moveServiceWorkerScript = () => {
const oldPath = "./public/service-worker.js"; const oldPath = path.resolve(__dirname, "../public/service-worker.js");
const newPath = "./src/service-worker.js"; const newPath = path.resolve(__dirname, "../src/service-worker.js");
fs.rename(oldPath, newPath, (error) => { fs.rename(oldPath, newPath, (error) => {
if (error) { if (error) {
+1 -1
View File
@@ -244,7 +244,7 @@ export const actionLoadScene = register({
} }
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={load} icon={load}
+10 -1
View File
@@ -13,7 +13,7 @@ import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "../element/binding"; } from "../element/binding";
import { isBindingElement } from "../element/typeChecks"; import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types"; import { AppState } from "../types";
export const actionFinalize = register({ export const actionFinalize = register({
@@ -33,6 +33,9 @@ export const actionFinalize = register({
endBindingElement, endBindingElement,
); );
} }
const selectedLinearElement = appState.selectedLinearElement
? new LinearElementEditor(element, scene, appState)
: null;
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
@@ -42,6 +45,7 @@ export const actionFinalize = register({
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
selectedLinearElement,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -181,6 +185,11 @@ export const actionFinalize = register({
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement, scene, appState)
: appState.selectedLinearElement,
pendingImageElementId: null, pendingImageElementId: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.activeTool.type === "freedraw",
+24 -14
View File
@@ -3,32 +3,42 @@ import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
} }
const selectedElementIds = elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked
) {
map[element.id] = true;
}
return map;
},
{},
);
return { return {
appState: selectGroupsForSelectedElements( appState: selectGroupsForSelectedElements(
{ {
...appState, ...appState,
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene, appState)
: null,
editingGroupId: null, editingGroupId: null,
selectedElementIds: elements.reduce( selectedElementIds,
(map: Record<ExcalidrawElement["id"], true>, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked
) {
map[element.id] = true;
}
return map;
},
{},
),
}, },
getNonDeletedElements(elements), getNonDeletedElements(elements),
), ),
+6 -3
View File
@@ -17,16 +17,19 @@ export const actionToggleLock = register({
const operation = getOperation(selectedElements); const operation = getOperation(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements); const selectedElementsMap = arrayToMap(selectedElements);
const lock = operation === "lock";
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) { if (!selectedElementsMap.has(element.id)) {
return element; return element;
} }
return newElementWith(element, { locked: operation === "lock" }); return newElementWith(element, { locked: lock });
}), }),
appState, appState: {
...appState,
selectedLinearElement: lock ? null : appState.selectedLinearElement,
},
commitToHistory: true, commitToHistory: true,
}; };
}, },
+1
View File
@@ -147,6 +147,7 @@ export class ActionManager {
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
PanelComponent.displayName = "PanelComponent";
const elements = this.getElementsIncludingDeleted(); const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const updateData = (formState?: any) => { const updateData = (formState?: any) => {
+2
View File
@@ -90,6 +90,7 @@ export const getDefaultAppState = (): Omit<
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null, pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null,
}; };
}; };
@@ -181,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false }, pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+54 -12
View File
@@ -26,17 +26,17 @@ import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
activeTool,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
activeTool: AppState["activeTool"]["type"];
}) => { }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -56,13 +56,13 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(activeTool) || hasBackground(appState.activeTool.type) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showChangeBackgroundIcons = const showChangeBackgroundIcons =
hasBackground(activeTool) || hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type)); targetElements.some((element) => hasBackground(element.type));
const showLinkIcon = const showLinkIcon =
@@ -79,23 +79,23 @@ export const SelectedShapeActions = ({
return ( return (
<div className="panelColumn"> <div className="panelColumn">
{((hasStrokeColor(activeTool) && {((hasStrokeColor(appState.activeTool.type) &&
activeTool !== "image" && appState.activeTool.type !== "image" &&
commonSelectedType !== "image") || commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(activeTool) || {(hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(activeTool === "freedraw" || {(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")} renderAction("changeStrokeShape")}
{(hasStrokeStyle(activeTool) || {(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
@@ -103,12 +103,12 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeSharpness(activeTool) || {(canChangeSharpness(appState.activeTool.type) ||
targetElements.some((element) => canChangeSharpness(element.type))) && ( targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</> <>{renderAction("changeSharpness")}</>
)} )}
{(hasText(activeTool) || {(hasText(appState.activeTool.type) ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some((element) => hasText(element.type))) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
@@ -123,7 +123,7 @@ export const SelectedShapeActions = ({
(element) => (element) =>
hasBoundTextElement(element) || isBoundToContainer(element), hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")} ) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(activeTool) || {(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
@@ -271,3 +271,45 @@ export const ZoomActions = ({
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
); );
export const UndoRedoActions = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`undo-redo-buttons ${className}`}>
{renderAction("undo", { size: "small" })}
{renderAction("redo", { size: "small" })}
</div>
);
export const ExitZenModeAction = ({
actionManager,
showExitZenModeBtn,
}: {
actionManager: ActionManager;
showExitZenModeBtn: boolean;
}) => (
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={() => actionManager.executeAction(actionToggleZenMode)}
>
{t("buttons.exitZenMode")}
</button>
);
export const FinalizeAction = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`finalize-button ${className}`}>
{renderAction("finalize", { size: "small" })}
</div>
);
+496 -233
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@ import "./Card.scss";
export const Card: React.FC<{ export const Card: React.FC<{
color: keyof OpenColor | "primary"; color: keyof OpenColor | "primary";
children?: React.ReactNode;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
+1
View File
@@ -8,6 +8,7 @@ export const CheckboxItem: React.FC<{
checked: boolean; checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void; onChange: (checked: boolean, event: React.MouseEvent) => void;
className?: string; className?: string;
children?: React.ReactNode;
}> = ({ children, checked, onChange, className }) => { }> = ({ children, checked, onChange, className }) => {
return ( return (
<div <div
+2
View File
@@ -343,6 +343,8 @@ const ColorInput = React.forwardRef(
}, },
); );
ColorInput.displayName = "ColorInput";
export const ColorPicker = ({ export const ColorPicker = ({
type, type,
color, color,
+1
View File
@@ -85,6 +85,7 @@ export const Dialog = (props: DialogProps) => {
<button <button
className="Modal__close" className="Modal__close"
onClick={onClose} onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDevice().isMobile ? back : close} {useDevice().isMobile ? back : close}
+106
View File
@@ -0,0 +1,106 @@
import clsx from "clsx";
import { ActionManager } from "../actions/manager";
import { AppState, ExcalidrawProps } from "../types";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "./Actions";
import { useDevice } from "./App";
import { Island } from "./Island";
import { Section } from "./Section";
import Stack from "./Stack";
const Footer = ({
appState,
actionManager,
renderCustomFooter,
showExitZenModeBtn,
}: {
appState: AppState;
actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean;
}) => {
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
>
<div
className={clsx("layer-ui__wrapper__footer-left zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{!appState.viewModeEnabled && (
<>
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
/>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)}
{showFinalize && (
<FinalizeAction
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
/>
)}
</Section>
</Stack.Col>
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled,
})}
>
{actionManager.renderAction("toggleShortcuts")}
</div>
<ExitZenModeAction
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
/>
</footer>
);
};
export default Footer;
+1
View File
@@ -58,6 +58,7 @@ const ExportButton: React.FC<{
onClick: () => void; onClick: () => void;
title: string; title: string;
shade?: number; shade?: number;
children?: React.ReactNode;
}> = ({ children, title, onClick, color, shade = 6 }) => { }> = ({ children, title, onClick, color, shade = 6 }) => {
return ( return (
<button <button
+79 -179
View File
@@ -10,7 +10,7 @@ import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
@@ -39,7 +39,7 @@ import { trackEvent } from "../analytics";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import { actionToggleZenMode } from "../actions"; import Footer from "./Footer";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -71,8 +71,8 @@ const LayerUI = ({
appState, appState,
files, files,
setAppState, setAppState,
canvas,
elements, elements,
canvas,
onCollabButtonClick, onCollabButtonClick,
onLockToggle, onLockToggle,
onPenModeToggle, onPenModeToggle,
@@ -210,8 +210,8 @@ const LayerUI = ({
)} )}
</Stack.Row> </Stack.Row>
<BackgroundPickerAndDarkModeToggle <BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState} appState={appState}
actionManager={actionManager}
setAppState={setAppState} setAppState={setAppState}
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
/> />
@@ -244,7 +244,6 @@ const LayerUI = ({
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
/> />
</Island> </Island>
</Section> </Section>
@@ -279,7 +278,6 @@ const LayerUI = ({
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer} focusContainer={focusContainer}
library={library} library={library}
theme={appState.theme}
files={files} files={files}
id={id} id={id}
appState={appState} appState={appState}
@@ -308,7 +306,7 @@ const LayerUI = ({
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
<Section heading="shapes"> <Section heading="shapes">
{(heading) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={1} gap={1}
@@ -383,100 +381,7 @@ const LayerUI = ({
); );
}; };
const renderBottomAppMenu = () => { return (
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
>
<div
className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
},
)}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{!appState.viewModeEnabled && (
<>
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)}
{!appState.viewModeEnabled &&
appState.multiElement &&
device.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
"transition-right disable-pointerEvents": appState.zenModeEnabled,
},
)}
>
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={() => actionManager.executeAction(actionToggleZenMode)}
>
{t("buttons.exitZenMode")}
</button>
</footer>
);
};
const dialogs = (
<> <>
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && ( {appState.errorMessage && (
@@ -504,86 +409,81 @@ const LayerUI = ({
} }
/> />
)} )}
</> {device.isMobile && (
); <MobileMenu
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
/>
)}
const renderStats = () => { {!device.isMobile && (
if (!appState.showStats) { <>
return null; <div
} className={clsx("layer-ui__wrapper", {
return ( "disable-pointerEvents":
<Stats appState.draggingElement ||
appState={appState} appState.resizingElement ||
setAppState={setAppState} (appState.editingElement &&
elements={elements} !isTextElement(appState.editingElement)),
onClose={() => { })}
actionManager.executeAction(actionToggleStats); style={
}} appState.isLibraryOpen &&
renderCustomStats={renderCustomStats} appState.isLibraryMenuDocked &&
/> device.canDeviceFitSidebar
); ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
}; : {}
}
return device.isMobile ? (
<>
{dialogs}
<MobileMenu
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderStats={renderStats}
/>
</>
) : (
<>
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement &&
!isTextElement(appState.editingElement)),
})}
style={
appState.isLibraryOpen &&
appState.isLibraryMenuDocked &&
device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderStats()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
> >
{t("buttons.scrollBackToContent")} {renderFixedSideContainer()}
</button> <Footer
)} appState={appState}
</div> actionManager={actionManager}
{appState.isLibraryOpen && ( renderCustomFooter={renderCustomFooter}
<div className="layer-ui__sidebar">{libraryMenu}</div> showExitZenModeBtn={showExitZenModeBtn}
/>
{appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
{appState.isLibraryOpen && (
<div className="layer-ui__sidebar">{libraryMenu}</div>
)}
</>
)} )}
</> </>
); );
+4 -6
View File
@@ -80,7 +80,6 @@ export const LibraryMenu = ({
onInsertLibraryItems, onInsertLibraryItems,
pendingElements, pendingElements,
onAddToLibrary, onAddToLibrary,
theme,
setAppState, setAppState,
files, files,
libraryReturnUrl, libraryReturnUrl,
@@ -93,7 +92,6 @@ export const LibraryMenu = ({
onClose: () => void; onClose: () => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles; files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -105,12 +103,12 @@ export const LibraryMenu = ({
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const device = useDevice(); const device = useDevice();
useOnClickOutside( useOnClickOutside(
ref, ref,
useCallback( useCallback(
(event) => { (event) => {
// If click on the library icon, do nothing. // If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".ToolIcon__library")) { if ((event.target as Element).closest(".ToolIcon__library")) {
return; return;
} }
@@ -224,7 +222,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]); }, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback( const onPublishLibSuccess = useCallback(
(data, libraryItems: LibraryItems) => { (data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false); setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName }); setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice(); const nextLibItems = libraryItems.slice();
@@ -290,7 +288,7 @@ export const LibraryMenu = ({
appState={appState} appState={appState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
library={library} library={library}
theme={theme} theme={appState.theme}
files={files} files={files}
id={id} id={id}
selectedItems={selectedItems} selectedItems={selectedItems}
+1 -2
View File
@@ -1,4 +1,3 @@
import { chunk } from "lodash";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
import Library from "../data/library"; import Library from "../data/library";
@@ -11,7 +10,7 @@ import {
LibraryItem, LibraryItem,
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
import { arrayToMap, muteFSAbortError } from "../utils"; import { arrayToMap, chunk, muteFSAbortError } from "../utils";
import { useDevice } from "./App"; import { useDevice } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons"; import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
+22 -12
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { AppState } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n"; import { t } from "../i18n";
import Stack from "./Stack"; import Stack from "./Stack";
@@ -18,6 +18,8 @@ import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@@ -42,7 +44,7 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderStats: () => JSX.Element | null; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@@ -62,13 +64,13 @@ export const MobileMenu = ({
showThemeBtn, showThemeBtn,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderStats, renderCustomStats,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
<Section heading="shapes"> <Section heading="shapes">
{(heading) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container"> <Stack.Row gap={1} className="App-toolbar-container">
<Island padding={1} className="App-toolbar"> <Island padding={1} className="App-toolbar">
@@ -119,7 +121,6 @@ export const MobileMenu = ({
const renderAppToolbar = () => { const renderAppToolbar = () => {
// Render eraser conditionally in mobile // Render eraser conditionally in mobile
const showEraser = const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement && !appState.editingElement &&
getSelectedElements(elements, appState).length === 0; getSelectedElements(elements, appState).length === 0;
@@ -138,11 +139,11 @@ export const MobileMenu = ({
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
{showEraser && actionManager.renderAction("eraser")} {showEraser
? actionManager.renderAction("eraser")
{actionManager.renderAction( : actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection", appState.multiElement ? "finalize" : "duplicateSelection",
)} )}
{actionManager.renderAction("deleteSelectedElements")} {actionManager.renderAction("deleteSelectedElements")}
</div> </div>
); );
@@ -184,7 +185,17 @@ export const MobileMenu = ({
return ( return (
<> <>
{!appState.viewModeEnabled && renderToolbar()} {!appState.viewModeEnabled && renderToolbar()}
{renderStats()} {!appState.openMenu && appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
@@ -221,7 +232,6 @@ export const MobileMenu = ({
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
/> />
</Section> </Section>
) : null} ) : null}
+2 -2
View File
@@ -8,7 +8,7 @@ import { useExcalidrawContainer, useDevice } from "./App";
import { AppState } from "../types"; import { AppState } from "../types";
import { THEME } from "../constants"; import { THEME } from "../constants";
export const Modal = (props: { export const Modal: React.FC<{
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
maxWidth?: number; maxWidth?: number;
@@ -16,7 +16,7 @@ export const Modal = (props: {
labelledBy: string; labelledBy: string;
theme?: AppState["theme"]; theme?: AppState["theme"];
closeOnClickOutside?: boolean; closeOnClickOutside?: boolean;
}) => { }> = (props) => {
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props; const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
const modalRoot = useBodyRoot(theme); const modalRoot = useBodyRoot(theme);
+2 -2
View File
@@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
}, },
null, // files null, // files
); );
previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
if (props.selected) { if (props.selected) {
@@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
})(); })();
return () => { return () => {
previewNode.removeChild(svg); previewNode.replaceChildren();
}; };
}, [props.spreadsheet, props.chartType, props.selected]); }, [props.spreadsheet, props.chartType, props.selected]);
-1
View File
@@ -69,7 +69,6 @@ export const Popover = ({
if (fitInViewport && popoverRef.current) { if (fitInViewport && popoverRef.current) {
const element = popoverRef.current; const element = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect(); const { x, y, width, height } = element.getBoundingClientRect();
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
//Position correctly when clicked on rightmost part or the bottom part of viewport //Position correctly when clicked on rightmost part or the bottom part of viewport
if (x + width - offsetLeft > viewportWidth) { if (x + width - offsetLeft > viewportWidth) {
+4 -5
View File
@@ -2,12 +2,11 @@ import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
interface SectionProps extends React.HTMLProps<HTMLElement> { export const Section: React.FC<{
heading: string; heading: string;
children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode); children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
} className?: string;
}> = ({ heading, children, ...props }) => {
export const Section = ({ heading, children, ...props }: SectionProps) => {
const { id } = useExcalidrawContainer(); const { id } = useExcalidrawContainer();
const header = ( const header = (
<h2 className="visually-hidden" id={`${id}-${heading}-title`}> <h2 className="visually-hidden" id={`${id}-${heading}-title`}>
+1 -5
View File
@@ -2,7 +2,6 @@ import React from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons"; import { close } from "./icons";
@@ -16,13 +15,10 @@ export const Stats = (props: {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const device = useDevice();
const boundingBox = getCommonBounds(props.elements); const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState); const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements); const selectedBoundingBox = getCommonBounds(selectedElements);
if (device.isMobile && props.appState.openMenu) {
return null;
}
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>
+2
View File
@@ -187,3 +187,5 @@ ToolButton.defaultProps = {
className: "", className: "",
size: "medium", size: "medium",
}; };
ToolButton.displayName = "ToolButton";
+2 -2
View File
@@ -356,7 +356,7 @@ export const getFileHandle = async (
}; };
/** /**
* attemps to detect if a buffer is a valid image by checking its leading bytes * attempts to detect if a buffer is a valid image by checking its leading bytes
*/ */
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null = let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
@@ -396,7 +396,7 @@ export const createFile = (
}); });
}; };
/** attemps to detect correct mimeType if none is set, or if an image /** attempts to detect correct mimeType if none is set, or if an image
* has an incorrect extension. * has an incorrect extension.
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
export const normalizeFile = async (file: File) => { export const normalizeFile = async (file: File) => {
+9 -4
View File
@@ -67,13 +67,14 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends ExcalidrawElement, T extends Required<Omit<ExcalidrawElement, "customData">> & {
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>, customData?: ExcalidrawElement["customData"];
>(
element: Required<T> & {
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
}, },
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
element: T,
extra: Pick< extra: Pick<
T, T,
// This extra Pick<T, keyof K> ensure no excess properties are passed. // This extra Pick<T, keyof K> ensure no excess properties are passed.
@@ -115,6 +116,10 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("customData" in element) {
base.customData = element.customData;
}
return { return {
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
+3 -2
View File
@@ -32,6 +32,7 @@ import { getElementAbsoluteCoords } from "./";
import "./Hyperlink.scss"; import "./Hyperlink.scss";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useExcalidrawAppState } from "../components/App";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@@ -48,15 +49,15 @@ let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
export const Hyperlink = ({ export const Hyperlink = ({
element, element,
appState,
setAppState, setAppState,
onLinkOpen, onLinkOpen,
}: { }: {
element: NonDeletedExcalidrawElement; element: NonDeletedExcalidrawElement;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"]; onLinkOpen: ExcalidrawProps["onLinkOpen"];
}) => { }) => {
const appState = useExcalidrawAppState();
const linkVal = element.link || ""; const linkVal = element.link || "";
const [inputVal, setInputVal] = useState(linkVal); const [inputVal, setInputVal] = useState(linkVal);
+113 -29
View File
@@ -18,6 +18,7 @@ import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number]; export type Bounds = readonly [number, number, number, number];
type MaybeQuadraticSolution = [number | null, number | null] | false;
// If the element is created from right to left, the width is going to be negative // If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
@@ -68,11 +69,102 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
return shape.sets[0].ops; return shape.sets[0].ops;
}; };
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
const getBezierValueForT = (
t: number,
p0: number,
p1: number,
p2: number,
p3: number,
) => {
const oneMinusT = 1 - t;
return (
Math.pow(oneMinusT, 3) * p0 +
3 * Math.pow(oneMinusT, 2) * t * p1 +
3 * oneMinusT * Math.pow(t, 2) * p2 +
Math.pow(t, 3) * p3
);
};
const solveQuadratic = (
p0: number,
p1: number,
p2: number,
p3: number,
): MaybeQuadraticSolution => {
const i = p1 - p0;
const j = p2 - p1;
const k = p3 - p2;
const a = 3 * i - 6 * j + 3 * k;
const b = 6 * j - 6 * i;
const c = 3 * i;
const sqrtPart = b * b - 4 * a * c;
const hasSolution = sqrtPart >= 0;
if (!hasSolution) {
return false;
}
let s1 = null;
let s2 = null;
let t1 = Infinity;
let t2 = Infinity;
if (a === 0) {
t1 = t2 = -c / b;
} else {
t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
}
if (t1 >= 0 && t1 <= 1) {
s1 = getBezierValueForT(t1, p0, p1, p2, p3);
}
if (t2 >= 0 && t2 <= 1) {
s2 = getBezierValueForT(t2, p0, p1, p2, p3);
}
return [s1, s2];
};
const getCubicBezierCurveBound = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
let minX = Math.min(p0[0], p3[0]);
let maxX = Math.max(p0[0], p3[0]);
if (solX) {
const xs = solX.filter((x) => x !== null) as number[];
minX = Math.min(minX, ...xs);
maxX = Math.max(maxX, ...xs);
}
let minY = Math.min(p0[1], p3[1]);
let maxY = Math.max(p0[1], p3[1]);
if (solY) {
const ys = solY.filter((y) => y !== null) as number[];
minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys);
}
return [minX, minY, maxX, maxY];
};
const getMinMaxXYFromCurvePathOps = ( const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => { ): [number, number, number, number] => {
let currentP: Point = [0, 0]; let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => { (limits, { op, data }) => {
// There are only four operation types: // There are only four operation types:
@@ -83,38 +175,29 @@ const getMinMaxXYFromCurvePathOps = (
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
// create points from bezier curve const _p1 = [data[0], data[1]] as Point;
// bezier curve stores data as a flattened array of three positions const _p2 = [data[2], data[3]] as Point;
// [x1, y1, x2, y2, x3, y3] const _p3 = [data[4], data[5]] as Point;
const p1 = [data[0], data[1]] as Point;
const p2 = [data[2], data[3]] as Point;
const p3 = [data[4], data[5]] as Point;
const p0 = currentP; const p1 = transformXY ? transformXY(..._p1) : _p1;
currentP = p3; const p2 = transformXY ? transformXY(..._p2) : _p2;
const p3 = transformXY ? transformXY(..._p3) : _p3;
const equation = (t: number, idx: number) => const p0 = transformXY ? transformXY(...currentP) : currentP;
Math.pow(1 - t, 3) * p3[idx] + currentP = _p3;
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
let t = 0; const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
while (t <= 1.0) { p0,
let x = equation(t, 0); p1,
let y = equation(t, 1); p2,
if (transformXY) { p3,
[x, y] = transformXY(x, y); );
}
limits.minY = Math.min(limits.minY, y); limits.minX = Math.min(limits.minX, minX);
limits.minX = Math.min(limits.minX, x); limits.minY = Math.min(limits.minY, minY);
limits.maxX = Math.max(limits.maxX, x); limits.maxX = Math.max(limits.maxX, maxX);
limits.maxY = Math.max(limits.maxY, y); limits.maxY = Math.max(limits.maxY, maxY);
t += 0.1;
}
} else if (op === "lineTo") { } else if (op === "lineTo") {
// TODO: Implement this // TODO: Implement this
} else if (op === "qcurveTo") { } else if (op === "qcurveTo") {
@@ -124,7 +207,6 @@ const getMinMaxXYFromCurvePathOps = (
}, },
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
@@ -420,6 +502,7 @@ export const getResizedElementAbsoluteCoords = (
element: ExcalidrawElement, element: ExcalidrawElement,
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
normalizePoints: boolean,
): [number, number, number, number] => { ): [number, number, number, number] => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [ return [
@@ -433,7 +516,8 @@ export const getResizedElementAbsoluteCoords = (
const points = rescalePoints( const points = rescalePoints(
0, 0,
nextWidth, nextWidth,
rescalePoints(1, nextHeight, element.points), rescalePoints(1, nextHeight, element.points, normalizePoints),
normalizePoints,
); );
let bounds: [number, number, number, number]; let bounds: [number, number, number, number];
+5 -1
View File
@@ -35,6 +35,7 @@ import { getShapeForElement } from "../renderer/renderElement";
import { hasBoundTextElement, isImageElement } from "./typeChecks"; import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@@ -64,7 +65,10 @@ export const hitTest = (
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
const point: Point = [x, y]; const point: Point = [x, y];
if (isElementSelected(appState, element)) { if (
isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState)
) {
return isPointHittingElementBoundingBox(element, point, threshold); return isPointHittingElementBoundingBox(element, point, threshold);
} }
+17 -6
View File
@@ -105,15 +105,26 @@ export const dragNewElement = (
true */ true */
widthAspectRatio?: number | null, widthAspectRatio?: number | null,
) => { ) => {
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
if (widthAspectRatio) { if (widthAspectRatio) {
height = width / widthAspectRatio; height = width / widthAspectRatio;
} else { } else {
({ width, height } = getPerfectElementSize( // Depending on where the cursor is at (x, y) relative to where the starting point is
elementType, // (originX, originY), we use ONLY width or height to control size increase.
width, // This allows the cursor to always "stick" to one of the sides of the bounding box.
y < originY ? -height : height, if (Math.abs(y - originY) > Math.abs(x - originX)) {
)); ({ width, height } = getPerfectElementSize(
elementType,
height,
x < originX ? -width : width,
));
} else {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
}
if (height < 0) { if (height < 0) {
height = -height; height = -height;
+1
View File
@@ -53,6 +53,7 @@ export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
getPerfectElementSize, getPerfectElementSize,
getLockedLinearCursorAlignSize,
isInvisiblySmallElement, isInvisiblySmallElement,
resizePerfectLineForNWHandler, resizePerfectLineForNWHandler,
getNormalizedDimensions, getNormalizedDimensions,
+537 -137
View File
@@ -5,8 +5,20 @@ import {
PointBinding, PointBinding,
ExcalidrawBindableElement, ExcalidrawBindableElement,
} from "./types"; } from "./types";
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math"; import {
import { getElementAbsoluteCoords } from "."; distance2d,
rotate,
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
getControlPointsForBezierCurve,
getBezierXY,
getBezierCurveLength,
mapIntervalToBezierT,
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import { getElementPointsCoords } from "./bounds"; import { getElementPointsCoords } from "./bounds";
import { Point, AppState } from "../types"; import { Point, AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -20,28 +32,50 @@ import {
} from "./binding"; } from "./binding";
import { tupleToCoors } from "../utils"; import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks"; import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
const visiblePointIndexesCache: {
points: number[];
zoom: number | null;
isEditingLinearElement: boolean;
} = { points: [], zoom: null, isEditingLinearElement: false };
export class LinearElementEditor { export class LinearElementEditor {
public elementId: ExcalidrawElement["id"] & { public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
/** indices */ /** indices */
public selectedPointsIndices: readonly number[] | null; public readonly selectedPointsIndices: readonly number[] | null;
public pointerDownState: Readonly<{ public readonly pointerDownState: Readonly<{
prevSelectedPointsIndices: readonly number[] | null; prevSelectedPointsIndices: readonly number[] | null;
/** index */ /** index */
lastClickedPoint: number; lastClickedPoint: number;
}>; }>;
/** whether you're dragging a point */ /** whether you're dragging a point */
public isDragging: boolean; public readonly isDragging: boolean;
public lastUncommittedPoint: Point | null; public readonly lastUncommittedPoint: Point | null;
public pointerOffset: Readonly<{ x: number; y: number }>; public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public startBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly startBindingElement:
public endBindingElement: ExcalidrawBindableElement | null | "keep"; | ExcalidrawBindableElement
| null
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: Point | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) { constructor(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
appState: AppState,
editingLinearElement = false,
) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
@@ -58,14 +92,15 @@ export class LinearElementEditor {
prevSelectedPointsIndices: null, prevSelectedPointsIndices: null,
lastClickedPoint: -1, lastClickedPoint: -1,
}; };
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// static methods // static methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 20; static POINT_HANDLE_SIZE = 10;
/** /**
* @param id the `elementId` from the instance of this class (so that we can * @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement) * statically guarantee this method returns an ExcalidrawLinearElement)
@@ -132,22 +167,20 @@ export class LinearElementEditor {
/** @returns whether point was dragged */ /** @returns whether point was dragged */
static handlePointDragging( static handlePointDragging(
event: PointerEvent,
appState: AppState, appState: AppState,
setState: React.Component<any, AppState>["setState"],
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: ( maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[], pointSceneCoords: { x: number; y: number }[],
) => void, ) => void,
linearElementEditor: LinearElementEditor,
): boolean { ): boolean {
if (!appState.editingLinearElement) { if (!linearElementEditor) {
return false; return false;
} }
const { editingLinearElement } = appState; const { selectedPointsIndices, elementId } = linearElementEditor;
const { selectedPointsIndices, elementId, isDragging } =
editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return false; return false;
@@ -155,54 +188,71 @@ export class LinearElementEditor {
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[ const draggingPoint = element.points[
editingLinearElement.pointerDownState.lastClickedPoint linearElementEditor.pointerDownState.lastClickedPoint
] as [number, number] | undefined; ] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
if (isDragging === false) { if (
setState({ shouldRotateWithDiscreteAngle(event) &&
editingLinearElement: { selectedPointsIndices.length === 1 &&
...editingLinearElement, element.points.length > 1
isDragging: true, ) {
}, const selectedIndex = selectedPointsIndices[0];
}); const referencePoint =
} element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
const newDraggingPointPosition = LinearElementEditor.createPointAt( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
scenePointerX - editingLinearElement.pointerOffset.x, referencePoint,
scenePointerY - editingLinearElement.pointerOffset.y, [scenePointerX, scenePointerY],
appState.gridSize, appState.gridSize,
); );
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; LinearElementEditor.movePoints(element, [
const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; {
index: selectedIndex,
LinearElementEditor.movePoints( point: [width + referencePoint[0], height + referencePoint[1]],
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
pointIndex ===
editingLinearElement.pointerDownState.lastClickedPoint
? LinearElementEditor.createPointAt(
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.gridSize,
)
: ([
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
return {
index: pointIndex,
point: newPointPosition,
isDragging: isDragging:
selectedIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
},
]);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
appState.gridSize,
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
pointIndex === pointIndex ===
editingLinearElement.pointerDownState.lastClickedPoint, linearElementEditor.pointerDownState.lastClickedPoint
}; ? LinearElementEditor.createPointAt(
}), element,
); scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
appState.gridSize,
)
: ([
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
return {
index: pointIndex,
point: newPointPosition,
isDragging:
pointIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
};
}),
);
}
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
@@ -256,10 +306,12 @@ export class LinearElementEditor {
return editingLinearElement; return editingLinearElement;
} }
const bindings: Partial< const bindings: Mutable<
Pick< Partial<
InstanceType<typeof LinearElementEditor>, Pick<
"startBindingElement" | "endBindingElement" InstanceType<typeof LinearElementEditor>,
"startBindingElement" | "endBindingElement"
>
> >
> = {}; > = {};
@@ -327,34 +379,310 @@ export class LinearElementEditor {
}; };
} }
static getEditorMidPoints = (
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
): typeof editorMidPointsCache["points"] => {
// Since its not needed outside editor unless 2 pointer lines
if (!appState.editingLinearElement && element.points.length > 2) {
return [];
}
if (
editorMidPointsCache.version === element.version &&
editorMidPointsCache.zoom === appState.zoom.value
) {
return editorMidPointsCache.points;
}
LinearElementEditor.updateEditorMidPointsCache(element, appState);
return editorMidPointsCache.points!;
};
static updateEditorMidPointsCache = (
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
let index = 0;
const midpoints: (Point | null)[] = [];
while (index < points.length - 1) {
if (
LinearElementEditor.isSegmentTooShort(
element,
element.points[index],
element.points[index + 1],
appState.zoom,
)
) {
midpoints.push(null);
index++;
continue;
}
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
);
midpoints.push(segmentMidPoint);
index++;
}
editorMidPointsCache.points = midpoints;
editorMidPointsCache.version = element.version;
editorMidPointsCache.zoom = appState.zoom.value;
};
static getSegmentMidpointHitCoords = (
linearElementEditor: LinearElementEditor,
scenePointer: { x: number; y: number },
appState: AppState,
) => {
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return null;
}
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
appState.selectedLinearElement,
appState.zoom,
scenePointer.x,
scenePointer.y,
);
if (clickedPointIndex >= 0) {
return null;
}
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
if (points.length >= 3 && !appState.editingLinearElement) {
return null;
}
const threshold =
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
const distance = distance2d(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
scenePointer.x,
scenePointer.y,
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
}
}
let index = 0;
const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = distance2d(
midPoints[index]![0],
midPoints[index]![1],
scenePointer.x,
scenePointer.y,
);
if (distance <= threshold) {
return midPoints[index];
}
}
index++;
}
return null;
};
static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
zoom: AppState["zoom"],
) {
let distance = distance2d(
startPoint[0],
startPoint[1],
endPoint[0],
endPoint[1],
);
if (element.points.length > 2 && element.strokeSharpness === "round") {
distance = getBezierCurveLength(element, endPoint);
}
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
}
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
endPointIndex: number,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
if (element.points.length > 2 && element.strokeSharpness === "round") {
const controlPoints = getControlPointsForBezierCurve(
element,
element.points[endPointIndex],
);
if (controlPoints) {
const t = mapIntervalToBezierT(
element,
element.points[endPointIndex],
0.5,
);
const [tx, ty] = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
[tx, ty],
);
}
}
return segmentMidPoint;
}
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
appState: AppState,
midPoint: Point,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return -1;
}
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
let index = 0;
while (index < midPoints.length - 1) {
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
return index + 1;
}
index++;
}
return -1;
}
static getVisiblePointIndexes(
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
): typeof visiblePointIndexesCache["points"] {
const isEditingLinearElement = !!appState.editingLinearElement;
if (appState.editingLinearElement) {
// So that when we exit the editor the points are calculated again
visiblePointIndexesCache.isEditingLinearElement = true;
return element.points.map((_, index) => index);
}
if (
visiblePointIndexesCache.points &&
visiblePointIndexesCache.zoom === appState.zoom.value &&
isEditingLinearElement === visiblePointIndexesCache.isEditingLinearElement
) {
return visiblePointIndexesCache.points;
}
LinearElementEditor.updateVisiblePointIndexesCache(element, appState);
return visiblePointIndexesCache.points;
}
static updateVisiblePointIndexesCache(
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
) {
const visiblePointIndexes: number[] = [];
let previousPoint: Point | null = null;
element.points.forEach((point, index) => {
let distance = Infinity;
if (previousPoint) {
distance =
distance2d(point[0], point[1], previousPoint[0], previousPoint[1]) *
appState.zoom.value;
}
const isExtremePoint = index === 0 || index === element.points.length - 1;
const threshold = 2 * LinearElementEditor.POINT_HANDLE_SIZE;
if (isExtremePoint || distance >= threshold) {
// hide n-1 point if distance is less than threshold
if (isExtremePoint && distance < threshold) {
visiblePointIndexes.pop();
}
visiblePointIndexes.push(index);
previousPoint = point;
}
});
visiblePointIndexesCache.points = visiblePointIndexes;
visiblePointIndexesCache.zoom = appState.zoom.value;
visiblePointIndexesCache.isEditingLinearElement =
!!appState.editingLinearElement;
}
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
appState: AppState, appState: AppState,
setState: React.Component<any, AppState>["setState"],
history: History, history: History,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
): { ): {
didAddPoint: boolean; didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null; hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
isMidPoint: boolean;
} { } {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = { const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false, didAddPoint: false,
hitElement: null, hitElement: null,
linearElementEditor: null,
isMidPoint: false,
}; };
if (!appState.editingLinearElement) { if (!linearElementEditor) {
return ret; return ret;
} }
const { elementId } = appState.editingLinearElement; const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return ret; return ret;
} }
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
linearElementEditor,
scenePointer,
appState,
);
if (segmentMidPoint) {
const index = LinearElementEditor.getSegmentMidPointIndex(
linearElementEditor,
appState,
segmentMidPoint,
);
const newMidPoint = LinearElementEditor.createPointAt(
element,
segmentMidPoint[0],
segmentMidPoint[1],
appState.gridSize,
);
const points = [
...element.points.slice(0, index),
newMidPoint,
...element.points.slice(index),
];
mutateElement(element, {
points,
});
if (event.altKey) { ret.didAddPoint = true;
if (appState.editingLinearElement.lastUncommittedPoint == null) { ret.isMidPoint = true;
}
if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
@@ -366,30 +694,29 @@ export class LinearElementEditor {
), ),
], ],
}); });
ret.didAddPoint = true;
} }
history.resumeRecording(); history.resumeRecording();
setState({ ret.linearElementEditor = {
editingLinearElement: { ...linearElementEditor,
...appState.editingLinearElement, pointerDownState: {
pointerDownState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
prevSelectedPointsIndices: lastClickedPoint: -1,
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: -1,
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
Scene.getScene(element)!,
),
}, },
}); selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
Scene.getScene(element)!,
),
};
ret.didAddPoint = true; ret.didAddPoint = true;
return ret; return ret;
} }
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element, appState.selectedLinearElement,
appState.zoom, appState.zoom,
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
@@ -397,7 +724,7 @@ export class LinearElementEditor {
// if we clicked on a point, set the element as hitElement otherwise // if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area // it would get deselected if the point is outside the hitbox area
if (clickedPointIndex > -1) { if (clickedPointIndex >= 0 || segmentMidPoint) {
ret.hitElement = element; ret.hitElement = element;
} else { } else {
// You might be wandering why we are storing the binding elements on // You might be wandering why we are storing the binding elements on
@@ -405,8 +732,7 @@ export class LinearElementEditor {
// from the end points of the `linearElement` - this is to allow disabling // from the end points of the `linearElement` - this is to allow disabling
// binding (which needs to happen at the point the user finishes moving // binding (which needs to happen at the point the user finishes moving
// the point). // the point).
const { startBindingElement, endBindingElement } = const { startBindingElement, endBindingElement } = linearElementEditor;
appState.editingLinearElement;
if (isBindingEnabled(appState) && isBindingElement(element)) { if (isBindingEnabled(appState) && isBindingElement(element)) {
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
element, element,
@@ -432,47 +758,58 @@ export class LinearElementEditor {
const nextSelectedPointsIndices = const nextSelectedPointsIndices =
clickedPointIndex > -1 || event.shiftKey clickedPointIndex > -1 || event.shiftKey
? event.shiftKey || ? event.shiftKey ||
appState.editingLinearElement.selectedPointsIndices?.includes( linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
clickedPointIndex,
)
? normalizeSelectedPoints([ ? normalizeSelectedPoints([
...(appState.editingLinearElement.selectedPointsIndices || []), ...(linearElementEditor.selectedPointsIndices || []),
clickedPointIndex, clickedPointIndex,
]) ])
: [clickedPointIndex] : [clickedPointIndex]
: null; : null;
ret.linearElementEditor = {
setState({ ...linearElementEditor,
editingLinearElement: { pointerDownState: {
...appState.editingLinearElement, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
pointerDownState: { lastClickedPoint: clickedPointIndex,
prevSelectedPointsIndices:
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
? {
x: scenePointer.x - targetPoint[0],
y: scenePointer.y - targetPoint[1],
}
: { x: 0, y: 0 },
}, },
}); selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
? {
x: scenePointer.x - targetPoint[0],
y: scenePointer.y - targetPoint[1],
}
: { x: 0, y: 0 },
};
if (ret.didAddPoint) {
ret.linearElementEditor = {
...ret.linearElementEditor,
};
}
return ret; return ret;
} }
static arePointsEqual(point1: Point | null, point2: Point | null) {
if (!point1 && !point2) {
return true;
}
if (!point1 || !point2) {
return false;
}
return arePointsEqual(point1, point2);
}
static handlePointerMove( static handlePointerMove(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
editingLinearElement: LinearElementEditor, appState: AppState,
gridSize: number | null, ): LinearElementEditor | null {
): LinearElementEditor { if (!appState.editingLinearElement) {
const { elementId, lastUncommittedPoint } = editingLinearElement; return null;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId); const element = LinearElementEditor.getElement(elementId);
if (!element) { if (!element) {
return editingLinearElement; return appState.editingLinearElement;
} }
const { points } = element; const { points } = element;
@@ -482,15 +819,36 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]); LinearElementEditor.deletePoints(element, [points.length - 1]);
} }
return { ...editingLinearElement, lastUncommittedPoint: null }; return {
...appState.editingLinearElement,
lastUncommittedPoint: null,
};
} }
const newPoint = LinearElementEditor.createPointAt( let newPoint: Point;
element,
scenePointerX - editingLinearElement.pointerOffset.x, if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
scenePointerY - editingLinearElement.pointerOffset.y, const lastCommittedPoint = points[points.length - 2];
gridSize,
); const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
lastCommittedPoint,
[scenePointerX, scenePointerY],
appState.gridSize,
);
newPoint = [
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
];
} else {
newPoint = LinearElementEditor.createPointAt(
element,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
appState.gridSize,
);
}
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(element, [
@@ -500,11 +858,10 @@ export class LinearElementEditor {
}, },
]); ]);
} else { } else {
LinearElementEditor.addPoints(element, [{ point: newPoint }]); LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
} }
return { return {
...editingLinearElement, ...appState.editingLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1], lastUncommittedPoint: element.points[element.points.length - 1],
}; };
} }
@@ -526,14 +883,14 @@ export class LinearElementEditor {
/** scene coords */ /** scene coords */
static getPointsGlobalCoordinates( static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
) { ): Point[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
return element.points.map((point) => { return element.points.map((point) => {
let { x, y } = element; let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y]; return [x, y] as const;
}); });
} }
@@ -551,7 +908,9 @@ export class LinearElementEditor {
const point = element.points[index]; const point = element.points[index];
const { x, y } = element; const { x, y } = element;
return rotate(x + point[0], y + point[1], cx, cy, element.angle); return point
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
: rotate(x, y, cx, cy, element.angle);
} }
static pointFromAbsoluteCoords( static pointFromAbsoluteCoords(
@@ -572,24 +931,37 @@ export class LinearElementEditor {
} }
static getPointIndexUnderCursor( static getPointIndexUnderCursor(
element: NonDeleted<ExcalidrawLinearElement>, linearElementEditor: LinearElementEditor | null,
zoom: AppState["zoom"], zoom: AppState["zoom"],
x: number, x: number,
y: number, y: number,
) { ) {
const pointHandles = this.getPointsGlobalCoordinates(element); if (!linearElementEditor) {
let idx = pointHandles.length; return -1;
}
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return -1;
}
const pointHandles =
LinearElementEditor.getPointsGlobalCoordinates(element);
let counter = visiblePointIndexesCache.points.length;
// loop from right to left because points on the right are rendered over // loop from right to left because points on the right are rendered over
// points on the left, thus should take precedence when clicking, if they // points on the left, thus should take precedence when clicking, if they
// overlap // overlap
while (--idx > -1) { while (--counter >= 0) {
const point = pointHandles[idx]; const index = visiblePointIndexesCache.points[counter];
const point = pointHandles[index];
if ( if (
distance2d(x, y, point[0], point[1]) * zoom.value < distance2d(x, y, point[0], point[1]) * zoom.value <
// +1px to account for outline stroke // +1px to account for outline stroke
this.POINT_HANDLE_SIZE / 2 + 1 LinearElementEditor.POINT_HANDLE_SIZE + 1
) { ) {
return idx; return index;
} }
} }
return -1; return -1;
@@ -746,6 +1118,7 @@ export class LinearElementEditor {
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
targetPoints: { point: Point }[], targetPoints: { point: Point }[],
) { ) {
const offsetX = 0; const offsetX = 0;
@@ -775,9 +1148,9 @@ export class LinearElementEditor {
if (selectedOriginPoint) { if (selectedOriginPoint) {
offsetX = offsetX =
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0]; selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
offsetY = offsetY =
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1]; selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
} }
const nextPoints = points.map((point, idx) => { const nextPoints = points.map((point, idx) => {
@@ -840,6 +1213,33 @@ export class LinearElementEditor {
y: element.y + rotated[1], y: element.y + rotated[1],
}); });
} }
private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>,
referencePoint: Point,
scenePointer: Point,
gridSize: number | null,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element,
referencePoint,
);
const [gridX, gridY] = getGridPoint(
scenePointer[0],
scenePointer[1],
gridSize,
);
const { width, height } = getLockedLinearCursorAlignSize(
referencePointCoords[0],
referencePointCoords[1],
gridX,
gridY,
);
return rotatePoint([width, height], [0, 0], -element.angle);
}
} }
const normalizeSelectedPoints = ( const normalizeSelectedPoints = (
+1
View File
@@ -198,6 +198,7 @@ const getAdjustedDimensions = (
element, element,
nextWidth, nextWidth,
nextHeight, nextHeight,
false,
); );
const deltaX1 = (x1 - nextX1) / 2; const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2; const deltaY1 = (y1 - nextY1) / 2;
+141 -130
View File
@@ -18,6 +18,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
getCommonBoundingBox,
} from "./bounds"; } from "./bounds";
import { import {
isFreeDrawElement, isFreeDrawElement,
@@ -137,8 +138,10 @@ export const transformElements = (
transformHandleType === "se" transformHandleType === "se"
) { ) {
resizeMultipleElements( resizeMultipleElements(
pointerDownState,
selectedElements, selectedElements,
transformHandleType, transformHandleType,
shouldResizeFromCenter,
pointerX, pointerX,
pointerY, pointerY,
); );
@@ -261,13 +264,15 @@ const rescalePointsInElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
width: number, width: number,
height: number, height: number,
normalizePoints: boolean,
) => ) =>
isLinearElement(element) || isFreeDrawElement(element) isLinearElement(element) || isFreeDrawElement(element)
? { ? {
points: rescalePoints( points: rescalePoints(
0, 0,
width, width,
rescalePoints(1, height, element.points), rescalePoints(1, height, element.points, normalizePoints),
normalizePoints,
), ),
} }
: {}; : {};
@@ -371,6 +376,7 @@ const resizeSingleTextElement = (
element, element,
nextWidth, nextWidth,
nextHeight, nextHeight,
false,
); );
const deltaX1 = (x1 - nextX1) / 2; const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2; const deltaY1 = (y1 - nextY1) / 2;
@@ -412,6 +418,7 @@ export const resizeSingleElement = (
stateAtResizeStart, stateAtResizeStart,
stateAtResizeStart.width, stateAtResizeStart.width,
stateAtResizeStart.height, stateAtResizeStart.height,
true,
); );
const startTopLeft: Point = [x1, y1]; const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2]; const startBottomRight: Point = [x2, y2];
@@ -429,6 +436,7 @@ export const resizeSingleElement = (
element, element,
element.width, element.width,
element.height, element.height,
true,
); );
const boundsCurrentWidth = esx2 - esx1; const boundsCurrentWidth = esx2 - esx1;
@@ -522,6 +530,7 @@ export const resizeSingleElement = (
stateAtResizeStart, stateAtResizeStart,
eleNewWidth, eleNewWidth,
eleNewHeight, eleNewHeight,
true,
); );
const newBoundsWidth = newBoundsX2 - newBoundsX1; const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1; const newBoundsHeight = newBoundsY2 - newBoundsY1;
@@ -592,6 +601,7 @@ export const resizeSingleElement = (
stateAtResizeStart, stateAtResizeStart,
eleNewWidth, eleNewWidth,
eleNewHeight, eleNewHeight,
true,
); );
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be // So we need to readjust (x,y) to be where the first point should be
@@ -637,146 +647,147 @@ export const resizeSingleElement = (
}; };
const resizeMultipleElements = ( const resizeMultipleElements = (
elements: readonly NonDeletedExcalidrawElement[], pointerDownState: PointerDownState,
selectedElements: readonly NonDeletedExcalidrawElement[],
transformHandleType: "nw" | "ne" | "sw" | "se", transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
) => { ) => {
const [x1, y1, x2, y2] = getCommonBounds(elements); // map selected elements to the original elements. While it never should
let scale: number; // happen that pointerDownState.originalElements won't contain the selected
let getNextXY: ( // elements during resize, this coupling isn't guaranteed, so to ensure
element: NonDeletedExcalidrawElement, // type safety we need to transform only those elements we filter.
origCoords: readonly [number, number, number, number], const targetElements = selectedElements.reduce(
finalCoords: readonly [number, number, number, number], (
) => { x: number; y: number }; acc: {
switch (transformHandleType) { /** element at resize start */
case "se": orig: NonDeletedExcalidrawElement;
scale = Math.max( /** latest element */
(pointerX - x1) / (x2 - x1), latest: NonDeletedExcalidrawElement;
(pointerY - y1) / (y2 - y1), }[],
); element,
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => { ) => {
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; const origElement = pointerDownState.originalElements.get(element.id);
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; if (origElement) {
return { x, y }; acc.push({ orig: origElement, latest: element });
}; }
break; return acc;
case "nw": },
scale = Math.max( [],
(x2 - pointerX) / (x2 - x1), );
(y2 - pointerY) / (y2 - y1),
); const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => { targetElements.map(({ orig }) => orig),
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; );
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; const direction = transformHandleType;
return { x, y };
}; const mapDirectionsToAnchors: Record<typeof direction, Point> = {
break; ne: [minX, maxY],
case "ne": se: [minX, minY],
scale = Math.max( sw: [maxX, minY],
(pointerX - x1) / (x2 - x1), nw: [maxX, maxY],
(y2 - pointerY) / (y2 - y1), };
);
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => { // anchor point must be on the opposite side of the dragged selection handle
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; // or be the center of the selection if alt is pressed
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; const [anchorX, anchorY]: Point = shouldResizeFromCenter
return { x, y }; ? [midX, midY]
}; : mapDirectionsToAnchors[direction];
break;
case "sw": const mapDirectionsToPointerSides: Record<
scale = Math.max( typeof direction,
(x2 - pointerX) / (x2 - x1), [x: boolean, y: boolean]
(pointerY - y1) / (y2 - y1), > = {
); ne: [pointerX >= anchorX, pointerY <= anchorY],
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => { se: [pointerX >= anchorX, pointerY >= anchorY],
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; sw: [pointerX <= anchorX, pointerY >= anchorY],
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; nw: [pointerX <= anchorX, pointerY <= anchorY],
return { x, y }; };
};
break; // pointer side relative to anchor
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
direction
].map((condition) => (condition ? 1 : -1));
// stop resizing if a pointer is on the other side of selection
if (pointerSideX < 0 && pointerSideY < 0) {
return;
} }
if (scale > 0) {
const updates = elements.reduce( const scale =
(prev, element) => { Math.max(
if (!prev) { (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
return prev; (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
) * (shouldResizeFromCenter ? 2 : 1);
if (scale === 1) {
return;
}
targetElements.forEach((element) => {
const width = element.orig.width * scale;
const height = element.orig.height * scale;
const x = anchorX + (element.orig.x - anchorX) * scale;
const y = anchorY + (element.orig.y - anchorY) * scale;
// readjust points for linear & free draw elements
const rescaledPoints = rescalePointsInElement(
element.orig,
width,
height,
false,
);
const update: {
width: number;
height: number;
x: number;
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
x,
y,
...rescaledPoints,
};
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
height - optionalPadding,
);
if (textMeasurements) {
if (isTextElement(element.orig)) {
update.fontSize = textMeasurements.size;
update.baseline = textMeasurements.baseline;
} }
const width = element.width * scale;
const height = element.height * scale;
const boundTextElement = getBoundTextElement(element);
let font: { fontSize?: number; baseline?: number } = {};
if (boundTextElement) { if (boundTextElement) {
const nextFont = measureFontSizeFromWH( boundTextUpdates = {
boundTextElement, fontSize: textMeasurements.size,
width - BOUND_TEXT_PADDING * 2, baseline: textMeasurements.baseline,
height - BOUND_TEXT_PADDING * 2,
);
if (nextFont === null) {
return null;
}
font = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
}; };
} }
}
if (isTextElement(element)) {
const nextFont = measureFontSizeFromWH(element, width, height);
if (nextFont === null) {
return null;
}
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
}
const origCoords = getElementAbsoluteCoords(element);
const rescaledPoints = rescalePointsInElement(element, width, height);
updateBoundElements(element, {
newSize: { width, height },
simultaneouslyUpdated: elements,
});
const finalCoords = getResizedElementAbsoluteCoords(
{
...element,
...rescaledPoints,
},
width,
height,
);
const { x, y } = getNextXY(element, origCoords, finalCoords);
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
},
[] as
| {
width: number;
height: number;
x: number;
y: number;
points?: (readonly [number, number])[];
fontSize?: number;
baseline?: number;
}[]
| null,
);
if (updates) {
elements.forEach((element, index) => {
mutateElement(element, updates[index]);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
mutateElement(boundTextElement, {
fontSize: updates[index].fontSize,
baseline: updates[index].baseline,
});
handleBindTextResize(element, transformHandleType);
}
});
} }
}
mutateElement(element.latest, update);
if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType);
}
});
}; };
const rotateMultipleElements = ( const rotateMultipleElements = (
+18 -16
View File
@@ -1,49 +1,51 @@
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import * as constants from "../constants"; import * as constants from "../constants";
const EPSILON_DIGITS = 3;
describe("getPerfectElementSize", () => { describe("getPerfectElementSize", () => {
it("should return height:0 if `elementType` is line and locked angle is 0", () => { it("should return height:0 if `elementType` is line and locked angle is 0", () => {
const { height, width } = getPerfectElementSize("line", 149, 10); const { height, width } = getPerfectElementSize("line", 149, 10);
expect(width).toEqual(149); expect(width).toBeCloseTo(149, EPSILON_DIGITS);
expect(height).toEqual(0); expect(height).toBeCloseTo(0, EPSILON_DIGITS);
}); });
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => { it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
const { height, width } = getPerfectElementSize("line", 10, 140); const { height, width } = getPerfectElementSize("line", 10, 140);
expect(width).toEqual(0); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toEqual(140); expect(height).toBeCloseTo(140, EPSILON_DIGITS);
}); });
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => { it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
const { height, width } = getPerfectElementSize("arrow", 200, 20); const { height, width } = getPerfectElementSize("arrow", 200, 20);
expect(width).toEqual(200); expect(width).toBeCloseTo(200, EPSILON_DIGITS);
expect(height).toEqual(0); expect(height).toBeCloseTo(0, EPSILON_DIGITS);
}); });
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => { it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
const { height, width } = getPerfectElementSize("arrow", 10, 100); const { height, width } = getPerfectElementSize("arrow", 10, 100);
expect(width).toEqual(0); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toEqual(100); expect(height).toBeCloseTo(100, EPSILON_DIGITS);
}); });
it("should return adjust height to be width * tan(locked angle)", () => { it("should return adjust height to be width * tan(locked angle)", () => {
const { height, width } = getPerfectElementSize("arrow", 120, 185); const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toEqual(120); expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toEqual(208); expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
}); });
it("should return height equals to width if locked angle is 45 deg", () => { it("should return height equals to width if locked angle is 45 deg", () => {
const { height, width } = getPerfectElementSize("arrow", 135, 145); const { height, width } = getPerfectElementSize("arrow", 135, 145);
expect(width).toEqual(135); expect(width).toBeCloseTo(135, EPSILON_DIGITS);
expect(height).toEqual(135); expect(height).toBeCloseTo(135, EPSILON_DIGITS);
}); });
it("should return height:0 and width:0 when width and height are 0", () => { it("should return height:0 and width:0 when width and height are 0", () => {
const { height, width } = getPerfectElementSize("arrow", 0, 0); const { height, width } = getPerfectElementSize("arrow", 0, 0);
expect(width).toEqual(0); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toEqual(0); expect(height).toBeCloseTo(0, EPSILON_DIGITS);
}); });
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => { describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => { it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4; (constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185); const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toEqual(120); expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toEqual(120); expect(height).toBeCloseTo(120, EPSILON_DIGITS);
}); });
}); });
}); });
+41 -3
View File
@@ -37,9 +37,7 @@ export const getPerfectElementSize = (
} else if (lockedAngle === Math.PI / 2) { } else if (lockedAngle === Math.PI / 2) {
width = 0; width = 0;
} else { } else {
height = height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
Math.round(absWidth * Math.tan(lockedAngle)) * Math.sign(height) ||
height;
} }
} else if (elementType !== "selection") { } else if (elementType !== "selection") {
height = absWidth * Math.sign(height); height = absWidth * Math.sign(height);
@@ -47,6 +45,46 @@ export const getPerfectElementSize = (
return { width, height }; return { width, height };
}; };
export const getLockedLinearCursorAlignSize = (
originX: number,
originY: number,
x: number,
y: number,
) => {
let width = x - originX;
let height = y - originY;
const lockedAngle =
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE;
if (lockedAngle === 0) {
height = 0;
} else if (lockedAngle === Math.PI / 2) {
width = 0;
} else {
// locked angle line, y = mx + b => mx - y + b = 0
const a1 = Math.tan(lockedAngle);
const b1 = -1;
const c1 = originY - a1 * originX;
// line through cursor, perpendicular to locked angle line
const a2 = -1 / a1;
const b2 = -1;
const c2 = y - a2 * x;
// intersection of the two lines above
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
// delta
width = intersectX - originX;
height = intersectY - originY;
}
return { width, height };
};
export const resizePerfectLineForNWHandler = ( export const resizePerfectLineForNWHandler = (
element: ExcalidrawElement, element: ExcalidrawElement,
x: number, x: number,
+33 -13
View File
@@ -1,9 +1,15 @@
import { ExcalidrawElement, PointerType } from "./types"; import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
PointerType,
} from "./types";
import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
| "n" | "n"
@@ -59,8 +65,6 @@ const OMIT_SIDES_FOR_LINE_BACKSLASH = {
s: true, s: true,
n: true, n: true,
w: true, w: true,
ne: true,
sw: true,
}; };
const generateTransformHandle = ( const generateTransformHandle = (
@@ -82,6 +86,7 @@ export const getTransformHandlesFromCoords = (
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {}, omitSides: { [T in TransformHandleType]?: boolean } = {},
margin = 4,
): TransformHandles => { ): TransformHandles => {
const size = transformHandleSizes[pointerType]; const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom.value; const handleWidth = size / zoom.value;
@@ -94,9 +99,7 @@ export const getTransformHandlesFromCoords = (
const height = y2 - y1; const height = y2 - y1;
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value;
const dashedLineMargin = 4 / zoom.value;
const centeringOffset = (size - 8) / (2 * zoom.value); const centeringOffset = (size - 8) / (2 * zoom.value);
const transformHandles: TransformHandles = { const transformHandles: TransformHandles = {
@@ -230,11 +233,7 @@ export const getTransformHandles = (
} }
let omitSides: { [T in TransformHandleType]?: boolean } = {}; let omitSides: { [T in TransformHandleType]?: boolean } = {};
if ( if (element.type === "freedraw" || isLinearElement(element)) {
element.type === "arrow" ||
element.type === "line" ||
element.type === "freedraw"
) {
if (element.points.length === 2) { if (element.points.length === 2) {
// only check the last point because starting point is always (0,0) // only check the last point because starting point is always (0,0)
const [, p1] = element.points; const [, p1] = element.points;
@@ -253,12 +252,33 @@ export const getTransformHandles = (
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} }
const dashedLineMargin = isLinearElement(element)
? DEFAULT_SPACING * 3
: DEFAULT_SPACING;
return getTransformHandlesFromCoords( return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element), getElementAbsoluteCoords(element),
element.angle, element.angle,
zoom, zoom,
pointerType, pointerType,
omitSides, omitSides,
dashedLineMargin,
); );
}; };
export const shouldShowBoundingBox = (
elements: NonDeletedExcalidrawElement[],
appState: AppState,
) => {
if (appState.editingLinearElement) {
return false;
}
if (elements.length > 1) {
return true;
}
const element = elements[0];
if (!isLinearElement(element)) {
return true;
}
return element.points.length > 2;
};
+1
View File
@@ -56,6 +56,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number; updated: number;
link: string | null; link: string | null;
locked: boolean; locked: boolean;
customData?: Record<string, any>;
}>; }>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
+5 -1
View File
@@ -7,6 +7,8 @@ import {
import { DEFAULT_VERSION } from "../constants"; import { DEFAULT_VERSION } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../clipboard";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
const STORAGE_SIZE_TIMEOUT = 500; const STORAGE_SIZE_TIMEOUT = 500;
@@ -20,6 +22,8 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
type Props = { type Props = {
setToast: (message: string) => void; setToast: (message: string) => void;
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
}; };
const CustomStats = (props: Props) => { const CustomStats = (props: Props) => {
const [storageSizes, setStorageSizes] = useState<StorageSizes>({ const [storageSizes, setStorageSizes] = useState<StorageSizes>({
@@ -31,7 +35,7 @@ const CustomStats = (props: Props) => {
getStorageSizes((sizes) => { getStorageSizes((sizes) => {
setStorageSizes(sizes); setStorageSizes(sizes);
}); });
}); }, [props.elements, props.appState]);
useEffect(() => () => getStorageSizes.cancel(), []); useEffect(() => () => getStorageSizes.cancel(), []);
const version = getVersion(); const version = getVersion();
-1
View File
@@ -33,7 +33,6 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_LIBRARY: "excalidraw-library",
VERSION_DATA_STATE: "version-dataState", VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
-13
View File
@@ -25,7 +25,6 @@ import {
INITIAL_SCENE_UPDATE_TIMEOUT, INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT, LOAD_IMAGES_TIMEOUT,
WS_SCENE_EVENT_TYPES, WS_SCENE_EVENT_TYPES,
STORAGE_KEYS,
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants"; } from "../app_constants";
import { import {
@@ -225,18 +224,6 @@ class Collab extends PureComponent<Props, CollabState> {
preventUnload(event); preventUnload(event);
} }
if (this.isCollaborating || this.portal.roomId) {
try {
localStorage?.setItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
JSON.stringify({
timestamp: Date.now(),
room: this.portal.roomId,
}),
);
} catch {}
}
}); });
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
+1 -1
View File
@@ -14,7 +14,7 @@ import {
} from "../app_constants"; } from "../app_constants";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { throttle } from "lodash"; import throttle from "lodash.throttle";
import { newElementWith } from "../../element/mutateElement"; import { newElementWith } from "../../element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation"; import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption"; import { encryptData } from "../../data/encryption";
+39 -19
View File
@@ -1,3 +1,4 @@
import polyfill from "../polyfill";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
@@ -83,6 +84,7 @@ import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation"; import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true; window.EXCALIDRAW_THROTTLE_RENDER = true;
const isExcalidrawPlusSignedUser = document.cookie.includes( const isExcalidrawPlusSignedUser = document.cookie.includes(
@@ -96,6 +98,7 @@ languageDetector.init({
const initializeScene = async (opts: { const initializeScene = async (opts: {
collabAPI: CollabAPI; collabAPI: CollabAPI;
excalidrawAPI: ExcalidrawImperativeAPI;
}): Promise< }): Promise<
{ scene: ExcalidrawInitialDataState | null } & ( { scene: ExcalidrawInitialDataState | null } & (
| { isExternalScene: true; id: string; key: string } | { isExternalScene: true; id: string; key: string }
@@ -180,8 +183,34 @@ const initializeScene = async (opts: {
} }
if (roomLinkData) { if (roomLinkData) {
const { excalidrawAPI } = opts;
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
return { return {
scene: await opts.collabAPI.startCollaboration(roomLinkData), // when collaborating, the state may have already been updated at this
// point (we may have received updates from other clients), so reconcile
// elements and appState with existing state
scene: {
...scene,
appState: {
...restoreAppState(
{
...scene?.appState,
theme: localDataState?.appState?.theme || scene?.appState?.theme,
},
excalidrawAPI.getAppState(),
),
// necessary if we're invoking from a hashchange handler which doesn't
// go through App.initializeScene() that resets this flag
isLoading: false,
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
},
isExternalScene: true, isExternalScene: true,
id: roomLinkData.roomId, id: roomLinkData.roomId,
key: roomLinkData.roomKey, key: roomLinkData.roomKey,
@@ -335,23 +364,9 @@ const ExcalidrawWrapper = () => {
} }
}; };
initializeScene({ collabAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
initialStatePromiseRef.current.promise.resolve({
...data.scene,
// at this point the state may have already been updated (e.g. when
// collaborating, we may have received updates from other clients)
appState: restoreAppState(
data.scene?.appState,
excalidrawAPI.getAppState(),
),
elements: reconcileElements(
data.scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
});
}); });
const onHashChange = async (event: HashChangeEvent) => { const onHashChange = async (event: HashChangeEvent) => {
@@ -366,7 +381,7 @@ const ExcalidrawWrapper = () => {
} }
excalidrawAPI.updateScene({ appState: { isLoading: true } }); excalidrawAPI.updateScene({ appState: { isLoading: true } });
initializeScene({ collabAPI }).then((data) => { initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
loadImages(data); loadImages(data);
if (data.scene) { if (data.scene) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
@@ -657,10 +672,15 @@ const ExcalidrawWrapper = () => {
[langCode], [langCode],
); );
const renderCustomStats = () => { const renderCustomStats = (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
return ( return (
<CustomStats <CustomStats
setToast={(message) => excalidrawAPI!.setToast({ message })} setToast={(message) => excalidrawAPI!.setToast({ message })}
appState={appState}
elements={elements}
/> />
); );
}; };
+9 -3
View File
@@ -1,8 +1,14 @@
import ReactDOM from "react-dom"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./excalidraw-app"; import ExcalidrawApp from "./excalidraw-app";
import "./excalidraw-app/pwa"; import "./excalidraw-app/pwa";
import "./excalidraw-app/sentry"; import "./excalidraw-app/sentry";
window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA; window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA;
const rootElement = document.getElementById("root")!;
ReactDOM.render(<ExcalidrawApp />, document.getElementById("root")); const root = createRoot(rootElement);
root.render(
<StrictMode>
<ExcalidrawApp />
</StrictMode>,
);
+1 -1
View File
@@ -80,5 +80,5 @@ export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
event.shiftKey; event.shiftKey;
export const shouldRotateWithDiscreteAngle = ( export const shouldRotateWithDiscreteAngle = (
event: MouseEvent | KeyboardEvent, event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
) => event.shiftKey; ) => event.shiftKey;
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.", "invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟", "resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟", "removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.", "invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع الملف غير مدعوم.", "unsupportedFileType": "نوع الملف غير مدعوم.",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "", "invalidEncryptionKey": ""
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Този файлов формат не се поддържа.", "unsupportedFileType": "Този файлов формат не се поддържа.",
+51 -52
View File
@@ -1,55 +1,55 @@
{ {
"labels": { "labels": {
"paste": "", "paste": "পেস্ট করুন",
"pasteCharts": "", "pasteCharts": "চার্টগুলো পেস্ট করুন",
"selectAll": "", "selectAll": "সব সিলেক্ট করুন",
"multiSelect": "", "multiSelect": "সিলেকশনে এলিমেন্ট এ্যাড করুন",
"moveCanvas": "", "moveCanvas": "ক্যানভাস মুভ করুন",
"cut": "", "cut": "কাট করুন",
"copy": "", "copy": "কপি করুন",
"copyAsPng": "", "copyAsPng": "PNG হিসেবে ক্লিপবোর্ডে কপি করুন",
"copyAsSvg": "", "copyAsSvg": "SVG হিসেবে ক্লিপবোর্ডে কপি করুন",
"copyText": "", "copyText": "টেক্সট হিসেবে ক্লিপবোর্ডে কপি করুন",
"bringForward": "", "bringForward": "সামনে আনুন",
"sendToBack": "", "sendToBack": "একদম পেছনে পাঠান",
"bringToFront": "", "bringToFront": "একদম সামনে আনুন",
"sendBackward": "", "sendBackward": "পেছনে পাঠান",
"delete": "", "delete": "ডিলিট করুন",
"copyStyles": "", "copyStyles": "স্টাইলগুলো কপি করুন",
"pasteStyles": "", "pasteStyles": "স্টাইলগুলো পেস্ট করুন",
"stroke": "", "stroke": "স্ট্রোক",
"background": "", "background": "ব্যাকগ্রাউন্ড",
"fill": "", "fill": "ফিল",
"strokeWidth": "", "strokeWidth": "স্ট্রোকের পুরুত্ব",
"strokeStyle": "", "strokeStyle": "স্ট্রোকের স্টাইল",
"strokeStyle_solid": "", "strokeStyle_solid": "সলিড",
"strokeStyle_dashed": "", "strokeStyle_dashed": "কাটা-কাটা",
"strokeStyle_dotted": "", "strokeStyle_dotted": "ফোটা-ফোটা",
"sloppiness": "", "sloppiness": "স্ট্রোকের ধরণ",
"opacity": "", "opacity": "অস্বচ্ছতা",
"textAlign": "", "textAlign": "লেখার দিক",
"edges": "", "edges": "কোণা",
"sharp": "", "sharp": "তীক্ষ্ণ",
"round": "", "round": "গোলাকার",
"arrowheads": "", "arrowheads": "তীরের মাথা",
"arrowhead_none": "", "arrowhead_none": "কিছু না",
"arrowhead_arrow": "", "arrowhead_arrow": "তীর",
"arrowhead_bar": "", "arrowhead_bar": "বার",
"arrowhead_dot": "", "arrowhead_dot": "ডট",
"arrowhead_triangle": "", "arrowhead_triangle": "ত্রিভুজ",
"fontSize": "", "fontSize": "ফন্ট সাইজ",
"fontFamily": "", "fontFamily": "ফন্ট ফ্যামিলি",
"onlySelected": "", "onlySelected": "শুধুমাত্র সিলেক্টেডগুলো",
"withBackground": "", "withBackground": "ব্যাকগ্রাউন্ড",
"exportEmbedScene": "", "exportEmbedScene": "সিন এম্বেড করুন",
"exportEmbedScene_details": "", "exportEmbedScene_details": "সিনের ডেটা এক্সপোর্টকৃত PNG/SVG ফাইলের মধ্যে সেভ করা হবে যাতে করে পরবর্তী সময়ে আপনি এডিট করতে পারেন । তবে এতে ফাইলের সাইজ বাড়বে ।.",
"addWatermark": "", "addWatermark": "",
"handDrawn": "", "handDrawn": "হাতে আঁকা",
"normal": "", "normal": "স্বাভাবিক",
"code": "", "code": "কোড",
"small": "", "small": "ছোট",
"medium": "", "medium": "মধ্যবর্তী",
"large": "", "large": "বড়",
"veryLarge": "", "veryLarge": "",
"solid": "", "solid": "",
"hachure": "", "hachure": "",
@@ -99,7 +99,7 @@
"flipVertical": "", "flipVertical": "",
"viewMode": "", "viewMode": "",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "", "share": "শেয়ার করুন",
"showStroke": "", "showStroke": "",
"showBackground": "", "showBackground": "",
"toggleTheme": "", "toggleTheme": "",
@@ -187,8 +187,7 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "", "invalidEncryptionKey": ""
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
+9 -10
View File
@@ -108,7 +108,7 @@
"decreaseFontSize": "Redueix la mida de la lletra", "decreaseFontSize": "Redueix la mida de la lletra",
"increaseFontSize": "Augmenta la mida de la lletra", "increaseFontSize": "Augmenta la mida de la lletra",
"unbindText": "Desvincular el text", "unbindText": "Desvincular el text",
"bindText": "", "bindText": "Ajusta el text al contenidor",
"link": { "link": {
"edit": "Edita l'enllaç", "edit": "Edita l'enllaç",
"create": "Crea un enllaç", "create": "Crea un enllaç",
@@ -121,12 +121,12 @@
"unlockAll": "Desbloca-ho tot" "unlockAll": "Desbloca-ho tot"
}, },
"statusPublished": "Publicat", "statusPublished": "Publicat",
"sidebarLock": "" "sidebarLock": "Manté la barra lateral oberta"
}, },
"library": { "library": {
"noItems": "", "noItems": "Encara no s'hi han afegit elements...",
"hint_emptyLibrary": "", "hint_emptyLibrary": "Trieu un element o un llenç per a afegir-lo aquí, o instal·leu una biblioteca del repositori públic, més avall.",
"hint_emptyPrivateLibrary": "" "hint_emptyPrivateLibrary": "Trieu un element o un llenç per a afegir-lo aquí."
}, },
"buttons": { "buttons": {
"clearReset": "Neteja el llenç", "clearReset": "Neteja el llenç",
@@ -187,8 +187,7 @@
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.", "invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?", "resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?", "removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?",
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada.", "invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipus de fitxer no suportat.", "unsupportedFileType": "Tipus de fitxer no suportat.",
@@ -196,7 +195,7 @@
"fileTooBig": "El fitxer és massa gros. La mida màxima permesa és {{maxSize}}.", "fileTooBig": "El fitxer és massa gros. La mida màxima permesa és {{maxSize}}.",
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.", "svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
"invalidSVGString": "SVG no vàlid.", "invalidSVGString": "SVG no vàlid.",
"cannotResolveCollabServer": "", "cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
"importLibraryError": "No s'ha pogut carregar la biblioteca" "importLibraryError": "No s'ha pogut carregar la biblioteca"
}, },
"toolBar": { "toolBar": {
@@ -307,7 +306,7 @@
"view": "Visualització", "view": "Visualització",
"zoomToFit": "Zoom per veure tots els elements", "zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció", "zoomToSelection": "Zoom per veure la selecció",
"toggleElementLock": "" "toggleElementLock": "Blocar/desblocar la selecció"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Neteja el llenç" "title": "Neteja el llenç"
@@ -325,7 +324,7 @@
"authorName": "Nom o usuari", "authorName": "Nom o usuari",
"libraryName": "Nom de la vostra biblioteca", "libraryName": "Nom de la vostra biblioteca",
"libraryDesc": "Descripció de la biblioteca per a ajudar a la gent a entendre'n el funcionament", "libraryDesc": "Descripció de la biblioteca per a ajudar a la gent a entendre'n el funcionament",
"githubHandle": "", "githubHandle": "Identificador de GitHub (opcional), per tal que pugueu editar la biblioteca una vegada enviada per a ser revisada",
"twitterHandle": "Usuari de twitter (opcional), per tal que puguem donar-vos crèdit quan fem la promoció a Twitter", "twitterHandle": "Usuari de twitter (opcional), per tal que puguem donar-vos crèdit quan fem la promoció a Twitter",
"website": "Enllaç al vostre lloc web personal o a qualsevol altre (opcional)" "website": "Enllaç al vostre lloc web personal o a qualsevol altre (opcional)"
}, },
+10 -11
View File
@@ -51,10 +51,10 @@
"medium": "Střední", "medium": "Střední",
"large": "Velké", "large": "Velké",
"veryLarge": "Velmi velké", "veryLarge": "Velmi velké",
"solid": "", "solid": "Plný",
"hachure": "", "hachure": "",
"crossHatch": "", "crossHatch": "",
"thin": "", "thin": "Tenký",
"bold": "", "bold": "",
"left": "", "left": "",
"center": "", "center": "",
@@ -68,7 +68,7 @@
"canvasColors": "", "canvasColors": "",
"canvasBackground": "Pozadí plátna", "canvasBackground": "Pozadí plátna",
"drawingCanvas": "", "drawingCanvas": "",
"layers": "", "layers": "Vrstvy",
"actions": "", "actions": "",
"language": "", "language": "",
"liveCollaboration": "", "liveCollaboration": "",
@@ -87,16 +87,16 @@
"libraries": "", "libraries": "",
"loadingScene": "", "loadingScene": "",
"align": "", "align": "",
"alignTop": "", "alignTop": "Zarovnat nahoru",
"alignBottom": "", "alignBottom": "Zarovnat dolů",
"alignLeft": "", "alignLeft": "Zarovnat vlevo",
"alignRight": "", "alignRight": "Zarovnejte vpravo",
"centerVertically": "", "centerVertically": "",
"centerHorizontally": "", "centerHorizontally": "",
"distributeHorizontally": "", "distributeHorizontally": "",
"distributeVertically": "", "distributeVertically": "",
"flipHorizontal": "", "flipHorizontal": "Převrátit vodorovně",
"flipVertical": "", "flipVertical": "Převrátit svisle",
"viewMode": "Náhled", "viewMode": "Náhled",
"toggleExportColorScheme": "", "toggleExportColorScheme": "",
"share": "Sdílet", "share": "Sdílet",
@@ -187,8 +187,7 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "", "invalidEncryptionKey": ""
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "", "invalidEncryptionKey": ""
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.", "invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?", "resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?", "removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert.", "invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert."
"browserZoom": "Die Zoomstufe Deines Browsers ist nicht auf 100% gesetzt, was dazu führen kann, dass der Zeichenbereich falsch angezeigt wird"
}, },
"errors": { "errors": {
"unsupportedFileType": "Nicht unterstützter Dateityp.", "unsupportedFileType": "Nicht unterstützter Dateityp.",
+67 -68
View File
@@ -9,7 +9,7 @@
"copy": "Αντιγραφή", "copy": "Αντιγραφή",
"copyAsPng": "Αντιγραφή στο πρόχειρο ως PNG", "copyAsPng": "Αντιγραφή στο πρόχειρο ως PNG",
"copyAsSvg": "Αντιγραφή στο πρόχειρο ως SVG", "copyAsSvg": "Αντιγραφή στο πρόχειρο ως SVG",
"copyText": "", "copyText": "Αντιγραφή στο πρόχειρο ως κείμενο",
"bringForward": "Στο προσκήνιο", "bringForward": "Στο προσκήνιο",
"sendToBack": "Ένα επίπεδο πίσω", "sendToBack": "Ένα επίπεδο πίσω",
"bringToFront": "Ένα επίπεδο μπροστά", "bringToFront": "Ένα επίπεδο μπροστά",
@@ -41,7 +41,7 @@
"fontFamily": "Γραμματοσειρά", "fontFamily": "Γραμματοσειρά",
"onlySelected": "Μόνο τα Επιλεγμένα", "onlySelected": "Μόνο τα Επιλεγμένα",
"withBackground": "Φόντο", "withBackground": "Φόντο",
"exportEmbedScene": "", "exportEmbedScene": "Ενσωμάτωση σκηνής",
"exportEmbedScene_details": "Τα δεδομένα σκηνής θα αποθηκευτούν στο αρχείο PNG/SVG προς εξαγωγή ώστε η σκηνή να είναι δυνατό να αποκατασταθεί από αυτό.\nΘα αυξήσει το μέγεθος του αρχείου προς εξαγωγή.", "exportEmbedScene_details": "Τα δεδομένα σκηνής θα αποθηκευτούν στο αρχείο PNG/SVG προς εξαγωγή ώστε η σκηνή να είναι δυνατό να αποκατασταθεί από αυτό.\nΘα αυξήσει το μέγεθος του αρχείου προς εξαγωγή.",
"addWatermark": "Προσθήκη \"Φτιαγμένο με Excalidraw\"", "addWatermark": "Προσθήκη \"Φτιαγμένο με Excalidraw\"",
"handDrawn": "Σχεδιασμένο στο χέρι", "handDrawn": "Σχεδιασμένο στο χέρι",
@@ -65,7 +65,7 @@
"cartoonist": "Σκιτσογράφος", "cartoonist": "Σκιτσογράφος",
"fileTitle": "Όνομα αρχείου", "fileTitle": "Όνομα αρχείου",
"colorPicker": "Επιλογή Χρώματος", "colorPicker": "Επιλογή Χρώματος",
"canvasColors": "", "canvasColors": "Χρησιμοποείται στον καμβά",
"canvasBackground": "Φόντο καμβά", "canvasBackground": "Φόντο καμβά",
"drawingCanvas": "Σχεδίαση καμβά", "drawingCanvas": "Σχεδίαση καμβά",
"layers": "Στρώματα", "layers": "Στρώματα",
@@ -105,28 +105,28 @@
"toggleTheme": "Εναλλαγή θέματος", "toggleTheme": "Εναλλαγή θέματος",
"personalLib": "Προσωπική Βιβλιοθήκη", "personalLib": "Προσωπική Βιβλιοθήκη",
"excalidrawLib": "Βιβλιοθήκη Excalidraw", "excalidrawLib": "Βιβλιοθήκη Excalidraw",
"decreaseFontSize": "", "decreaseFontSize": "Μείωση μεγέθους γραμματοσειράς",
"increaseFontSize": "", "increaseFontSize": "Αύξηση μεγέθους γραμματοσειράς",
"unbindText": "", "unbindText": "Αποσύνδεση κειμένου",
"bindText": "", "bindText": "Δέσμευση κειμένου στο δοχείο",
"link": { "link": {
"edit": "", "edit": "Επεξεργασία συνδέσμου",
"create": "", "create": "Δημιουργία συνδέσμου",
"label": "" "label": "Σύνδεσμος"
}, },
"elementLock": { "elementLock": {
"lock": "", "lock": "Κλείδωμα",
"unlock": "", "unlock": "Ξεκλείδωμα",
"lockAll": "", "lockAll": "Κλείδωμα όλων",
"unlockAll": "" "unlockAll": "Ξεκλείδωμα όλων"
}, },
"statusPublished": "", "statusPublished": "Δημοσιευμένο",
"sidebarLock": "" "sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή"
}, },
"library": { "library": {
"noItems": "", "noItems": "Δεν έχουν προστεθεί αντικείμενα ακόμη...",
"hint_emptyLibrary": "", "hint_emptyLibrary": "Επιλέξτε ένα στοιχείο στον καμβά για να το προσθέσετε εδώ, ή εγκαταστήστε μια βιβλιοθήκη από το δημόσιο αποθετήριο, παρακάτω.",
"hint_emptyPrivateLibrary": "" "hint_emptyPrivateLibrary": "Επιλέξτε ένα στοιχείο στον καμβά για να το προσθέσετε εδώ."
}, },
"buttons": { "buttons": {
"clearReset": "Επαναφορά του καμβά", "clearReset": "Επαναφορά του καμβά",
@@ -174,7 +174,7 @@
"couldNotLoadInvalidFile": "Δεν μπόρεσε να ανοίξει εσφαλμένο αρχείο", "couldNotLoadInvalidFile": "Δεν μπόρεσε να ανοίξει εσφαλμένο αρχείο",
"importBackendFailed": "Η εισαγωγή από το backend απέτυχε.", "importBackendFailed": "Η εισαγωγή από το backend απέτυχε.",
"cannotExportEmptyCanvas": "Δεν είναι δυνατή η εξαγωγή κενού καμβά.", "cannotExportEmptyCanvas": "Δεν είναι δυνατή η εξαγωγή κενού καμβά.",
"couldNotCopyToClipboard": "", "couldNotCopyToClipboard": "Αδυναμία αντιγραφής στο πρόχειρο.",
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.", "decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.", "uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;", "loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
@@ -182,22 +182,21 @@
"errorAddingToLibrary": "Αδυναμία προσθήκης αντικειμένου στη βιβλιοθήκη", "errorAddingToLibrary": "Αδυναμία προσθήκης αντικειμένου στη βιβλιοθήκη",
"errorRemovingFromLibrary": "Αδυναμία αφαίρεσης αντικειμένου από τη βιβλιοθήκη", "errorRemovingFromLibrary": "Αδυναμία αφαίρεσης αντικειμένου από τη βιβλιοθήκη",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;", "confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "", "imageDoesNotContainScene": "Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει την ενσωμάτωση σκηνής κατά την εξαγωγή;",
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας", "cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
"invalidSceneUrl": "", "invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;", "resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.", "invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.", "unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",
"imageInsertError": "Αδυναμία εισαγωγής εικόνας. Προσπαθήστε ξανά αργότερα...", "imageInsertError": "Αδυναμία εισαγωγής εικόνας. Προσπαθήστε ξανά αργότερα...",
"fileTooBig": "Το αρχείο είναι πολύ μεγάλο. Το μέγιστο επιτρεπόμενο μέγεθος είναι {{maxSize}}.", "fileTooBig": "Το αρχείο είναι πολύ μεγάλο. Το μέγιστο επιτρεπόμενο μέγεθος είναι {{maxSize}}.",
"svgImageInsertError": "", "svgImageInsertError": "Αδυναμία εισαγωγής εικόνας SVG. Η σήμανση της SVG δεν φαίνεται έγκυρη.",
"invalidSVGString": "Μη έγκυρο SVG.", "invalidSVGString": "Μη έγκυρο SVG.",
"cannotResolveCollabServer": "", "cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.",
"importLibraryError": "" "importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης"
}, },
"toolBar": { "toolBar": {
"selection": "Επιλογή", "selection": "Επιλογή",
@@ -212,8 +211,8 @@
"library": "Βιβλιοθήκη", "library": "Βιβλιοθήκη",
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο", "lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
"penMode": "", "penMode": "",
"link": "", "link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "" "eraser": "Γόμα"
}, },
"headings": { "headings": {
"canvasActions": "Ενέργειες καμβά", "canvasActions": "Ενέργειες καμβά",
@@ -230,16 +229,16 @@
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις", "linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT", "lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο", "resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
"resizeImage": "", "resizeImage": "Μπορείτε να αλλάξετε το μέγεθος ελεύθερα κρατώντας πατημένο το SHIFT,\nκρατήστε πατημένο το ALT για να αλλάξετε το μέγεθος από το κέντρο",
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή", "rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία", "lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
"lineEditor_pointSelected": "", "lineEditor_pointSelected": "Πατήστε Διαγραφή για αφαίρεση σημείου(ων),\nCtrlOrCmd+D για αντιγραφή, ή σύρετε για μετακίνηση",
"lineEditor_nothingSelected": "", "lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για να επεξεργαστείτε (κρατήστε πατημένο το SHIFT για να επιλέξετε πολλαπλά),\nή κρατήστε πατημένο το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
"placeImage": "", "placeImage": "Κάντε κλικ για να τοποθετήσετε την εικόνα ή κάντε κλικ και σύρετε για να ορίσετε το μέγεθός της χειροκίνητα",
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη", "publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη",
"bindTextToElement": "", "bindTextToElement": "Πατήστε Enter για προσθήκη κειμένου",
"deepBoxSelect": "", "deepBoxSelect": "Κρατήστε πατημένο το CtrlOrCmd για να επιλέξετε βαθιά, και να αποτρέψετε τη μεταφορά",
"eraserRevert": "" "eraserRevert": "Κρατήστε πατημένο το Alt για να επαναφέρετε τα στοιχεία που σημειώθηκαν για διαγραφή"
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης", "cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
@@ -267,39 +266,39 @@
"desc_inProgressIntro": "Η ζωντανή συνεργασία με άλλους είναι σε ενεργή.", "desc_inProgressIntro": "Η ζωντανή συνεργασία με άλλους είναι σε ενεργή.",
"desc_shareLink": "Μοιραστείτε τον σύνδεσμο με όποιον θέλετε να δουλέψετε μαζί:", "desc_shareLink": "Μοιραστείτε τον σύνδεσμο με όποιον θέλετε να δουλέψετε μαζί:",
"desc_exitSession": "Η διακοπή θα σας αποσυνδέσει από το δωμάτιο, αλλά θα μπορείτε να συνεχίσετε να δουλεύετε στον πίνακα, τοπικά. Σημειώσατε ότι αυτό δεν θα επηρεάσει τον πίνακα άλλων, και θα μπορούν ακόμα να συνεισφέρουν στην δική τους έκδοση.", "desc_exitSession": "Η διακοπή θα σας αποσυνδέσει από το δωμάτιο, αλλά θα μπορείτε να συνεχίσετε να δουλεύετε στον πίνακα, τοπικά. Σημειώσατε ότι αυτό δεν θα επηρεάσει τον πίνακα άλλων, και θα μπορούν ακόμα να συνεισφέρουν στην δική τους έκδοση.",
"shareTitle": "" "shareTitle": "Συμμετάσχετε σε μια ζωντανή συνεδρία συνεργασίας για το Excalidraw"
}, },
"errorDialog": { "errorDialog": {
"title": "Σφάλμα" "title": "Σφάλμα"
}, },
"exportDialog": { "exportDialog": {
"disk_title": "Αποθήκευση στο δίσκο", "disk_title": "Αποθήκευση στο δίσκο",
"disk_details": "", "disk_details": "Εξαγωγή δεδομένων σκηνής σε ένα αρχείο από το οποίο μπορείτε να εισάγετε αργότερα.",
"disk_button": "Αποθήκευση σε αρχείο", "disk_button": "Αποθήκευση σε αρχείο",
"link_title": "Κοινόχρηστος σύνδεσμος", "link_title": "Κοινόχρηστος σύνδεσμος",
"link_details": "Εξαγωγή ως σύνδεσμο μόνο για ανάγνωση.", "link_details": "Εξαγωγή ως σύνδεσμο μόνο για ανάγνωση.",
"link_button": "Εξαγωγή σε Σύνδεση", "link_button": "Εξαγωγή σε Σύνδεση",
"excalidrawplus_description": "", "excalidrawplus_description": "Αποθηκεύστε τη σκηνή στο χώρο εργασίας σας Excalidraw+.",
"excalidrawplus_button": "Εξαγωγή", "excalidrawplus_button": "Εξαγωγή",
"excalidrawplus_exportError": "" "excalidrawplus_exportError": "Δεν ήταν δυνατή η εξαγωγή στο Excalidraw+ αυτή τη στιγμή..."
}, },
"helpDialog": { "helpDialog": {
"blog": "Διαβάστε το Blog μας", "blog": "Διαβάστε το Blog μας",
"click": "κλικ", "click": "κλικ",
"deepSelect": "", "deepSelect": "Βαθιά επιλογή",
"deepBoxSelect": "", "deepBoxSelect": "Βαθιά επιλογή μέσα στο πλαίσιο και αποτροπή συρσίματος",
"curvedArrow": "Κυρτό βέλος", "curvedArrow": "Κυρτό βέλος",
"curvedLine": "Κυρτή γραμμή", "curvedLine": "Κυρτή γραμμή",
"documentation": "Εγχειρίδιο", "documentation": "Εγχειρίδιο",
"doubleClick": "διπλό κλικ", "doubleClick": "διπλό κλικ",
"drag": "σύρε", "drag": "σύρε",
"editor": "Επεξεργαστής", "editor": "Επεξεργαστής",
"editSelectedShape": "", "editSelectedShape": "Επεξεργασία επιλεγμένου σχήματος (κείμενο/βέλος/γραμμή)",
"github": "Βρήκατε πρόβλημα; Υποβάλετε το", "github": "Βρήκατε πρόβλημα; Υποβάλετε το",
"howto": "Ακολουθήστε τους οδηγούς μας", "howto": "Ακολουθήστε τους οδηγούς μας",
"or": "ή", "or": "ή",
"preventBinding": "Αποτροπή δέσμευσης βέλων", "preventBinding": "Αποτροπή δέσμευσης βέλων",
"tools": "", "tools": "Εργαλεία",
"shortcuts": "Συντομεύσεις πληκτρολογίου", "shortcuts": "Συντομεύσεις πληκτρολογίου",
"textFinish": "Ολοκλήρωση επεξεργασίας (επεξεργαστής κειμένου)", "textFinish": "Ολοκλήρωση επεξεργασίας (επεξεργαστής κειμένου)",
"textNewLine": "Προσθήκη νέας γραμμής (επεξεργαστής κειμένου)", "textNewLine": "Προσθήκη νέας γραμμής (επεξεργαστής κειμένου)",
@@ -307,54 +306,54 @@
"view": "Προβολή", "view": "Προβολή",
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία", "zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
"zoomToSelection": "Ζουμ στην επιλογή", "zoomToSelection": "Ζουμ στην επιλογή",
"toggleElementLock": "" "toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Καθαρισμός καμβά" "title": "Καθαρισμός καμβά"
}, },
"publishDialog": { "publishDialog": {
"title": "", "title": "Δημοσίευση βιβλιοθήκης",
"itemName": "", "itemName": "Όνομα αντικειμένου",
"authorName": "Όνομα δημιουργού", "authorName": "Όνομα δημιουργού",
"githubUsername": "GitHub username", "githubUsername": "GitHub username",
"twitterUsername": "Twitter username", "twitterUsername": "Twitter username",
"libraryName": "Όνομα βιβλιοθήκης", "libraryName": "Όνομα βιβλιοθήκης",
"libraryDesc": "", "libraryDesc": "Περιγραφή βιβλιοθήκης",
"website": "Ιστοσελίδα", "website": "Ιστοσελίδα",
"placeholder": { "placeholder": {
"authorName": "", "authorName": "Όνομα ή όνομα χρήστη",
"libraryName": "", "libraryName": "Όνομα της βιβλιοθήκης σας",
"libraryDesc": "", "libraryDesc": "Περιγραφή της βιβλιοθήκης σας ώστε να βοηθήσει το κοινό να κατανοήσει τη χρήση της",
"githubHandle": "", "githubHandle": "Όνομα χρήστη στο GitHub (προαιρετικό), ώστε να μπορείτε να επεξεργαστείτε τη βιβλιοθήκη αφού υποβληθεί για αξιολόγηση",
"twitterHandle": "", "twitterHandle": "Όνομα χρήστη Twitter (προαιρετικό), ώστε να γνωρίζουμε σε ποιον/η να δώσουμε εύσημα κατά την προώθηση μέσω Twitter",
"website": "" "website": "Σύνδεσμος για την προσωπική σας ιστοσελίδα ή αλλού (προαιρετικό)"
}, },
"errors": { "errors": {
"required": "Απαιτείται", "required": "Απαιτείται",
"website": "Εισάγετε μια έγκυρη διεύθυνση URL" "website": "Εισάγετε μια έγκυρη διεύθυνση URL"
}, },
"noteDescription": { "noteDescription": {
"pre": "", "pre": "Υποβάλετε τη βιβλιοθήκη σας για να συμπεριληφθεί στο ",
"link": "", "link": "δημόσιο αποθετήριο βιβλιοθήκης",
"post": "" "post": "ώστε να χρησιμοποιηθεί από άλλα άτομα στα σχέδιά τους."
}, },
"noteGuidelines": { "noteGuidelines": {
"pre": "", "pre": "Η βιβλιοθήκη πρέπει πρώτα να εγκριθεί χειροκίνητα. Παρακαλώ διαβάστε τους ",
"link": "οδηγίες", "link": "οδηγίες",
"post": "" "post": " πριν την υποβολή. Θα χρειαστείτε έναν λογαριασμό GitHub για την επικοινωνία και για να προβείτε σε αλλαγές εφ' όσον χρειαστεί, αλλά δεν είναι αυστηρή απαίτηση."
}, },
"noteLicense": { "noteLicense": {
"pre": "", "pre": "Με την υποβολή, συμφωνείτε ότι η βιβλιοθήκη θα δημοσιευθεί υπό την ",
"link": "", "link": "Άδεια MIT, ",
"post": "" "post": "που εν συντομία σημαίνει ότι ο καθένας μπορεί να τα χρησιμοποιήσει χωρίς περιορισμούς."
}, },
"noteItems": "", "noteItems": "Κάθε αντικείμενο της βιβλιοθήκης πρέπει να έχει το δικό του όνομα ώστε να μπορεί να φιλτραριστεί. Θα συμπεριληφθούν τα ακόλουθα αντικείμενα βιβλιοθήκης:",
"atleastOneLibItem": "", "atleastOneLibItem": "Παρακαλώ επιλέξτε τουλάχιστον ένα αντικείμενο βιβλιοθήκης για να ξεκινήσετε",
"republishWarning": "" "republishWarning": "Σημείωση: μερικά από τα επιλεγμένα αντικέιμενα έχουν ήδη επισημανθεί ως δημοσιευμένα/υποβεβλημένα. Θα πρέπει να υποβάλετε αντικείμενα εκ νέου μόνο για να ενημερώσετε μία ήδη υπάρχουσα βιβλιοθήκη ή υποβολή."
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "", "title": "Η βιβλιοθήκη υποβλήθηκε",
"content": "", "content": "Ευχαριστούμε {{authorName}}. Η βιβλιοθήκη σας έχει υποβληθεί για αξιολόγηση. Μπορείτε να παρακολουθείτε τη διαδικασία",
"link": "εδώ" "link": "εδώ"
}, },
"confirmDialog": { "confirmDialog": {
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.", "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?", "resetLibrary": "This will clear your library. Are you sure?",
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?", "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."
"browserZoom": "Your browser's zoom level is not set to 100% which may cause the board to display incorrectly"
}, },
"errors": { "errors": {
"unsupportedFileType": "Unsupported file type.", "unsupportedFileType": "Unsupported file type.",
+2 -3
View File
@@ -124,7 +124,7 @@
"sidebarLock": "Mantener barra lateral abierta" "sidebarLock": "Mantener barra lateral abierta"
}, },
"library": { "library": {
"noItems": "", "noItems": "No hay elementos añadidos todavía...",
"hint_emptyLibrary": "Seleccione un elemento en el lienzo para añadirlo aquí, o instale una biblioteca del repositorio público, a continuación.", "hint_emptyLibrary": "Seleccione un elemento en el lienzo para añadirlo aquí, o instale una biblioteca del repositorio público, a continuación.",
"hint_emptyPrivateLibrary": "Seleccione un elemento del lienzo para añadirlo aquí." "hint_emptyPrivateLibrary": "Seleccione un elemento del lienzo para añadirlo aquí."
}, },
@@ -187,8 +187,7 @@
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.", "invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?", "resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
"removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?", "removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?",
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada.", "invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada."
"browserZoom": "El nivel de zoom de tu navegador no está configurado al 100%, lo que puede causar que el tablero se muestre de manera incorrecta"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo de archivo no admitido.", "unsupportedFileType": "Tipo de archivo no admitido.",
+5 -6
View File
@@ -121,12 +121,12 @@
"unlockAll": "Desblokeatu guztiak" "unlockAll": "Desblokeatu guztiak"
}, },
"statusPublished": "Argitaratua", "statusPublished": "Argitaratua",
"sidebarLock": "" "sidebarLock": "Mantendu alboko barra irekita"
}, },
"library": { "library": {
"noItems": "", "noItems": "Oraindik ez da elementurik gehitu...",
"hint_emptyLibrary": "", "hint_emptyLibrary": "Hautatu oihaleko elementu bat hemen gehitzeko, edo instalatu liburutegi bat beheko biltegi publikotik.",
"hint_emptyPrivateLibrary": "" "hint_emptyPrivateLibrary": "Hautatu oihaleko elementu bat hemen gehitzeko."
}, },
"buttons": { "buttons": {
"clearReset": "Garbitu oihala", "clearReset": "Garbitu oihala",
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.", "invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.",
"resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?", "resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?",
"removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?", "removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?",
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago.", "invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Onartu gabeko fitxategi mota.", "unsupportedFileType": "Onartu gabeko fitxategi mota.",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.", "invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?", "resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?", "removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.", "invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع فایل پشتیبانی نشده.", "unsupportedFileType": "نوع فایل پشتیبانی نشده.",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.", "invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?", "resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?", "removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä.", "invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Tiedostotyyppiä ei tueta.", "unsupportedFileType": "Tiedostotyyppiä ei tueta.",
+34 -35
View File
@@ -10,29 +10,29 @@
"copyAsPng": "Copier dans le presse-papier en PNG", "copyAsPng": "Copier dans le presse-papier en PNG",
"copyAsSvg": "Copier dans le presse-papier en SVG", "copyAsSvg": "Copier dans le presse-papier en SVG",
"copyText": "Copier dans le presse-papier en tant que texte", "copyText": "Copier dans le presse-papier en tant que texte",
"bringForward": "Envoyer vers l'avant", "bringForward": "Avancer d'un plan",
"sendToBack": "Mettre en arrière-plan", "sendToBack": "Déplacer à l'arrière-plan",
"bringToFront": "Mettre au premier plan", "bringToFront": "Placer au premier plan",
"sendBackward": "Envoyer vers l'arrière", "sendBackward": "Reculer d'un plan",
"delete": "Supprimer", "delete": "Supprimer",
"copyStyles": "Copier les styles", "copyStyles": "Copier les styles",
"pasteStyles": "Coller les styles", "pasteStyles": "Coller les styles",
"stroke": "Trait", "stroke": "Trait",
"background": "Arrière-plan", "background": "Fond",
"fill": "Remplissage", "fill": "Motif du fond",
"strokeWidth": "Largeur du trait", "strokeWidth": "Épaisseur du trait",
"strokeStyle": "Style du trait", "strokeStyle": "Style du trait",
"strokeStyle_solid": "Plein", "strokeStyle_solid": "Continu",
"strokeStyle_dashed": "Tirets", "strokeStyle_dashed": "Tirets",
"strokeStyle_dotted": "Pointillé", "strokeStyle_dotted": "Pointillés",
"sloppiness": "Style de tracé", "sloppiness": "Style de tracé",
"opacity": "Opacité", "opacity": "Opacité",
"textAlign": "Alignement du texte", "textAlign": "Alignement du texte",
"edges": "Angles", "edges": "Angles",
"sharp": "Pointus", "sharp": "Pointus",
"round": "Arrondis", "round": "Arrondis",
"arrowheads": "Extrémités de flèche", "arrowheads": "Extrémités",
"arrowhead_none": "Aucune", "arrowhead_none": "Sans",
"arrowhead_arrow": "Flèche", "arrowhead_arrow": "Flèche",
"arrowhead_bar": "Barre", "arrowhead_bar": "Barre",
"arrowhead_dot": "Point", "arrowhead_dot": "Point",
@@ -43,23 +43,23 @@
"withBackground": "Arrière-plan", "withBackground": "Arrière-plan",
"exportEmbedScene": "Intégrer la scène", "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é.", "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 \"Fait avec Excalidraw\"", "addWatermark": "Ajouter \"Réalisé avec Excalidraw\"",
"handDrawn": "À la main", "handDrawn": "À la main",
"normal": "Normale", "normal": "Normale",
"code": "Code", "code": "Code",
"small": "Petit", "small": "Petite",
"medium": "Moyen", "medium": "Moyenne",
"large": "Grand", "large": "Grande",
"veryLarge": "Très grand", "veryLarge": "Très grande",
"solid": "Solide", "solid": "Solide",
"hachure": "Hachure", "hachure": "Hachures",
"crossHatch": "Hachure croisée", "crossHatch": "Hachures croisées",
"thin": "Fin", "thin": "Fine",
"bold": "Épais", "bold": "Épaisse",
"left": "Gauche", "left": "À gauche",
"center": "Centre", "center": "Au centre",
"right": "Droite", "right": "À droite",
"extraBold": "Très épais", "extraBold": "Très épaisse",
"architect": "Architecte", "architect": "Architecte",
"artist": "Artiste", "artist": "Artiste",
"cartoonist": "Caricaturiste", "cartoonist": "Caricaturiste",
@@ -68,7 +68,7 @@
"canvasColors": "Utilisé sur la zone de dessin", "canvasColors": "Utilisé sur la zone de dessin",
"canvasBackground": "Arrière-plan du canevas", "canvasBackground": "Arrière-plan du canevas",
"drawingCanvas": "Zone de dessin", "drawingCanvas": "Zone de dessin",
"layers": "Calques", "layers": "Disposition",
"actions": "Actions", "actions": "Actions",
"language": "Langue", "language": "Langue",
"liveCollaboration": "Collaboration en direct", "liveCollaboration": "Collaboration en direct",
@@ -86,22 +86,22 @@
"libraryLoadingMessage": "Chargement de la bibliothèque…", "libraryLoadingMessage": "Chargement de la bibliothèque…",
"libraries": "Parcourir les bibliothèques", "libraries": "Parcourir les bibliothèques",
"loadingScene": "Chargement de la scène…", "loadingScene": "Chargement de la scène…",
"align": "Aligner", "align": "Alignement",
"alignTop": "Aligner en haut", "alignTop": "Aligner en haut",
"alignBottom": "Aligner en bas", "alignBottom": "Aligner en bas",
"alignLeft": "Aligner à gauche", "alignLeft": "Aligner à gauche",
"alignRight": "Aligner à droite", "alignRight": "Aligner à droite",
"centerVertically": "Centrer verticalement", "centerVertically": "Centrer verticalement",
"centerHorizontally": "Centrer horizontalement", "centerHorizontally": "Centrer horizontalement",
"distributeHorizontally": "Distribuer horizontalement", "distributeHorizontally": "Répartir horizontalement",
"distributeVertically": "Distribuer verticalement", "distributeVertically": "Répartir verticalement",
"flipHorizontal": "Retourner horizontalement", "flipHorizontal": "Retourner horizontalement",
"flipVertical": "Retourner verticalement", "flipVertical": "Retourner verticalement",
"viewMode": "Mode présentation", "viewMode": "Mode présentation",
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur", "toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur",
"share": "Partager", "share": "Partager",
"showStroke": "Afficher le sélecteur de couleur de trait", "showStroke": "Afficher le sélecteur de couleur de trait",
"showBackground": "Afficher le sélecteur de couleur d'arrière-plan", "showBackground": "Afficher le sélecteur de couleur de fond",
"toggleTheme": "Changer le thème", "toggleTheme": "Changer le thème",
"personalLib": "Bibliothèque personnelle", "personalLib": "Bibliothèque personnelle",
"excalidrawLib": "Bibliothèque Excalidraw", "excalidrawLib": "Bibliothèque Excalidraw",
@@ -170,9 +170,9 @@
"alerts": { "alerts": {
"clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?", "clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?",
"couldNotCreateShareableLink": "Impossible de créer un lien de partage.", "couldNotCreateShareableLink": "Impossible de créer un lien de partage.",
"couldNotCreateShareableLinkTooBig": "Impossible de créer un lien partageable : la scène est trop volumineuse", "couldNotCreateShareableLinkTooBig": "Impossible de créer un lien de partage : la scène est trop volumineuse",
"couldNotLoadInvalidFile": "Impossible de charger un fichier invalide", "couldNotLoadInvalidFile": "Impossible de charger un fichier invalide",
"importBackendFailed": "L'importation depuis le backend a échoué.", "importBackendFailed": "L'importation depuis le serveur a échoué.",
"cannotExportEmptyCanvas": "Impossible d'exporter un canevas vide.", "cannotExportEmptyCanvas": "Impossible d'exporter un canevas vide.",
"couldNotCopyToClipboard": "Impossible de copier dans le presse-papiers.", "couldNotCopyToClipboard": "Impossible de copier dans le presse-papiers.",
"decryptFailed": "Les données n'ont pas pu être déchiffrées.", "decryptFailed": "Les données n'ont pas pu être déchiffrées.",
@@ -187,8 +187,7 @@
"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.", "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 ?", "resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?", "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."
"browserZoom": "Le niveau de zoom de votre navigateur n'est pas défini sur 100 %, ce qui peut entraîner un affichage incorrect du tableau"
}, },
"errors": { "errors": {
"unsupportedFileType": "Type de fichier non supporté.", "unsupportedFileType": "Type de fichier non supporté.",
@@ -229,7 +228,7 @@
"text_editing": "Appuyez sur ÉCHAP ou Ctrl/Cmd+ENTRÉE pour terminer l'édition", "text_editing": "Appuyez sur ÉCHAP ou Ctrl/Cmd+ENTRÉE pour terminer l'édition",
"linearElementMulti": "Cliquez sur le dernier point ou appuyez sur Échap ou Entrée pour terminer", "linearElementMulti": "Cliquez sur le dernier point ou appuyez sur Échap ou Entrée pour terminer",
"lockAngle": "Vous pouvez restreindre l'angle en maintenant MAJ", "lockAngle": "Vous pouvez restreindre l'angle en maintenant MAJ",
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement,\nmaintenez la touche ALT pour redimensionner par rapport au centre", "resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement, maintenez la touche ALT pour redimensionner par rapport au centre",
"resizeImage": "Vous pouvez redimensionner librement en maintenant SHIFT,\nmaintenez ALT pour redimensionner depuis le centre", "resizeImage": "Vous pouvez redimensionner librement en maintenant SHIFT,\nmaintenez ALT pour redimensionner depuis le centre",
"rotate": "Vous pouvez restreindre les angles en maintenant MAJ pendant la rotation", "rotate": "Vous pouvez restreindre les angles en maintenant MAJ pendant la rotation",
"lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points", "lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points",
@@ -238,7 +237,7 @@
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement", "placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
"publishLibrary": "Publier votre propre bibliothèque", "publishLibrary": "Publier votre propre bibliothèque",
"bindTextToElement": "Appuyer sur Entrée pour ajouter du texte", "bindTextToElement": "Appuyer sur Entrée pour ajouter du texte",
"deepBoxSelect": "Maintenir CtrlOuCmd pour sélectionner dans les groupes, et empêcher le déplacement", "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"
}, },
"canvasError": { "canvasError": {
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "", "invalidEncryptionKey": ""
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.", "invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?", "resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
"removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?", "removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?",
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל.", "invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "סוג הקובץ אינו נתמך.", "unsupportedFileType": "סוג הקובץ אינו נתמך.",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "", "invalidEncryptionKey": ""
"browserZoom": "आपके ब्राउज़र का ज़ूम लेवल 100% नहीं हैं इस कारण दृष्य पटल ग़लत दिख सकता हैं"
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.", "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?", "resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?",
"removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?", "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."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Nem támogatott fájltípus.", "unsupportedFileType": "Nem támogatott fájltípus.",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.", "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?", "resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?", "removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan.", "invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
"browserZoom": "Pembesaran peramban Anda tidak 100% yang mana dapat menyebabkan layar tidak menampilkan dengan benar"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipe file tidak didukung.", "unsupportedFileType": "Tipe file tidak didukung.",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.", "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?", "resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
"removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?", "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."
"browserZoom": "Il livello di zoom del tuo browser non è impostato al 100%, il che potrebbe causare una visualizzazione scorretta della scheda"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo di file non supportato.", "unsupportedFileType": "Tipo di file non supportato.",
+5 -6
View File
@@ -121,12 +121,12 @@
"unlockAll": "すべてのロックを解除" "unlockAll": "すべてのロックを解除"
}, },
"statusPublished": "公開済み", "statusPublished": "公開済み",
"sidebarLock": "" "sidebarLock": "サイドバーを開いたままにする"
}, },
"library": { "library": {
"noItems": "", "noItems": "まだアイテムが追加されていません…",
"hint_emptyLibrary": "", "hint_emptyLibrary": "キャンバス上のアイテムを選択してここに追加するか、以下の公開リポジトリからライブラリをインストールしてください。",
"hint_emptyPrivateLibrary": "" "hint_emptyPrivateLibrary": "キャンバス上のアイテムを選択すると、ここに追加されます。"
}, },
"buttons": { "buttons": {
"clearReset": "キャンバスのリセット", "clearReset": "キャンバスのリセット",
@@ -187,8 +187,7 @@
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。", "invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
"resetLibrary": "ライブラリを消去します。本当によろしいですか?", "resetLibrary": "ライブラリを消去します。本当によろしいですか?",
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?", "removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。", "invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "サポートされていないファイル形式です。", "unsupportedFileType": "サポートされていないファイル形式です。",
+1 -2
View File
@@ -187,8 +187,7 @@
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.", "invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.",
"resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?", "resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?",
"removeItemsFromsLibrary": "Ad tekkseḍ {{count}} n uferdis (en) si temkarḍit?", "removeItemsFromsLibrary": "Ad tekkseḍ {{count}} n uferdis (en) si temkarḍit?",
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa.", "invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa."
"browserZoom": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.", "unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.",

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