Compare commits

...

25 Commits

Author SHA1 Message Date
dependabot[bot] 77732e2d4a build(deps): bump express from 4.18.1 to 4.21.0 in /dev-docs
Bumps [express](https://github.com/expressjs/express) from 4.18.1 to 4.21.0.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.1...4.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-12 10:57:51 +00:00
David Luzar 80f3b75d42 chore: revert vite 5.4.2 -> 5.0.12 (#8499) 2024-09-12 12:56:45 +02:00
hocino dc812bee19 docs: replace dead link (#8494)
* docs update dead link on main-menu page

* doc: fix dead link
2024-09-12 13:20:18 +05:30
David Luzar 01e83cc9a5 fix: default sidebar triggers & behavior (#8498) 2024-09-11 20:19:14 +02:00
David Luzar 813f9b702e feat: merge search sidebar back to default sidebar (#8497) 2024-09-11 19:26:01 +02:00
David Luzar fd39712ba6 fix: improve canvas search scroll behavior further (#8491) 2024-09-11 18:01:18 +02:00
zsviczian b46ca0192b fix: addFiles clears the whole image cache when each file is added - regression from #8471 (#8490)
Update App.tsx
2024-09-11 07:57:41 +02:00
David Luzar 72b7c937b1 feat: smarter zooming when scrolling to match & only match on search/switch (#8488) 2024-09-10 21:26:01 +02:00
David Luzar d107215564 fix: select instead of focus search input (#8483) 2024-09-09 19:57:22 +02:00
Ryan Di 6959a363f0 feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-09 17:12:07 +02:00
Abhishek Mehandiratta 5a11c70714 fix: image rendering issue when passed in initialData (#8471) 2024-09-08 23:56:00 +02:00
Aakansha Doshi 6ff56c36e3 fix: add partial mocking (#8473)
* fix: add partial mocking

* lint

* Update packages/utils/export.test.ts
2024-09-06 16:41:37 +05:30
Aakansha Doshi 51ea184938 build: upgrade vite to 5.4.x, vitest to 2.x and related vite packages (#8459)
* build: upgrade vite to 5.x, vitest to 2.x and related vite packages

* upgrade vitest-ui and coverage

* pass empty set to fix type error and update snap

* set ignoreEmptyLines to false

* update threshold

* update coverage threshold

* downgrade vite-plugin-pwa as its better to push separately with testing

* add package resolutions for strip-ansi, string-width and wrap-ansi

* disable pwa

* only add resolution for strip-ansi
2024-09-05 18:35:36 +05:30
dependabot[bot] 21fff26d31 build(deps): bump micromatch from 4.0.5 to 4.0.8 in /dev-docs (#8458)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 15:46:37 +05:30
Márk Tolmács f4dd23fc31 chore: Unify math types, utils and functions (#8389)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-02 22:23:38 +00:00
dependabot[bot] e3d1dee9d0 build(deps): bump micromatch from 4.0.7 to 4.0.8 (#8450)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 00:37:49 +02:00
hocino e3f31df747 docs : update dead link on main-menu page (#8454) 2024-09-01 22:31:13 +00:00
David Luzar 60e75406e0 refactor: remove unused env variable (#8457) 2024-09-02 00:25:12 +02:00
zsviczian b396e07b90 fix: PropertiesPopover maxWidth changing fixed units to relative units (#8456) 2024-09-01 18:18:37 +02:00
zsviczian 2d1d84a47b fix: View mode wheel zooming does not work (#8452)
* Update App.tsx

* fix: prevent zooming browser over DOM

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-31 12:06:38 +02:00
Marcel Mraz ee30225062 feat: subset font glyphs for SVG export (#8384)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-30 22:26:06 +02:00
David Luzar 16cae4fc07 feat: reset copyStatus on export dialog settings change (#8443) 2024-08-29 15:10:08 +02:00
David Luzar 576bc0dbe5 feat: tweak copy button success animation (#8441) 2024-08-29 00:43:01 +02:00
David Luzar 00af35c692 feat: enable panning/zoom while in wysiwyg (#8437) 2024-08-29 00:42:46 +02:00
Márk Tolmács ea7c702cfc feat: Visual debugger (#8344)
Add visual debugger to the Excalidraw app (only).
2024-08-27 19:46:00 +02:00
169 changed files with 12121 additions and 4394 deletions
-2
View File
@@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
+1
View File
@@ -8,3 +8,4 @@ public/workbox
packages/excalidraw/types
examples/**/public
dev-dist
coverage
@@ -133,7 +133,7 @@ function App() {
}
```
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
### MainMenu.Group
@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
getDimensions,<br/>&nbsp;
files,<br/>&nbsp;
exportPadding?: number;<br/>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
</pre>
| Name | Type | Default | Description |
+204 -88
View File
@@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "2.2.0"
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
"@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@@ -2720,21 +2720,21 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
body-parser@1.20.0:
version "1.20.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==
body-parser@1.20.3:
version "1.20.3"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6"
integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==
dependencies:
bytes "3.1.2"
content-type "~1.0.4"
content-type "~1.0.5"
debug "2.6.9"
depd "2.0.0"
destroy "1.2.0"
http-errors "2.0.0"
iconv-lite "0.4.24"
on-finished "2.4.1"
qs "6.10.3"
raw-body "2.5.1"
qs "6.13.0"
raw-body "2.5.2"
type-is "~1.6.18"
unpipe "1.0.0"
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
braces@^3.0.2, braces@~3.0.2:
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -2854,6 +2861,17 @@ call-bind@^1.0.0:
function-bind "^1.1.1"
get-intrinsic "^1.0.2"
call-bind@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==
dependencies:
es-define-property "^1.0.0"
es-errors "^1.3.0"
function-bind "^1.1.2"
get-intrinsic "^1.2.4"
set-function-length "^1.2.1"
callsites@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
@@ -3179,6 +3197,11 @@ content-type@~1.0.4:
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
content-type@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
convert-source-map@^1.7.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
@@ -3191,10 +3214,10 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
copy-text-to-clipboard@^3.0.1:
version "3.0.1"
@@ -3471,6 +3494,15 @@ defer-to-connect@^1.0.1:
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
define-data-property@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
dependencies:
es-define-property "^1.0.0"
es-errors "^1.3.0"
gopd "^1.0.1"
define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -3728,6 +3760,11 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
encodeurl@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
end-of-stream@^1.1.0:
version "1.4.4"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
@@ -3760,6 +3797,18 @@ error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
es-define-property@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845"
integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==
dependencies:
get-intrinsic "^1.2.4"
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-module-lexer@^0.9.0:
version "0.9.3"
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19"
@@ -3869,36 +3918,36 @@ execa@^5.0.0:
strip-final-newline "^2.0.0"
express@^4.17.3:
version "4.18.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf"
integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==
version "4.21.0"
resolved "https://registry.yarnpkg.com/express/-/express-4.21.0.tgz#d57cb706d49623d4ac27833f1cbc466b668eb915"
integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"
body-parser "1.20.0"
body-parser "1.20.3"
content-disposition "0.5.4"
content-type "~1.0.4"
cookie "0.5.0"
cookie "0.6.0"
cookie-signature "1.0.6"
debug "2.6.9"
depd "2.0.0"
encodeurl "~1.0.2"
encodeurl "~2.0.0"
escape-html "~1.0.3"
etag "~1.8.1"
finalhandler "1.2.0"
finalhandler "1.3.1"
fresh "0.5.2"
http-errors "2.0.0"
merge-descriptors "1.0.1"
merge-descriptors "1.0.3"
methods "~1.1.2"
on-finished "2.4.1"
parseurl "~1.3.3"
path-to-regexp "0.1.7"
path-to-regexp "0.1.10"
proxy-addr "~2.0.7"
qs "6.10.3"
qs "6.13.0"
range-parser "~1.2.1"
safe-buffer "5.2.1"
send "0.18.0"
serve-static "1.15.0"
send "0.19.0"
serve-static "1.16.2"
setprototypeof "1.2.0"
statuses "2.0.1"
type-is "~1.6.18"
@@ -4011,13 +4060,20 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"
finalhandler@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
finalhandler@1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019"
integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==
dependencies:
debug "2.6.9"
encodeurl "~1.0.2"
encodeurl "~2.0.0"
escape-html "~1.0.3"
on-finished "2.4.1"
parseurl "~1.3.3"
@@ -4142,6 +4198,11 @@ function-bind@^1.1.1:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@@ -4156,6 +4217,17 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1:
has "^1.0.3"
has-symbols "^1.0.3"
get-intrinsic@^1.1.3, get-intrinsic@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
has-proto "^1.0.1"
has-symbols "^1.0.3"
hasown "^2.0.0"
get-own-enumerable-property-symbols@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664"
@@ -4267,6 +4339,13 @@ globby@^13.1.1:
merge2 "^1.4.1"
slash "^4.0.0"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==
dependencies:
get-intrinsic "^1.1.3"
got@^9.6.0:
version "9.6.0"
resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
@@ -4328,6 +4407,18 @@ has-property-descriptors@^1.0.0:
dependencies:
get-intrinsic "^1.1.1"
has-property-descriptors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
dependencies:
es-define-property "^1.0.0"
has-proto@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd"
integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==
has-symbols@^1.0.1, has-symbols@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
@@ -4345,6 +4436,13 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
hasown@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
dependencies:
function-bind "^1.1.2"
hast-to-hyperscript@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz#9b67fd188e4c81e8ad66f803855334173920218d"
@@ -5186,10 +5284,10 @@ memfs@^3.1.2, memfs@^3.4.3:
dependencies:
fs-monkey "^1.0.3"
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==
merge-descriptors@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
merge-stream@^2.0.0:
version "2.0.0"
@@ -5207,11 +5305,11 @@ methods@~1.1.2:
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.2"
braces "^3.0.3"
picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@@ -5411,10 +5509,10 @@ object-assign@^4.1.0, object-assign@^4.1.1:
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-inspect@^1.9.0:
version "1.12.2"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
object-inspect@^1.13.1:
version "1.13.2"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff"
integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==
object-keys@^1.1.1:
version "1.1.1"
@@ -5656,10 +5754,10 @@ path-parse@^1.0.7:
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==
path-to-regexp@0.1.10:
version "0.1.10"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b"
integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==
path-to-regexp@2.2.1:
version "2.2.1"
@@ -6094,12 +6192,12 @@ pure-color@^1.2.0:
resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e"
integrity sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==
qs@6.10.3:
version "6.10.3"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e"
integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==
qs@6.13.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906"
integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==
dependencies:
side-channel "^1.0.4"
side-channel "^1.0.6"
queue-microtask@^1.2.2:
version "1.2.3"
@@ -6130,10 +6228,10 @@ range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
raw-body@2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
@@ -6190,14 +6288,13 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1"
text-table "^0.2.0"
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler "^0.20.2"
scheduler "^0.23.0"
react-error-overlay@^6.0.11:
version "6.0.11"
@@ -6260,6 +6357,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies:
"@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
dependencies:
"@types/react" "*"
prop-types "^15.6.2"
react-router-config@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@@ -6310,13 +6415,12 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
react@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1:
version "2.3.7"
@@ -6664,13 +6768,12 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
scheduler@^0.23.0:
version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0:
version "2.7.0"
@@ -6758,10 +6861,10 @@ semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
dependencies:
lru-cache "^6.0.0"
send@0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"
integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==
send@0.19.0:
version "0.19.0"
resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8"
integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==
dependencies:
debug "2.6.9"
depd "2.0.0"
@@ -6811,15 +6914,27 @@ serve-index@^1.9.1:
mime-types "~2.1.17"
parseurl "~1.3.2"
serve-static@1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540"
integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==
serve-static@1.16.2:
version "1.16.2"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296"
integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==
dependencies:
encodeurl "~1.0.2"
encodeurl "~2.0.0"
escape-html "~1.0.3"
parseurl "~1.3.3"
send "0.18.0"
send "0.19.0"
set-function-length@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
dependencies:
define-data-property "^1.1.4"
es-errors "^1.3.0"
function-bind "^1.1.2"
get-intrinsic "^1.2.4"
gopd "^1.0.1"
has-property-descriptors "^1.0.2"
setimmediate@^1.0.5:
version "1.0.5"
@@ -6874,14 +6989,15 @@ shelljs@^0.8.5:
interpret "^1.0.0"
rechoir "^0.6.2"
side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
side-channel@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
dependencies:
call-bind "^1.0.0"
get-intrinsic "^1.0.2"
object-inspect "^1.9.0"
call-bind "^1.0.7"
es-errors "^1.3.0"
get-intrinsic "^1.2.4"
object-inspect "^1.13.1"
signal-exit@^3.0.2, signal-exit@^3.0.3:
version "3.0.7"
+38 -1
View File
@@ -120,6 +120,11 @@ import {
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
polyfill();
@@ -337,6 +342,8 @@ const ExcalidrawWrapper = () => {
resolvablePromise<ExcalidrawInitialDataState | null>();
}
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW
@@ -362,6 +369,23 @@ const ExcalidrawWrapper = () => {
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
const [, forceRefresh] = useState(false);
useEffect(() => {
if (import.meta.env.DEV) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
window.visualDebug = {
data: [],
};
} else {
delete window.visualDebug;
}
forceRefresh((prev) => !prev);
}
}, [excalidrawAPI]);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
@@ -622,6 +646,11 @@ const ExcalidrawWrapper = () => {
}
});
}
// Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
@@ -820,6 +849,7 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@@ -845,7 +875,7 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger />
@@ -1077,6 +1107,13 @@ const ExcalidrawWrapper = () => {
},
]}
/>
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw>
</div>
);
+1
View File
@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
+23 -19
View File
@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(() => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
});
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
},
);
+21
View File
@@ -2,11 +2,13 @@ import React from "react";
import {
loginIcon,
ExcalLogo,
eyeIcon,
} from "../../packages/excalidraw/components/icons";
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
return (
<MainMenu>
@@ -28,6 +31,7 @@ export const AppMainMenu: React.FC<{
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{import.meta.env.DEV && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
} else {
window.visualDebug = { data: [] };
saveDebugState({ enabled: true });
}
props?.refresh();
}}
>
Visual Debug
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
+301
View File
@@ -0,0 +1,301 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "../../packages/excalidraw/renderer/helpers";
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
import {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "../../packages/math";
const renderLine = (
context: CanvasRenderingContext2D,
zoom: number,
segment: LineSegment<GlobalPoint>,
color: string,
) => {
context.save();
context.strokeStyle = color;
context.beginPath();
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
context.stroke();
context.restore();
};
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
context.beginPath();
context.moveTo(-10 * zoom, -10 * zoom);
context.lineTo(10 * zoom, 10 * zoom);
context.moveTo(10 * zoom, -10 * zoom);
context.lineTo(-10 * zoom, 10 * zoom);
context.stroke();
context.save();
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
appState: AppState,
) => {
frame.forEach((el: DebugElement) => {
switch (true) {
case isLineSegment(el.data):
renderLine(
context,
appState.zoom.value,
el.data as LineSegment<GlobalPoint>,
el.color,
);
break;
}
});
};
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
viewBackgroundColor: "transparent",
});
// Apply zoom
context.save();
context.translate(
appState.scrollX * appState.zoom.value,
appState.scrollY * appState.zoom.value,
);
renderOrigin(context, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
window.visualDebug?.data &&
window.visualDebug.data.length > 0
) {
// Render only one frame
const [idx] = debugFrameData();
render(window.visualDebug.data[idx], context, appState);
} else {
// Render all debug frames
window.visualDebug?.data.forEach((frame) => {
render(frame, context, appState);
});
}
if (window.visualDebug) {
window.visualDebug!.data =
window.visualDebug?.data.map((frame) =>
frame.filter((el) => el.permanent),
) ?? [];
}
};
const debugFrameData = (): [number, number] => {
const currentFrame = window.visualDebug?.currentFrame ?? 0;
const frameCount = window.visualDebug?.data.length ?? 0;
if (frameCount > 0) {
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
}
return [0, 0];
};
export const saveDebugState = (debug: { enabled: boolean }) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
JSON.stringify(debug),
);
} catch (error: any) {
console.error(error);
}
};
export const debugRenderer = throttleRAF(
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
_debugRenderer(canvas, appState, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
let debug;
try {
const savedDebugState = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
);
if (savedDebugState) {
debug = JSON.parse(savedDebugState) as { enabled: boolean };
}
} catch (error: any) {
console.error(error);
}
return debug ?? { enabled: false };
};
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1)
) {
window.visualDebug!.currentFrame = 0;
}
window.visualDebug!.currentFrame += 1;
onChange();
}, [onChange]);
const moveBackward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1) ||
window.visualDebug?.currentFrame < 1
) {
window.visualDebug!.currentFrame = 1;
}
window.visualDebug!.currentFrame -= 1;
onChange();
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
if (window.visualDebug) {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
onChange();
}, [onChange]);
return (
<>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={trashFrames}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{TrashIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={moveBackward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon flip />
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={reset}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{CloseIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-backward"
aria-label="Move backward"
type="button"
onClick={moveForward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon />
</div>
</button>
</>
);
};
interface DebugCanvasProps {
appState: AppState;
scale: number;
}
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;
+14 -1
View File
@@ -20,6 +20,10 @@ import {
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "../../packages/excalidraw/constants";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
appState: AppState,
) => {
try {
const _appState = clearAppStateForLocalStorage(appState);
if (
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
_appState.openSidebar = null;
}
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
-23
View File
@@ -128,29 +128,6 @@
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
+13 -2
View File
@@ -73,8 +73,8 @@ export default defineConfig({
},
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
@@ -108,6 +108,17 @@ export default defineConfig({
},
},
},
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
+9 -7
View File
@@ -6,6 +6,7 @@
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/excalidraw",
"examples/excalidraw/*"
],
@@ -20,8 +21,8 @@
"@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2",
"@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "2.0.5",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
@@ -36,12 +37,12 @@
"rewire": "6.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.6.1",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.6.0",
"vitest-canvas-mock": "0.3.2"
"vite-plugin-svgr": "4.2.0",
"vitest": "2.0.5",
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": "18.0.0 - 20.x.x"
@@ -82,6 +83,7 @@
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {
"@types/react": "18.2.0"
"@types/react": "18.2.0",
"strip-ansi": "6.0.1"
}
}
+58 -51
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import type { AppState } from "../types";
import type { AppState, Offsets } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
import { clamp } from "../math";
import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps =
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
return Math.min(adjustedZoomValue, 1);
};
export const zoomToFitBounds = ({
bounds,
appState,
canvasOffsets,
fitToViewport = false,
viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: {
bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
const canvasOffsetTop = canvasOffsets?.top ?? 0;
const canvasOffsetRight = canvasOffsets?.right ?? 0;
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
const effectiveCanvasWidth =
appState.width - canvasOffsetLeft - canvasOffsetRight;
const effectiveCanvasHeight =
appState.height - canvasOffsetTop - canvasOffsetBottom;
let adjustedZoomValue;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
adjustedZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * clamp(viewportZoomFactor, 0.1, 1);
newZoomValue = getNormalizedZoom(newZoomValue);
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
effectiveCanvasWidth / commonBoundsWidth,
effectiveCanvasHeight / commonBoundsHeight,
) * viewportZoomFactor;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds,
{
width: appState.width,
height: appState.height,
width: effectiveCanvasWidth,
height: effectiveCanvasHeight,
},
viewportZoomFactor,
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
const newZoomValue = getNormalizedZoom(
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
offsets: canvasOffsets,
zoom: { value: newZoomValue },
});
return {
appState: {
...appState,
scrollX,
scrollY,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue },
},
storeAction: StoreAction.NONE,
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
};
export const zoomToFit = ({
canvasOffsets,
targetElements,
appState,
fitToViewport,
viewportZoomFactor,
minZoom,
maxZoom,
}: {
canvasOffsets?: Offsets;
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({
canvasOffsets,
bounds: commonBounds,
appState,
fitToViewport,
viewportZoomFactor,
minZoom,
maxZoom,
});
};
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null,
},
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null,
},
fitToViewport: true,
viewportZoomFactor: 0.7,
canvasOffsets: app.getEditorUIOffsets(),
});
},
// NOTE this action should use shift-2 per figma, alas
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) =>
perform: (elements, appState, _, app) =>
zoomToFit({
targetElements: elements,
appState: {
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
userToFollow: null,
},
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
keyTest: (event) =>
event.code === CODES.ONE &&
@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
perform: (elements, appState, formData, app) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
if (!ret) {
return {
elements,
appState: newAppState,
storeAction: StoreAction.CAPTURE,
};
} catch {
return false;
}
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
};
}
return {
@@ -6,7 +6,6 @@ import { done } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
maybeBindLinearElement,
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
import { point } from "../../math";
import { isPathALoop } from "../shapes";
export const actionFinalize = register({
name: "finalize",
@@ -112,10 +113,10 @@ export const actionFinalize = register({
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
points: linePoints.map((point, index) =>
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? ([firstPoint[0], firstPoint[1]] as const)
: point,
? point(firstPoint[0], firstPoint[1])
: p,
),
});
}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@@ -115,6 +115,8 @@ import {
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { point, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -850,7 +852,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueGlyphs = new Set<string>();
let uniqueChars = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@@ -898,8 +900,8 @@ export const actionChangeFontFamily = register({
}
if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([
...uniqueGlyphs,
uniqueChars = new Set([
...uniqueChars,
...Array.from(newElement.originalText),
]);
}
@@ -919,12 +921,9 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const glyphs = Array.from(uniqueGlyphs.values()).join();
const chars = Array.from(uniqueChars.values()).join();
if (
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
@@ -936,8 +935,8 @@ export const actionChangeFontFamily = register({
);
}
} else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
@@ -1651,10 +1650,10 @@ export const actionChangeArrowType = register({
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
(p): LocalPoint =>
point(p[0] - newElement.x, p[1] - newElement.y),
),
[0, 0],
vector(0, 0),
{
...(startElement && newElement.startBinding
? {
@@ -0,0 +1,55 @@
import { KEYS } from "../keys";
import { register } from "./register";
import type { AppState } from "../types";
import { searchIcon } from "../components/icons";
import { StoreAction } from "../store";
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
export const actionToggleSearchMenu = register({
name: "searchMenu",
icon: searchIcon,
keywords: ["search", "find"],
label: "search.title",
viewMode: true,
trackEvent: {
category: "search_menu",
action: "toggle",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
const searchInput =
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
if (searchInput?.matches(":focus")) {
return {
appState: { ...appState, openSidebar: null },
storeAction: StoreAction.NONE,
};
}
searchInput?.focus();
searchInput?.select();
return false;
}
return {
appState: {
...appState,
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
openDialog: null,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});
+2
View File
@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
+3 -1
View File
@@ -51,7 +51,8 @@ export type ShortcutName =
>
| "saveScene"
| "imageExport"
| "commandPalette";
| "commandPalette"
| "searchMenu";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+4 -2
View File
@@ -137,7 +137,8 @@ export type ActionName =
| "wrapTextInContainer"
| "commandPalette"
| "autoResize"
| "elementStats";
| "elementStats"
| "searchMenu";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -191,7 +192,8 @@ export interface Action {
| "history"
| "menu"
| "collab"
| "hyperlink";
| "hyperlink"
| "search_menu";
action?: string;
predicate?: (
appState: Readonly<AppState>,
+1 -1
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
export const trackEvent = (
category: string,
+2
View File
@@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
searchMatches: [],
};
};
@@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <
+7 -17
View File
@@ -1,3 +1,5 @@
import type { Radians } from "../math";
import { point } from "../math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
@@ -203,7 +205,7 @@ const chartXLabels = (
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87,
angle: 5.87 as Radians,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
@@ -258,10 +260,7 @@ const chartLines = (
x,
y,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
points: [point(0, 0), point(chartWidth, 0)],
});
const yLine = newLinearElement({
@@ -272,10 +271,7 @@ const chartLines = (
x,
y,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
points: [point(0, 0), point(0, -chartHeight)],
});
const maxLine = newLinearElement({
@@ -288,10 +284,7 @@ const chartLines = (
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [
[0, 0],
[chartWidth, 0],
],
points: [point(0, 0), point(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
@@ -448,10 +441,7 @@ const chartTypeLine = (
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [
[0, 0],
[0, cy],
],
points: [point(0, 0), point(0, cy)],
});
});
+2 -1
View File
@@ -49,6 +49,7 @@ import {
} from "./icons";
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants";
export const canChangeStrokeColor = (
appState: UIAppState,
@@ -426,7 +427,7 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"];
zoom: Zoom;
}) => (
<Stack.Col gap={1} className="zoom-actions">
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
<Stack.Row align="center">
{renderAction("zoomOut")}
{renderAction("resetZoom")}
+177 -127
View File
@@ -210,12 +210,6 @@ import {
isElementCompletelyInViewport,
isElementInViewport,
} from "../element/sizeHelpers";
import {
distance2d,
getCornerRadius,
getGridPoint,
isPathALoop,
} from "../math";
import {
calculateScrollCenter,
getElementsWithinSelection,
@@ -230,7 +224,13 @@ import type {
ScrollBars,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
import {
findShapeByKey,
getBoundTextShape,
getCornerRadius,
getElementShape,
isPathALoop,
} from "../shapes";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import type {
@@ -259,6 +259,7 @@ import type {
ElementsPendingErasure,
GenerateDiagramToCode,
NullableGridSize,
Offsets,
} from "../types";
import {
debounce,
@@ -386,6 +387,7 @@ import {
getReferenceSnapPoints,
SnapCache,
isGridModeEnabled,
getGridPoint,
} from "../snapping";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
@@ -439,6 +441,9 @@ import {
FlowChartNavigator,
getLinkDirectionFromKey,
} from "../element/flowchart";
import { searchItemInFocusAtom } from "./SearchMenu";
import type { LocalPoint, Radians } from "../../math";
import { point, pointDistance, vector } from "../../math";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -545,6 +550,7 @@ class App extends React.Component<AppProps, AppState> {
public scene: Scene;
public fonts: Fonts;
public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[];
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
@@ -552,7 +558,7 @@ class App extends React.Component<AppProps, AppState> {
public id: string;
private store: Store;
private history: History;
private excalidrawContainerValue: {
public excalidrawContainerValue: {
container: HTMLDivElement | null;
id: string;
};
@@ -679,6 +685,7 @@ class App extends React.Component<AppProps, AppState> {
this.canvas = document.createElement("canvas");
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
this.visibleElements = [];
this.store = new Store();
this.history = new History();
@@ -1477,6 +1484,7 @@ class App extends React.Component<AppProps, AppState> {
newElementId: this.state.newElement?.id,
pendingImageElementId: this.state.pendingImageElementId,
});
this.visibleElements = visibleElements;
const allElementsMap = this.scene.getNonDeletedElementsMap();
@@ -2292,6 +2300,9 @@ class App extends React.Component<AppProps, AppState> {
storeAction: StoreAction.UPDATE,
});
// clear the shape and image cache so that any images in initialData
// can be loaded fresh
this.clearImageShapeCache();
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also
@@ -2357,6 +2368,16 @@ class App extends React.Component<AppProps, AppState> {
return false;
};
private clearImageShapeCache(filesMap?: BinaryFiles) {
const files = filesMap ?? this.files;
this.scene.getNonDeletedElements().forEach((element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
}
public async componentDidMount() {
this.unmounted = false;
this.excalidrawContainerValue.container =
@@ -2515,7 +2536,7 @@ class App extends React.Component<AppProps, AppState> {
addEventListener(
this.excalidrawContainerRef.current,
EVENT.WHEEL,
this.onWheel,
this.handleWheel,
{ passive: false },
),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
@@ -2578,6 +2599,11 @@ class App extends React.Component<AppProps, AppState> {
addEventListener(window, EVENT.RESIZE, this.onResize, false),
addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
addEventListener(window, EVENT.BLUR, this.onBlur, false),
addEventListener(
this.excalidrawContainerRef.current,
EVENT.WHEEL,
this.handleWheel,
),
addEventListener(
this.excalidrawContainerRef.current,
EVENT.DRAG_OVER,
@@ -3207,6 +3233,7 @@ class App extends React.Component<AppProps, AppState> {
if (opts.fitToContent) {
this.scrollToContent(newElements, {
fitToContent: true,
canvasOffsets: this.getEditorUIOffsets(),
});
}
};
@@ -3519,7 +3546,7 @@ class App extends React.Component<AppProps, AppState> {
target:
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?:
opts?: (
| {
fitToContent?: boolean;
fitToViewport?: never;
@@ -3536,7 +3563,12 @@ class App extends React.Component<AppProps, AppState> {
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
},
}
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
},
) => {
this.cancelInProgressAnimation?.();
@@ -3549,10 +3581,13 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
});
zoom = appState.zoom;
scrollX = appState.scrollX;
@@ -3666,15 +3701,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
this.scene.getNonDeletedElements().forEach((element) => {
if (
isInitializedImageElement(element) &&
filesMap.has(element.fileId)
) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
this.clearImageShapeCache(Object.fromEntries(filesMap));
this.scene.triggerUpdate();
this.addNewImagesToImageCache();
@@ -3788,40 +3815,42 @@ class App extends React.Component<AppProps, AppState> {
},
);
private getEditorUIOffsets = (): {
top: number;
right: number;
bottom: number;
left: number;
} => {
public getEditorUIOffsets = (): Offsets => {
const toolbarBottom =
this.excalidrawContainerRef?.current
?.querySelector(".App-toolbar")
?.getBoundingClientRect()?.bottom ?? 0;
const sidebarWidth = Math.max(
this.excalidrawContainerRef?.current
?.querySelector(".default-sidebar")
?.getBoundingClientRect()?.width ?? 0,
);
const propertiesPanelWidth = Math.max(
this.excalidrawContainerRef?.current
?.querySelector(".App-menu__left")
?.getBoundingClientRect()?.width ?? 0,
0,
);
const sidebarRect = this.excalidrawContainerRef?.current
?.querySelector(".sidebar")
?.getBoundingClientRect();
const propertiesPanelRect = this.excalidrawContainerRef?.current
?.querySelector(".App-menu__left")
?.getBoundingClientRect();
const PADDING = 16;
return getLanguage().rtl
? {
top: toolbarBottom,
right: propertiesPanelWidth,
bottom: 0,
left: sidebarWidth,
top: toolbarBottom + PADDING,
right:
Math.max(
this.state.width -
(propertiesPanelRect?.left ?? this.state.width),
0,
) + PADDING,
bottom: PADDING,
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
}
: {
top: toolbarBottom,
right: sidebarWidth,
bottom: 0,
left: propertiesPanelWidth,
top: toolbarBottom + PADDING,
right: Math.max(
this.state.width -
(sidebarRect?.left ?? this.state.width) +
PADDING,
0,
),
bottom: PADDING,
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
};
};
@@ -3906,7 +3935,7 @@ class App extends React.Component<AppProps, AppState> {
animate: true,
duration: 300,
fitToContent: true,
viewportZoomFactor: 0.8,
canvasOffsets: this.getEditorUIOffsets(),
});
}
@@ -3962,6 +3991,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(nextNode, {
animate: true,
duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
});
}
}
@@ -4318,13 +4348,6 @@ class App extends React.Component<AppProps, AppState> {
},
);
private onWheel = withBatchedUpdates((event: WheelEvent) => {
// prevent browser pinch zoom on DOM elements
if (!(event.target instanceof HTMLCanvasElement) && event.ctrlKey) {
event.preventDefault();
}
});
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (this.state.viewModeEnabled) {
@@ -4401,6 +4424,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(firstNode, {
animate: true,
duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
});
}
}
@@ -4846,7 +4870,7 @@ class App extends React.Component<AppProps, AppState> {
this.getElementHitThreshold(),
);
return isPointInShape([x, y], selectionShape);
return isPointInShape(point(x, y), selectionShape);
}
// take bound text element into consideration for hit collision as well
@@ -5037,7 +5061,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [],
lineHeight,
angle: container?.angle ?? 0,
angle: container?.angle ?? (0 as Radians),
frameId: topLayerFrame ? topLayerFrame.id : null,
});
@@ -5205,7 +5229,7 @@ class App extends React.Component<AppProps, AppState> {
element,
this.scene.getNonDeletedElementsMap(),
this.state,
[scenePointer.x, scenePointer.y],
point(scenePointer.x, scenePointer.y),
this.device.editor.isMobile,
)
);
@@ -5216,11 +5240,12 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
isTouchScreen: boolean,
) => {
const draggedDistance = distance2d(
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
const draggedDistance = pointDistance(
point(
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
),
point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
);
if (
!this.hitLinkElement ||
@@ -5239,7 +5264,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
elementsMap,
this.state,
[lastPointerDownCoords.x, lastPointerDownCoords.y],
point(lastPointerDownCoords.x, lastPointerDownCoords.y),
this.device.editor.isMobile,
);
const lastPointerUpCoords = viewportCoordsToSceneCoords(
@@ -5250,7 +5275,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
elementsMap,
this.state,
[lastPointerUpCoords.x, lastPointerUpCoords.y],
point(lastPointerUpCoords.x, lastPointerUpCoords.y),
this.device.editor.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
@@ -5499,17 +5524,18 @@ class App extends React.Component<AppProps, AppState> {
// if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point
if (
distance2d(
scenePointerX - rx,
scenePointerY - ry,
lastPoint[0],
lastPoint[1],
pointDistance(
point(scenePointerX - rx, scenePointerY - ry),
lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(
multiElement,
{
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
points: [
...points,
point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
],
},
false,
);
@@ -5521,11 +5547,9 @@ class App extends React.Component<AppProps, AppState> {
} else if (
points.length > 2 &&
lastCommittedPoint &&
distance2d(
scenePointerX - rx,
scenePointerY - ry,
lastCommittedPoint[0],
lastCommittedPoint[1],
pointDistance(
point(scenePointerX - rx, scenePointerY - ry),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@@ -5572,10 +5596,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
[
...points.slice(0, -1),
[
point<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
),
],
undefined,
undefined,
@@ -5591,10 +5615,10 @@ class App extends React.Component<AppProps, AppState> {
{
points: [
...points.slice(0, -1),
[
point<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
),
],
},
false,
@@ -5819,17 +5843,15 @@ class App extends React.Component<AppProps, AppState> {
}
};
const distance = distance2d(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
point(scenePointer.x, scenePointer.y),
);
const threshold = this.getElementHitThreshold();
const point = { ...pointerDownState.lastCoords };
const p = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y);
const hitElements = this.getElementsAtPosition(p.x, p.y);
processElements(hitElements);
// Exit since we reached current point
@@ -5841,12 +5863,10 @@ class App extends React.Component<AppProps, AppState> {
samplingInterval = Math.min(samplingInterval + threshold, distance);
const distanceRatio = samplingInterval / distance;
const nextX =
(1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
const nextY =
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
point.x = nextX;
point.y = nextY;
const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
p.x = nextX;
p.y = nextY;
}
pointerDownState.lastCoords.x = scenePointer.x;
@@ -5972,6 +5992,16 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) {
this.setState((state) => ({
searchMatches: state.searchMatches.map((searchMatch) => ({
...searchMatch,
focus: false,
})),
}));
jotaiStore.set(searchItemInFocusAtom, null);
}
// since contextMenu options are potentially evaluated on each render,
// and an contextMenu action may depend on selection state, we must
// close the contextMenu before we update the selection on pointerDown
@@ -6327,7 +6357,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
[scenePointer.x, scenePointer.y],
point(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
@@ -6384,8 +6414,8 @@ class App extends React.Component<AppProps, AppState> {
};
// Returns whether the event is a panning
private handleCanvasPanUsingWheelOrSpaceDrag = (
event: React.PointerEvent<HTMLElement>,
public handleCanvasPanUsingWheelOrSpaceDrag = (
event: React.PointerEvent<HTMLElement> | MouseEvent,
): boolean => {
if (
!(
@@ -6394,13 +6424,24 @@ class App extends React.Component<AppProps, AppState> {
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
isHandToolActive(this.state) ||
this.state.viewModeEnabled)
) ||
this.state.editingTextElement
)
) {
return false;
}
isPanning = true;
event.preventDefault();
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) {
// necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// as such, the above is broken when panning canvas while in wysiwyg
event.preventDefault();
}
let nextPastePrevented = false;
const isLinux =
@@ -7007,7 +7048,7 @@ class App extends React.Component<AppProps, AppState> {
simulatePressure,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [[0, 0]],
points: [point<LocalPoint>(0, 0)],
pressures: simulatePressure ? [] : [event.pressure],
});
@@ -7215,11 +7256,9 @@ class App extends React.Component<AppProps, AppState> {
if (
multiElement.points.length > 1 &&
lastCommittedPoint &&
distance2d(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
lastCommittedPoint[0],
lastCommittedPoint[1],
pointDistance(
point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
) {
this.actionManager.executeAction(actionFinalize);
@@ -7320,7 +7359,7 @@ class App extends React.Component<AppProps, AppState> {
};
});
mutateElement(element, {
points: [...element.points, [0, 0]],
points: [...element.points, point<LocalPoint>(0, 0)],
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
@@ -7572,11 +7611,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "line")
) {
if (
distance2d(
pointerCoords.x,
pointerCoords.y,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointDistance(
point(pointerCoords.x, pointerCoords.y),
point(pointerDownState.origin.x, pointerDownState.origin.y),
) < DRAGGING_THRESHOLD
) {
return;
@@ -7925,7 +7962,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(
newElement,
{
points: [...points, [dx, dy]],
points: [...points, point<LocalPoint>(dx, dy)],
pressures,
},
false,
@@ -7954,7 +7991,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(
newElement,
{
points: [...points, [dx, dy]],
points: [...points, point<LocalPoint>(dx, dy)],
},
false,
);
@@ -7962,8 +7999,8 @@ class App extends React.Component<AppProps, AppState> {
mutateElbowArrow(
newElement,
elementsMap,
[...points.slice(0, -1), [dx, dy]],
[0, 0],
[...points.slice(0, -1), point<LocalPoint>(dx, dy)],
vector(0, 0),
undefined,
{
isDragging: true,
@@ -7974,7 +8011,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(
newElement,
{
points: [...points.slice(0, -1), [dx, dy]],
points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
},
false,
);
@@ -8283,9 +8320,9 @@ class App extends React.Component<AppProps, AppState> {
: [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, {
points: [...points, [dx, dy]],
points: [...points, point<LocalPoint>(dx, dy)],
pressures,
lastCommittedPoint: [dx, dy],
lastCommittedPoint: point<LocalPoint>(dx, dy),
});
this.actionManager.executeAction(actionFinalize);
@@ -8332,7 +8369,10 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(newElement, {
points: [
...newElement.points,
[pointerCoords.x - newElement.x, pointerCoords.y - newElement.y],
point<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
});
this.setState({
@@ -8642,11 +8682,9 @@ class App extends React.Component<AppProps, AppState> {
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
this.eraserTrail.endPath();
const draggedDistance = distance2d(
pointerStart.clientX,
pointerStart.clientY,
pointerEnd.clientX,
pointerEnd.clientY,
const draggedDistance = pointDistance(
point(pointerStart.clientX, pointerStart.clientY),
point(pointerEnd.clientX, pointerEnd.clientY),
);
if (draggedDistance === 0) {
@@ -9472,7 +9510,6 @@ class App extends React.Component<AppProps, AppState> {
// NOTE wheel, touchstart, touchend events must be registered outside
// of react because react binds them them passively (so we can't prevent
// default on them)
this.interactiveCanvas.addEventListener(EVENT.WHEEL, this.handleWheel);
this.interactiveCanvas.addEventListener(
EVENT.TOUCH_START,
this.onTouchStart,
@@ -9480,10 +9517,6 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
// -----------------------------------------------------------------------
} else {
this.interactiveCanvas?.removeEventListener(
EVENT.WHEEL,
this.handleWheel,
);
this.interactiveCanvas?.removeEventListener(
EVENT.TOUCH_START,
this.onTouchStart,
@@ -10078,7 +10111,24 @@ class App extends React.Component<AppProps, AppState> {
(
event: WheelEvent | React.WheelEvent<HTMLDivElement | HTMLCanvasElement>,
) => {
// if not scrolling on canvas/wysiwyg, ignore
if (
!(
event.target instanceof HTMLCanvasElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLIFrameElement
)
) {
// prevent zooming the browser (but allow scrolling DOM)
if (event[KEYS.CTRL_OR_CMD]) {
event.preventDefault();
}
return;
}
event.preventDefault();
if (isPanning) {
return;
}
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "208px" }}
style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions";
import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types";
@@ -382,6 +386,15 @@ function CommandPaletteInner({
}
},
},
{
label: t("search.title"),
category: DEFAULT_CATEGORIES.app,
icon: searchIcon,
viewMode: true,
perform: () => {
actionManager.executeAction(actionToggleSearchMenu);
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],
@@ -1,8 +1,11 @@
import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import type { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar";
import "../components/dropdownMenu/DropdownMenu.scss";
import { SearchMenu } from "./SearchMenu";
import { LibraryIcon, searchIcon } from "./icons";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
{children}
</DefaultSidebarTabTriggersTunnel.In>
);
};
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
return (
<Sidebar
{...rest}
name="default"
key="default"
className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference}
docked={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null)
isForceDocked || onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
>
<Sidebar.Tabs>
<Sidebar.Header>
{rest.__fallback && (
<div
style={{
color: "var(--color-primary)",
fontSize: "1.2em",
fontWeight: "bold",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
{searchIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
{LibraryIcon}
</Sidebar.TabTrigger>
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.TabTriggers>
</Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>
@@ -1,5 +1,19 @@
@import "../css/variables.module.scss";
@keyframes successStatusAnimation {
0% {
transform: scale(0.35);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}
.excalidraw {
.ExcButton {
--text-color: transparent;
@@ -22,9 +36,10 @@
visibility: visible;
position: absolute;
width: 1rem;
height: 1rem;
font-size: 1rem;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
}
&.ExcButton--status-loading,
@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut
label={t("commandPalette.title")}
shortcuts={
@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping";
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
interface HintViewerProps {
appState: UIAppState;
@@ -30,6 +31,14 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.length
) {
return t("hints.dismissSearch");
}
if (appState.openSidebar && !device.editor.canFitSidebar) {
return null;
}
@@ -90,7 +90,20 @@ const ImageExportModal = ({
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus } = useCopyStatus();
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
useEffect(() => {
// if user changes setting right after export to clipboard, reset the status
// so they don't have to wait for the timeout to click the button again
resetCopyStatus();
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
resetCopyStatus,
]);
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
@@ -108,6 +121,7 @@ const ImageExportModal = ({
if (!maxWidth) {
return;
}
exportToCanvas({
elements: exportedElements,
appState: {
+4 -3
View File
@@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
@@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps {
actionManager: ActionManager;
appState: UIAppState;
@@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
exportBackground: true,
},
files: null,
skipInliningFonts: true,
});
node.innerHTML = svg.outerHTML;
})();
@@ -0,0 +1,110 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__search {
flex: 1 0 auto;
display: flex;
flex-direction: column;
padding: 8px 0 0 0;
}
.layer-ui__search-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.75rem;
.ExcTextField {
flex: 1 0 auto;
}
.ExcTextField__input {
background-color: #f5f5f9;
@at-root .excalidraw.theme--dark#{&} {
background-color: #31303b;
}
border-radius: var(--border-radius-md);
border: 0;
input::placeholder {
font-size: 0.9rem;
}
}
}
.layer-ui__search-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 8px 0 8px;
margin: 0 0.75rem 0.25rem 0.75rem;
font-size: 0.8em;
.result-nav {
display: flex;
.result-nav-btn {
width: 36px;
height: 36px;
--button-border: transparent;
&:active {
background-color: var(--color-surface-high);
}
&:first {
margin-right: 4px;
}
}
}
}
.layer-ui__search-result-container {
overflow-y: auto;
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.layer-ui__result-item {
display: flex;
align-items: center;
min-height: 2rem;
flex: 0 0 auto;
padding: 0.25rem 0.75rem;
cursor: pointer;
border: 1px solid transparent;
outline: none;
margin: 0 0.75rem;
border-radius: var(--border-radius-md);
.text-icon {
width: 1rem;
height: 1rem;
margin-right: 0.75rem;
}
.preview-text {
flex: 1;
max-height: 48px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
&:hover {
background-color: var(--color-surface-high);
}
&:active {
border-color: var(--color-primary);
}
&.active {
background-color: var(--color-surface-high);
}
}
}
@@ -0,0 +1,718 @@
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
import { TextField } from "./TextField";
import { Button } from "./Button";
import { useApp, useExcalidrawSetAppState } from "./App";
import { debounce } from "lodash";
import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
import { CLASSES, EVENT } from "../constants";
import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss";
import { round } from "../../math";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
const SEARCH_DEBOUNCE = 350;
type SearchMatchItem = {
textElement: ExcalidrawTextElement;
searchQuery: SearchQuery;
index: number;
preview: {
indexInSearchQuery: number;
previewText: string;
moreBefore: boolean;
moreAfter: boolean;
};
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
type SearchMatches = {
nonce: number | null;
items: SearchMatchItem[];
};
type SearchQuery = string & { _brand: "SearchQuery" };
export const SearchMenu = () => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
nonce: null,
items: [],
});
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
if (isSearching) {
return;
}
if (
searchQuery !== searchedQueryRef.current ||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
) {
searchedQueryRef.current = null;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
});
}
}, [
isSearching,
searchQuery,
elementsMap,
app,
setAppState,
setFocusIndex,
lastSceneNonceRef,
]);
const goToNextItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return (focusIndex + 1) % searchMatches.items.length;
});
}
};
const goToPreviousItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return focusIndex - 1 < 0
? searchMatches.items.length - 1
: focusIndex - 1;
});
}
};
useEffect(() => {
setAppState((state) => {
return {
searchMatches: state.searchMatches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
};
});
}, [focusIndex, setAppState]);
useEffect(() => {
if (searchMatches.items.length > 0 && focusIndex !== null) {
const match = searchMatches.items[focusIndex];
if (match) {
const zoomValue = app.state.zoom.value;
const matchAsElement = newTextElement({
text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height,
fontSize: match.textElement.fontSize,
fontFamily: match.textElement.fontFamily,
});
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
const fontSize = match.textElement.fontSize;
const isTextTiny =
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
if (
!isElementCompletelyInViewport(
[matchAsElement],
app.canvas.width / window.devicePixelRatio,
app.canvas.height / window.devicePixelRatio,
{
offsetLeft: app.state.offsetLeft,
offsetTop: app.state.offsetTop,
scrollX: app.state.scrollX,
scrollY: app.state.scrollY,
zoom: app.state.zoom,
},
app.scene.getNonDeletedElementsMap(),
app.getEditorUIOffsets(),
) ||
isTextTiny
) {
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
if (isTextTiny) {
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
zoomOptions = { fitToContent: true };
} else {
zoomOptions = {
fitToViewport: true,
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
};
}
} else {
zoomOptions = { fitToContent: true };
}
app.scrollToContent(matchAsElement, {
animate: true,
duration: 300,
...zoomOptions,
canvasOffsets: app.getEditorUIOffsets(),
});
}
}
}
}, [focusIndex, searchMatches, app]);
useEffect(() => {
return () => {
setFocusIndex(null);
searchedQueryRef.current = null;
lastSceneNonceRef.current = undefined;
setAppState({
searchMatches: [],
});
setIsSearching(false);
};
}, [setAppState, setFocusIndex]);
const stableState = useStable({
goToNextItem,
goToPreviousItem,
searchMatches,
});
useEffect(() => {
const eventHandler = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
!app.state.openDialog &&
!app.state.openPopup
) {
event.preventDefault();
event.stopPropagation();
setAppState({
openSidebar: null,
});
return;
}
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
event.preventDefault();
event.stopPropagation();
if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) {
setAppState({
openDialog: null,
});
}
searchInputRef.current?.focus();
searchInputRef.current?.select();
} else {
setAppState({
openSidebar: null,
});
}
}
if (
event.target instanceof HTMLElement &&
event.target.closest(".layer-ui__search")
) {
if (stableState.searchMatches.items.length) {
if (event.key === KEYS.ENTER) {
event.stopPropagation();
stableState.goToNextItem();
}
if (event.key === KEYS.ARROW_UP) {
event.stopPropagation();
stableState.goToPreviousItem();
} else if (event.key === KEYS.ARROW_DOWN) {
event.stopPropagation();
stableState.goToNextItem();
}
}
}
};
// `capture` needed to prevent firing on initial open from App.tsx,
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
});
}, [setAppState, stableState, app]);
const matchCount = `${searchMatches.items.length} ${
searchMatches.items.length === 1
? t("search.singleResult")
: t("search.multipleResults")
}`;
return (
<div className="layer-ui__search">
<div className="layer-ui__search-header">
<TextField
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
value={inputValue}
ref={searchInputRef}
placeholder={t("search.placeholder")}
icon={searchIcon}
onChange={(value) => {
setInputValue(value);
setIsSearching(true);
const searchQuery = value.trim() as SearchQuery;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
setFocusIndex(index);
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
setIsSearching(false);
});
}}
selectOnRender
/>
</div>
<div className="layer-ui__search-count">
{searchMatches.items.length > 0 && (
<>
{focusIndex !== null && focusIndex > -1 ? (
<div>
{focusIndex + 1} / {matchCount}
</div>
) : (
<div>{matchCount}</div>
)}
<div className="result-nav">
<Button
onSelect={() => {
goToNextItem();
}}
className="result-nav-btn"
>
{collapseDownIcon}
</Button>
<Button
onSelect={() => {
goToPreviousItem();
}}
className="result-nav-btn"
>
{upIcon}
</Button>
</div>
</>
)}
{searchMatches.items.length === 0 &&
searchQuery &&
searchedQueryRef.current && (
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
)}
</div>
<MatchList
matches={searchMatches}
onItemClick={setFocusIndex}
focusIndex={focusIndex}
searchQuery={searchQuery}
/>
</div>
);
};
const ListItem = (props: {
preview: SearchMatchItem["preview"];
searchQuery: SearchQuery;
highlighted: boolean;
onClick?: () => void;
}) => {
const preview = [
props.preview.moreBefore ? "..." : "",
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
props.preview.previewText.slice(
props.preview.indexInSearchQuery,
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.previewText.slice(
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.moreAfter ? "..." : "",
];
return (
<div
tabIndex={-1}
className={clsx("layer-ui__result-item", {
active: props.highlighted,
})}
onClick={props.onClick}
ref={(ref) => {
if (props.highlighted) {
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
}
}}
>
<div className="preview-text">
{preview.flatMap((text, idx) => (
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
))}
</div>
</div>
);
};
interface MatchListProps {
matches: SearchMatches;
onItemClick: (index: number) => void;
focusIndex: number | null;
searchQuery: SearchQuery;
}
const MatchListBase = (props: MatchListProps) => {
return (
<div className="layer-ui__search-result-container">
{props.matches.items.map((searchMatch, index) => (
<ListItem
key={searchMatch.textElement.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
</div>
);
};
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
return (
prevProps.matches.nonce === nextProps.matches.nonce &&
prevProps.focusIndex === nextProps.focusIndex
);
};
const MatchList = memo(MatchListBase, areEqual);
const getMatchPreview = (
text: string,
index: number,
searchQuery: SearchQuery,
) => {
const WORDS_BEFORE = 2;
const WORDS_AFTER = 5;
const substrBeforeQuery = text.slice(0, index);
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
// text = "small", query = "mall", not complete before
// text = "small", query = "smal", complete before
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
const startWordIndex =
wordsBeforeQuery.length -
WORDS_BEFORE -
1 -
(isQueryCompleteBefore ? 0 : 1);
let wordsBeforeAsString =
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
(isQueryCompleteBefore ? " " : "");
const MAX_ALLOWED_CHARS = 20;
wordsBeforeAsString =
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
: wordsBeforeAsString;
const substrAfterQuery = text.slice(index + searchQuery.length);
const wordsAfter = substrAfterQuery.split(/\s+/);
// text = "small", query = "mall", complete after
// text = "small", query = "smal", not complete after
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
const numberOfWordsToTake = isQueryCompleteAfter
? WORDS_AFTER + 1
: WORDS_AFTER;
const wordsAfterAsString =
(isQueryCompleteAfter ? "" : " ") +
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
return {
indexInSearchQuery: wordsBeforeAsString.length,
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
moreBefore: startWordIndex > 0,
moreAfter: wordsAfter.length > numberOfWordsToTake,
};
};
const normalizeWrappedText = (
wrappedText: string,
originalText: string,
): string => {
const wrappedLines = wrappedText.split("\n");
const normalizedLines: string[] = [];
let originalIndex = 0;
for (let i = 0; i < wrappedLines.length; i++) {
let currentLine = wrappedLines[i];
const nextLine = wrappedLines[i + 1];
if (nextLine) {
const nextLineIndexInOriginal = originalText.indexOf(
nextLine,
originalIndex,
);
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
while (j > 0) {
currentLine += " ";
j--;
}
}
}
normalizedLines.push(currentLine);
originalIndex = originalIndex + currentLine.length;
}
return normalizedLines.join("\n");
};
const getMatchedLines = (
textElement: ExcalidrawTextElement,
searchQuery: SearchQuery,
index: number,
) => {
const normalizedText = normalizeWrappedText(
textElement.text,
textElement.originalText,
);
const lines = normalizedText.split("\n");
const lineIndexRanges = [];
let currentIndex = 0;
let lineNumber = 0;
for (const line of lines) {
const startIndex = currentIndex;
const endIndex = startIndex + line.length - 1;
lineIndexRanges.push({
line,
startIndex,
endIndex,
lineNumber,
});
// Move to the next line's start index
currentIndex = endIndex + 1;
lineNumber++;
}
let startIndex = index;
let remainingQuery = textElement.originalText.slice(
index,
index + searchQuery.length,
);
const matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[] = [];
for (const lineIndexRange of lineIndexRanges) {
if (remainingQuery === "") {
break;
}
if (
startIndex >= lineIndexRange.startIndex &&
startIndex <= lineIndexRange.endIndex
) {
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
const textToStart = lineIndexRange.line.slice(
0,
startIndex - lineIndexRange.startIndex,
);
const matchedWord = remainingQuery.slice(0, matchCapacity);
remainingQuery = remainingQuery.slice(matchCapacity);
const offset = measureText(
textToStart,
getFontString(textElement),
textElement.lineHeight,
true,
);
// measureText returns a non-zero width for the empty string
// which is not what we're after here, hence the check and the correction
if (textToStart === "") {
offset.width = 0;
}
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
const lineLength = measureText(
lineIndexRange.line,
getFontString(textElement),
textElement.lineHeight,
true,
);
const spaceToStart =
textElement.textAlign === "center"
? (textElement.width - lineLength.width) / 2
: textElement.width - lineLength.width;
offset.width += spaceToStart;
}
const { width, height } = measureText(
matchedWord,
getFontString(textElement),
textElement.lineHeight,
);
const offsetX = offset.width;
const offsetY = lineIndexRange.lineNumber * offset.height;
matchedLines.push({
offsetX,
offsetY,
width,
height,
});
startIndex += matchCapacity;
}
}
return matchedLines;
};
const escapeSpecialCharacters = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
};
const handleSearch = debounce(
(
searchQuery: SearchQuery,
app: AppClassProperties,
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
) => {
if (!searchQuery || searchQuery === "") {
cb([], null);
return;
}
const elements = app.scene.getNonDeletedElements();
const texts = elements.filter((el) =>
isTextElement(el),
) as ExcalidrawTextElement[];
texts.sort((a, b) => a.y - b.y);
const matchItems: SearchMatchItem[] = [];
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
for (const textEl of texts) {
let match = null;
const text = textEl.originalText;
while ((match = regex.exec(text)) !== null) {
const preview = getMatchPreview(text, match.index, searchQuery);
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
if (matchedLines.length > 0) {
matchItems.push({
textElement: textEl,
searchQuery,
preview,
index: match.index,
matchedLines,
});
}
}
}
const visibleIds = new Set(
app.visibleElements.map((visibleElement) => visibleElement.id),
);
const focusIndex =
matchItems.findIndex((matchItem) =>
visibleIds.has(matchItem.textElement.id),
) ?? null;
cb(matchItems, focusIndex);
},
SEARCH_DEBOUNCE,
);
@@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface AngleProps {
element: ExcalidrawElement;
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
}
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
});
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
}
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
<DragInput
label="A"
icon={angleIcon}
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
const nextAngle = degreesToRadians(nextValue as Degrees);
for (const element of editableLatestIndividualElements) {
if (!element) {
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
}
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(
latestElement,
@@ -109,7 +110,7 @@ const MultiAngle = ({
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
const angles = editableLatestIndividualElements.map(
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
(el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
);
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
@@ -13,13 +13,14 @@ import type {
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types";
import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { point, type GlobalPoint } from "../../../math";
interface MultiDimensionProps {
property: "width" | "height";
@@ -104,7 +105,7 @@ const resizeGroup = (
nextHeight: number,
initialHeight: number,
aspectRatio: number,
anchor: Point,
anchor: GlobalPoint,
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
point(x1, y1),
property,
latestElements,
originalElements,
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
point(x1, y1),
property,
latestElements,
originalElements,
@@ -4,7 +4,6 @@ import type {
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
@@ -14,6 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math";
interface MultiPositionProps {
property: "x" | "y";
@@ -43,11 +43,9 @@ const moveElements = (
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
origElement.angle,
);
@@ -98,11 +96,9 @@ const moveGroupTo = (
latestElement.y + latestElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
latestElement.x,
latestElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(latestElement.x, latestElement.y),
point(cx, cy),
latestElement.angle,
);
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
origElement.angle,
);
@@ -246,7 +240,11 @@ const MultiPosition = ({
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
const [topLeftX, topLeftY] = pointRotateRads(
point(el.x, el.y),
point(cx, cy),
el.angle,
);
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}),
@@ -1,10 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math";
interface PositionProps {
property: "x" | "y";
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
origElement.angle,
);
@@ -94,11 +92,9 @@ const Position = ({
scene,
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = rotate(
element.x,
element.y,
element.x + element.width / 2,
element.y + element.height / 2,
const [topLeftX, topLeftY] = pointRotateRads(
point(element.x, element.y),
point(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =
@@ -19,12 +19,13 @@ import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../../element/types";
import { degreeToRadian, rotate } from "../../math";
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
import type { Degrees } from "../../../math";
import { degreesToRadians, point, pointRotateRads } from "../../../math";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -46,7 +47,9 @@ const testInputProperty = (
expect(input.value).toBe(initialValue.toString());
UI.updateInput(input, String(nextValue));
if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
expect(element[property]).toBe(
degreesToRadians(Number(nextValue) as Degrees),
);
} else if (property === "fontSize" && isTextElement(element)) {
expect(element[property]).toBe(Number(nextValue));
} else if (property !== "fontSize") {
@@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
@@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
let [newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
@@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
[newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
let [currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
[currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
@@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
UI.updateInput(angle, "40");
const angleInRadian = degreeToRadian(40);
const angleInRadian = degreesToRadians(40 as Degrees);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
expect(frame.angle).toBe(0);
+9 -12
View File
@@ -1,3 +1,5 @@
import type { Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
@@ -30,7 +32,6 @@ import {
getElementsInGroup,
isInGroup,
} from "../../groups";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
@@ -229,23 +230,19 @@ export const moveElement = (
originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
originalElement.x,
originalElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(originalElement.x, originalElement.y),
point(cx, cy),
originalElement.angle,
);
const changeInX = newTopLeftX - topLeftX;
const changeInY = newTopLeftY - topLeftY;
const [x, y] = rotate(
newTopLeftX,
newTopLeftY,
cx + changeInX,
cy + changeInY,
-originalElement.angle,
const [x, y] = pointRotateRads(
point(newTopLeftX, newTopLeftY),
point(cx + changeInX, cy + changeInY),
-originalElement.angle as Radians,
);
mutateElement(
@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import { isFiniteNumber } from "../../../math";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
+21 -7
View File
@@ -3,16 +3,29 @@
.excalidraw {
--ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent;
--ExcTextField--background: var(--color-surface-low);
--ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline);
--ExcTextField--border: var(--color-gray-20);
--ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField {
position: relative;
svg {
position: absolute;
top: 50%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
&--fullWidth {
width: 100%;
flex-grow: 1;
@@ -37,7 +50,6 @@
display: flex;
flex-direction: row;
align-items: center;
padding: 0 1rem;
height: 3rem;
@@ -45,6 +57,8 @@
border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem;
padding: 0 0.75rem;
&:not(&--readonly) {
&:hover {
border-color: var(--ExcTextField--border-hover);
@@ -80,10 +94,6 @@
width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) {
&:hover {
background-color: initial;
@@ -105,5 +115,9 @@
}
}
}
&--hasIcon .ExcTextField__input {
padding-left: 2.5rem;
}
}
}
+10 -2
View File
@@ -21,7 +21,9 @@ type TextFieldProps = {
fullWidth?: boolean;
selectOnRender?: boolean;
icon?: React.ReactNode;
label?: string;
className?: string;
placeholder?: string;
isRedacted?: boolean;
} & ({ value: string } | { defaultValue: string });
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
selectOnRender,
onKeyDown,
isRedacted = false,
icon,
className,
...rest
},
ref,
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
useLayoutEffect(() => {
if (selectOnRender) {
// focusing first is needed because vitest/jsdom
innerRef.current?.focus();
innerRef.current?.select();
}
}, [selectOnRender]);
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
return (
<div
className={clsx("ExcTextField", {
className={clsx("ExcTextField", className, {
"ExcTextField--fullWidth": fullWidth,
"ExcTextField--hasIcon": !!icon,
})}
onClick={() => {
innerRef.current?.focus();
}}
>
<div className="ExcTextField__label">{label}</div>
{icon}
{label && <div className="ExcTextField__label">{label}</div>}
<div
className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly,
@@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement,
searchMatches: appState.searchMatches,
});
const areEqual = (
@@ -1,4 +1,4 @@
import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
import {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
@@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
import { point, type GlobalPoint } from "../../../math";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@@ -176,10 +177,12 @@ export const Hyperlink = ({
if (timeoutId) {
clearTimeout(timeoutId);
}
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
event.clientX,
event.clientY,
]) as boolean;
const shouldHide = shouldHideLinkPopup(
element,
elementsMap,
appState,
point(event.clientX, event.clientY),
) as boolean;
if (shouldHide) {
timeoutId = window.setTimeout(() => {
setAppState({ showHyperlinkPopup: false });
@@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[clientX, clientY]: Point,
[clientX, clientY]: GlobalPoint,
): Boolean => {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX, clientY },
@@ -1,3 +1,5 @@
import type { GlobalPoint, Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -6,9 +8,8 @@ import type {
ElementsMap,
NonDeletedExcalidrawElement,
} from "../../element/types";
import { rotate } from "../../math";
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
import type { AppState, Point, UIAppState } from "../../types";
import type { AppState, UIAppState } from "../../types";
export const EXTERNAL_LINK_IMG = document.createElement("img");
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
@@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
angle: Radians,
appState: Pick<UIAppState, "zoom">,
): Bounds => {
const size = DEFAULT_LINK_SIZE;
@@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
const x = x2 + dashedLineMargin - centeringOffset;
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = rotate(
x + linkWidth / 2,
y + linkHeight / 2,
centerX,
centerY,
const [rotatedX, rotatedY] = pointRotateRads(
point(x + linkWidth / 2, y + linkHeight / 2),
point(centerX, centerY),
angle,
);
return [
@@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[x, y]: Point,
[x, y]: GlobalPoint,
) => {
const threshold = 4 / appState.zoom.value;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -73,7 +72,7 @@ export const isPointHittingLink = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[x, y]: Point,
[x, y]: GlobalPoint,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
@@ -86,5 +85,5 @@ export const isPointHittingLink = (
) {
return true;
}
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
};
+8
View File
@@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
</g>,
tablerIconProps,
);
export const upIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 15l6 -6l6 6" />
</g>,
tablerIconProps,
);
@@ -15,6 +15,7 @@ import {
LoadIcon,
MoonIcon,
save,
searchIcon,
SunIcon,
TrashIcon,
usersIcon,
@@ -27,6 +28,7 @@ import {
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleSearchMenu,
actionToggleTheme,
} from "../../actions";
import clsx from "clsx";
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
import { THEME } from "../../constants";
import type { Theme } from "../../element/types";
import { trackEvent } from "../../analytics";
import "./DefaultItems.scss";
export const LoadScene = () => {
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
};
CommandPalette.displayName = "CommandPalette";
export const SearchMenu = (opts?: { className?: string }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<DropdownMenuItem
icon={searchIcon}
data-testid="search-menu-button"
onSelect={() => {
actionManager.executeAction(actionToggleSearchMenu);
}}
shortcut={getShortcutFromShortcutName("searchMenu")}
aria-label={t("search.title")}
className={opts?.className}
>
{t("search.title")}
</DropdownMenuItem>
);
};
SearchMenu.displayName = "SearchMenu";
export const Help = () => {
const { t } = useI18n();
+3
View File
@@ -112,6 +112,8 @@ export const ENV = {
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
};
/**
@@ -375,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
};
export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
export const DEFAULT_SIDEBAR = {
name: "default",
+1 -1
View File
@@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left {
overflow-y: auto;
padding: 0.75rem;
width: 200px;
width: 12.5rem;
box-sizing: border-box;
position: absolute;
}
+3 -3
View File
@@ -144,9 +144,9 @@
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%);
--color-surface-mid: hsl(240 25% 96%);
--color-surface-low: hsl(240 25% 94%);
--color-surface-high: #f1f0ff;
--color-surface-mid: #f2f2f7;
--color-surface-low: #ececf4;
--color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0;
+9 -17
View File
@@ -40,11 +40,7 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import {
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -58,6 +54,8 @@ import {
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, point } from "../../math";
type RestoredAppState = Omit<
AppState,
@@ -152,7 +150,7 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
angle: element.angle || 0,
angle: element.angle || (0 as Radians),
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
@@ -266,10 +264,7 @@ const restoreElement = (
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
? [point(0, 0), point(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -293,14 +288,11 @@ const restoreElement = (
});
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
let x: number | undefined = element.x;
let y: number | undefined = element.y;
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
? [point(0, 0), point(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
+3 -5
View File
@@ -2,6 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types";
import { point } from "../../math";
const opts = { regenerateIds: false };
@@ -911,10 +912,7 @@ describe("Test Transform", () => {
x: 111.262,
y: 57,
strokeWidth: 2,
points: [
[0, 0],
[272.985, 0],
],
points: [point(0, 0), point(272.985, 0)],
label: {
text: "How are you?",
fontSize: 20,
@@ -937,7 +935,7 @@ describe("Test Transform", () => {
x: 77.017,
y: 79,
strokeWidth: 2,
points: [[0, 0]],
points: [point(0, 0)],
label: {
text: "Friendship",
fontSize: 20,
+4 -9
View File
@@ -53,6 +53,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
import { point, type LocalPoint } from "../../math";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -417,7 +418,7 @@ const bindLinearElementToElement = (
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON(linearElement.points) as [number, number][];
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
// left to right so shift the arrow towards right
if (
@@ -535,10 +536,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({
width,
height,
points: [
[0, 0],
[width, height],
],
points: [point(0, 0), point(width, height)],
...element,
});
@@ -551,10 +549,7 @@ export const convertToExcalidrawElements = (
width,
height,
endArrowhead: "arrow",
points: [
[0, 0],
[width, height],
],
points: [point(0, 0), point(width, height)],
...element,
type: "arrow",
});
+169 -141
View File
@@ -1,8 +1,8 @@
import * as GA from "../ga";
import * as GAPoint from "../gapoints";
import * as GADirection from "../gadirections";
import * as GALine from "../galines";
import * as GATransform from "../gatransforms";
import * as GA from "../../math/ga/ga";
import * as GAPoint from "../../math/ga/gapoints";
import * as GADirection from "../../math/ga/gadirections";
import * as GALine from "../../math/ga/galines";
import * as GATransform from "../../math/ga/gatransforms";
import type {
ExcalidrawBindableElement,
@@ -10,7 +10,6 @@ import type {
ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
@@ -26,11 +25,12 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
ExcalidrawRectanguloidElement,
} from "./types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import type { AppState, Point } from "../types";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import {
@@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
import {
aabbForElement,
clamp,
distanceSq2d,
getCenterForBounds,
getCenterForElement,
pointInsideBounds,
pointToVector,
rotatePoint,
} from "../math";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import {
compareHeading,
HEADING_DOWN,
@@ -72,7 +62,18 @@ import {
vectorToHeading,
type Heading,
} from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
import type { LocalPoint, Radians } from "../../math";
import {
lineSegment,
point,
pointRotateRads,
type GlobalPoint,
vectorFromPoint,
pointFromPair,
pointDistanceSq,
clamp,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -649,7 +650,7 @@ export const updateBoundElements = (
update,
): update is NonNullable<{
index: number;
point: Point;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
);
@@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = (
};
export const getHeadingForElbowArrowSnap = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: Point,
origPoint: GlobalPoint,
): Heading => {
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
if (!bindableElement || !aabb) {
return otherPointHeading;
@@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = (
if (!distance) {
return vectorToHeading(
pointToVector(point, getCenterForElement(bindableElement)),
vectorFromPoint(
p,
point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
),
),
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
};
const getDistanceForBinding = (
point: Readonly<Point>,
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) => {
@@ -745,89 +752,87 @@ const getDistanceForBinding = (
};
export const bindPointToSnapToElementOutline = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap,
): Point => {
): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) {
// TODO: Dirty hacks until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point);
const heading = headingForPointFromElement(bindableElement, aabb, p);
const intersections = [
...intersectElementWithLine(
...(intersectElementWithLine(
bindableElement,
[point[0], point[1] - 2 * bindableElement.height],
[point[0], point[1] + 2 * bindableElement.height],
point(p[0], p[1] - 2 * bindableElement.height),
point(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE,
elementsMap,
),
...intersectElementWithLine(
) ?? []),
...(intersectElementWithLine(
bindableElement,
[point[0] - 2 * bindableElement.width, point[1]],
[point[0] + 2 * bindableElement.width, point[1]],
point(p[0] - 2 * bindableElement.width, p[1]),
point(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE,
elementsMap,
),
) ?? []),
];
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap),
distanceToBindableElement(bindableElement, p, elementsMap),
);
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
intersections.sort(
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) =>
isVertical
? Math.abs(point[1] - i[1]) < 0.1
: Math.abs(point[0] - i[0]) < 0.1,
? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point;
}
return point;
return p;
};
const headingToMidBindPoint = (
point: Point,
p: GlobalPoint,
bindableElement: ExcalidrawBindableElement,
aabb: Bounds,
): Point => {
): GlobalPoint => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(pointToVector(point, center));
const heading = vectorToHeading(vectorFromPoint(p, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return rotatePoint(
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
return pointRotateRads(
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center,
bindableElement.angle,
);
default:
return rotatePoint(
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
return pointRotateRads(
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center,
bindableElement.angle,
);
@@ -836,22 +841,25 @@ const headingToMidBindPoint = (
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: Point,
): Point => {
const center = getCenterForElement(element);
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
p: GlobalPoint,
): GlobalPoint => {
const center = point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y],
return pointRotateRads<GlobalPoint>(
point(element.x - FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
}
return rotatePoint(
[element.x, element.y - FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
@@ -861,14 +869,14 @@ export const avoidRectangularCorner = (
) {
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
center,
element.angle,
);
}
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
return pointRotateRads(
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center,
element.angle,
);
@@ -881,20 +889,20 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[
return pointRotateRads(
point(
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
],
),
center,
element.angle,
);
}
return rotatePoint(
[
return pointRotateRads(
point(
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
],
),
center,
element.angle,
);
@@ -907,14 +915,14 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
}
return rotatePoint(
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
return pointRotateRads(
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
@@ -925,12 +933,12 @@ export const avoidRectangularCorner = (
export const snapToMid = (
element: ExcalidrawBindableElement,
p: Point,
p: GlobalPoint,
tolerance: number = 0.05,
): Point => {
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
const nonRotated = rotatePoint(p, center, -angle);
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
@@ -943,22 +951,30 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThrehsold
) {
// LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
return pointRotateRads(
point(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
return pointRotateRads(
point(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// RIGHT
return rotatePoint(
[x + width + FIXED_BINDING_DISTANCE, center[1]],
return pointRotateRads(
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
@@ -968,8 +984,8 @@ export const snapToMid = (
nonRotated[0] < center[0] + horizontalThrehsold
) {
// DOWN
return rotatePoint(
[center[0], y + height + FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(center[0], y + height + FIXED_BINDING_DISTANCE),
center,
angle,
);
@@ -984,7 +1000,7 @@ const updateBoundPoint = (
binding: PointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): Point | null => {
): LocalPoint | null => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
@@ -1006,15 +1022,15 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = [
const globalMidPoint = point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
] as Point;
const global = [
);
const global = point<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
] as Point;
const rotatedGlobal = rotatePoint(
);
const rotatedGlobal = pointRotateRads(
global,
globalMidPoint,
bindableElement.angle,
@@ -1040,7 +1056,7 @@ const updateBoundPoint = (
elementsMap,
);
let newEdgePoint: Point;
let newEdgePoint: GlobalPoint;
// The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point
@@ -1054,7 +1070,7 @@ const updateBoundPoint = (
binding.gap,
elementsMap,
);
if (intersections.length === 0) {
if (!intersections || intersections.length === 0) {
// This should never happen, since focusPoint should always be
// inside the element, but just in case, bail out
newEdgePoint = focusPointAbsolute;
@@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement,
elementsMap,
);
const globalMidPoint = [
const globalMidPoint = point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point;
const nonRotatedSnappedGlobalPoint = rotatePoint(
);
const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint,
globalMidPoint,
-hoveredElement.angle,
) as Point;
-hoveredElement.angle as Radians,
);
return {
fixedPoint: normalizeFixedPoint([
@@ -1320,8 +1336,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape([x, y], shape, threshold) ||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
isPointOnShape(point(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(point(x, y), aabbForElement(element)))
);
};
@@ -1339,7 +1356,7 @@ export const maxBindingGap = (
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
switch (element.type) {
@@ -1359,19 +1376,13 @@ export const distanceToBindableElement = (
};
const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
point: Point,
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element,
point,
p,
elementsMap,
);
return Math.max(
@@ -1382,7 +1393,7 @@ const distanceToRectangle = (
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@@ -1396,7 +1407,7 @@ const distanceToDiamond = (
const distanceToEllipse = (
element: ExcalidrawEllipseElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
@@ -1405,7 +1416,7 @@ const distanceToEllipse = (
const ellipseParamsForTest = (
element: ExcalidrawEllipseElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Line] => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@@ -1467,7 +1478,7 @@ const ellipseParamsForTest = (
// so we only need to perform hit tests for the positive quadrant.
const pointRelativeToElement = (
element: ExcalidrawElement,
pointTuple: Point,
pointTuple: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
@@ -1516,9 +1527,9 @@ const coordsCenter = (
const determineFocusDistance = (
element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates
a: Point,
a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element)
b: Point,
b: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1559,13 +1570,13 @@ const determineFocusPoint = (
// The oriented, relative distance from the center of `element` of the
// returned focusPoint
focus: number,
adjecentPoint: Point,
adjecentPoint: GlobalPoint,
elementsMap: ElementsMap,
): Point => {
): GlobalPoint => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const center = coordsCenter(x1, y1, x2, y2);
return GAPoint.toTuple(center);
return pointFromPair(GAPoint.toTuple(center));
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
const adjecentPointRel = GATransform.apply(
@@ -1589,7 +1600,9 @@ const determineFocusPoint = (
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
break;
}
return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
return pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
);
};
// Returns 2 or 0 intersection points between line going through `a` and `b`
@@ -1597,15 +1610,15 @@ const determineFocusPoint = (
const intersectElementWithLine = (
element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates
a: Point,
a: GlobalPoint,
// Another point on the line, in absolute coordinates
b: Point,
b: GlobalPoint,
// If given, the element is inflated by this value
gap: number = 0,
elementsMap: ElementsMap,
): Point[] => {
): GlobalPoint[] | undefined => {
if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap);
return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1619,8 +1632,14 @@ const intersectElementWithLine = (
aRel,
gap,
);
return intersections.map((point) =>
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
return intersections.map(
(point) =>
pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
),
// pointFromArray(
// ,
// ),
);
};
@@ -2173,12 +2192,18 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
) => {
): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element),
return pointRotateRads(
point(
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
),
element.angle,
);
};
@@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = (
const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
): [GlobalPoint, GlobalPoint] => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
@@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = (
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const startPoint: Point =
const startPoint =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
)
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
const endPoint: Point =
: point<GlobalPoint>(
arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1],
);
const endPoint =
endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
)
: [
: point<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
];
);
return [startPoint, endPoint];
};
+5 -3
View File
@@ -1,3 +1,5 @@
import type { LocalPoint } from "../../math";
import { point } from "../../math";
import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -123,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416,
}),
points: [
[0, 0] as [number, number],
[67.33984375, 92.48828125] as [number, number],
[-102.7890625, 52.15625] as [number, number],
point<LocalPoint>(0, 0),
point<LocalPoint>(67.33984375, 92.48828125),
point<LocalPoint>(-102.7890625, 52.15625),
],
} as ExcalidrawLinearElement;
+187 -109
View File
@@ -7,10 +7,10 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
} from "./types";
import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core";
import type { AppState, Point } from "../types";
import type { AppState } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
@@ -22,9 +22,24 @@ import {
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { arrayToMap } from "../utils";
import { arrayToMap, invariant } from "../utils";
import type {
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "../../math";
import {
degreesToRadians,
lineSegment,
point,
pointDistance,
pointFromArray,
pointRotateRads,
} from "../../math";
import type { Mutable } from "../utility-types";
export type RectangleBox = {
x: number;
@@ -97,7 +112,11 @@ export class ElementBounds {
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
pointRotateRads(
point(x, y),
point(cx - element.x, cy - element.y),
element.angle,
),
),
);
@@ -110,10 +129,26 @@ export class ElementBounds {
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const [x11, y11] = pointRotateRads(
point(cx, y1),
point(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(cx, y2),
point(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x1, cy),
point(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, cy),
point(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@@ -128,10 +163,26 @@ export class ElementBounds {
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const [x11, y11] = pointRotateRads(
point(x1, y1),
point(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(x1, y2),
point(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x2, y2),
point(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, y1),
point(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
? getContainerElement(element, elementsMap)
: null;
if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition(
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
return [
coords.x,
coords.y,
coords.x + element.width,
coords.y + element.height,
coords.x + element.width / 2,
coords.y + element.height / 2,
x,
y,
x + element.width,
y + element.height,
x + element.width / 2,
y + element.height / 2,
];
}
}
@@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): [Point, Point][] => {
): LineSegment<GlobalPoint>[] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center: Point = [cx, cy];
const center: GlobalPoint = point(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
const segments: LineSegment<GlobalPoint>[] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
segments.push(
lineSegment(
pointRotateRads(
point(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
point(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
);
i++;
}
@@ -246,40 +299,40 @@ export const getElementLineSegments = (
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
lineSegment(nw, ne),
lineSegment(sw, se),
lineSegment(nw, sw),
lineSegment(ne, se),
lineSegment(nw, e),
lineSegment(sw, e),
lineSegment(ne, w),
lineSegment(se, w),
];
};
@@ -386,10 +439,10 @@ const solveQuadratic = (
};
const getCubicBezierCurveBound = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
@@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => {
let currentP: Point = [0, 0];
let currentP: GlobalPoint = point(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => {
@@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
// move, bcurveTo, lineTo, and curveTo
if (op === "move") {
// change starting point
currentP = data as unknown as Point;
const p: GlobalPoint | undefined = pointFromArray(data);
invariant(p != null, "Op data is not a point");
currentP = p;
// move operation does not draw anything; so, it always
// returns false
} else if (op === "bcurveTo") {
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 _p1 = point<GlobalPoint>(data[0], data[1]);
const _p2 = point<GlobalPoint>(data[2], data[3]);
const _p3 = point<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(..._p1) : _p1;
const p2 = transformXY ? transformXY(..._p2) : _p2;
const p3 = transformXY ? transformXY(..._p3) : _p3;
const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(_p2) : _p2;
const p3 = transformXY ? transformXY(_p3) : _p3;
const p0 = transformXY ? transformXY(...currentP) : currentP;
const p0 = transformXY ? transformXY(currentP) : currentP;
currentP = _p3;
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
@@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
};
/** @returns number in degrees */
export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
switch (arrowhead) {
case "bar":
return 90;
return 90 as Degrees;
case "arrow":
return 20;
return 20 as Degrees;
default:
return 25;
return 25 as Degrees;
}
};
@@ -533,19 +588,24 @@ export const getArrowheadPoints = (
const index = position === "start" ? 1 : ops.length - 1;
const data = ops[index].data;
const p3 = [data[4], data[5]] as Point;
const p2 = [data[2], data[3]] as Point;
const p1 = [data[0], data[1]] as Point;
invariant(data.length === 6, "Op data length is not 6");
const p3 = point(data[4], data[5]);
const p2 = point(data[2], data[3]);
const p1 = point(data[0], data[1]);
// We need to find p0 of the bezier curve.
// It is typically the last point of the previous
// curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1];
let p0: Point = [0, 0];
let p0 = point(0, 0);
if (prevOp.op === "move") {
p0 = prevOp.data as unknown as Point;
const p = pointFromArray(prevOp.data);
invariant(p != null, "Op data is not a point");
p0 = p;
} else if (prevOp.op === "bcurveTo") {
p0 = [prevOp.data[4], prevOp.data[5]];
p0 = point(prevOp.data[4], prevOp.data[5]);
}
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@@ -610,8 +670,16 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
// Return points
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
const [x3, y3] = pointRotateRads(
point(xs, ys),
point(x2, y2),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
point(xs, ys),
point(x2, y2),
degreesToRadians(angle),
);
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point
@@ -621,12 +689,10 @@ export const getArrowheadPoints = (
if (position === "start") {
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = rotate(
x2 + minSize * 2,
y2,
x2,
y2,
Math.atan2(py - y2, px - x2),
[ox, oy] = pointRotateRads(
point(x2 + minSize * 2, y2),
point(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
);
} else {
const [px, py] =
@@ -634,12 +700,10 @@ export const getArrowheadPoints = (
? element.points[element.points.length - 2]
: [0, 0];
[ox, oy] = rotate(
x2 - minSize * 2,
y2,
x2,
y2,
Math.atan2(y2 - py, x2 - px),
[ox, oy] = pointRotateRads(
point(x2 - minSize * 2, y2),
point(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
);
}
@@ -665,7 +729,10 @@ const generateLinearElementShape = (
return "linearPath";
})();
return generator[method](element.points as Mutable<Point>[], options);
return generator[method](
element.points as Mutable<LocalPoint>[] as RoughPoint[],
options,
);
};
const getLinearElementRotatedBounds = (
@@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = rotate(
element.x + pointX,
element.y + pointY,
cx,
cy,
const [x, y] = pointRotateRads(
point(element.x + pointX, element.y + pointY),
point(cx, cy),
element.angle,
);
@@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>(
point(element.x + x, element.y + y),
point(cx, cy),
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
if (boundTextElement) {
@@ -861,7 +930,10 @@ export const getClosestElementBounds = (
const elementsMap = arrayToMap(elements);
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
const distance = pointDistance(
point((x1 + x2) / 2, (y1 + y2) / 2),
point(from.x, from.y),
);
if (distance < minDistance) {
minDistance = distance;
@@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
-scrollY + height / zoom.value,
];
};
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
+21 -19
View File
@@ -1,14 +1,11 @@
import { isPathALoop, isPointWithinBounds } from "../math";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawRectangleElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
import type { Polygon, GeometricShape } from "../../utils/geometry/shape";
import type { GeometricShape } from "../../utils/geometry/shape";
import { getPolygonShape } from "../../utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "../../utils/collision";
import { isTransparent } from "../utils";
@@ -18,7 +15,9 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape } from "../shapes";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, point } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs = {
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
element: ExcalidrawElement;
shape: GeometricShape;
shape: GeometricShape<Point>;
threshold?: number;
frameNameBound?: FrameNameBounds | null;
};
export const hitElementItself = ({
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
element,
shape,
threshold = 10,
frameNameBound = null,
}: HitTestArgs) => {
}: HitTestArgs<Point>) => {
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold)
: isPointOnShape([x, y], shape, threshold);
isPointInShape(point(x, y), shape) ||
isPointOnShape(point(x, y), shape, threshold)
: isPointOnShape(point(x, y), shape, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape([x, y], {
hit = isPointInShape(point(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon,
.data as Polygon<Point>,
});
}
@@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
};
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
export const hitElementBoundingBoxOnly = <
Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap,
) => {
return (
@@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
);
};
export const hitElementBoundText = (
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
textShape: GeometricShape | null,
textShape: GeometricShape<Point> | null,
): boolean => {
return !!textShape && isPointInShape([x, y], textShape);
return !!textShape && isPointInShape(point(x, y), textShape);
};
+1 -1
View File
@@ -11,7 +11,6 @@ import type {
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import {
isArrowElement,
@@ -21,6 +20,7 @@ import {
} from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
+8 -9
View File
@@ -10,7 +10,6 @@ import {
import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "../math";
import type {
ElementsMap,
ExcalidrawBindableElement,
@@ -20,7 +19,7 @@ import type {
OrderedExcalidrawElement,
} from "./types";
import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements, Point } from "../types";
import type { AppState, PendingExcalidrawElements } from "../types";
import { mutateElement } from "./mutateElement";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
import {
@@ -30,6 +29,8 @@ import {
isFlowchartNodeElement,
} from "./typeChecks";
import { invariant } from "../utils";
import { point, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left";
@@ -81,13 +82,14 @@ const getNodeRelatives = (
"not an ExcalidrawBindableElement",
);
const edgePoint: Point =
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
]);
] as Readonly<LocalPoint>);
acc.push({
relative,
@@ -419,10 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth,
points: [
[0, 0],
[endX, endY],
],
points: [point(0, 0), point(endX, endY)],
elbowed: true,
});
+78 -46
View File
@@ -1,12 +1,18 @@
import { lineAngle } from "../../utils/geometry/geometry";
import type { Point, Vector } from "../../utils/geometry/shape";
import type {
LocalPoint,
GlobalPoint,
Triangle,
Vector,
Radians,
} from "../../math";
import {
getCenterForBounds,
PointInTriangle,
rotatePoint,
scalePointFromOrigin,
} from "../math";
import type { Bounds } from "./bounds";
point,
pointRotateRads,
pointScaleFromOrigin,
radiansToDegrees,
triangleIncludesPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
@@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = (a: Point, b: Point) => {
const angle = lineAngle([a, b]);
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
a: Point,
b: Point,
) => {
const angle = radiansToDegrees(
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
@@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) =>
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = (
export const headingForPointFromElement = <
Point extends GlobalPoint | LocalPoint,
>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<Point>,
p: Readonly<LocalPoint | GlobalPoint>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
if (p[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
} else if (p[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
} else if (p[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
} else if (p[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y],
const top = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = rotatePoint(
scalePointFromOrigin(
[element.x + element.width, element.y + element.height / 2],
const right = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y + element.height],
const bottom = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y + element.height),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = rotatePoint(
scalePointFromOrigin(
[element.x, element.y + element.height / 2],
const left = pointRotateRads(
pointScaleFromOrigin(
point(element.x, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@@ -104,43 +117,62 @@ export const headingForPointFromElement = (
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
return headingForDiamond(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
} else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
} else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
const topLeft = pointScaleFromOrigin(
point(aabb[0], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
) as Point;
const topRight = pointScaleFromOrigin(
point(aabb[2], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
) as Point;
const bottomLeft = pointScaleFromOrigin(
point(aabb[0], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
) as Point;
const bottomRight = pointScaleFromOrigin(
point(aabb[2], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
) as Point;
return PointInTriangle(point, topLeft, topRight, midPoint)
return triangleIncludesPoint(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
: triangleIncludesPoint(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
: triangleIncludesPoint(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
? HEADING_DOWN
: HEADING_LEFT;
};
export const flipHeading = (h: Heading): Heading =>
[
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
] as Heading;
+200 -177
View File
@@ -11,19 +11,6 @@ import type {
FixedPointBinding,
SceneElementsMap,
} from "./types";
import {
distance2d,
rotate,
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
getControlPointsForBezierCurve,
getBezierXY,
getBezierCurveLength,
mapIntervalToBezierT,
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
import {
@@ -32,7 +19,6 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import type {
Point,
AppState,
PointerCoords,
InteractiveCanvasAppState,
@@ -46,7 +32,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { toBrandedType, tupleToCoors } from "../utils";
import { invariant, toBrandedType, tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
@@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
pointCenter,
point,
pointRotateRads,
pointsEqual,
vector,
type GlobalPoint,
type LocalPoint,
pointDistance,
} from "../../math";
import {
getBezierCurveLength,
getBezierXY,
getControlPointsForBezierCurve,
isPathALoop,
mapIntervalToBezierT,
} from "../shapes";
import { getGridPoint } from "../snapping";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
@@ -80,7 +85,7 @@ export class LinearElementEditor {
lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
value: GlobalPoint | null;
index: number | null;
added: boolean;
};
@@ -88,7 +93,7 @@ export class LinearElementEditor {
/** whether you're dragging a point */
public readonly isDragging: boolean;
public readonly lastUncommittedPoint: Point | null;
public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement:
| ExcalidrawBindableElement
@@ -96,13 +101,13 @@ export class LinearElementEditor {
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: Point | null;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!arePointsEqual(element.points[0], [0, 0])) {
if (!pointsEqual(element.points[0], point(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
}
@@ -280,7 +285,7 @@ export class LinearElementEditor {
element,
elementsMap,
referencePoint,
[scenePointerX, scenePointerY],
point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
@@ -289,7 +294,10 @@ export class LinearElementEditor {
[
{
index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]],
point: point(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
@@ -310,7 +318,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
@@ -319,10 +327,10 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
: ([
: point(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
);
return {
index: pointIndex,
point: newPointPosition,
@@ -515,7 +523,7 @@ export class LinearElementEditor {
);
let index = 0;
const midpoints: (Point | null)[] = [];
const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) {
if (
LinearElementEditor.isSegmentTooShort(
@@ -549,7 +557,7 @@ export class LinearElementEditor {
scenePointer: { x: number; y: number },
appState: AppState,
elementsMap: ElementsMap,
) => {
): GlobalPoint | null => {
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@@ -579,11 +587,12 @@ export class LinearElementEditor {
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
const distance = distance2d(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
),
point(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
@@ -594,11 +603,9 @@ export class LinearElementEditor {
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = distance2d(
midPoints[index]![0],
midPoints[index]![1],
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(midPoints[index]![0], midPoints[index]![1]),
point(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return midPoints[index];
@@ -612,15 +619,13 @@ export class LinearElementEditor {
static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
startPoint: GlobalPoint | LocalPoint,
endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"],
) {
let distance = distance2d(
startPoint[0],
startPoint[1],
endPoint[0],
endPoint[1],
let distance = pointDistance(
point(startPoint[0], startPoint[1]),
point(endPoint[0], endPoint[1]),
);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
@@ -631,12 +636,12 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
startPoint: GlobalPoint,
endPoint: GlobalPoint,
endPointIndex: number,
elementsMap: ElementsMap,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
): GlobalPoint {
let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve(
element,
@@ -649,16 +654,15 @@ export class LinearElementEditor {
0.5,
);
const [tx, ty] = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
[tx, ty],
getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap,
);
}
@@ -670,7 +674,7 @@ export class LinearElementEditor {
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
appState: AppState,
midPoint: Point,
midPoint: GlobalPoint,
elementsMap: ElementsMap,
) {
const element = LinearElementEditor.getElement(
@@ -822,11 +826,12 @@ export class LinearElementEditor {
const cy = (y1 + y2) / 2;
const targetPoint =
clickedPointIndex > -1 &&
rotate(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
cx,
cy,
pointRotateRads(
point(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
),
point(cx, cy),
element.angle,
);
@@ -865,14 +870,17 @@ export class LinearElementEditor {
return ret;
}
static arePointsEqual(point1: Point | null, point2: Point | null) {
static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
point1: Point | null,
point2: Point | null,
) {
if (!point1 && !point2) {
return true;
}
if (!point1 || !point2) {
return false;
}
return arePointsEqual(point1, point2);
return pointsEqual(point1, point2);
}
static handlePointerMove(
@@ -909,7 +917,7 @@ export class LinearElementEditor {
};
}
let newPoint: Point;
let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
@@ -918,14 +926,14 @@ export class LinearElementEditor {
element,
elementsMap,
lastCommittedPoint,
[scenePointerX, scenePointerY],
point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
newPoint = [
newPoint = point(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
];
);
} else {
newPoint = LinearElementEditor.createPointAt(
element,
@@ -965,30 +973,36 @@ export class LinearElementEditor {
/** scene coords */
static getPointGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
point: Point,
p: LocalPoint,
elementsMap: ElementsMap,
) {
): GlobalPoint {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
element.angle,
);
}
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
): Point[] {
): GlobalPoint[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return element.points.map((point) => {
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
return element.points.map((p) => {
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
element.angle,
);
});
}
@@ -997,7 +1011,7 @@ export class LinearElementEditor {
indexMaybeFromEnd: number, // -1 for last element
elementsMap: ElementsMap,
): Point {
): GlobalPoint {
const index =
indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd
@@ -1005,35 +1019,36 @@ export class LinearElementEditor {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const point = element.points[index];
const p = element.points[index];
const { x, y } = element;
return point
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
: rotate(x, y, cx, cy, element.angle);
return p
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
}
static pointFromAbsoluteCoords(
element: NonDeleted<ExcalidrawLinearElement>,
absoluteCoords: Point,
absoluteCoords: GlobalPoint,
elementsMap: ElementsMap,
): Point {
): LocalPoint {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
return point(
absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y,
);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [x, y] = rotate(
absoluteCoords[0],
absoluteCoords[1],
cx,
cy,
-element.angle,
const [x, y] = pointRotateRads(
point(absoluteCoords[0], absoluteCoords[1]),
point(cx, cy),
-element.angle as Radians,
);
return [x - element.x, y - element.y];
return point(x - element.x, y - element.y);
}
static getPointIndexUnderCursor(
@@ -1052,9 +1067,9 @@ export class LinearElementEditor {
// points on the left, thus should take precedence when clicking, if they
// overlap
while (--idx > -1) {
const point = pointHandles[idx];
const p = pointHandles[idx];
if (
distance2d(x, y, point[0], point[1]) * zoom.value <
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
@@ -1070,20 +1085,18 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
gridSize: NullableGridSize,
): Point {
): LocalPoint {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = rotate(
pointerOnGrid[0],
pointerOnGrid[1],
cx,
cy,
-element.angle,
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerOnGrid[0], pointerOnGrid[1]),
point(cx, cy),
-element.angle as Radians,
);
return [rotatedX - element.x, rotatedY - element.y];
return point(rotatedX - element.x, rotatedY - element.y);
}
/**
@@ -1091,15 +1104,19 @@ export class LinearElementEditor {
* expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization.
*/
static getNormalizedPoints(element: ExcalidrawLinearElement) {
static getNormalizedPoints(element: ExcalidrawLinearElement): {
points: LocalPoint[];
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
points: points.map((point) => {
return [point[0] - offsetX, point[1] - offsetY] as const;
points: points.map((p) => {
return point(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX,
y: element.y + offsetY,
@@ -1116,17 +1133,23 @@ export class LinearElementEditor {
static duplicateSelectedPoints(
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
if (!appState.editingLinearElement) {
return false;
}
): AppState {
invariant(
appState.editingLinearElement,
"Not currently editing a linear element",
);
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element || selectedPointsIndices === null) {
return false;
}
invariant(
element,
"The linear element does not exist in the provided Scene",
);
invariant(
selectedPointsIndices != null,
"There are no selected points to duplicate",
);
const { points } = element;
@@ -1134,9 +1157,9 @@ export class LinearElementEditor {
let pointAddedToEnd = false;
let indexCursor = -1;
const nextPoints = points.reduce((acc: Point[], point, index) => {
const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
++indexCursor;
acc.push(point);
acc.push(p);
const isSelected = selectedPointsIndices.includes(index);
if (isSelected) {
@@ -1147,8 +1170,8 @@ export class LinearElementEditor {
}
acc.push(
nextPoint
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
: [point[0], point[1]],
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: point(p[0], p[1]),
);
nextSelectedIndices.push(indexCursor + 1);
@@ -1169,7 +1192,7 @@ export class LinearElementEditor {
[
{
index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30],
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
},
],
elementsMap,
@@ -1177,12 +1200,10 @@ export class LinearElementEditor {
}
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
};
}
@@ -1209,10 +1230,10 @@ export class LinearElementEditor {
}
}
const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
@@ -1229,7 +1250,7 @@ export class LinearElementEditor {
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: Point }[],
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
const offsetX = 0;
@@ -1247,7 +1268,7 @@ export class LinearElementEditor {
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
@@ -1277,11 +1298,11 @@ export class LinearElementEditor {
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
}
const nextPoints = points.map((point, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx);
const nextPoints: LocalPoint[] = points.map((p, idx) => {
const selectedPointData = targetPoints.find((t) => t.index === idx);
if (selectedPointData) {
if (selectedPointData.index === 0) {
return point;
return p;
}
const deltaX =
@@ -1289,14 +1310,9 @@ export class LinearElementEditor {
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
return [
point[0] + deltaX - offsetX,
point[1] + deltaY - offsetY,
] as const;
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
});
LinearElementEditor._updatePoints(
@@ -1349,11 +1365,9 @@ export class LinearElementEditor {
}
const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d(
origin.x,
origin.y,
pointerCoords.x,
pointerCoords.y,
const dist = pointDistance(
point(origin.x, origin.y),
point(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
@@ -1418,7 +1432,7 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[],
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
@@ -1461,7 +1475,7 @@ export class LinearElementEditor {
element,
mergedElementsMap,
nextPoints,
[offsetX, offsetY],
vector(offsetX, offsetY),
bindings,
options,
);
@@ -1474,7 +1488,11 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
const rotated = pointRotateRads(
point(offsetX, offsetY),
point(dX, dY),
element.angle,
);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
@@ -1487,8 +1505,8 @@ export class LinearElementEditor {
private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
referencePoint: Point,
scenePointer: Point,
referencePoint: LocalPoint,
scenePointer: GlobalPoint,
gridSize: NullableGridSize,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
@@ -1517,7 +1535,11 @@ export class LinearElementEditor {
gridY,
);
return rotatePoint([width, height], [0, 0], -element.angle);
return pointRotateRads(
point(width, height),
point(0, 0),
-element.angle as Radians,
);
}
static getBoundTextElementPosition = (
@@ -1548,7 +1570,7 @@ export class LinearElementEditor {
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]);
midSegmentMidpoint = pointCenter(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
@@ -1585,37 +1607,38 @@ export class LinearElementEditor {
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = point(cx, cy);
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
const counterRotateBoundTextTopLeft = rotatePoint(
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
const topLeftRotatedPoint = pointRotateRads(
point(x1, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextTopRight = rotatePoint(
[boundTextX2, boundTextY1],
[cx, cy],
-element.angle,
const topRightRotatedPoint = pointRotateRads(
point(x2, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy],
-element.angle,
const counterRotateBoundTextTopLeft = pointRotateRads(
point(boundTextX1, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = rotatePoint(
[boundTextX2, boundTextY2],
[cx, cy],
-element.angle,
const counterRotateBoundTextTopRight = pointRotateRads(
point(boundTextX2, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomLeft = pointRotateRads(
point(boundTextX1, boundTextY2),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = pointRotateRads(
point(boundTextX2, boundTextY2),
centerPoint,
-element.angle as Radians,
);
if (
+2 -3
View File
@@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import type { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
@@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
let didChangePoints = false;
let index = prevPoints.length;
while (--index) {
const prevPoint: Point = prevPoints[index];
const nextPoint: Point = nextPoints[index];
const prevPoint = prevPoints[index];
const nextPoint = nextPoints[index];
if (
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]
@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math";
import { point } from "../../math";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
@@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
});
const copy = duplicateElement(null, new Map(), element);
+49 -3
View File
@@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
measureText,
@@ -48,6 +47,7 @@ import {
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
import type { Radians } from "../../math";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0,
height = 0,
angle = 0,
angle = 0 as Radians,
groupIds = [],
frameId = null,
index = null,
@@ -223,7 +223,6 @@ export const newTextElement = (
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
@@ -348,6 +347,53 @@ const getAdjustedDimensions = (
};
};
const adjustXYWithRotation = (
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
deltaX1: number,
deltaY1: number,
deltaX2: number,
deltaY2: number,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};
export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
+127 -87
View File
@@ -1,7 +1,5 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
@@ -38,7 +36,7 @@ import type {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import type { Point, PointerDownState } from "../types";
import type { PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
@@ -55,16 +53,15 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
return angle + 2 * Math.PI;
}
if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI;
}
return angle;
};
import type { GlobalPoint } from "../../math";
import {
pointCenter,
normalizeRadians,
point,
pointFromPair,
pointRotateRads,
type Radians,
} from "../../math";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@@ -158,16 +155,17 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: number;
let angle: Radians;
if (isFrameLikeElement(element)) {
angle = 0;
angle = 0 as Radians;
} else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
angle = ((5 * Math.PI) / 2 +
Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
}
angle = normalizeAngle(angle);
angle = normalizeRadians(angle as Radians);
}
const boundTextElementId = getBoundTextElementId(element);
@@ -240,12 +238,10 @@ const resizeSingleTextElement = (
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerX, pointerY),
point(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
@@ -279,20 +275,26 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = [x1, y1] as [number, number];
let newTopLeft = point<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
newTopLeft = point<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
];
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
newTopLeft = point<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
newTopLeft = point<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
}
if (["s", "n"].includes(transformHandleType)) {
@@ -308,13 +310,17 @@ const resizeSingleTextElement = (
}
const angle = element.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
const newCenter = point<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
@@ -334,14 +340,14 @@ const resizeSingleTextElement = (
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const startTopLeft = point<GlobalPoint>(x1, y1);
const startBottomRight = point<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
@@ -407,13 +413,21 @@ const resizeSingleTextElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
@@ -446,15 +460,15 @@ export const resizeSingleElement = (
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const startTopLeft = point(x1, y1);
const startBottomRight = point(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle,
-stateAtResizeStart.angle as Radians,
);
// Get bounds corners rendered on screen
@@ -628,13 +642,21 @@ export const resizeSingleElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
// 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
@@ -793,21 +815,21 @@ export const resizeMultipleElements = (
const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, Point> = {
ne: [minX, maxY],
se: [minX, minY],
sw: [maxX, minY],
nw: [maxX, maxY],
e: [minX, minY + height / 2],
w: [maxX, minY + height / 2],
n: [minX + width / 2, maxY],
s: [minX + width / 2, minY],
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
ne: point(minX, maxY),
se: point(minX, minY),
sw: point(maxX, minY),
nw: point(maxX, maxY),
e: point(minX, minY + height / 2),
w: point(maxX, minY + height / 2),
n: point(minX + width / 2, maxY),
s: point(minX + width / 2, minY),
};
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY]
const [anchorX, anchorY] = shouldResizeFromCenter
? point(midX, midY)
: anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -898,7 +920,9 @@ export const resizeMultipleElements = (
const width = orig.width * scaleX;
const height = orig.height * scaleY;
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
const angle = normalizeRadians(
(orig.angle * flipFactorX * flipFactorY) as Radians,
);
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
const offsetX = orig.x - anchorX;
@@ -1029,12 +1053,10 @@ const rotateMultipleElements = (
const cy = (y1 + y2) / 2;
const origAngle =
originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = rotate(
cx,
cy,
centerX,
centerY,
centerAngle + origAngle - element.angle,
const [rotatedCX, rotatedCY] = pointRotateRads(
point(cx, cy),
point(centerX, centerY),
(centerAngle + origAngle - element.angle) as Radians,
);
if (isArrowElement(element) && isElbowArrow(element)) {
@@ -1046,7 +1068,7 @@ const rotateMultipleElements = (
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
@@ -1063,7 +1085,7 @@ const rotateMultipleElements = (
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
@@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = (
: getCommonBounds(selectedElements);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
[x, y] = rotate(x, y, cx, cy, -angle);
const angle = (
selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians;
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
switch (transformHandleType) {
case "n":
return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
return pointRotateRads(
point(x - (x1 + x2) / 2, y - y1),
point(0, 0),
angle,
);
case "s":
return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
return pointRotateRads(
point(x - (x1 + x2) / 2, y - y2),
point(0, 0),
angle,
);
case "w":
return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
return pointRotateRads(
point(x - x1, y - (y1 + y2) / 2),
point(0, 0),
angle,
);
case "e":
return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
return pointRotateRads(
point(x - x2, y - (y1 + y2) / 2),
point(0, 0),
angle,
);
case "nw":
return rotate(x - x1, y - y1, 0, 0, angle);
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
case "ne":
return rotate(x - x2, y - y1, 0, 0, angle);
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
case "sw":
return rotate(x - x1, y - y2, 0, 0, angle);
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
case "se":
return rotate(x - x2, y - y2, 0, 0, angle);
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
default:
return [0, 0];
}
+36 -25
View File
@@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import {
angleToDegrees,
pointOnLine,
pointRotate,
} from "../../utils/geometry/geometry";
import type { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import {
point,
pointOnLineSegment,
pointRotateRads,
type Radians,
} from "../../math";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@@ -38,7 +39,7 @@ const isInsideTransformHandle = (
y >= transformHandle[1] &&
y <= transformHandle[1] + transformHandle[3];
export const resizeTest = (
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
@@ -91,15 +92,17 @@ export const resizeTest = (
if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(element.angle),
point(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING),
point(cx, cy),
element.angle,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([x, y], side as Line, SPACING)) {
if (
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
) {
return dir as TransformHandleType;
}
}
@@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
};
export const getTransformHandleTypeFromCoords = (
export const getTransformHandleTypeFromCoords = <
Point extends GlobalPoint | LocalPoint,
>(
[x1, y1, x2, y2]: Bounds,
scenePointerX: number,
scenePointerY: number,
@@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
0 as Radians,
zoom,
pointerType,
getOmitSidesForDevice(device),
@@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(0),
point(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING),
point(cx, cy),
0 as Radians,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
if (
pointOnLineSegment(
point(scenePointerX, scenePointerY),
side as LineSegment<Point>,
SPACING,
)
) {
return dir as TransformHandleType;
}
}
@@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
const getSelectionBorders = (
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
angleInDegrees: number,
angle: Radians,
) => {
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
const topRight = pointRotate([x2, y1], angleInDegrees, center);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
const topLeft = pointRotateRads(point(x1, y1), center, angle);
const topRight = pointRotateRads(point(x2, y1), center, angle);
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
return {
n: [topLeft, topRight],
+5 -10
View File
@@ -17,6 +17,7 @@ import type {
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
import { point } from "../../math";
const { h } = window;
@@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
[-45 - arrow.x, -100.1 - arrow.y],
[45 - arrow.x, 99.9 - arrow.y],
point(-45 - arrow.x, -100.1 - arrow.y),
point(45 - arrow.x, 99.9 - arrow.y),
]);
expect(arrow.points).toEqual([
[0, 0],
@@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1,
width: 90,
height: 200,
points: [
[0, 0],
[90, 200],
],
points: [point(0, 0), point(90, 200)],
}) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
@@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [
[0, 0],
[90, 200],
]);
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
expect(arrow.points).toEqual([
[0, 0],
+124 -75
View File
@@ -1,16 +1,19 @@
import { cross } from "../../utils/geometry/geometry";
import BinaryHeap from "../binaryheap";
import type { Radians } from "../../math";
import {
aabbForElement,
arePointsEqual,
pointInsideBounds,
pointToVector,
scalePointFromOrigin,
scaleVector,
translatePoint,
} from "../math";
point,
pointScaleFromOrigin,
pointTranslate,
vector,
vectorCross,
vectorFromPoint,
vectorScale,
type GlobalPoint,
type LocalPoint,
type Vector,
} from "../../math";
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points";
import type { Point } from "../types";
import { aabbForElement, pointInsideBounds } from "../shapes";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
@@ -25,6 +28,8 @@ import {
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import {
compareHeading,
flipHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
@@ -41,6 +46,8 @@ import type {
} from "./types";
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = {
f: number;
g: number;
@@ -48,8 +55,8 @@ type Node = {
closed: boolean;
visited: boolean;
parent: Node | null;
pos: Point;
addr: [number, number];
pos: GlobalPoint;
addr: GridAddress;
};
type Grid = {
@@ -63,8 +70,8 @@ const BASE_PADDING = 40;
export const mutateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly Point[],
offset?: Point,
nextPoints: readonly LocalPoint[],
offset?: Vector,
otherUpdates?: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
@@ -75,14 +82,20 @@ export const mutateElbowArrow = (
informMutation?: boolean;
},
) => {
const origStartGlobalPoint = translatePoint(nextPoints[0], [
arrow.x + (offset ? offset[0] : 0),
arrow.y + (offset ? offset[1] : 0),
]);
const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
arrow.x + (offset ? offset[0] : 0),
arrow.y + (offset ? offset[1] : 0),
]);
const origStartGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[0],
vector(arrow.x, arrow.y),
),
offset,
);
const origEndGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[nextPoints.length - 1],
vector(arrow.x, arrow.y),
),
offset,
);
const startElement =
arrow.startBinding &&
@@ -275,7 +288,10 @@ export const mutateElbowArrow = (
);
if (path) {
const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[];
const points = path.map((node) => [
node.pos[0],
node.pos[1],
]) as GlobalPoint[];
startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint);
@@ -284,7 +300,7 @@ export const mutateElbowArrow = (
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0,
angle: 0 as Radians,
},
options?.informMutation,
);
@@ -363,7 +379,7 @@ const astar = (
}
// Intersect
const neighborHalfPoint = scalePointFromOrigin(
const neighborHalfPoint = pointScaleFromOrigin(
neighbor.pos,
current.pos,
0.5,
@@ -380,17 +396,17 @@ const astar = (
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
const previousDirection = current.parent
? vectorToHeading(pointToVector(current.pos, current.parent.pos))
? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
: startHeading;
// Do not allow going in reverse
const reverseHeading = scaleVector(previousDirection, -1);
const reverseHeading = flipHeading(previousDirection);
const neighborIsReverseRoute =
arePointsEqual(reverseHeading, neighborHeading) ||
(arePointsEqual(start.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, startHeading)) ||
(arePointsEqual(end.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, endHeading));
compareHeading(reverseHeading, neighborHeading) ||
(gridAddressesEqual(start.addr, neighbor.addr) &&
compareHeading(neighborHeading, startHeading)) ||
(gridAddressesEqual(end.addr, neighbor.addr) &&
compareHeading(neighborHeading, endHeading));
if (neighborIsReverseRoute) {
continue;
}
@@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => {
return path;
};
const m_dist = (a: Point, b: Point) =>
const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
/**
@@ -541,7 +557,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY),
vector(a[0] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]],
@@ -557,7 +578,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2;
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY),
vector(a[2] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]],
@@ -573,7 +599,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2;
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY),
vector(a[0] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@@ -589,7 +620,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY),
vector(a[2] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@@ -615,9 +651,9 @@ const generateDynamicAABBs = (
*/
const calculateGrid = (
aabbs: Bounds[],
start: Point,
start: GlobalPoint,
startHeading: Heading,
end: Point,
end: GlobalPoint,
endHeading: Heading,
common: Bounds,
): Grid => {
@@ -662,8 +698,8 @@ const calculateGrid = (
closed: false,
visited: false,
parent: null,
addr: [col, row] as [number, number],
pos: [x, y] as Point,
addr: [col, row] as GridAddress,
pos: [x, y] as GlobalPoint,
}),
),
),
@@ -673,17 +709,17 @@ const calculateGrid = (
const getDonglePosition = (
bounds: Bounds,
heading: Heading,
point: Point,
): Point => {
p: GlobalPoint,
): GlobalPoint => {
switch (heading) {
case HEADING_UP:
return [point[0], bounds[1]];
return point(p[0], bounds[1]);
case HEADING_RIGHT:
return [bounds[2], point[1]];
return point(bounds[2], p[1]);
case HEADING_DOWN:
return [point[0], bounds[3]];
return point(p[0], bounds[3]);
}
return [bounds[0], point[1]];
return point(bounds[0], p[1]);
};
const estimateSegmentCount = (
@@ -826,7 +862,7 @@ const gridNodeFromAddr = (
/**
* Get node for global point on canvas (if exists)
*/
const pointToGridNode = (point: Point, grid: Grid): Node | null => {
const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
for (let col = 0; col < grid.col; col++) {
for (let row = 0; row < grid.row; row++) {
const candidate = gridNodeFromAddr([col, row], grid);
@@ -865,15 +901,24 @@ const getBindableElementForId = (
};
const normalizedArrowElementUpdate = (
global: Point[],
global: GlobalPoint[],
externalOffsetX?: number,
externalOffsetY?: number,
) => {
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
const points = global.map(
(point) => [point[0] - offsetX, point[1] - offsetY] as const,
const points = global.map((p) =>
pointTranslate<GlobalPoint, LocalPoint>(
p,
vectorScale(vectorFromPoint(global[0]), -1),
),
);
return {
@@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = (
};
/// If last and current segments have the same heading, skip the middle point
const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
points
.slice(2)
.reduce(
(result, point) =>
arePointsEqual(
(result, p) =>
compareHeading(
vectorToHeading(
pointToVector(result[result.length - 1], result[result.length - 2]),
vectorFromPoint(
result[result.length - 1],
result[result.length - 2],
),
),
vectorToHeading(pointToVector(point, result[result.length - 1])),
vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
)
? [...result.slice(0, -1), point]
: [...result, point],
? [...result.slice(0, -1), p]
: [...result, p],
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
);
@@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
initialPoint: Point,
otherPoint: Point,
initialPoint: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): Point => {
): GlobalPoint => {
if (isDragging) {
if (hoveredElement) {
const snapPoint = getSnapPoint(
@@ -956,36 +1004,34 @@ const getGlobalPoint = (
};
const getSnapPoint = (
point: Point,
otherPoint: Point,
p: GlobalPoint,
otherPoint: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) =>
bindPointToSnapToElementOutline(
isRectanguloidElement(element)
? avoidRectangularCorner(element, point)
: point,
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
otherPoint,
element,
elementsMap,
);
const getBindPointHeading = (
point: Point,
otherPoint: Point,
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: Point,
origPoint: GlobalPoint,
) =>
getHeadingForElbowArrowSnap(
point,
p,
otherPoint,
hoveredElement,
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(
distanceToBindableElement(hoveredElement, point, elementsMap),
distanceToBindableElement(hoveredElement, p, elementsMap),
) as [number, number, number, number],
),
elementsMap,
@@ -993,8 +1039,8 @@ const getBindPointHeading = (
);
const getHoveredElements = (
origStartGlobalPoint: Point,
origEndGlobalPoint: Point,
origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
// TODO: Might be a performance bottleneck and the Map type
@@ -1018,3 +1064,6 @@ const getHoveredElements = (
),
];
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
a[0] === b[0] && a[1] === b[1];
+2 -7
View File
@@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types";
import type { AppState, Offsets, Zoom } from "../types";
import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils";
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
scrollY: number;
},
elementsMap: ElementsMap,
padding?: Partial<{
top: number;
right: number;
bottom: number;
left: number;
}>,
padding?: Offsets,
) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords(
+4 -3
View File
@@ -284,16 +284,17 @@ export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => {
text = text
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height };
};
@@ -19,6 +19,7 @@ import type {
import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
import { point } from "../../math";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
type: "line",
width: 100,
height: 0,
points: [
[0, 0],
[100, 0],
],
points: [point(0, 0), point(100, 0)],
});
const textSize = 20;
const text = API.createElement({
+39 -14
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, isSafari } from "../constants";
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -38,7 +38,11 @@ import {
actionDecreaseFontSize,
actionIncreaseFontSize,
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import {
actionResetZoom,
actionZoomIn,
actionZoomOut,
} from "../actions/actionCanvas";
import type App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
@@ -379,6 +383,10 @@ export const textWysiwyg = ({
event.preventDefault();
app.actionManager.executeAction(actionZoomOut);
updateWysiwygStyle();
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionResetZoom);
updateWysiwygStyle();
} else if (actionDecreaseFontSize.keyTest(event)) {
app.actionManager.executeAction(actionDecreaseFontSize);
} else if (actionIncreaseFontSize.keyTest(event)) {
@@ -593,6 +601,7 @@ export const textWysiwyg = ({
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate();
unbindOnScroll();
editable.remove();
};
@@ -619,10 +628,29 @@ export const textWysiwyg = ({
});
};
const temporarilyDisableSubmit = () => {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const target = event?.target;
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
if (target instanceof HTMLTextAreaElement) {
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
temporarilyDisableSubmit();
return;
}
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
@@ -630,17 +658,14 @@ export const textWysiwyg = ({
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) ||
isPropertiesTrigger
) {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
temporarilyDisableSubmit();
} else if (
event.target instanceof HTMLElement &&
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do
@@ -659,7 +684,7 @@ export const textWysiwyg = ({
};
// handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest(
".properties-content",
@@ -669,6 +694,10 @@ export const textWysiwyg = ({
}
});
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
updateWysiwygStyle();
});
// ---------------------------------------------------------------------------
let isDestroyed = false;
@@ -699,10 +728,6 @@ export const textWysiwyg = ({
requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true });
});
window.addEventListener("wheel", stopEvent, {
passive: false,
capture: true,
});
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
@@ -7,7 +7,6 @@ import type {
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {
isElbowArrow,
@@ -19,6 +18,8 @@ import {
isAndroid,
isIOS,
} from "../constants";
import type { Radians } from "../../math";
import { point, pointRotateRads } from "../../math";
export type TransformHandleDirection =
| "n"
@@ -91,9 +92,13 @@ const generateTransformHandle = (
height: number,
cx: number,
cy: number,
angle: number,
angle: Radians,
): TransformHandle => {
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
const [xx, yy] = pointRotateRads(
point(x + width / 2, y + height / 2),
point(cx, cy),
angle,
);
return [xx - width / 2, yy - height / 2, width, height];
};
@@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number,
angle: Radians,
zoom: Zoom,
pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {},
+12 -1
View File
@@ -2,6 +2,7 @@ import { ROUNDNESS } from "../constants";
import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -189,7 +190,8 @@ export const isRectangularElement = (
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe")
element.type === "magicframe" ||
element.type === "freedraw")
);
};
@@ -322,3 +324,12 @@ export const isFixedPointBinding = (
): binding is FixedPointBinding => {
return binding.fixedPoint != null;
};
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
+15 -6
View File
@@ -1,4 +1,4 @@
import type { Point } from "../types";
import type { LocalPoint, Radians } from "../../math";
import type {
FONT_FAMILY,
ROUNDNESS,
@@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
opacity: number;
width: number;
height: number;
angle: number;
angle: Radians;
/** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */
seed: number;
@@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawRectanguloidElement =
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
@@ -283,8 +292,8 @@ export type Arrowhead =
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
points: readonly Point[];
lastCommittedPoint: Point | null;
points: readonly LocalPoint[];
lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null;
@@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge<
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly Point[];
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: Point | null;
lastCommittedPoint: LocalPoint | null;
}>;
export type FileId = string & { _brand: "FileId" };
+71 -17
View File
@@ -1,12 +1,14 @@
import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader";
export interface Font {
urls: URL[];
fontFace: FontFace;
getContent(): Promise<string>;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
@@ -32,21 +34,32 @@ export class ExcalidrawFont implements Font {
}
/**
* Tries to fetch woff2 content, based on the registered urls.
* Returns last defined url in case of errors.
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
* NOTE: assumes usage of `dataurl` outside the browser environment
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/
public async getContent(): Promise<string> {
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
// it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch
return url.toString();
const arrayBuffer = Buffer.from(
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try {
@@ -57,13 +70,13 @@ export class ExcalidrawFont implements Font {
});
if (response.ok) {
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
return base64;
}
// response not ok, try to continue
@@ -89,6 +102,48 @@ export class ExcalidrawFont implements Font {
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
/**
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
*
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs
*
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
try {
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
const { compress, decompress } = await loadWoff2();
const { subset } = await loadHbSubset();
const decompressedBinary = decompress(arrayBuffer).buffer;
const subsetSnft = subset(decompressedBinary, codePoints);
const compressedBinary = compress(subsetSnft.buffer);
return ExcalidrawFont.toBase64(compressedBinary.buffer);
} catch (e) {
console.error("Skipped glyph subsetting", e);
// Fallback to encoding whole font in case of errors
return ExcalidrawFont.toBase64(arrayBuffer);
}
}
private static async toBase64(arrayBuffer: ArrayBuffer) {
let base64: string;
if (typeof Buffer !== "undefined") {
// node + server-side
base64 = Buffer.from(arrayBuffer).toString("base64");
} else {
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
}
return `data:font/woff2;base64,${base64}`;
}
private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
@@ -118,15 +173,14 @@ export class ExcalidrawFont implements Font {
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
return urls;
}
private static getFormat(url: URL) {
try {
const pathname = new URL(url).pathname;
const parts = pathname.split(".");
const parts = new URL(url).pathname.split(".");
if (parts.length === 1) {
return "";
@@ -0,0 +1,202 @@
/**
* Modified version of hb-subset bindings from "subset-font" package https://github.com/papandreou/subset-font/blob/3f711c8aa29a426c7f22655861abfb976950f527/index.js
*
* CHANGELOG:
* - removed dependency on node APIs to work inside the browser
* - removed dependency on font fontverter for brotli compression
* - removed dependencies on lodash and p-limit
* - removed options for preserveNameIds, variationAxes, noLayoutClosure (not needed for now)
* - replaced text input with codepoints
* - rewritten in typescript and with esm modules
Copyright (c) 2012, Andreas Lind Petersen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the author nor the names of contributors may
be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// function HB_TAG(str) {
// return str.split("").reduce((a, ch) => {
// return (a << 8) + ch.charCodeAt(0);
// }, 0);
// }
function subset(
hbSubsetWasm: any,
heapu8: Uint8Array,
font: ArrayBuffer,
codePoints: ReadonlySet<number>,
) {
const input = hbSubsetWasm.hb_subset_input_create_or_fail();
if (input === 0) {
throw new Error(
"hb_subset_input_create_or_fail (harfbuzz) returned zero, indicating failure",
);
}
const fontBuffer = hbSubsetWasm.malloc(font.byteLength);
heapu8.set(new Uint8Array(font), fontBuffer);
// Create the face
const blob = hbSubsetWasm.hb_blob_create(
fontBuffer,
font.byteLength,
2, // HB_MEMORY_MODE_WRITABLE
0,
0,
);
const face = hbSubsetWasm.hb_face_create(blob, 0);
hbSubsetWasm.hb_blob_destroy(blob);
// Do the equivalent of --font-features=*
const layoutFeatures = hbSubsetWasm.hb_subset_input_set(
input,
6, // HB_SUBSET_SETS_LAYOUT_FEATURE_TAG
);
hbSubsetWasm.hb_set_clear(layoutFeatures);
hbSubsetWasm.hb_set_invert(layoutFeatures);
// if (preserveNameIds) {
// const inputNameIds = harfbuzzJsWasm.hb_subset_input_set(
// input,
// 4, // HB_SUBSET_SETS_NAME_ID
// );
// for (const nameId of preserveNameIds) {
// harfbuzzJsWasm.hb_set_add(inputNameIds, nameId);
// }
// }
// if (noLayoutClosure) {
// harfbuzzJsWasm.hb_subset_input_set_flags(
// input,
// harfbuzzJsWasm.hb_subset_input_get_flags(input) | 0x00000200, // HB_SUBSET_FLAGS_NO_LAYOUT_CLOSURE
// );
// }
// Add unicodes indices
const inputUnicodes = hbSubsetWasm.hb_subset_input_unicode_set(input);
for (const c of codePoints) {
hbSubsetWasm.hb_set_add(inputUnicodes, c);
}
// if (variationAxes) {
// for (const [axisName, value] of Object.entries(variationAxes)) {
// if (typeof value === "number") {
// // Simple case: Pin/instance the variation axis to a single value
// if (
// !harfbuzzJsWasm.hb_subset_input_pin_axis_location(
// input,
// face,
// HB_TAG(axisName),
// value,
// )
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `hb_subset_input_pin_axis_location (harfbuzz) returned zero when pinning ${axisName} to ${value}, indicating failure. Maybe the axis does not exist in the font?`,
// );
// }
// } else if (value && typeof value === "object") {
// // Complex case: Reduce the variation space of the axis
// if (
// typeof value.min === "undefined" ||
// typeof value.max === "undefined"
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `${axisName}: You must provide both a min and a max value when setting the axis range`,
// );
// }
// if (
// !harfbuzzJsWasm.hb_subset_input_set_axis_range(
// input,
// face,
// HB_TAG(axisName),
// value.min,
// value.max,
// // An explicit NaN makes harfbuzz use the existing default value, clamping to the new range if necessary
// value.default ?? NaN,
// )
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `hb_subset_input_set_axis_range (harfbuzz) returned zero when setting the range of ${axisName} to [${value.min}; ${value.max}] and a default value of ${value.default}, indicating failure. Maybe the axis does not exist in the font?`,
// );
// }
// }
// }
// }
let subset;
try {
subset = hbSubsetWasm.hb_subset_or_fail(face, input);
if (subset === 0) {
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
throw new Error(
"hb_subset_or_fail (harfbuzz) returned zero, indicating failure. Maybe the input file is corrupted?",
);
}
} finally {
// Clean up
hbSubsetWasm.hb_subset_input_destroy(input);
}
// Get result blob
const result = hbSubsetWasm.hb_face_reference_blob(subset);
const offset = hbSubsetWasm.hb_blob_get_data(result, 0);
const subsetByteLength = hbSubsetWasm.hb_blob_get_length(result);
if (subsetByteLength === 0) {
hbSubsetWasm.hb_blob_destroy(result);
hbSubsetWasm.hb_face_destroy(subset);
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
throw new Error(
"Failed to create subset font, maybe the input file is corrupted?",
);
}
const subsetFont = new Uint8Array(
heapu8.subarray(offset, offset + subsetByteLength),
);
// Clean up
hbSubsetWasm.hb_blob_destroy(result);
hbSubsetWasm.hb_face_destroy(subset);
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
return subsetFont;
}
export default {
subset,
};
@@ -0,0 +1,58 @@
/**
* Lazy loads wasm and respective bindings for font subsetting based on the harfbuzzjs.
*/
let loadedWasm: ReturnType<typeof load> | null = null;
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
const [binary, bindings] = await Promise.all([
import("./hb-subset.wasm"),
import("./hb-subset.bindings"),
]);
WebAssembly.instantiate(binary.default).then((module) => {
try {
const harfbuzzJsWasm = module.instance.exports;
// @ts-expect-error since `.buffer` is custom prop
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
const hbSubset = {
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => {
return bindings.default.subset(
harfbuzzJsWasm,
heapu8,
fontBuffer,
codePoints,
);
},
};
resolve(hbSubset);
} catch (e) {
reject(e);
}
});
} catch (error) {
reject(error);
}
});
};
// lazy load the default export
export default (): ReturnType<typeof load> => {
if (!loadedWasm) {
loadedWasm = load();
}
return loadedWasm;
};
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
/**
* Lazy loads wasm and respective bindings for woff2 compression and decompression.
*/
type Vector = any;
let loadedWasm: ReturnType<typeof load> | null = null;
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
compress: (buffer: ArrayBuffer) => Uint8Array;
decompress: (buffer: ArrayBuffer) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
const [binary, bindings] = await Promise.all([
import("./woff2.wasm"),
import("./woff2.bindings"),
]);
// initializing the module manually, so that we could pass in the wasm binary
bindings
.default({ wasmBinary: binary.default })
.then(
(module: {
woff2Enc: (buffer: ArrayBuffer, byteLength: number) => Vector;
woff2Dec: (buffer: ArrayBuffer, byteLength: number) => Vector;
}) => {
try {
// re-map from internal vector into byte array
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
const arr = [];
for (let i = 0, l = vector.size(); i < l; i++) {
arr.push(vector.get(i));
}
return new Uint8Array(arr);
}
// re-exporting only compress and decompress functions (also avoids infinite loop inside emscripten bindings)
const woff2 = {
compress: (buffer: ArrayBuffer) =>
convertFromVecToUint8Array(
module.woff2Enc(buffer, buffer.byteLength),
),
decompress: (buffer: ArrayBuffer) =>
convertFromVecToUint8Array(
module.woff2Dec(buffer, buffer.byteLength),
),
};
resolve(woff2);
} catch (e) {
reject(e);
}
},
);
} catch (e) {
reject(e);
}
});
};
// lazy loaded default export
export default (): ReturnType<typeof load> => {
if (!loadedWasm) {
loadedWasm = load();
}
return loadedWasm;
};
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -11,7 +11,6 @@ import type {
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { isPointWithinBounds } from "./math";
import {
getBoundTextElement,
getContainerElement,
@@ -30,6 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
import type { ReadonlySetLike } from "./utility-types";
import { isPointWithinBounds, point } from "../math";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
@@ -159,9 +159,9 @@ export const isCursorInFrame = (
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
return isPointWithinBounds(
[fx1, fy1],
[cursorCoords.x, cursorCoords.y],
[fx2, fy2],
point(fx1, fy1),
point(cursorCoords.x, cursorCoords.y),
point(fx2, fy2),
);
};
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
const TIMEOUT = 2000;
@@ -15,8 +15,13 @@ export const useCopyStatus = () => {
}, TIMEOUT);
};
const resetCopyStatus = useCallback(() => {
setCopyStatus(null);
}, []);
return {
copyStatus,
resetCopyStatus,
onCopy,
};
};
@@ -18,6 +18,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
},
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
});
};
@@ -40,6 +41,7 @@ export const useLibraryItemSvg = (
// When there is no svg in cache export it and save to cache
(async () => {
const exportedSvg = await exportLibraryItemToSvg(elements);
// TODO: should likely be removed for custom fonts
exportedSvg.querySelector(".style-fonts")?.remove();
if (exportedSvg) {
+8
View File
@@ -162,6 +162,13 @@
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
},
"search": {
"title": "Find on canvas",
"noMatch": "No matches found...",
"singleResult": "result",
"multipleResults": "results",
"placeholder": "Find text on canvas..."
},
"buttons": {
"clearReset": "Reset the canvas",
"exportJSON": "Export to file",
@@ -297,6 +304,7 @@
"shapes": "Shapes"
},
"hints": {
"dismissSearch": "Escape to dismiss search",
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line",
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
-99
View File
@@ -1,99 +0,0 @@
import {
isPointOnSymmetricArc,
rangeIntersection,
rangesOverlap,
rotate,
} from "./math";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
const x1 = 10;
const y1 = 20;
const x2 = 20;
const y2 = 30;
const angle = Math.PI / 2;
const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle);
expect([rotatedX, rotatedY]).toEqual([30, 20]);
const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle);
expect(res2).toEqual([x1, x2]);
});
});
describe("range overlap", () => {
it("should overlap when range a contains range b", () => {
expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
});
it("should overlap when range b contains range a", () => {
expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
});
it("should overlap when range a and b intersect", () => {
expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
});
});
describe("range intersection", () => {
it("should intersect completely with itself", () => {
expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
});
it("should intersect irrespective of order", () => {
expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
});
it("should intersect at the edge", () => {
expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
});
it("should not intersect", () => {
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
});
});
describe("point on arc", () => {
it("should detect point on simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[0.92291667, 0.385],
),
).toBe(true);
});
it("should not detect point outside of a simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[-0.92291667, 0.385],
),
).toBe(false);
});
it("should not detect point with good angle but incorrect radius", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[-0.5, 0.5],
),
).toBe(false);
});
});
-715
View File
@@ -1,715 +0,0 @@
import type {
NormalizedZoomValue,
NullableGridSize,
Point,
Zoom,
} from "./types";
import {
DEFAULT_ADAPTIVE_RADIUS,
LINE_CONFIRM_THRESHOLD,
DEFAULT_PROPORTIONAL_RADIUS,
ROUNDNESS,
} from "./constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "./element/types";
import type { Bounds } from "./element/bounds";
import { getCurvePathOps } from "./element/bounds";
import type { Mutable } from "./utility-types";
import { ShapeCache } from "./scene/ShapeCache";
import type { Vector } from "../utils/geometry/shape";
export const rotate = (
// target point to rotate
x: number,
y: number,
// point to rotate against
cx: number,
cy: number,
angle: number,
): [number, number] =>
// 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
// 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
// https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
[
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
];
export const rotatePoint = (
point: Point,
center: Point,
angle: number,
): [number, number] => rotate(point[0], point[1], center[0], center[1], angle);
export const adjustXYWithRotation = (
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
deltaX1: number,
deltaY1: number,
deltaX2: number,
deltaY2: number,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};
export const getPointOnAPath = (point: Point, path: Point[]) => {
const [px, py] = point;
const [start, ...other] = path;
let [lastX, lastY] = start;
let kLine: number = 0;
let idx: number = 0;
// if any item in the array is true, it means that a point is
// on some segment of a line based path
const retVal = other.some(([x2, y2], i) => {
// we always take a line when dealing with line segments
const x1 = lastX;
const y1 = lastY;
lastX = x2;
lastY = y2;
// if a point is not within the domain of the line segment
// it is not on the line segment
if (px < x1 || px > x2) {
return false;
}
// check if all points lie on the same line
// y1 = kx1 + b, y2 = kx2 + b
// y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
// coefficient for the line (p0, p1)
const kL = (y2 - y1) / (x2 - x1);
// coefficient for the line segment (p0, point)
const kP1 = (py - y1) / (px - x1);
// coefficient for the line segment (point, p1)
const kP2 = (py - y2) / (px - x2);
// because we are basing both lines from the same starting point
// the only option for collinearity is having same coefficients
// using it for floating point comparisons
const epsilon = 0.3;
// if coefficient is more than an arbitrary epsilon,
// these lines are nor collinear
if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
return false;
}
// store the coefficient because we are goint to need it
kLine = kL;
idx = i;
return true;
});
// Return a coordinate that is always on the line segment
if (retVal === true) {
return { x: point[0], y: kLine * point[0], segment: idx };
}
return null;
};
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
const xd = x2 - x1;
const yd = y2 - y1;
return Math.hypot(xd, yd);
};
export const distanceSq2d = (p1: Point, p2: Point) => {
const xd = p2[0] - p1[0];
const yd = p2[1] - p1[1];
return xd * xd + yd * yd;
};
export const centerPoint = (a: Point, b: Point): Point => {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = distance2d(first[0], first[1], last[0], last[1]);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
// Draw a line from the point to the right till infiinty
// Check how many lines of the polygon does this infinite line intersects with
// If the number of intersections is odd, point is in the polygon
export const isPointInPolygon = (
points: Point[],
x: number,
y: number,
): boolean => {
const vertices = points.length;
// There must be at least 3 vertices in polygon
if (vertices < 3) {
return false;
}
const extreme: Point = [Number.MAX_SAFE_INTEGER, y];
const p: Point = [x, y];
let count = 0;
for (let i = 0; i < vertices; i++) {
const current = points[i];
const next = points[(i + 1) % vertices];
if (doSegmentsIntersect(current, next, p, extreme)) {
if (orderedColinearOrientation(current, p, next) === 0) {
return isPointWithinBounds(current, p, next);
}
count++;
}
}
// true if count is off
return count % 2 === 1;
};
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
// This is an approximation to "does `q` lie on a segment `pr`" check.
export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
return (
q[0] <= Math.max(p[0], r[0]) &&
q[0] >= Math.min(p[0], r[0]) &&
q[1] <= Math.max(p[1], r[1]) &&
q[1] >= Math.min(p[1], r[1])
);
};
// For the ordered points p, q, r, return
// 0 if p, q, r are colinear
// 1 if Clockwise
// 2 if counterclickwise
const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
if (val === 0) {
return 0;
}
return val > 0 ? 1 : 2;
};
// Check is p1q1 intersects with p2q2
const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
const o1 = orderedColinearOrientation(p1, q1, p2);
const o2 = orderedColinearOrientation(p1, q1, q2);
const o3 = orderedColinearOrientation(p2, q2, p1);
const o4 = orderedColinearOrientation(p2, q2, q1);
if (o1 !== o2 && o3 !== o4) {
return true;
}
// p1, q1 and p2 are colinear and p2 lies on segment p1q1
if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
return true;
}
// p1, q1 and p2 are colinear and q2 lies on segment p1q1
if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
return true;
}
// p2, q2 and p1 are colinear and p1 lies on segment p2q2
if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
return true;
}
// p2, q2 and q1 are colinear and q1 lies on segment p2q2
if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
return true;
}
return false;
};
// TODO: Rounding this point causes some shake when free drawing
export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
if (gridSize) {
return [
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
}
return [x, y];
};
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
export const getControlPointsForBezierCurve = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
const ops = getCurvePathOps(shape[0]);
let currentP: Mutable<Point> = [0, 0];
let index = 0;
let minDistance = Infinity;
let controlPoints: Mutable<Point>[] | null = null;
while (index < ops.length) {
const { op, data } = ops[index];
if (op === "move") {
currentP = data as unknown as Mutable<Point>;
}
if (op === "bcurveTo") {
const p0 = currentP;
const p1 = [data[0], data[1]] as Mutable<Point>;
const p2 = [data[2], data[3]] as Mutable<Point>;
const p3 = [data[4], data[5]] as Mutable<Point>;
const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]);
if (distance < minDistance) {
minDistance = distance;
controlPoints = [p0, p1, p2, p3];
}
currentP = p3;
}
index++;
}
return controlPoints;
};
export const getBezierXY = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number,
) => {
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const tx = equation(t, 0);
const ty = equation(t, 1);
return [tx, ty];
};
export const getPointsInBezierCurve = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve(
element,
endPoint,
)!;
if (!controlPoints) {
return [];
}
const pointsOnCurve: Mutable<Point>[] = [];
let t = 1;
// Take 20 points on curve for better accuracy
while (t > 0) {
const point = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
pointsOnCurve.push([point[0], point[1]]);
t -= 0.05;
}
if (pointsOnCurve.length) {
if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
pointsOnCurve.push([endPoint[0], endPoint[1]]);
}
}
return pointsOnCurve;
};
export const getBezierCurveArcLengths = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const arcLengths: number[] = [];
arcLengths[0] = 0;
const points = getPointsInBezierCurve(element, endPoint);
let index = 0;
let distance = 0;
while (index < points.length - 1) {
const segmentDistance = distance2d(
points[index][0],
points[index][1],
points[index + 1][0],
points[index + 1][1],
);
distance += segmentDistance;
arcLengths.push(distance);
index++;
}
return arcLengths;
};
export const getBezierCurveLength = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
return arcLengths.at(-1) as number;
};
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
export const mapIntervalToBezierT = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
const pointsCount = arcLengths.length - 1;
const curveLength = arcLengths.at(-1) as number;
const targetLength = interval * curveLength;
let low = 0;
let high = pointsCount;
let index = 0;
// Doing a binary search to find the largest length that is less than the target length
while (low < high) {
index = Math.floor(low + (high - low) / 2);
if (arcLengths[index] < targetLength) {
low = index + 1;
} else {
high = index;
}
}
if (arcLengths[index] > targetLength) {
index--;
}
if (arcLengths[index] === targetLength) {
return index / pointsCount;
}
return (
1 -
(index +
(targetLength - arcLengths[index]) /
(arcLengths[index + 1] - arcLengths[index])) /
pointsCount
);
};
export const arePointsEqual = (p1: Point, p2: Point) => {
return p1[0] === p2[0] && p1[1] === p2[1];
};
export const isRightAngle = (angle: number) => {
// if our angles were mathematically accurate, we could just check
//
// angle % (Math.PI / 2) === 0
//
// but since we're in floating point land, we need to round.
//
// Below, after dividing by Math.PI, a multiple of 0.5 indicates a right
// angle, which we can check with modulo after rounding.
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
};
export const radianToDegree = (r: number) => {
return (r * 180) / Math.PI;
};
export const degreeToRadian = (d: number) => {
return (d / 180) * Math.PI;
};
// Given two ranges, return if the two ranges overlap with each other
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
export const rangesOverlap = (
[a0, a1]: [number, number],
[b0, b1]: [number, number],
) => {
if (a0 <= b0) {
return a1 >= b0;
}
if (a0 >= b0) {
return b1 >= a0;
}
return false;
};
// Given two ranges,return ther intersection of the two ranges if any
// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
export const rangeIntersection = (
rangeA: [number, number],
rangeB: [number, number],
): [number, number] | null => {
const rangeStart = Math.max(rangeA[0], rangeB[0]);
const rangeEnd = Math.min(rangeA[1], rangeB[1]);
if (rangeStart <= rangeEnd) {
return [rangeStart, rangeEnd];
}
return null;
};
export const isValueInRange = (value: number, min: number, max: number) => {
return value >= min && value <= max;
};
export const translatePoint = (p: Point, v: Vector): Point => [
p[0] + v[0],
p[1] + v[1],
];
export const scaleVector = (v: Vector, scalar: number): Vector => [
v[0] * scalar,
v[1] * scalar,
];
export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [
p[0] - origin[0],
p[1] - origin[1],
];
export const scalePointFromOrigin = (
p: Point,
mid: Point,
multiplier: number,
) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier));
const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
(p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => {
const d1 = triangleSign(pt, v1, v2);
const d2 = triangleSign(pt, v2, v3);
const d3 = triangleSign(pt, v3, v1);
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(has_neg && has_pos);
};
export const magnitudeSq = (vector: Vector) =>
vector[0] * vector[0] + vector[1] * vector[1];
export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector));
export const normalize = (vector: Vector): Vector => {
const m = magnitude(vector);
return [vector[0] / m, vector[1] / m];
};
export const addVectors = (
vec1: Readonly<Vector>,
vec2: Readonly<Vector>,
): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]];
export const subtractVectors = (
vec1: Readonly<Vector>,
vec2: Readonly<Vector>,
): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]];
export const pointInsideBounds = (p: Point, bounds: Bounds): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = [bbox.midX, bbox.midY] as Point;
const [topLeftX, topLeftY] = rotatePoint(
[bbox.minX, bbox.minY],
center,
element.angle,
);
const [topRightX, topRightY] = rotatePoint(
[bbox.maxX, bbox.minY],
center,
element.angle,
);
const [bottomRightX, bottomRightY] = rotatePoint(
[bbox.maxX, bbox.maxY],
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = rotatePoint(
[bbox.minX, bbox.maxY],
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
type PolarCoords = [number, number];
/**
* Return the polar coordinates for the given carthesian point represented by
* (x, y) for the center point 0,0 where the first number returned is the radius,
* the second is the angle in radians.
*/
export const carthesian2Polar = ([x, y]: Point): PolarCoords => [
Math.hypot(x, y),
Math.atan2(y, x),
];
/**
* Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
* corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
*/
type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
/**
* Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
* is part of a circle contour centered on 0, 0.
*/
export const isPointOnSymmetricArc = (
{ radius: arcRadius, startAngle, endAngle }: SymmetricArc,
point: Point,
): boolean => {
const [radius, angle] = carthesian2Polar(point);
return startAngle < endAngle
? Math.abs(radius - arcRadius) < 0.0000001 &&
startAngle <= angle &&
endAngle >= angle
: startAngle <= angle || endAngle >= angle;
};
export const getCenterForBounds = (bounds: Bounds): Point => [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
];
export const getCenterForElement = (element: ExcalidrawElement): Point => [
element.x + element.width / 2,
element.y + element.height / 2,
];
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds([a[0], a[1]], b) ||
pointInsideBounds([a[2], a[1]], b) ||
pointInsideBounds([a[2], a[3]], b) ||
pointInsideBounds([a[0], a[3]], b) ||
pointInsideBounds([b[0], b[1]], a) ||
pointInsideBounds([b[2], b[1]], a) ||
pointInsideBounds([b[2], b[3]], a) ||
pointInsideBounds([b[0], b[3]], a);
export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
};
export const round = (value: number, precision: number) => {
const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
};
+2
View File
@@ -113,6 +113,8 @@
"esbuild-sass-plugin": "2.16.0",
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"import-meta-loader": "1.1.0",
"mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1",
+10 -6
View File
@@ -1,6 +1,8 @@
import type { Point } from "./types";
import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math";
export const getSizeFromPoints = (points: readonly Point[]) => {
export const getSizeFromPoints = (
points: readonly (GlobalPoint | LocalPoint)[],
) => {
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
return {
@@ -10,7 +12,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
};
/** @arg dimension, 0 for rescaling only x, 1 for y */
export const rescalePoints = (
export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
dimension: 0 | 1,
newSize: number,
points: readonly Point[],
@@ -31,7 +33,7 @@ export const rescalePoints = (
if (newCoordinate < nextMinCoordinate) {
nextMinCoordinate = newCoordinate;
}
return newPoint as unknown as Point;
return newPoint as Point;
});
if (!normalize) {
@@ -45,11 +47,13 @@ export const rescalePoints = (
const translation = minCoordinate - nextMinCoordinate;
const nextPoints = scaledPoints.map(
(scaledPoint) =>
const nextPoints = scaledPoints.map((scaledPoint) =>
pointFromPair<Point>(
scaledPoint.map((value, currentDimension) => {
return currentDimension === dimension ? value + translation : value;
}) as [number, number],
),
);
return nextPoints;
};
@@ -30,8 +30,12 @@ import {
shouldShowBoundingBox,
} from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils";
import type { InteractiveCanvasAppState, Point } from "../types";
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
THEME,
} from "../constants";
import { type InteractiveCanvasAppState } from "../types";
import { renderSnaps } from "../renderer/renderSnaps";
@@ -69,7 +73,8 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import { getCornerRadius } from "../math";
import type { GlobalPoint, LocalPoint, Radians } from "../../math";
import { getCornerRadius } from "../shapes";
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
@@ -101,7 +106,7 @@ const renderLinearElementPointHighlight = (
context.restore();
};
const highlightPoint = (
const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
point: Point,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
@@ -168,7 +173,7 @@ const strokeDiamondWithRotation = (
context.restore();
};
const renderSingleLinearPoint = (
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
point: Point,
@@ -499,7 +504,7 @@ const renderLinearPointHandles = (
element,
elementsMap,
appState,
).filter((midPoint) => midPoint !== null) as Point[];
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
midPoints.forEach((segmentMidPoint) => {
if (
@@ -931,7 +936,7 @@ const _renderInteractiveScene = ({
context.setLineDash(initialLineDash);
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
0 as Radians,
appState.zoom,
"mouse",
isFrameSelected
@@ -951,9 +956,48 @@ const _renderInteractiveScene = ({
context.restore();
}
appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
const element = elementsMap.get(id);
if (element && isTextElement(element)) {
const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
context.save();
if (appState.theme === THEME.LIGHT) {
if (focus) {
context.fillStyle = "rgba(255, 124, 0, 0.4)";
} else {
context.fillStyle = "rgba(255, 226, 0, 0.4)";
}
} else if (focus) {
context.fillStyle = "rgba(229, 82, 0, 0.4)";
} else {
context.fillStyle = "rgba(99, 52, 0, 0.4)";
}
context.translate(appState.scrollX, appState.scrollY);
context.translate(cx, cy);
context.rotate(element.angle);
matchedLines.forEach((matchedLine) => {
context.fillRect(
elementX1 + matchedLine.offsetX - cx,
elementY1 + matchedLine.offsetY - cy,
matchedLine.width,
matchedLine.height,
);
});
context.restore();
}
});
renderSnaps(context, appState);
// Reset zoom
context.restore();
renderRemoteCursors({
@@ -27,7 +27,6 @@ import type {
InteractiveCanvasRenderConfig,
} from "../scene/types";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import type {
AppState,
@@ -60,6 +59,8 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame";
import { ShapeCache } from "../scene/ShapeCache";
import { getVerticalOffset } from "../fonts";
import { isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@@ -907,7 +908,8 @@ export const renderElement = (
(!element.angle ||
// or check if angle is a right angle in which case we can still
// disable smoothing without adversely affecting the result
isRightAngle(element.angle))
// We need less-than comparison because of FP artihmetic
isRightAngleRads(element.angle))
) {
// Disabling smoothing makes output much sharper, especially for
// text. Unless for non-right angles, where the aliasing is really
+27 -18
View File
@@ -1,6 +1,7 @@
import { point, type GlobalPoint, type LocalPoint } from "../../math";
import { THEME } from "../constants";
import type { PointSnapLine, PointerSnapLine } from "../snapping";
import type { InteractiveCanvasAppState, Point } from "../types";
import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b";
const SNAP_COLOR_DARK = "#ff0000";
@@ -85,7 +86,7 @@ const drawPointerSnapLine = (
}
};
const drawCross = (
const drawCross = <Point extends LocalPoint | GlobalPoint>(
[x, y]: Point,
appState: InteractiveCanvasAppState,
context: CanvasRenderingContext2D,
@@ -106,18 +107,18 @@ const drawCross = (
context.restore();
};
const drawLine = (
const drawLine = <Point extends LocalPoint | GlobalPoint>(
from: Point,
to: Point,
context: CanvasRenderingContext2D,
) => {
context.beginPath();
context.lineTo(...from);
context.lineTo(...to);
context.lineTo(from[0], from[1]);
context.lineTo(to[0], to[1]);
context.stroke();
};
const drawGapLine = (
const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
from: Point,
to: Point,
direction: "horizontal" | "vertical",
@@ -138,24 +139,28 @@ const drawGapLine = (
const halfPoint = [(from[0] + to[0]) / 2, from[1]];
// (1)
if (!appState.zenModeEnabled) {
drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
drawLine(
point(from[0], from[1] - FULL),
point(from[0], from[1] + FULL),
context,
);
}
// (3)
drawLine(
[halfPoint[0] - QUARTER, halfPoint[1] - HALF],
[halfPoint[0] - QUARTER, halfPoint[1] + HALF],
point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
context,
);
drawLine(
[halfPoint[0] + QUARTER, halfPoint[1] - HALF],
[halfPoint[0] + QUARTER, halfPoint[1] + HALF],
point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
context,
);
if (!appState.zenModeEnabled) {
// (4)
drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
// (2)
drawLine(from, to, context);
@@ -164,24 +169,28 @@ const drawGapLine = (
const halfPoint = [from[0], (from[1] + to[1]) / 2];
// (1)
if (!appState.zenModeEnabled) {
drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
drawLine(
point(from[0] - FULL, from[1]),
point(from[0] + FULL, from[1]),
context,
);
}
// (3)
drawLine(
[halfPoint[0] - HALF, halfPoint[1] - QUARTER],
[halfPoint[0] + HALF, halfPoint[1] - QUARTER],
point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
context,
);
drawLine(
[halfPoint[0] - HALF, halfPoint[1] + QUARTER],
[halfPoint[0] + HALF, halfPoint[1] + QUARTER],
point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
context,
);
if (!appState.zenModeEnabled) {
// (4)
drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
// (2)
drawLine(from, to, context);
@@ -30,13 +30,13 @@ import type {
NonDeletedExcalidrawElement,
} from "../element/types";
import { getContainingFrame } from "../frame";
import { getCornerRadius, isPathALoop } from "../math";
import { ShapeCache } from "../scene/ShapeCache";
import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
import { getVerticalOffset } from "../fonts";
import { getCornerRadius, isPathALoop } from "../shapes";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,

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