Compare commits

...
Author SHA1 Message Date
Daniel J. Geiger c93e2fa9ce Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-10-06 19:09:35 -05:00
David LuzarandGitHub 47ee8a0094 refactor: point() -> pointFrom() to fix compiler issue (#8578) 2024-10-01 21:27:17 +02:00
a977dd1bf5 feat: Added reddit links as embeddable (#8099)
feat: #8063 Added reddit links as embeddable

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2024-09-28 11:49:18 +05:30
Aakansha DoshiandGitHub 3fe1883f3f feat: prefer user defined coords and dimensions over calculated for for frame (#8517)
* feat: prefer user defined coords and dimensions over calculated for frame

* update changelog

* lint

* show the info only in dev mode and when children present
2024-09-24 21:09:15 +05:30
Marcel MrazandGitHub a80cb5896a feat: self-hosting existing google fonts (#8540) 2024-09-24 17:30:21 +02:00
David LuzarandGitHub 6dfa18414a test: decrease min coverage thresholds (#8541) 2024-09-24 12:01:28 +00:00
8ca4cf3260 feat: flip arrowheads if only arrow(s) selected (#8525)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2024-09-19 15:46:36 +02:00
Márk TolmácsandGitHub f3f0ab7c83 fix: Elbow arrow fixedpoint flipping now properly flips on inverted resize and flip action (#8324)
* Flipping action now properly mirrors selections with elbow arrows
* Flipping action now re-centers the selection to the original center to avoid "walking" selections on repeated flipping
2024-09-19 08:47:23 +02:00
David LuzarandGitHub 44a1c8d857 fix: svg and png frame clipping cases (#8515) 2024-09-18 00:20:22 +02:00
Márk TolmácsandGitHub e0a22edfbd fix: Re-route elbow arrows when pasted (#8448)
Re-route elbow arrows when pasted
2024-09-17 12:20:40 +02:00
Márk TolmácsandGitHub c07f5a0c80 feat: Common elbow mid segments (#8440)
Common start or end segment length for elbow arrows regardless of arrowhead is present
2024-09-17 10:11:07 +02:00
David LuzarandGitHub 508f16dc04 refactor: rename example App.tsx -> ExampleApp.tsx (#8501) 2024-09-13 16:56:32 +02:00
c1b310c56b fix: Buffer dependency (#8474)
* fix Buffer dependency

* moved to encode.ts

* move base64 parsing out

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-12 15:48:47 +02:00
zsviczianandGitHub d4900e8f19 fix: Linear element complete button disabled (#8492) 2024-09-12 14:59:38 +02:00
zsviczianandGitHub caf2db934c fix: aspect ratio of distorted images are not preserved in SVG exports (#8061) 2024-09-12 14:11:08 +02:00
zsviczianandGitHub 60e3801691 fix: WYSIWYG editor padding is not normalized with zoom.value (#8481) 2024-09-12 13:42:39 +02:00
David LuzarandGitHub 80f3b75d42 chore: revert vite 5.4.2 -> 5.0.12 (#8499) 2024-09-12 12:56:45 +02:00
hocinoandGitHub 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 LuzarandGitHub 01e83cc9a5 fix: default sidebar triggers & behavior (#8498) 2024-09-11 20:19:14 +02:00
David LuzarandGitHub 813f9b702e feat: merge search sidebar back to default sidebar (#8497) 2024-09-11 19:26:01 +02:00
David LuzarandGitHub fd39712ba6 fix: improve canvas search scroll behavior further (#8491) 2024-09-11 18:01:18 +02:00
zsviczianandGitHub 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 LuzarandGitHub 72b7c937b1 feat: smarter zooming when scrolling to match & only match on search/switch (#8488) 2024-09-10 21:26:01 +02:00
David LuzarandGitHub d107215564 fix: select instead of focus search input (#8483) 2024-09-09 19:57:22 +02:00
6959a363f0 feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-09 17:12:07 +02:00
Abhishek MehandirattaandGitHub 5a11c70714 fix: image rendering issue when passed in initialData (#8471) 2024-09-08 23:56:00 +02:00
Aakansha DoshiandGitHub 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 DoshiandGitHub 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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
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
Daniel J. Geiger 039562cd61 Fix test failures from May merge. 2024-07-26 20:03:02 -05:00
Daniel J. Geiger 629cd307fd Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-07-26 20:02:06 -05:00
Daniel J. Geiger 81e3dd5406 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-05-08 18:41:06 -05:00
Daniel J. Geiger 704bbd6e0f fix: Missed in merging 2024-02-18 18:53:25 -06:00
Daniel J. Geiger bff220e0f5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-02-18 18:52:00 -06:00
Daniel J. Geiger ce595ff18c chore: Clean up src/i18n.ts changes 2023-12-02 19:07:48 -06:00
Daniel J. Geiger 1dfadb4d26 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-12-02 14:50:21 -06:00
Daniel J. Geiger 3fb902f1d8 Resolve merge conflicts 2023-11-22 16:18:53 -06:00
DanielJGeiger 63a91a883f feat: Expose ActionManager.registerAction through ExcalidrawImperativeAPI (#6995)
* feat: Expose `ActionManager` through `ExcalidrawImperativeAPI`

* Only expose `registerAction` instead of `ActionManager`
2023-11-22 16:02:06 -06:00
Daniel J. Geiger 5164bdb782 refactor: Remove some hard-coding in the MathJax subtype. 2023-11-22 15:50:07 -06:00
Daniel J. Geiger 9642a6e756 fix: Use a narrower type for custom shortcut names and reduce hard-coding. 2023-11-22 14:47:53 -06:00
Daniel J. Geiger f71ded4bf9 fix: An empty island would render if no subtypes were registered. 2023-11-22 11:29:43 -06:00
Daniel J. Geiger 3aa1365acb fix: Distinguish subtype toggles from regular tools. 2023-11-22 11:19:34 -06:00
Daniel J. Geiger 00691631d8 fix: Narrow the type of Action.name while still allowing custom names 2023-11-18 10:56:17 -06:00
Daniel J. Geiger cbb349e34b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-11-18 09:36:19 -06:00
Daniel J. Geiger 453757756d Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-11-18 09:35:29 -06:00
Daniel J. Geiger c456c1e713 fix: Only expose registerAction instead of ActionManager in the API 2023-11-03 19:12:46 -05:00
Daniel J. Geiger daf305af34 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-11-03 19:11:26 -05:00
Daniel J. Geiger 6966a1022c fix: Don't filter element.subtype in restoreElementWithProperties.
Subtypes are dynamically registered and may vary across deployments.
2023-10-14 14:24:05 -05:00
Daniel J. Geiger fc7ea757b2 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-10-14 13:05:07 -05:00
Daniel J. Geiger e5934f23c0 Re-add the MathJax patch for Vite. 2023-10-14 12:27:23 -05:00
Daniel J. Geiger 1cad91ca5f Temporarily drop the MathJax patch. 2023-10-14 12:23:23 -05:00
Daniel J. Geiger 6b2e5516ca Refactor the MathJax patch for Vite. 2023-10-04 18:59:50 -05:00
Daniel J. Geiger dd4bf91128 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-10-04 18:38:51 -05:00
Daniel J. Geiger ef0fcc1537 refactor: Replace the useSubtypes selection hook with a generic useSubtype hook 2023-09-23 15:54:27 -05:00
Daniel J. Geiger ec26aeead2 refactor: Refactor and add a test 2023-09-22 17:33:34 -05:00
Daniel J. Geiger 62f5475c4a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-22 15:19:21 -05:00
Daniel J. Geiger 7225915b82 fix: 4d6d6cf1 had a line-height regression for sufficiently short math symbols 2023-09-22 14:34:44 -05:00
Daniel J. Geiger 8eb3191b3f refactor: Move MathJax into src/element/subtypes for the
`excalidraw-app` separation, maintaining lazy-loading of MathJax.
2023-09-22 14:25:15 -05:00
Daniel J. Geiger 4d6d6cf129 fix: Text-only measurements off by a pixel 2023-09-22 10:17:51 -05:00
Daniel J. Geiger 208285b7ba Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-17 15:40:45 -05:00
Daniel J. Geiger 372a4868da chore: Only use transform-origin in the text editor if rendered
dimensions don't match the editor dimensions.
2023-09-15 13:40:46 -05:00
Daniel J. Geiger 05800d8599 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-15 10:52:15 -05:00
Daniel J. Geiger 1f496d9f64 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-11 19:22:29 -05:00
Daniel J. Geiger e0221ddf20 fix: Inform scenes of mutations when a subtype finishes loading. 2023-09-10 16:49:06 -05:00
Daniel J. Geiger 1bd86942f3 refactor: Simplify a file. 2023-09-10 16:47:29 -05:00
Daniel J. Geiger fd9a172da9 refactor: Relocate a type definition. 2023-09-08 13:12:50 -05:00
Daniel J. Geiger 1f9847ed98 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-08 10:31:19 -05:00
Daniel J. Geiger 4e4802b19e chore: Don't bundle #6050 or #5511. 2023-09-01 14:30:52 -05:00
Daniel J. Geiger 23eb08088e chore: Drop @excalidraw/extensions and move the MathJax subtype into
`src/excalidraw-app/subtypes` to leave `@excalidraw/excalidraw` untouched.

`@excalidraw/extensions` mostly contained boilerplate and obscured the
main new features here: `ExcalidrawElement` subtypes and MathJax support.
2023-09-01 13:40:27 -05:00
Daniel J. Geiger e8a6053251 Revert "Add a semicolon."
This reverts commit 456433e8f0.
2023-08-24 11:11:11 -05:00
Daniel J. Geiger 456433e8f0 Add a semicolon. 2023-08-24 10:35:12 -05:00
Daniel J. Geiger 38e3a4e8e1 fix: Further patch AsciiMath to work with Vite in production mode also. 2023-08-24 10:16:04 -05:00
Daniel J. Geiger 27a8cda8fd fix: Patch AsciiMath to work with Vite.
Incorporates PR mathjax/MathJax-src#854 by @masx200.
2023-08-23 11:27:12 -05:00
Daniel J. Geiger dd5053149a @excalidraw/extensions: Fixes for Vite. 2023-08-22 16:18:28 -05:00
Daniel J. Geiger 40ec02b280 chore: Update @excalidraw/extensions configs. 2023-08-22 09:54:13 -05:00
Daniel J. Geiger b81aa19ff9 fix: Migrate @excalidraw/extensions environment variable names to Vite. 2023-08-22 08:51:01 -05:00
Daniel J. Geiger e4ddd08bb1 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-08-21 16:09:37 -05:00
Daniel J. Geiger 795176b256 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-06-15 14:36:09 -05:00
Daniel J. Geiger be057bde39 MathJax: Use $ as LaTeX delimiters. Fall back to \( and \) if detected. Interpret \$ as a text literal "$" sign. 2023-06-15 13:56:33 -05:00
Daniel J. Geiger 94f4b727bb Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-05-17 14:36:53 -05:00
Daniel J. Geiger 63698572db Subtypes: add another test. 2023-04-28 13:03:03 -05:00
Daniel J. Geiger ab3467973f fix: No more debounced refresh() for subtypes. 2023-04-28 09:47:03 -05:00
Daniel J. Geiger 91fe07d9c5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-26 16:43:42 -05:00
Daniel J. Geiger 28cc821047 Fix a merge lint issue 2023-04-24 15:29:55 -05:00
Daniel J. Geiger 7dc728a459 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-24 13:08:44 -05:00
Daniel J. Geiger 12c651af6d Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-20 18:52:45 -05:00
Daniel J. Geiger 9d0cafe10b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-14 18:34:08 -05:00
Daniel J. Geiger fb24221587 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-10 20:24:03 -05:00
Daniel J. Geiger ef347cc685 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-08 09:52:03 -05:00
Daniel J. Geiger 2d3b9e0c66 fix: Properly avoid concurrent invocations of loadMathJax(). 2023-03-18 09:52:44 -05:00
Daniel J. Geiger bdb0dd064b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-17 11:19:18 -05:00
Daniel J. Geiger b17ed4dc29 fix: Don't cache wrapped text before MathJax finishes loading. 2023-03-13 13:01:52 -05:00
Daniel J. Geiger b988f67759 fix: Better legibility when editing some math elements. 2023-03-13 12:57:33 -05:00
Daniel J. Geiger 089aaa8792 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-13 11:33:26 -05:00
Daniel J. Geiger 28261c4b29 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-06 09:06:55 -06:00
Daniel J. Geiger 3fbed86d3e Fixes for math element dimensions before/upon loading MathJax. 2023-02-27 15:32:36 -06:00
Daniel J. Geiger 38b3d90fa6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 15:32:15 -06:00
Daniel J. Geiger 82b597ab8b fix: Catch MathML errors and render the "ERR" block instead. 2023-02-27 14:19:06 -06:00
Daniel J. Geiger 4c939cefad Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 14:18:41 -06:00
Daniel J. Geiger 8f0d9f5230 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-19 16:02:22 -06:00
Daniel J. Geiger fcde0ac3de Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-02-07 21:10:26 -06:00
Daniel J. Geiger b07dfba4b8 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-03 17:49:32 -06:00
Daniel J. Geiger 1089cdb278 Refactor: Modify fewer components. 2023-02-01 21:25:04 -06:00
Daniel J. Geiger 7246a6b17a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-01 17:34:12 -06:00
Daniel J. Geiger 04a96caf78 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-31 15:26:03 -06:00
Daniel J. Geiger 14c6ea938a Refactor: Drop isActionName and convert getCustomActions to
`filterActions`.
2023-01-28 21:27:25 -06:00
Daniel J. Geiger 87aba3f619 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-28 18:44:03 -06:00
Daniel J. Geiger c8d4e8c421 Simplify custom Actions: universal Action predicates instead of
action-specific guards.
2023-01-27 13:23:40 -06:00
Daniel J. Geiger 512e506798 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-26 17:38:48 -06:00
Daniel J. Geiger b4e742bda0 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-25 16:54:14 -06:00
Daniel J. Geiger 5a3f4fd08f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-24 19:27:05 -06:00
Daniel J. Geiger 34515f2952 Fixes. 2023-01-24 19:09:07 -06:00
Daniel J. Geiger 08f430b3ac Fix tests. 2023-01-23 20:23:51 -06:00
Daniel J. Geiger 59e74f94e6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-17 15:12:39 -06:00
Daniel J. Geiger ddc393bd9d Make filtering of custom actions optional. 2023-01-08 19:30:01 -06:00
Daniel J. Geiger 9e5948ac28 Filter all context menu items (standard and custom) through
`isActionEnabled`.
2023-01-08 17:37:32 -06:00
Daniel J. Geiger f86d0f9102 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-08 17:06:23 -06:00
Daniel J. Geiger ace031e992 Update to latest Action changes. 2023-01-07 15:47:19 -06:00
Daniel J. Geiger 45faf7d58f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-07 11:58:15 -06:00
Daniel J. Geiger 8c558a0f33 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-05 11:36:23 -06:00
Daniel J. Geiger 65059cb166 Fix tests introduced by the arrow labels feature. 2023-01-02 13:30:11 -06:00
Daniel J. Geiger 9158e2d989 fix: Remove leftovers from a merge. 2023-01-02 12:48:53 -06:00
Daniel J. Geiger 12da1862a0 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-02 12:45:33 -06:00
Daniel J. Geiger 67fb3210ab fix: Correct existing subtypes test coverage; add test coverage for
subtype actions; and a subtype action fix.
2023-01-02 12:43:19 -06:00
Daniel J. Geiger 13d69d8cef Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2022-12-31 16:00:26 -06:00
Daniel J. Geiger 0f6ad916c0 fix: Cache SVGs separately for mixed-text and math-only modes. 2022-12-30 10:48:47 -06:00
Daniel J. Geiger 9ee2bf36cf Render LaTeX matrices correctly in math-only mode. 2022-12-30 10:26:46 -06:00
Daniel J. Geiger 86f5c2ebcf feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com 2022-12-27 15:11:52 -06:00
189 changed files with 11669 additions and 4680 deletions
+1
View File
@@ -8,3 +8,4 @@ public/workbox
packages/excalidraw/types packages/excalidraw/types
examples/**/public examples/**/public
dev-dist dev-dist
coverage
@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
getDimensions,<br/>&nbsp; getDimensions,<br/>&nbsp;
files,<br/>&nbsp; files,<br/>&nbsp;
exportPadding?: number;<br/> 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> </pre>
| Name | Type | Default | Description | | Name | Type | Default | Description |
+41 -22
View File
@@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0" "@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "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" version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0" balanced-match "^1.0.0"
concat-map "0.0.1" 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" version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
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.2.0: finalhandler@1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5" version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies: dependencies:
braces "^3.0.2" braces "^3.0.3"
picomatch "^2.3.1" picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
text-table "^0.2.0" text-table "^0.2.0"
react-dom@^17.0.2: react-dom@18.2.0:
version "17.0.2" version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" scheduler "^0.23.0"
scheduler "^0.20.2"
react-error-overlay@^6.0.11: react-error-overlay@^6.0.11:
version "6.0.11" version "6.0.11"
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies: dependencies:
"@babel/runtime" "^7.10.3" "@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: react-router-config@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0" use-composed-ref "^1.3.0"
use-latest "^1.2.1" use-latest "^1.2.1"
react@^17.0.2: react@18.2.0:
version "17.0.2" version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1: readable-stream@^2.0.1:
version "2.3.7" version "2.3.7"
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.2: scheduler@^0.23.0:
version "0.20.2" version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0: schema-utils@2.7.0:
version "2.7.0" version "2.7.0"
@@ -40,7 +40,7 @@ import type {
} from "@excalidraw/excalidraw/dist/excalidraw/element/types"; } from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types"; import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
import "./App.scss"; import "./ExampleApp.scss";
type Comment = { type Comment = {
x: number; x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
excalidrawLib: typeof TExcalidraw; excalidrawLib: typeof TExcalidraw;
} }
export default function App({ export default function ExampleApp({
appTitle, appTitle,
useCustom, useCustom,
customArgs, customArgs,
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as excalidrawLib from "@excalidraw/excalidraw"; import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../components/App"; import App from "../../components/ExampleApp";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
@@ -1,4 +1,4 @@
import App from "../components/App"; import App from "../components/ExampleApp";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
+9 -1
View File
@@ -4,6 +4,7 @@ import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState"; import { getDefaultAppState } from "../packages/excalidraw/appState";
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary"; import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { useMathSubtype } from "../packages/excalidraw/element/subtypes/mathjax";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
@@ -355,6 +356,8 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] = const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>(); useCallbackRefState<ExcalidrawImperativeAPI>();
useMathSubtype(excalidrawAPI);
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -649,7 +652,12 @@ const ExcalidrawWrapper = () => {
// Render the debug scene if the debug canvas is available // Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) { if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio); debugRenderer(
debugCanvasRef.current,
appState,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
} }
}; };
@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
/> />
)} )}
<MainMenu.DefaultItems.CommandPalette className="highlighted" /> <MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
+25 -7
View File
@@ -1,7 +1,6 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types"; import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils"; import { throttleRAF } from "../../packages/excalidraw/utils";
import type { LineSegment } from "../../packages/utils";
import { import {
bootstrapCanvas, bootstrapCanvas,
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
@@ -13,12 +12,16 @@ import {
TrashIcon, TrashIcon,
} from "../../packages/excalidraw/components/icons"; } from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import { isLineSegment } from "../../packages/excalidraw/element/typeChecks"; import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "../../packages/math";
const renderLine = ( const renderLine = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
zoom: number, zoom: number,
segment: LineSegment, segment: LineSegment<GlobalPoint>,
color: string, color: string,
) => { ) => {
context.save(); context.save();
@@ -47,10 +50,15 @@ const render = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: AppState, appState: AppState,
) => { ) => {
frame.forEach((el) => { frame.forEach((el: DebugElement) => {
switch (true) { switch (true) {
case isLineSegment(el.data): case isLineSegment(el.data):
renderLine(context, appState.zoom.value, el.data, el.color); renderLine(
context,
appState.zoom.value,
el.data as LineSegment<GlobalPoint>,
el.color,
);
break; break;
} }
}); });
@@ -60,12 +68,17 @@ const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -130,8 +143,13 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
}; };
export const debugRenderer = throttleRAF( export const debugRenderer = throttleRAF(
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => { (
_debugRenderer(canvas, appState, scale); canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
}, },
{ trailing: true }, { trailing: true },
); );
+14 -1
View File
@@ -20,6 +20,10 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; 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 { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
appState: AppState, appState: AppState,
) => { ) => {
try { try {
const _appState = clearAppStateForLocalStorage(appState);
if (
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
_appState.openSidebar = null;
}
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)), JSON.stringify(clearElementsForLocalStorage(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) { } catch (error: any) {
-9
View File
@@ -130,15 +130,6 @@
</script> </script>
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits --> <!-- Register Assistant as the UI font, before the scene inits -->
<link <link
rel="stylesheet" rel="stylesheet"
+2
View File
@@ -48,6 +48,8 @@ export default defineConfig({
}, },
}, },
sourcemap: true, sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
}, },
plugins: [ plugins: [
woff2BrowserPlugin(), woff2BrowserPlugin(),
+12 -7
View File
@@ -6,6 +6,7 @@
"excalidraw-app", "excalidraw-app",
"packages/excalidraw", "packages/excalidraw",
"packages/utils", "packages/utils",
"packages/math",
"examples/excalidraw", "examples/excalidraw",
"examples/excalidraw/*" "examples/excalidraw/*"
], ],
@@ -20,8 +21,8 @@
"@types/react-dom": "18.2.0", "@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0", "@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0", "@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0", "@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "0.32.2", "@vitest/ui": "2.0.5",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
@@ -31,17 +32,19 @@
"husky": "7.0.4", "husky": "7.0.4",
"jsdom": "22.1.0", "jsdom": "22.1.0",
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
"patch-package": "8.0.0",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"postinstall-postinstall": "2.1.0",
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-checker": "0.6.1", "vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4", "vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0", "vite-plugin-svgr": "4.2.0",
"vitest": "1.6.0", "vitest": "2.0.5",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": "18.0.0 - 20.x.x" "node": "18.0.0 - 20.x.x"
@@ -60,6 +63,7 @@
"locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install", "prepare": "husky install",
"postinstall": "patch-package",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start", "start": "yarn --cwd ./excalidraw-app start",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
@@ -82,6 +86,7 @@
"clean-install": "yarn rm:node_modules && yarn install" "clean-install": "yarn rm:node_modules && yarn install"
}, },
"resolutions": { "resolutions": {
"@types/react": "18.2.0" "@types/react": "18.2.0",
"strip-ansi": "6.0.1"
} }
} }
+3
View File
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135) - `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
@@ -303,6 +305,7 @@ define: {
## 0.16.0 (2023-09-19) ## 0.16.0 (2023-09-19)
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText, measureTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@@ -31,7 +31,7 @@ import type {
} from "../element/types"; } from "../element/types";
import type { AppState } from "../types"; import type { AppState } from "../types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -51,11 +51,9 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) { if (boundTextElement) {
const { width, height } = measureText( const { width, height } = measureTextElement(boundTextElement, {
boundTextElement.originalText, text: boundTextElement.originalText,
getFontString(boundTextElement), });
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
+58 -51
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import type { AppState } from "../types"; import type { AppState, Offsets } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { clamp } from "../math"; import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
const adjustedZoomValue = const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1); smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = return Math.min(adjustedZoomValue, 1);
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
canvasOffsets,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 1, viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2; const centerY = (y1 + y2) / 2;
let newZoomValue; const canvasOffsetLeft = canvasOffsets?.left ?? 0;
let scrollX; const canvasOffsetTop = canvasOffsets?.top ?? 0;
let scrollY; 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) { if (fitToViewport) {
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
newZoomValue = adjustedZoomValue =
Math.min( Math.min(
appState.width / commonBoundsWidth, effectiveCanvasWidth / commonBoundsWidth,
appState.height / commonBoundsHeight, effectiveCanvasHeight / commonBoundsHeight,
) * clamp(viewportZoomFactor, 0.1, 1); ) * viewportZoomFactor;
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;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport( adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds, bounds,
{ {
width: appState.width, width: effectiveCanvasWidth,
height: appState.height, height: effectiveCanvasHeight,
}, },
viewportZoomFactor, 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 { return {
appState: { appState: {
...appState, ...appState,
scrollX, scrollX: centerScroll.scrollX,
scrollY, scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
}; };
export const zoomToFit = ({ export const zoomToFit = ({
canvasOffsets,
targetElements, targetElements,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}: { }: {
canvasOffsets?: Offsets;
targetElements: readonly ExcalidrawElement[]; targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({ return zoomToFitBounds({
canvasOffsets,
bounds: commonBounds, bounds: commonBounds,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}); });
}; };
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE shift-2 should have been assigned actionZoomToFitSelection. // NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7, canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon, icon: zoomAreaIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => perform: (elements, appState, _, app) =>
zoomToFit({ zoomToFit({
targetElements: elements, targetElements: elements,
appState: { appState: {
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}), }),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints( // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
appState, try {
app.scene.getNonDeletedElementsMap(), const newAppState = LinearElementEditor.duplicateSelectedPoints(
); appState,
app.scene.getNonDeletedElementsMap(),
);
if (!ret) { return {
elements,
appState: newAppState,
storeAction: StoreAction.CAPTURE,
};
} catch {
return false; return false;
} }
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
};
} }
return { return {
@@ -6,7 +6,6 @@ import { done } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
maybeBindLinearElement, maybeBindLinearElement,
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { pointFrom } from "../../math";
import { isPathALoop } from "../shapes";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
@@ -112,10 +113,10 @@ export const actionFinalize = register({
const linePoints = multiPointElement.points; const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
mutateElement(multiPointElement, { mutateElement(multiPointElement, {
points: linePoints.map((point, index) => points: linePoints.map((p, index) =>
index === linePoints.length - 1 index === linePoints.length - 1
? ([firstPoint[0], firstPoint[1]] as const) ? pointFrom(firstPoint[0], firstPoint[1])
: point, : p,
), ),
}); });
} }
@@ -216,6 +217,7 @@ export const actionFinalize = register({
onClick={updateData} onClick={updateData}
visible={appState.multiElement != null} visible={appState.multiElement != null}
size={data?.size || "medium"} size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/> />
), ),
}); });
@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw } from "../index";
import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { pointFrom } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
describe("flipping re-centers selection", () => {
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
const elements = [
API.createElement({
type: "rectangle",
id: "rec1",
x: 100,
y: 100,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow",
id: "arr",
x: 149.9,
y: 95,
width: 156,
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
},
startArrowhead: null,
endArrowhead: "arrow",
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
],
elbowed: true,
}),
];
await render(<Excalidraw initialData={{ elements }} />);
API.setSelectedElements(elements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
});
});
+69 -2
View File
@@ -2,6 +2,8 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
@@ -18,7 +20,13 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { isLinearElement } from "../element/typeChecks"; import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -109,7 +117,23 @@ const flipElements = (
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
app: AppClassProperties, app: AppClassProperties,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); if (
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements( resizeMultipleElements(
elementsMap, elementsMap,
@@ -131,5 +155,48 @@ const flipElements = (
[], [],
); );
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
);
// ---------------------------------------------------------------------------
return selectedElements; return selectedElements;
}; };
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; 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 type { StoreActionType } from "../store";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@@ -115,6 +115,8 @@ import {
} from "../element/binding"; } from "../element/binding";
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -1648,10 +1650,10 @@ export const actionChangeArrowType = register({
newElement, newElement,
elementsMap, elementsMap,
[finalStartPoint, finalEndPoint].map( [finalStartPoint, finalEndPoint].map(
(point) => (p): LocalPoint =>
[point[0] - newElement.x, point[1] - newElement.y] as Point, pointFrom(p[0] - newElement.x, p[1] - newElement.y),
), ),
[0, 0], vector(0, 0),
{ {
...(startElement && newElement.startBinding ...(startElement && newElement.startBinding
? { ? {
@@ -1683,19 +1685,6 @@ export const actionChangeArrowType = register({
: {}), : {}),
}, },
); );
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
} }
return newElement; return newElement;
@@ -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 { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
+60 -8
View File
@@ -6,6 +6,7 @@ import type {
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource, ActionSource,
ActionPredicateFn,
} from "./types"; } from "./types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -45,6 +46,7 @@ const trackAction = (
export class ActionManager { export class ActionManager {
actions = {} as Record<ActionName, Action>; actions = {} as Record<ActionName, Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -72,6 +74,37 @@ export class ActionManager {
this.app = app; this.app = app;
} }
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
filterActions(
filter: ActionPredicateFn,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
},
): Action[] {
// For testing
if (this === undefined) {
return [];
}
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
const actions: Action[] = [];
for (const key in this.actions) {
const action = this.actions[key as ActionName];
if (filter(action, elements, appState, this.app, data)) {
actions.push(action);
}
}
return actions;
}
registerAction(action: Action) { registerAction(action: Action) {
this.actions[action.name] = action; this.actions[action.name] = action;
} }
@@ -88,7 +121,7 @@ export class ActionManager {
(action) => (action) =>
(action.name in canvasActions (action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions] ? canvasActions[action.name as keyof typeof canvasActions]
: true) && : this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
@@ -147,7 +180,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] && "PanelComponent" in this.actions[name] &&
(name in canvasActions (name in canvasActions
? canvasActions[name as keyof typeof canvasActions] ? canvasActions[name as keyof typeof canvasActions]
: true) : this.isActionEnabled(this.actions[name], { noPredicates: true }))
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
@@ -169,6 +202,7 @@ export class ActionManager {
return ( return (
<PanelComponent <PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()} elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
@@ -182,13 +216,31 @@ export class ActionManager {
return null; return null;
}; };
isActionEnabled = (action: Action) => { isActionEnabled = (
const elements = this.getElementsIncludingDeleted(); action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const data = opts?.data;
return ( if (
!action.predicate || !opts?.noPredicates &&
action.predicate(elements, appState, this.app.props, this.app) action.predicate &&
); !action.predicate(elements, appState, this.app.props, this.app, data)
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, this.app, data)) {
enabled = false;
}
});
return enabled;
}; };
} }
+14 -2
View File
@@ -2,11 +2,12 @@ import { isDarwin } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import type { SubtypeOf } from "../utility-types"; import type { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import type { ActionName } from "./types"; import type { ActionName, CustomActionName } from "./types";
export type ShortcutName = export type ShortcutName =
| SubtypeOf< | SubtypeOf<
ActionName, ActionName,
| CustomActionName
| "toggleTheme" | "toggleTheme"
| "loadScene" | "loadScene"
| "clearCanvas" | "clearCanvas"
@@ -51,7 +52,17 @@ export type ShortcutName =
> >
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette"; | "commandPalette"
| "searchMenu";
export const registerCustomShortcuts = (
shortcuts: Record<CustomActionName, string[]>,
) => {
for (const key in shortcuts) {
const shortcut = key as CustomActionName;
shortcutMap[shortcut] = shortcuts[shortcut];
}
};
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -112,6 +123,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+19 -2
View File
@@ -41,10 +41,24 @@ type ActionFn = (
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
export const makeCustomActionName = (name: string) =>
`custom.${name}` as CustomActionName;
export type CustomActionName = `custom.${string}`;
export type ActionName = export type ActionName =
| CustomActionName
| "copy" | "copy"
| "cut" | "cut"
| "paste" | "paste"
@@ -137,7 +151,8 @@ export type ActionName =
| "wrapTextInContainer" | "wrapTextInContainer"
| "commandPalette" | "commandPalette"
| "autoResize" | "autoResize"
| "elementStats"; | "elementStats"
| "searchMenu";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -178,6 +193,7 @@ export interface Action {
appState: AppState, appState: AppState,
appProps: ExcalidrawProps, appProps: ExcalidrawProps,
app: AppClassProperties, app: AppClassProperties,
data?: Record<string, any>,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
@@ -191,7 +207,8 @@ export interface Action {
| "history" | "history"
| "menu" | "menu"
| "collab" | "collab"
| "hyperlink"; | "hyperlink"
| "search_menu";
action?: string; action?: string;
predicate?: ( predicate?: (
appState: Readonly<AppState>, appState: Readonly<AppState>,
+4
View File
@@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
userToFollow: null, userToFollow: null,
followedBy: new Set(), followedBy: new Set(),
searchMatches: [],
}; };
}; };
@@ -169,6 +170,8 @@ const APP_STATE_STORAGE_CONF = (<
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
activeSubtypes: { browser: true, export: false, server: false },
customData: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
@@ -236,6 +239,7 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false }, userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+28 -18
View File
@@ -1,3 +1,5 @@
import type { Radians } from "../math";
import { pointFrom } from "../math";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX, DEFAULT_CHART_COLOR_INDEX,
@@ -11,6 +13,8 @@ import {
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import type { NonDeletedExcalidrawElement } from "./element/types"; import type { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
import type { AppState } from "./types";
import { selectSubtype } from "./element/subtypes";
export type ChartElements = readonly NonDeletedExcalidrawElement[]; export type ChartElements = readonly NonDeletedExcalidrawElement[];
@@ -23,6 +27,8 @@ export interface Spreadsheet {
title: string | null; title: string | null;
labels: string[] | null; labels: string[] | null;
values: number[]; values: number[];
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
} }
export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
@@ -193,20 +199,25 @@ const chartXLabels = (
groupId: string, groupId: string,
backgroundColor: string, backgroundColor: string,
): ChartElements => { ): ChartElements => {
const custom = selectSubtype(spreadsheet, "text");
return ( return (
spreadsheet.labels?.map((label, index) => { spreadsheet.labels?.map((label, index) => {
return newTextElement({ return newTextElement({
groupIds: [groupId], groupIds: [groupId],
backgroundColor, backgroundColor,
...commonProps, ...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label, text:
label.length > 8 && custom.subtype === undefined
? `${label.slice(0, 5)}...`
: label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
angle: 5.87, angle: 5.87 as Radians,
fontSize: 16, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
...custom,
}); });
}) || [] }) || []
); );
@@ -227,6 +238,7 @@ const chartYLabels = (
y: y - BAR_GAP, y: y - BAR_GAP,
text: "0", text: "0",
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
const maxYLabel = newTextElement({ const maxYLabel = newTextElement({
@@ -237,6 +249,7 @@ const chartYLabels = (
y: y - BAR_HEIGHT - minYLabel.height / 2, y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(), text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
return [minYLabel, maxYLabel]; return [minYLabel, maxYLabel];
@@ -258,10 +271,8 @@ const chartLines = (
x, x,
y, y,
width: chartWidth, width: chartWidth,
points: [ points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
[0, 0], ...selectSubtype(spreadsheet, "line"),
[chartWidth, 0],
],
}); });
const yLine = newLinearElement({ const yLine = newLinearElement({
@@ -272,10 +283,8 @@ const chartLines = (
x, x,
y, y,
height: chartHeight, height: chartHeight,
points: [ points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
[0, 0], ...selectSubtype(spreadsheet, "line"),
[0, -chartHeight],
],
}); });
const maxLine = newLinearElement({ const maxLine = newLinearElement({
@@ -288,10 +297,8 @@ const chartLines = (
strokeStyle: "dotted", strokeStyle: "dotted",
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [ points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
[0, 0], ...selectSubtype(spreadsheet, "line"),
[chartWidth, 0],
],
}); });
return [xLine, yLine, maxLine]; return [xLine, yLine, maxLine];
@@ -318,6 +325,7 @@ const chartBaseElements = (
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, roundness: null,
textAlign: "center", textAlign: "center",
...selectSubtype(spreadsheet, "text"),
}) })
: null; : null;
@@ -334,6 +342,7 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
...selectSubtype(spreadsheet, "rectangle"),
}) })
: null; : null;
@@ -366,6 +375,7 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP, y: y - barHeight - BAR_GAP,
width: BAR_WIDTH, width: BAR_WIDTH,
height: barHeight, height: barHeight,
...selectSubtype(spreadsheet, "rectangle"),
}); });
}); });
@@ -416,6 +426,7 @@ const chartTypeLine = (
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
points: points as any, points: points as any,
...selectSubtype(spreadsheet, "line"),
}); });
const dots = spreadsheet.values.map((value, index) => { const dots = spreadsheet.values.map((value, index) => {
@@ -432,6 +443,7 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2, y: y + cy - BAR_GAP * 2,
width: BAR_GAP, width: BAR_GAP,
height: BAR_GAP, height: BAR_GAP,
...selectSubtype(spreadsheet, "ellipse"),
}); });
}); });
@@ -448,10 +460,8 @@ const chartTypeLine = (
height: cy, height: cy,
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [ points: [pointFrom(0, 0), pointFrom(0, cy)],
[0, 0], ...selectSubtype(spreadsheet, "line"),
[0, cy],
],
}); });
}); });
+6 -1
View File
@@ -2,7 +2,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import type { BinaryFiles } from "./types"; import type { AppState, BinaryFiles } from "./types";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import { import {
@@ -333,6 +333,7 @@ const parseClipboardEvent = async (
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
appState?: AppState,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste); const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
@@ -349,6 +350,10 @@ export const parseClipboard = async (
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value); !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
if (spreadsheetResult) { if (spreadsheetResult) {
if ("spreadsheet" in spreadsheetResult) {
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
spreadsheetResult.spreadsheet.customData = appState?.customData;
}
return spreadsheetResult; return spreadsheetResult;
} }
} catch (error: any) { } catch (error: any) {
@@ -21,6 +21,7 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { SubtypeShapeActions } from "./Subtypes";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons"; import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { import {
@@ -136,6 +137,7 @@ export const SelectedShapeActions = ({
{canChangeBackgroundColor(appState, targetElements) && ( {canChangeBackgroundColor(appState, targetElements) && (
<div>{renderAction("changeBackgroundColor")}</div> <div>{renderAction("changeBackgroundColor")}</div>
)} )}
<SubtypeShapeActions elements={targetElements} />
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(appState.activeTool.type) ||
+284 -113
View File
@@ -185,6 +185,7 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@@ -210,12 +211,6 @@ import {
isElementCompletelyInViewport, isElementCompletelyInViewport,
isElementInViewport, isElementInViewport,
} from "../element/sizeHelpers"; } from "../element/sizeHelpers";
import {
distance2d,
getCornerRadius,
getGridPoint,
isPathALoop,
} from "../math";
import { import {
calculateScrollCenter, calculateScrollCenter,
getElementsWithinSelection, getElementsWithinSelection,
@@ -230,7 +225,13 @@ import type {
ScrollBars, ScrollBars,
} from "../scene/types"; } from "../scene/types";
import { getStateForZoom } from "../scene/zoom"; 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 { getSelectionBoxShape } from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision"; import { isPointInShape } from "../../utils/collision";
import type { import type {
@@ -259,6 +260,7 @@ import type {
ElementsPendingErasure, ElementsPendingErasure,
GenerateDiagramToCode, GenerateDiagramToCode,
NullableGridSize, NullableGridSize,
Offsets,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@@ -286,6 +288,7 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
toBrandedType,
} from "../utils"; } from "../utils";
import { import {
createSrcDoc, createSrcDoc,
@@ -298,6 +301,18 @@ import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import type {
SubtypeLoadedCb,
SubtypeRecord,
SubtypePrepFn,
} from "../element/subtypes";
import {
checkRefreshOnSubtypeLoad,
isSubtypeAction,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../element/subtypes";
import { import {
dataURLToFile, dataURLToFile,
generateIdFromFile, generateIdFromFile,
@@ -386,6 +401,7 @@ import {
getReferenceSnapPoints, getReferenceSnapPoints,
SnapCache, SnapCache,
isGridModeEnabled, isGridModeEnabled,
getGridPoint,
} from "../snapping"; } from "../snapping";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
@@ -433,12 +449,15 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds"; import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid"; import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
import { import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
getLinkDirectionFromKey, getLinkDirectionFromKey,
} from "../element/flowchart"; } from "../element/flowchart";
import { searchItemInFocusAtom } from "./SearchMenu";
import type { LocalPoint, Radians } from "../../math";
import { pointFrom, pointDistance, vector } from "../../math";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -545,6 +564,7 @@ class App extends React.Component<AppProps, AppState> {
public scene: Scene; public scene: Scene;
public fonts: Fonts; public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[];
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
@@ -552,7 +572,7 @@ class App extends React.Component<AppProps, AppState> {
public id: string; public id: string;
private store: Store; private store: Store;
private history: History; private history: History;
private excalidrawContainerValue: { public excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
id: string; id: string;
}; };
@@ -679,6 +699,7 @@ class App extends React.Component<AppProps, AppState> {
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
this.rc = rough.canvas(this.canvas); this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene); this.renderer = new Renderer(this.scene);
this.visibleElements = [];
this.store = new Store(); this.store = new Store();
this.history = new History(); this.history = new History();
@@ -701,6 +722,7 @@ class App extends React.Component<AppProps, AppState> {
registerAction: (action: Action) => { registerAction: (action: Action) => {
this.actionManager.registerAction(action); this.actionManager.registerAction(action);
}, },
addSubtype: this.addSubtype,
refresh: this.refresh, refresh: this.refresh,
setToast: this.setToast, setToast: this.setToast,
id: this.id, id: this.id,
@@ -737,6 +759,19 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction( this.actionManager.registerAction(
createRedoAction(this.history, this.store), createRedoAction(this.history, this.store),
); );
this.actionManager.registerActionPredicate(subtypeActionPredicate);
}
private addSubtype(record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) {
const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => {
const elements = this.getSceneElementsIncludingDeleted();
// If there are any elements of the just-registered subtype,
// refresh the scene to re-render each such element.
if (checkRefreshOnSubtypeLoad(hasSubtype, elements)) {
this.refresh();
}
};
return prepareSubtype(record, subtypePrepFn, subtypeLoadedCb);
} }
private onWindowMessage(event: MessageEvent) { private onWindowMessage(event: MessageEvent) {
@@ -1477,6 +1512,7 @@ class App extends React.Component<AppProps, AppState> {
newElementId: this.state.newElement?.id, newElementId: this.state.newElement?.id,
pendingImageElementId: this.state.pendingImageElementId, pendingImageElementId: this.state.pendingImageElementId,
}); });
this.visibleElements = visibleElements;
const allElementsMap = this.scene.getNonDeletedElementsMap(); const allElementsMap = this.scene.getNonDeletedElementsMap();
@@ -2292,6 +2328,9 @@ class App extends React.Component<AppProps, AppState> {
storeAction: StoreAction.UPDATE, 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 // FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all // fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also // fonts and rerender scene text elements once done. This also
@@ -2357,6 +2396,16 @@ class App extends React.Component<AppProps, AppState> {
return false; 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() { public async componentDidMount() {
this.unmounted = false; this.unmounted = false;
this.excalidrawContainerValue.container = this.excalidrawContainerValue.container =
@@ -2928,7 +2977,7 @@ class App extends React.Component<AppProps, AppState> {
// event else some browsers (FF...) will clear the clipboardData // event else some browsers (FF...) will clear the clipboardData
// (something something security) // (something something security)
let file = event?.clipboardData?.files[0]; let file = event?.clipboardData?.files[0];
const data = await parseClipboard(event, isPlainPaste); const data = await parseClipboard(event, isPlainPaste, this.state);
if (!file && !isPlainPaste) { if (!file && !isPlainPaste) {
if (data.mixedContent) { if (data.mixedContent) {
return this.addElementsFromMixedContentPaste(data.mixedContent, { return this.addElementsFromMixedContentPaste(data.mixedContent, {
@@ -3088,7 +3137,45 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, undefined); let elements = opts.elements.map((el, _, elements) => {
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -3212,6 +3299,7 @@ class App extends React.Component<AppProps, AppState> {
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(newElements, { this.scrollToContent(newElements, {
fitToContent: true, fitToContent: true,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
}; };
@@ -3327,6 +3415,7 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: this.state.currentItemFontFamily, fontFamily: this.state.currentItemFontFamily,
textAlign: DEFAULT_TEXT_ALIGN, textAlign: DEFAULT_TEXT_ALIGN,
verticalAlign: DEFAULT_VERTICAL_ALIGN, verticalAlign: DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
locked: false, locked: false,
}; };
const fontString = getFontString({ const fontString = getFontString({
@@ -3524,7 +3613,7 @@ class App extends React.Component<AppProps, AppState> {
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: opts?: (
| { | {
fitToContent?: boolean; fitToContent?: boolean;
fitToViewport?: never; fitToViewport?: never;
@@ -3541,7 +3630,12 @@ class App extends React.Component<AppProps, AppState> {
viewportZoomFactor?: number; viewportZoomFactor?: number;
animate?: boolean; animate?: boolean;
duration?: number; duration?: number;
}, }
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
},
) => { ) => {
this.cancelInProgressAnimation?.(); this.cancelInProgressAnimation?.();
@@ -3554,10 +3648,13 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.fitToContent || opts?.fitToViewport) { if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({ const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements, targetElements,
appState: this.state, appState: this.state,
fitToViewport: !!opts?.fitToViewport, fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor, viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
}); });
zoom = appState.zoom; zoom = appState.zoom;
scrollX = appState.scrollX; scrollX = appState.scrollX;
@@ -3671,15 +3768,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...Object.fromEntries(filesMap) }; this.files = { ...this.files, ...Object.fromEntries(filesMap) };
this.scene.getNonDeletedElements().forEach((element) => { this.clearImageShapeCache(Object.fromEntries(filesMap));
if (
isInitializedImageElement(element) &&
filesMap.has(element.fileId)
) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
this.scene.triggerUpdate(); this.scene.triggerUpdate();
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
@@ -3793,40 +3882,42 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
private getEditorUIOffsets = (): { public getEditorUIOffsets = (): Offsets => {
top: number;
right: number;
bottom: number;
left: number;
} => {
const toolbarBottom = const toolbarBottom =
this.excalidrawContainerRef?.current this.excalidrawContainerRef?.current
?.querySelector(".App-toolbar") ?.querySelector(".App-toolbar")
?.getBoundingClientRect()?.bottom ?? 0; ?.getBoundingClientRect()?.bottom ?? 0;
const sidebarWidth = Math.max( const sidebarRect = this.excalidrawContainerRef?.current
this.excalidrawContainerRef?.current ?.querySelector(".sidebar")
?.querySelector(".default-sidebar") ?.getBoundingClientRect();
?.getBoundingClientRect()?.width ?? 0, const propertiesPanelRect = this.excalidrawContainerRef?.current
); ?.querySelector(".App-menu__left")
const propertiesPanelWidth = Math.max( ?.getBoundingClientRect();
this.excalidrawContainerRef?.current
?.querySelector(".App-menu__left") const PADDING = 16;
?.getBoundingClientRect()?.width ?? 0,
0,
);
return getLanguage().rtl return getLanguage().rtl
? { ? {
top: toolbarBottom, top: toolbarBottom + PADDING,
right: propertiesPanelWidth, right:
bottom: 0, Math.max(
left: sidebarWidth, this.state.width -
(propertiesPanelRect?.left ?? this.state.width),
0,
) + PADDING,
bottom: PADDING,
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
} }
: { : {
top: toolbarBottom, top: toolbarBottom + PADDING,
right: sidebarWidth, right: Math.max(
bottom: 0, this.state.width -
left: propertiesPanelWidth, (sidebarRect?.left ?? this.state.width) +
PADDING,
0,
),
bottom: PADDING,
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
}; };
}; };
@@ -3911,7 +4002,7 @@ class App extends React.Component<AppProps, AppState> {
animate: true, animate: true,
duration: 300, duration: 300,
fitToContent: true, fitToContent: true,
viewportZoomFactor: 0.8, canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
@@ -3967,6 +4058,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(nextNode, { this.scrollToContent(nextNode, {
animate: true, animate: true,
duration: 300, duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
} }
@@ -4399,6 +4491,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(firstNode, { this.scrollToContent(firstNode, {
animate: true, animate: true,
duration: 300, duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
} }
@@ -4844,7 +4937,7 @@ class App extends React.Component<AppProps, AppState> {
this.getElementHitThreshold(), this.getElementHitThreshold(),
); );
return isPointInShape([x, y], selectionShape); return isPointInShape(pointFrom(x, y), selectionShape);
} }
// take bound text element into consideration for hit collision as well // take bound text element into consideration for hit collision as well
@@ -5032,10 +5125,11 @@ class App extends React.Component<AppProps, AppState> {
verticalAlign: parentCenterPosition verticalAlign: parentCenterPosition
? VERTICAL_ALIGN.MIDDLE ? VERTICAL_ALIGN.MIDDLE
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
lineHeight, lineHeight,
angle: container?.angle ?? 0, angle: container?.angle ?? (0 as Radians),
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -5203,7 +5297,7 @@ class App extends React.Component<AppProps, AppState> {
element, element,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state, this.state,
[scenePointer.x, scenePointer.y], pointFrom(scenePointer.x, scenePointer.y),
this.device.editor.isMobile, this.device.editor.isMobile,
) )
); );
@@ -5214,11 +5308,15 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
isTouchScreen: boolean, isTouchScreen: boolean,
) => { ) => {
const draggedDistance = distance2d( const draggedDistance = pointDistance(
this.lastPointerDownEvent!.clientX, pointFrom(
this.lastPointerDownEvent!.clientY, this.lastPointerDownEvent!.clientX,
this.lastPointerUpEvent!.clientX, this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientY, ),
pointFrom(
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
),
); );
if ( if (
!this.hitLinkElement || !this.hitLinkElement ||
@@ -5237,7 +5335,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement, this.hitLinkElement,
elementsMap, elementsMap,
this.state, this.state,
[lastPointerDownCoords.x, lastPointerDownCoords.y], pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y),
this.device.editor.isMobile, this.device.editor.isMobile,
); );
const lastPointerUpCoords = viewportCoordsToSceneCoords( const lastPointerUpCoords = viewportCoordsToSceneCoords(
@@ -5248,7 +5346,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement, this.hitLinkElement,
elementsMap, elementsMap,
this.state, this.state,
[lastPointerUpCoords.x, lastPointerUpCoords.y], pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y),
this.device.editor.isMobile, this.device.editor.isMobile,
); );
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
@@ -5497,17 +5595,18 @@ class App extends React.Component<AppProps, AppState> {
// if we haven't yet created a temp point and we're beyond commit-zone // if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point // threshold, add a point
if ( if (
distance2d( pointDistance(
scenePointerX - rx, pointFrom(scenePointerX - rx, scenePointerY - ry),
scenePointerY - ry, lastPoint,
lastPoint[0],
lastPoint[1],
) >= LINE_CONFIRM_THRESHOLD ) >= LINE_CONFIRM_THRESHOLD
) { ) {
mutateElement( mutateElement(
multiElement, multiElement,
{ {
points: [...points, [scenePointerX - rx, scenePointerY - ry]], points: [
...points,
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
],
}, },
false, false,
); );
@@ -5519,11 +5618,9 @@ class App extends React.Component<AppProps, AppState> {
} else if ( } else if (
points.length > 2 && points.length > 2 &&
lastCommittedPoint && lastCommittedPoint &&
distance2d( pointDistance(
scenePointerX - rx, pointFrom(scenePointerX - rx, scenePointerY - ry),
scenePointerY - ry, lastCommittedPoint,
lastCommittedPoint[0],
lastCommittedPoint[1],
) < LINE_CONFIRM_THRESHOLD ) < LINE_CONFIRM_THRESHOLD
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@@ -5570,10 +5667,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
[ [
...points.slice(0, -1), ...points.slice(0, -1),
[ pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted, lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted, lastCommittedY + dyFromLastCommitted,
], ),
], ],
undefined, undefined,
undefined, undefined,
@@ -5589,10 +5686,10 @@ class App extends React.Component<AppProps, AppState> {
{ {
points: [ points: [
...points.slice(0, -1), ...points.slice(0, -1),
[ pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted, lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted, lastCommittedY + dyFromLastCommitted,
], ),
], ],
}, },
false, false,
@@ -5817,17 +5914,15 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
const distance = distance2d( const distance = pointDistance(
pointerDownState.lastCoords.x, pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
pointerDownState.lastCoords.y, pointFrom(scenePointer.x, scenePointer.y),
scenePointer.x,
scenePointer.y,
); );
const threshold = this.getElementHitThreshold(); const threshold = this.getElementHitThreshold();
const point = { ...pointerDownState.lastCoords }; const p = { ...pointerDownState.lastCoords };
let samplingInterval = 0; let samplingInterval = 0;
while (samplingInterval <= distance) { while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y); const hitElements = this.getElementsAtPosition(p.x, p.y);
processElements(hitElements); processElements(hitElements);
// Exit since we reached current point // Exit since we reached current point
@@ -5839,12 +5934,10 @@ class App extends React.Component<AppProps, AppState> {
samplingInterval = Math.min(samplingInterval + threshold, distance); samplingInterval = Math.min(samplingInterval + threshold, distance);
const distanceRatio = samplingInterval / distance; const distanceRatio = samplingInterval / distance;
const nextX = const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
(1 - distanceRatio) * point.x + distanceRatio * scenePointer.x; const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
const nextY = p.x = nextX;
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y; p.y = nextY;
point.x = nextX;
point.y = nextY;
} }
pointerDownState.lastCoords.x = scenePointer.x; pointerDownState.lastCoords.x = scenePointer.x;
@@ -5970,6 +6063,16 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); 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, // since contextMenu options are potentially evaluated on each render,
// and an contextMenu action may depend on selection state, we must // and an contextMenu action may depend on selection state, we must
// close the contextMenu before we update the selection on pointerDown // close the contextMenu before we update the selection on pointerDown
@@ -6325,7 +6428,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement, this.hitLinkElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.state, this.state,
[scenePointer.x, scenePointer.y], pointFrom(scenePointer.x, scenePointer.y),
) )
) { ) {
this.handleEmbeddableCenterClick(this.hitLinkElement); this.handleEmbeddableCenterClick(this.hitLinkElement);
@@ -6398,8 +6501,16 @@ class App extends React.Component<AppProps, AppState> {
} }
isPanning = true; isPanning = true;
// 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) { if (!this.state.editingTextElement) {
// preventing defualt while text editing messes with cursor/focus // 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(); event.preventDefault();
} }
@@ -7008,7 +7119,7 @@ class App extends React.Component<AppProps, AppState> {
simulatePressure, simulatePressure,
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
points: [[0, 0]], points: [pointFrom<LocalPoint>(0, 0)],
pressures: simulatePressure ? [] : [event.pressure], pressures: simulatePressure ? [] : [event.pressure],
}); });
@@ -7171,6 +7282,7 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
roundness: null, roundness: null,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
...selectSubtype(this.state, "image"),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -7216,11 +7328,12 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
multiElement.points.length > 1 && multiElement.points.length > 1 &&
lastCommittedPoint && lastCommittedPoint &&
distance2d( pointDistance(
pointerDownState.origin.x - rx, pointFrom(
pointerDownState.origin.y - ry, pointerDownState.origin.x - rx,
lastCommittedPoint[0], pointerDownState.origin.y - ry,
lastCommittedPoint[1], ),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD ) < LINE_CONFIRM_THRESHOLD
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -7286,6 +7399,7 @@ class App extends React.Component<AppProps, AppState> {
null, null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
@@ -7305,6 +7419,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.currentItemRoundness === "round" this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS } ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null, : null,
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -7321,7 +7436,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
}); });
mutateElement(element, { mutateElement(element, {
points: [...element.points, [0, 0]], points: [...element.points, pointFrom<LocalPoint>(0, 0)],
}); });
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
@@ -7385,6 +7500,7 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: this.getCurrentItemRoundness(elementType), roundness: this.getCurrentItemRoundness(elementType),
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
} as const; } as const;
@@ -7573,11 +7689,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "line") this.state.activeTool.type === "line")
) { ) {
if ( if (
distance2d( pointDistance(
pointerCoords.x, pointFrom(pointerCoords.x, pointerCoords.y),
pointerCoords.y, pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
pointerDownState.origin.x,
pointerDownState.origin.y,
) < DRAGGING_THRESHOLD ) < DRAGGING_THRESHOLD
) { ) {
return; return;
@@ -7926,7 +8040,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement( mutateElement(
newElement, newElement,
{ {
points: [...points, [dx, dy]], points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures, pressures,
}, },
false, false,
@@ -7955,7 +8069,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement( mutateElement(
newElement, newElement,
{ {
points: [...points, [dx, dy]], points: [...points, pointFrom<LocalPoint>(dx, dy)],
}, },
false, false,
); );
@@ -7963,8 +8077,8 @@ class App extends React.Component<AppProps, AppState> {
mutateElbowArrow( mutateElbowArrow(
newElement, newElement,
elementsMap, elementsMap,
[...points.slice(0, -1), [dx, dy]], [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
[0, 0], vector(0, 0),
undefined, undefined,
{ {
isDragging: true, isDragging: true,
@@ -7975,7 +8089,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement( mutateElement(
newElement, newElement,
{ {
points: [...points.slice(0, -1), [dx, dy]], points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
}, },
false, false,
); );
@@ -8284,9 +8398,9 @@ class App extends React.Component<AppProps, AppState> {
: [...newElement.pressures, childEvent.pressure]; : [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, { mutateElement(newElement, {
points: [...points, [dx, dy]], points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures, pressures,
lastCommittedPoint: [dx, dy], lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
}); });
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -8333,7 +8447,10 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(newElement, { mutateElement(newElement, {
points: [ points: [
...newElement.points, ...newElement.points,
[pointerCoords.x - newElement.x, pointerCoords.y - newElement.y], pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
], ],
}); });
this.setState({ this.setState({
@@ -8643,11 +8760,9 @@ class App extends React.Component<AppProps, AppState> {
if (isEraserActive(this.state) && pointerStart && pointerEnd) { if (isEraserActive(this.state) && pointerStart && pointerEnd) {
this.eraserTrail.endPath(); this.eraserTrail.endPath();
const draggedDistance = distance2d( const draggedDistance = pointDistance(
pointerStart.clientX, pointFrom(pointerStart.clientX, pointerStart.clientY),
pointerStart.clientY, pointFrom(pointerEnd.clientX, pointerEnd.clientY),
pointerEnd.clientX,
pointerEnd.clientY,
); );
if (draggedDistance === 0) { if (draggedDistance === 0) {
@@ -9965,6 +10080,39 @@ class App extends React.Component<AppProps, AppState> {
const elementsToHighlight = new Set<ExcalidrawElement>(); const elementsToHighlight = new Set<ExcalidrawElement>();
selectedFrames.forEach((frame) => { selectedFrames.forEach((frame) => {
const elementsInFrame = getFrameChildren(
this.scene.getNonDeletedElements(),
frame.id,
);
// keep elements' positions relative to their frames on frames resizing
if (transformHandleType) {
if (transformHandleType.includes("w")) {
elementsInFrame.forEach((element) => {
mutateElement(element, {
x:
frame.x +
(frameElementsOffsetsMap.get(frame.id + element.id)?.x || 0),
y:
frame.y +
(frameElementsOffsetsMap.get(frame.id + element.id)?.y || 0),
});
});
}
if (transformHandleType.includes("n")) {
elementsInFrame.forEach((element) => {
mutateElement(element, {
x:
frame.x +
(frameElementsOffsetsMap.get(frame.id + element.id)?.x || 0),
y:
frame.y +
(frameElementsOffsetsMap.get(frame.id + element.id)?.y || 0),
});
});
}
}
getElementsInResizingFrame( getElementsInResizingFrame(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
frame, frame,
@@ -9985,6 +10133,29 @@ class App extends React.Component<AppProps, AppState> {
private getContextMenuItems = ( private getContextMenuItems = (
type: "canvas" | "element", type: "canvas" | "element",
): ContextMenuItems => {
const subtype: ContextMenuItems = [];
this.actionManager
.filterActions(isSubtypeAction)
.forEach(
(action) =>
this.actionManager.isActionEnabled(action, { data: {} }) &&
subtype.push(action),
);
if (subtype.length > 0) {
subtype.push(CONTEXT_MENU_SEPARATOR);
}
const standard: ContextMenuItems = this._getContextMenuItems(type).filter(
(item) =>
!item ||
item === CONTEXT_MENU_SEPARATOR ||
this.actionManager.isActionEnabled(item, { noPredicates: true }),
);
return [...subtype, ...standard];
};
private _getContextMenuItems = (
type: "canvas" | "element",
): ContextMenuItems => { ): ContextMenuItems => {
const options: ContextMenuItems = []; const options: ContextMenuItems = [];
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes"; import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback"; import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions"; import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai"; import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types"; 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"), label: t("labels.changeStroke"),
keywords: ["color", "outline"], keywords: ["color", "outline"],
@@ -1,8 +1,11 @@
import clsx from "clsx"; 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 { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState"; import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import type { MarkOptional, Merge } from "../utility-types"; import type { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils"; import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu"; import { LibraryMenu } from "./LibraryMenu";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common"; import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar"; import { Sidebar } from "./Sidebar/Sidebar";
import "../components/dropdownMenu/DropdownMenu.scss";
import { SearchMenu } from "./SearchMenu";
import { LibraryIcon, searchIcon } from "./icons";
const DefaultSidebarTrigger = withInternalFallback( const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger", "DefaultSidebarTrigger",
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
); );
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger"; DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({ const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels(); const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return ( return (
<DefaultSidebarTabTriggersTunnel.In> <DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers> {children}
</DefaultSidebarTabTriggersTunnel.In> </DefaultSidebarTabTriggersTunnel.In>
); );
}; };
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
const { DefaultSidebarTabTriggersTunnel } = useTunnels(); const { DefaultSidebarTabTriggersTunnel } = useTunnels();
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
return ( return (
<Sidebar <Sidebar
{...rest} {...rest}
name="default" name="default"
key="default" key="default"
className={clsx("default-sidebar", className)} className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference} docked={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
onDock={ onDock={
// `onDock=false` disables docking. // `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking. // if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null) isForceDocked || onDock === false || (!onDock && docked != null)
? undefined ? undefined
: // compose to allow the host app to listen on default behavior : // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => { composeEventHandlers(onDock, (docked) => {
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
> >
<Sidebar.Tabs> <Sidebar.Tabs>
<Sidebar.Header> <Sidebar.Header>
{rest.__fallback && ( <Sidebar.TabTriggers>
<div <Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
style={{ {searchIcon}
color: "var(--color-primary)", </Sidebar.TabTrigger>
fontSize: "1.2em", <Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
fontWeight: "bold", {LibraryIcon}
textOverflow: "ellipsis", </Sidebar.TabTrigger>
overflow: "hidden", <DefaultSidebarTabTriggersTunnel.Out />
whiteSpace: "nowrap", </Sidebar.TabTriggers>
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}> <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu /> <LibraryMenu />
</Sidebar.Tab> </Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children} {children}
</Sidebar.Tabs> </Sidebar.Tabs>
</Sidebar> </Sidebar>
@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.fullTitle")} label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]} shortcuts={[getShortcutKey("Alt+/")]}
/> />
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut <Shortcut
label={t("commandPalette.title")} label={t("commandPalette.title")}
shortcuts={ shortcuts={
@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart"; import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping"; import { isGridModeEnabled } from "../snapping";
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
@@ -30,6 +31,14 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; 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) { if (appState.openSidebar && !device.editor.canFitSidebar) {
return null; return null;
} }
+6 -3
View File
@@ -53,17 +53,18 @@ import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState"; import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar"; import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { SubtypeToggles } from "./Subtypes";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: UIAppState; appState: UIAppState;
@@ -99,6 +100,7 @@ const DefaultMainMenu: React.FC<{
{UIOptions.canvasActions.saveAsImage && ( {UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
)} )}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
@@ -298,6 +300,7 @@ const LayerUI = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<SubtypeToggles />
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
@@ -24,6 +24,7 @@ import { PenModeButton } from "./PenModeButton";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { SubtypeToggles } from "./Subtypes";
type MobileMenuProps = { type MobileMenuProps = {
appState: UIAppState; appState: UIAppState;
@@ -89,6 +90,7 @@ export const MobileMenu = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<SubtypeToggles />
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
@@ -3,7 +3,7 @@ import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts"; import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts"; import { renderSpreadsheet } from "../charts";
import type { ChartType } from "../element/types"; import type { ChartType, ElementsMap } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types"; import type { UIAppState } from "../types";
@@ -11,6 +11,12 @@ import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; import "./PasteChartDialog.scss";
import { ensureSubtypesLoaded } from "../element/subtypes";
import { isTextElement } from "../element";
import {
getContainerElement,
redrawTextBoundingBox,
} from "../element/textElement";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -26,41 +32,64 @@ const ChartPreviewBtn = (props: {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
if (!props.spreadsheet) {
return;
}
const elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
setChartElements(elements);
let svg: SVGSVGElement; let svg: SVGSVGElement;
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg( (async () => {
elements, let elements: ChartElements;
{ await ensureSubtypesLoaded(
exportBackground: false, props.spreadsheet?.activeSubtypes ?? [],
viewBackgroundColor: oc.white, () => {
}, if (!props.spreadsheet) {
null, // files return;
); }
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) { elements = renderSpreadsheet(
(previewNode.parentNode as HTMLDivElement).focus(); props.chartType,
} props.spreadsheet,
0,
0,
);
const elementsMap = new Map() as ElementsMap;
for (const element of elements) {
if (!element.isDeleted) {
elementsMap.set(element.id, element);
}
}
elements.forEach(
(el) =>
isTextElement(el) &&
redrawTextBoundingBox(
el,
getContainerElement(el, elementsMap),
elementsMap,
),
);
setChartElements(elements);
},
).then(async () => {
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
});
})();
return () => {
previewNode.replaceChildren();
};
})(); })();
return () => {
previewNode.replaceChildren();
};
}, [props.spreadsheet, props.chartType, props.selected]); }, [props.spreadsheet, props.chartType, props.selected]);
return ( return (
@@ -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 { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks"; import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface AngleProps { interface AngleProps {
element: ExcalidrawElement; element: ExcalidrawElement;
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
} }
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue); const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
} }
const originalAngleInDegrees = const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100; Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange); const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) { if (shouldChangeByStepSize) {
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
nextAngleInDegrees = nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees); const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
<DragInput <DragInput
label="A" label="A"
icon={angleIcon} icon={angleIcon}
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100} value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]} elements={[element]}
dragInputCallback={handleDegreeChange} dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")} editable={isPropertyEditable(element, "angle")}
@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks"; import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface MultiAngleProps { interface MultiAngleProps {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
); );
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue); const nextAngle = degreesToRadians(nextValue as Degrees);
for (const element of editableLatestIndividualElements) { for (const element of editableLatestIndividualElements) {
if (!element) { if (!element) {
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
} }
const originalElement = editableOriginalIndividualElements[i]; const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees = const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100; Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange); const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) { if (shouldChangeByStepSize) {
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
nextAngleInDegrees = nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees); const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement( mutateElement(
latestElement, latestElement,
@@ -109,7 +110,7 @@ const MultiAngle = ({
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"), (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
); );
const angles = editableLatestIndividualElements.map( 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"; const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
@@ -13,13 +13,14 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types"; import type { AppState } from "../../types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math";
interface MultiDimensionProps { interface MultiDimensionProps {
property: "width" | "height"; property: "width" | "height";
@@ -104,7 +105,7 @@ const resizeGroup = (
nextHeight: number, nextHeight: number,
initialHeight: number, initialHeight: number,
aspectRatio: number, aspectRatio: number,
anchor: Point, anchor: GlobalPoint,
property: MultiDimensionProps["property"], property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[], latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
initialHeight, initialHeight,
aspectRatio, aspectRatio,
[x1, y1], pointFrom(x1, y1),
property, property,
latestElements, latestElements,
originalElements, originalElements,
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
initialHeight, initialHeight,
aspectRatio, aspectRatio,
[x1, y1], pointFrom(x1, y1),
property, property,
latestElements, latestElements,
originalElements, originalElements,
@@ -4,7 +4,6 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
@@ -14,6 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils"; import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
interface MultiPositionProps { interface MultiPositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -43,11 +43,9 @@ const moveElements = (
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
origElement.x, pointFrom(origElement.x, origElement.y),
origElement.y, pointFrom(cx, cy),
cx,
cy,
origElement.angle, origElement.angle,
); );
@@ -98,11 +96,9 @@ const moveGroupTo = (
latestElement.y + latestElement.height / 2, latestElement.y + latestElement.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
latestElement.x, pointFrom(latestElement.x, latestElement.y),
latestElement.y, pointFrom(cx, cy),
cx,
cy,
latestElement.angle, latestElement.angle,
); );
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
origElement.x, pointFrom(origElement.x, origElement.y),
origElement.y, pointFrom(cx, cy),
cx,
cy,
origElement.angle, origElement.angle,
); );
@@ -246,7 +240,11 @@ const MultiPosition = ({
const [el] = elementsInUnit; const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; 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(
pointFrom(el.x, el.y),
pointFrom(cx, cy),
el.angle,
);
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}), }),
@@ -1,10 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
origElement.x, pointFrom(origElement.x, origElement.y),
origElement.y, pointFrom(cx, cy),
cx,
cy,
origElement.angle, origElement.angle,
); );
@@ -94,11 +92,9 @@ const Position = ({
scene, scene,
appState, appState,
}: PositionProps) => { }: PositionProps) => {
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
element.x, pointFrom(element.x, element.y),
element.y, pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.x + element.width / 2,
element.y + element.height / 2,
element.angle, element.angle,
); );
const value = const value =
@@ -19,12 +19,13 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../../element/types"; } from "../../element/types";
import { degreeToRadian, rotate } from "../../math";
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom"; import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
import { getCommonBounds, isTextElement } from "../../element"; import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api"; import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import type { Degrees } from "../../../math";
import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -46,7 +47,9 @@ const testInputProperty = (
expect(input.value).toBe(initialValue.toString()); expect(input.value).toBe(initialValue.toString());
UI.updateInput(input, String(nextValue)); UI.updateInput(input, String(nextValue));
if (property === "angle") { 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)) { } else if (property === "fontSize" && isTextElement(element)) {
expect(element[property]).toBe(Number(nextValue)); expect(element[property]).toBe(Number(nextValue));
} else if (property !== "fontSize") { } else if (property !== "fontSize") {
@@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
rectangle.x, pointFrom(rectangle.x, rectangle.y),
rectangle.y, pointFrom(cx, cy),
cx,
cy,
rectangle.angle, rectangle.angle,
); );
@@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45); testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = rotate( let [newTopLeftX, newTopLeftY] = pointRotateRads(
rectangle.x, pointFrom(rectangle.x, rectangle.y),
rectangle.y, pointFrom(cx, cy),
cx,
cy,
rectangle.angle, rectangle.angle,
); );
@@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66); testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = rotate( [newTopLeftX, newTopLeftY] = pointRotateRads(
rectangle.x, pointFrom(rectangle.x, rectangle.y),
rectangle.y, pointFrom(cx, cy),
cx,
cy,
rectangle.angle, rectangle.angle,
); );
expect(newTopLeftX.toString()).not.toEqual(xInput.value); expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
rectangle.x, pointFrom(rectangle.x, rectangle.y),
rectangle.y, pointFrom(cx, cy),
cx,
cy,
rectangle.angle, rectangle.angle,
); );
testInputProperty(rectangle, "width", "W", rectangle.width, 400); testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
let [currentTopLeftX, currentTopLeftY] = rotate( let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
rectangle.x, pointFrom(rectangle.x, rectangle.y),
rectangle.y, pointFrom(cx, cy),
cx,
cy,
rectangle.angle, rectangle.angle,
); );
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
[currentTopLeftX, currentTopLeftY] = rotate( [currentTopLeftX, currentTopLeftY] = pointRotateRads(
rectangle.x, pointFrom(rectangle.x, rectangle.y),
rectangle.y, pointFrom(cx, cy),
cx,
cy,
rectangle.angle, rectangle.angle,
); );
@@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
UI.updateInput(angle, "40"); UI.updateInput(angle, "40");
const angleInRadian = degreeToRadian(40); const angleInRadian = degreesToRadians(40 as Degrees);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
expect(text?.angle).toBeCloseTo(angleInRadian, 4); expect(text?.angle).toBeCloseTo(angleInRadian, 4);
expect(frame.angle).toBe(0); expect(frame.angle).toBe(0);
+9 -12
View File
@@ -1,3 +1,5 @@
import type { Radians } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
updateBoundElements, updateBoundElements,
@@ -30,7 +32,6 @@ import {
getElementsInGroup, getElementsInGroup,
isInGroup, isInGroup,
} from "../../groups"; } from "../../groups";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFontString } from "../../utils"; import { getFontString } from "../../utils";
@@ -229,23 +230,19 @@ export const moveElement = (
originalElement.x + originalElement.width / 2, originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2, originalElement.y + originalElement.height / 2,
]; ];
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = pointRotateRads(
originalElement.x, pointFrom(originalElement.x, originalElement.y),
originalElement.y, pointFrom(cx, cy),
cx,
cy,
originalElement.angle, originalElement.angle,
); );
const changeInX = newTopLeftX - topLeftX; const changeInX = newTopLeftX - topLeftX;
const changeInY = newTopLeftY - topLeftY; const changeInY = newTopLeftY - topLeftY;
const [x, y] = rotate( const [x, y] = pointRotateRads(
newTopLeftX, pointFrom(newTopLeftX, newTopLeftY),
newTopLeftY, pointFrom(cx + changeInX, cy + changeInY),
cx + changeInX, -originalElement.angle as Radians,
cy + changeInY,
-originalElement.angle,
); );
mutateElement( mutateElement(
+188
View File
@@ -0,0 +1,188 @@
import { getShortcutKey, updateActiveTool } from "../utils";
import { t } from "../i18n";
import type { Action } from "../actions/types";
import { makeCustomActionName } from "../actions/types";
import clsx from "clsx";
import type { Subtype, SubtypeRecord } from "../element/subtypes";
import {
getSubtypeNames,
hasAlwaysEnabledActions,
isSubtypeAction,
isValidSubtype,
subtypeCollides,
} from "../element/subtypes";
import type { ExcalidrawElement, Theme } from "../element/types";
import {
useExcalidrawActionManager,
useExcalidrawContainer,
useExcalidrawSetAppState,
} from "./App";
import type { ContextMenuItems } from "./ContextMenu";
import { Island } from "./Island";
export const SubtypeButton = (
subtype: Subtype,
parentType: SubtypeRecord["parents"][number],
icon: ({ theme }: { theme: Theme }) => JSX.Element,
key?: string,
) => {
const title = key !== undefined ? ` - ${getShortcutKey(key)}` : "";
const keyTest: Action["keyTest"] =
key !== undefined ? (event) => event.code === `Key${key}` : undefined;
const subtypeAction: Action = {
name: makeCustomActionName(subtype),
label: t(`toolBar.${subtype}`),
trackEvent: false,
predicate: (...rest) => rest[4]?.subtype === subtype,
perform: (elements, appState) => {
const inactive = !appState.activeSubtypes?.includes(subtype) ?? true;
const activeSubtypes: Subtype[] = [];
if (appState.activeSubtypes) {
activeSubtypes.push(...appState.activeSubtypes);
}
let activated = false;
if (inactive) {
// Ensure `element.subtype` is well-defined
if (!subtypeCollides(subtype, activeSubtypes)) {
activeSubtypes.push(subtype);
activated = true;
}
} else {
// Can only be active if appState.activeSubtypes is defined
// and contains subtype.
activeSubtypes.splice(activeSubtypes.indexOf(subtype), 1);
}
const type =
appState.activeTool.type !== "custom" &&
isValidSubtype(subtype, appState.activeTool.type)
? appState.activeTool.type
: parentType;
const activeTool = !inactive
? appState.activeTool
: updateActiveTool(appState, { type });
const selectedElementIds = activated ? {} : appState.selectedElementIds;
const selectedGroupIds = activated ? {} : appState.selectedGroupIds;
return {
appState: {
...appState,
activeSubtypes,
selectedElementIds,
selectedGroupIds,
activeTool,
},
storeAction: "capture",
};
},
keyTest,
PanelComponent: ({ elements, appState, updateData, data }) => (
<button
className={clsx("ToolIcon_type_button", "ToolIcon_type_button--show", {
ToolIcon: true,
"ToolIcon--selected":
appState.activeSubtypes !== undefined &&
appState.activeSubtypes.includes(subtype),
"ToolIcon--plain": true,
})}
title={`${t(`toolBar.${subtype}`)}${title}`}
aria-label={t(`toolBar.${subtype}`)}
onClick={() => {
updateData(null);
}}
onContextMenu={
data && "onContextMenu" in data
? (event: React.MouseEvent) => {
if (
appState.activeSubtypes === undefined ||
(appState.activeSubtypes !== undefined &&
!appState.activeSubtypes.includes(subtype))
) {
updateData(null);
}
data.onContextMenu(event, subtype);
}
: undefined
}
>
{
<div className="ToolIcon__icon" aria-hidden="true">
{icon.call(this, { theme: appState.theme })}
</div>
}
</button>
),
};
if (key === "") {
delete subtypeAction.keyTest;
}
return subtypeAction;
};
export const SubtypeToggles = () => {
const am = useExcalidrawActionManager();
const { container } = useExcalidrawContainer();
const setAppState = useExcalidrawSetAppState();
const onContextMenu = (
event: React.MouseEvent<HTMLButtonElement>,
subtype: string,
) => {
event.preventDefault();
const { top: offsetTop, left: offsetLeft } =
container!.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
const items: ContextMenuItems = [];
am.filterActions(isSubtypeAction).forEach(
(action) =>
am.isActionEnabled(action, { data: { subtype } }) && items.push(action),
);
setAppState({}, () => {
setAppState({
contextMenu: { top, left, items },
});
});
};
// Only render if one or more subtypes are registered
if (getSubtypeNames().length === 0) {
return <></>;
}
return (
<>
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
{getSubtypeNames().map((subtype) =>
am.renderAction(
makeCustomActionName(subtype),
hasAlwaysEnabledActions(subtype) ? { onContextMenu } : {},
),
)}
</Island>
</>
);
};
SubtypeToggles.displayName = "SubtypeToggles";
export const SubtypeShapeActions = (props: {
elements: readonly ExcalidrawElement[];
}) => {
const am = useExcalidrawActionManager();
return (
<>
{am
.filterActions(isSubtypeAction, { elements: props.elements })
.map((action) => am.renderAction(action.name))}
</>
);
};
SubtypeShapeActions.displayName = "SubtypeShapeActions";
@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons"; import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss"; import "./TTDDialog.scss";
import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import { isFiniteNumber } from "../../../math";
const MIN_PROMPT_LENGTH = 3; const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000; const MAX_PROMPT_LENGTH = 1000;
+21 -7
View File
@@ -3,16 +3,29 @@
.excalidraw { .excalidraw {
--ExcTextField--color: var(--color-on-surface); --ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-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--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface); --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--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover); --ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active); --ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant); --ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField { .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 { &--fullWidth {
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
@@ -37,7 +50,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 0 1rem;
height: 3rem; height: 3rem;
@@ -45,6 +57,8 @@
border: 1px solid var(--ExcTextField--border); border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0 0.75rem;
&:not(&--readonly) { &:not(&--readonly) {
&:hover { &:hover {
border-color: var(--ExcTextField--border-hover); border-color: var(--ExcTextField--border-hover);
@@ -80,10 +94,6 @@
width: 100%; width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) { &:not(:focus) {
&:hover { &:hover {
background-color: initial; 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; fullWidth?: boolean;
selectOnRender?: boolean; selectOnRender?: boolean;
icon?: React.ReactNode;
label?: string; label?: string;
className?: string;
placeholder?: string; placeholder?: string;
isRedacted?: boolean; isRedacted?: boolean;
} & ({ value: string } | { defaultValue: string }); } & ({ value: string } | { defaultValue: string });
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
selectOnRender, selectOnRender,
onKeyDown, onKeyDown,
isRedacted = false, isRedacted = false,
icon,
className,
...rest ...rest
}, },
ref, ref,
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
useLayoutEffect(() => { useLayoutEffect(() => {
if (selectOnRender) { if (selectOnRender) {
// focusing first is needed because vitest/jsdom
innerRef.current?.focus();
innerRef.current?.select(); innerRef.current?.select();
} }
}, [selectOnRender]); }, [selectOnRender]);
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
return ( return (
<div <div
className={clsx("ExcTextField", { className={clsx("ExcTextField", className, {
"ExcTextField--fullWidth": fullWidth, "ExcTextField--fullWidth": fullWidth,
"ExcTextField--hasIcon": !!icon,
})} })}
onClick={() => { onClick={() => {
innerRef.current?.focus(); innerRef.current?.focus();
}} }}
> >
<div className="ExcTextField__label">{label}</div> {icon}
{label && <div className="ExcTextField__label">{label}</div>}
<div <div
className={clsx("ExcTextField__input", { className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly, "ExcTextField__input--readonly": readonly,
@@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement, editingTextElement: appState.editingTextElement,
searchMatches: appState.searchMatches,
}); });
const areEqual = ( const areEqual = (
@@ -1,4 +1,4 @@
import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types"; import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
import { import {
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
@@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App"; import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks"; import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers"; import { getLinkHandleFromCoords } from "./helpers";
import { pointFrom, type GlobalPoint } from "../../../math";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@@ -176,10 +177,12 @@ export const Hyperlink = ({
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [ const shouldHide = shouldHideLinkPopup(
event.clientX, element,
event.clientY, elementsMap,
]) as boolean; appState,
pointFrom(event.clientX, event.clientY),
) as boolean;
if (shouldHide) { if (shouldHide) {
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
setAppState({ showHyperlinkPopup: false }); setAppState({ showHyperlinkPopup: false });
@@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: AppState, appState: AppState,
[clientX, clientY]: Point, [clientX, clientY]: GlobalPoint,
): Boolean => { ): Boolean => {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX, clientY }, { clientX, clientY },
@@ -1,3 +1,5 @@
import type { GlobalPoint, Radians } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds"; import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds"; import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -6,9 +8,8 @@ import type {
ElementsMap, ElementsMap,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../../element/types"; } from "../../element/types";
import { rotate } from "../../math";
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement"; 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"); export const EXTERNAL_LINK_IMG = document.createElement("img");
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( 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 = ( export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: Radians,
appState: Pick<UIAppState, "zoom">, appState: Pick<UIAppState, "zoom">,
): Bounds => { ): Bounds => {
const size = DEFAULT_LINK_SIZE; const size = DEFAULT_LINK_SIZE;
@@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
const x = x2 + dashedLineMargin - centeringOffset; const x = x2 + dashedLineMargin - centeringOffset;
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = rotate( const [rotatedX, rotatedY] = pointRotateRads(
x + linkWidth / 2, pointFrom(x + linkWidth / 2, y + linkHeight / 2),
y + linkHeight / 2, pointFrom(centerX, centerY),
centerX,
centerY,
angle, angle,
); );
return [ return [
@@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: AppState, appState: AppState,
[x, y]: Point, [x, y]: GlobalPoint,
) => { ) => {
const threshold = 4 / appState.zoom.value; const threshold = 4 / appState.zoom.value;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -73,7 +72,7 @@ export const isPointHittingLink = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: AppState, appState: AppState,
[x, y]: Point, [x, y]: GlobalPoint,
isMobile: boolean, isMobile: boolean,
) => { ) => {
if (!element.link || appState.selectedElementIds[element.id]) { if (!element.link || appState.selectedElementIds[element.id]) {
@@ -86,5 +85,10 @@ export const isPointHittingLink = (
) { ) {
return true; return true;
} }
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); return isPointHittingLinkIcon(
element,
elementsMap,
appState,
pointFrom(x, y),
);
}; };
+8
View File
@@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
</g>, </g>,
tablerIconProps, 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, LoadIcon,
MoonIcon, MoonIcon,
save, save,
searchIcon,
SunIcon, SunIcon,
TrashIcon, TrashIcon,
usersIcon, usersIcon,
@@ -27,6 +28,7 @@ import {
actionLoadScene, actionLoadScene,
actionSaveToActiveFile, actionSaveToActiveFile,
actionShortcuts, actionShortcuts,
actionToggleSearchMenu,
actionToggleTheme, actionToggleTheme,
} from "../../actions"; } from "../../actions";
import clsx from "clsx"; import clsx from "clsx";
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
import { THEME } from "../../constants"; import { THEME } from "../../constants";
import type { Theme } from "../../element/types"; import type { Theme } from "../../element/types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import "./DefaultItems.scss"; import "./DefaultItems.scss";
export const LoadScene = () => { export const LoadScene = () => {
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
}; };
CommandPalette.displayName = "CommandPalette"; 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 = () => { export const Help = () => {
const { t } = useI18n(); const { t } = useI18n();
+2
View File
@@ -113,6 +113,7 @@ export const ENV = {
export const CLASSES = { export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
}; };
/** /**
@@ -376,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
}; };
export const LIBRARY_SIDEBAR_TAB = "library"; export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
export const DEFAULT_SIDEBAR = { export const DEFAULT_SIDEBAR = {
name: "default", name: "default",
+3 -3
View File
@@ -144,9 +144,9 @@
--border-radius-md: 0.375rem; --border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%); --color-surface-high: #f1f0ff;
--color-surface-mid: hsl(240 25% 96%); --color-surface-mid: #f2f2f7;
--color-surface-low: hsl(240 25% 94%); --color-surface-low: #ececf4;
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f; --color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0; --color-brand-hover: #5753d0;
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2", "backgroundColor": "#d8f5a2",
"boundElements": [ "boundElements": [
{ {
"id": "id45", "id": "id47",
"type": "arrow", "type": "arrow",
}, },
{ {
"id": "id46", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id46", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id47", "elementId": "id49",
"fixedPoint": null, "fixedPoint": null,
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
@@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id45", "id": "id47",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id48", "id": "id50",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id48", "id": "id50",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id49", "id": "id51",
"type": "text", "type": "text",
}, },
], ],
@@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id48", "containerId": "id50",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id38", "id": "id40",
"type": "text", "type": "text",
}, },
], ],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"elbowed": false, "elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id40", "elementId": "id42",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id39", "elementId": "id41",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id37", "containerId": "id39",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id37", "id": "id39",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id37", "id": "id39",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id42", "id": "id44",
"type": "text", "type": "text",
}, },
], ],
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"elbowed": false, "elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id44", "elementId": "id46",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id43", "elementId": "id45",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id41", "containerId": "id43",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id41", "id": "id43",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id41", "id": "id43",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id54", "id": "id56",
"type": "text", "type": "text",
}, },
{ {
@@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id55", "id": "id57",
"type": "text", "type": "text",
}, },
], ],
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id56", "id": "id58",
"type": "text", "type": "text",
}, },
{ {
@@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id57", "id": "id59",
"type": "text", "type": "text",
}, },
{ {
@@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id58", "id": "id60",
"type": "text", "type": "text",
}, },
], ],
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id59", "id": "id61",
"type": "text", "type": "text",
}, },
], ],
+9
View File
@@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
: byteStringToString(window.atob(base64)); : byteStringToString(window.atob(base64));
}; };
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(base64, "base64").buffer;
}
// Browser environment
return byteStringToArrayBuffer(atob(base64));
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// text encoding // text encoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+29 -23
View File
@@ -5,6 +5,7 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding,
FontFamilyValues, FontFamilyValues,
OrderedExcalidrawElement, OrderedExcalidrawElement,
PointBinding, PointBinding,
@@ -21,6 +22,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
@@ -40,11 +42,7 @@ import {
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { import { getUpdatedTimestamp, updateActiveTool } from "../utils";
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement"; import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -58,6 +56,8 @@ import {
getNormalizedGridStep, getNormalizedGridStep,
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -103,8 +103,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = ( const repairBinding = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
binding: PointBinding | null, binding: PointBinding | FixedPointBinding | null,
): PointBinding | null => { ): PointBinding | FixedPointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
@@ -112,14 +112,17 @@ const repairBinding = (
return { return {
...binding, ...binding,
focus: binding.focus || 0, focus: binding.focus || 0,
fixedPoint: isElbowArrow(element) ...(isElbowArrow(element) && isFixedPointBinding(binding)
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0]) ? {
: null, fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: {}),
}; };
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & { T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -152,7 +155,7 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness, roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity: opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.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, x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0, y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor, strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
@@ -182,6 +185,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element || "customData" in extra) { if ("customData" in element || "customData" in extra) {
base.customData = base.customData =
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
@@ -266,10 +272,7 @@ const restoreElement = (
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
? [ ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
[0, 0],
[element.width, element.height],
]
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -293,14 +296,11 @@ const restoreElement = (
}); });
case "arrow": { case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element; const { startArrowhead = null, endArrowhead = "arrow" } = element;
let x = element.x; let x: number | undefined = element.x;
let y = element.y; let y: number | undefined = element.y;
let points = // migrate old arrow model to new one let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
? [ ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
[0, 0],
[element.width, element.height],
]
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -601,6 +601,12 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
if ("activeSubtypes" in appState) {
nextAppState.activeSubtypes = appState.activeSubtypes;
}
if ("customData" in appState) {
nextAppState.customData = appState.customData;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+50 -47
View File
@@ -2,6 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform"; import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform"; import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types"; import type { ExcalidrawArrowElement } from "../element/types";
import { pointFrom } from "../../math";
const opts = { regenerateIds: false }; const opts = { regenerateIds: false };
@@ -308,28 +309,32 @@ describe("Test Transform", () => {
}); });
describe("Test Frames", () => { describe("Test Frames", () => {
const elements: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
];
it("should transform frames and update frame ids when regenerated", () => { it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{ ...elements,
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{ {
type: "frame", type: "frame",
children: ["1", "2"], children: ["1", "2"],
@@ -351,28 +356,9 @@ describe("Test Transform", () => {
}); });
}); });
it("should consider max of calculated and frame dimensions when provided", () => { it("should consider user defined frame dimensions over calculated when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{ ...elements,
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{ {
type: "frame", type: "frame",
children: ["1", "2"], children: ["1", "2"],
@@ -387,7 +373,27 @@ describe("Test Transform", () => {
); );
const frame = excalidrawElements.find((ele) => ele.type === "frame")!; const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800); expect(frame.width).toBe(800);
expect(frame.height).toBe(126); expect(frame.height).toBe(100);
});
it("should consider user defined frame coordinates calculated when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
...elements,
{
type: "frame",
children: ["1", "2"],
name: "My frame",
x: 100,
y: 300,
},
];
const excalidrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
expect(frame.x).toBe(100);
expect(frame.y).toBe(300);
}); });
}); });
@@ -911,10 +917,7 @@ describe("Test Transform", () => {
x: 111.262, x: 111.262,
y: 57, y: 57,
strokeWidth: 2, strokeWidth: 2,
points: [ points: [pointFrom(0, 0), pointFrom(272.985, 0)],
[0, 0],
[272.985, 0],
],
label: { label: {
text: "How are you?", text: "How are you?",
fontSize: 20, fontSize: 20,
@@ -937,7 +940,7 @@ describe("Test Transform", () => {
x: 77.017, x: 77.017,
y: 79, y: 79,
strokeWidth: 2, strokeWidth: 2,
points: [[0, 0]], points: [pointFrom(0, 0)],
label: { label: {
text: "Friendship", text: "Friendship",
fontSize: 20, fontSize: 20,
+26 -14
View File
@@ -46,6 +46,7 @@ import {
assertNever, assertNever,
cloneJSON, cloneJSON,
getFontString, getFontString,
isDevEnv,
toBrandedType, toBrandedType,
} from "../utils"; } from "../utils";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
@@ -53,6 +54,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks"; import { isArrowElement } from "../element/typeChecks";
import { pointFrom, type LocalPoint } from "../../math";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -417,7 +419,7 @@ const bindLinearElementToElement = (
const endPointIndex = linearElement.points.length - 1; const endPointIndex = linearElement.points.length - 1;
const delta = 0.5; 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 // left to right so shift the arrow towards right
if ( if (
@@ -535,10 +537,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({ excalidrawElement = newLinearElement({
width, width,
height, height,
points: [ points: [pointFrom(0, 0), pointFrom(width, height)],
[0, 0],
[width, height],
],
...element, ...element,
}); });
@@ -551,10 +550,7 @@ export const convertToExcalidrawElements = (
width, width,
height, height,
endArrowhead: "arrow", endArrowhead: "arrow",
points: [ points: [pointFrom(0, 0), pointFrom(width, height)],
[0, 0],
[width, height],
],
...element, ...element,
type: "arrow", type: "arrow",
}); });
@@ -722,7 +718,7 @@ export const convertToExcalidrawElements = (
} }
// Once all the excalidraw elements are created, we can add frames since we // Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possibe after all // need to calculate coordinates and dimensions of frame which is possible after all
// frame children are processed. // frame children are processed.
for (const [id, element] of elementsWithIds) { for (const [id, element] of elementsWithIds) {
if (element.type !== "frame" && element.type !== "magicframe") { if (element.type !== "frame" && element.type !== "magicframe") {
@@ -769,10 +765,26 @@ export const convertToExcalidrawElements = (
maxX = maxX + PADDING; maxX = maxX + PADDING;
maxY = maxY + PADDING; maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher const frameX = frame?.x || minX;
const width = Math.max(frame?.width, maxX - minX); const frameY = frame?.y || minY;
const height = Math.max(frame?.height, maxY - minY); const frameWidth = frame?.width || maxX - minX;
Object.assign(frame, { x: minX, y: minY, width, height }); const frameHeight = frame?.height || maxY - minY;
Object.assign(frame, {
x: frameX,
y: frameY,
width: frameWidth,
height: frameHeight,
});
if (
isDevEnv() &&
element.children.length &&
(frame?.x || frame?.y || frame?.width || frame?.height)
) {
console.info(
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
);
}
} }
return elementStore.getElements(); return elementStore.getElements();
+181 -143
View File
@@ -1,8 +1,8 @@
import * as GA from "../ga"; import * as GA from "../../math/ga/ga";
import * as GAPoint from "../gapoints"; import * as GAPoint from "../../math/ga/gapoints";
import * as GADirection from "../gadirections"; import * as GADirection from "../../math/ga/gadirections";
import * as GALine from "../galines"; import * as GALine from "../../math/ga/galines";
import * as GATransform from "../gatransforms"; import * as GATransform from "../../math/ga/gatransforms";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
@@ -10,7 +10,6 @@ import type {
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
@@ -26,11 +25,12 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPoint, FixedPoint,
SceneElementsMap, SceneElementsMap,
ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState, Point } from "../types"; import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene"; import { getElementAtPosition } from "../scene";
import { import {
@@ -39,6 +39,7 @@ import {
isBindingElement, isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement, isRectangularElement,
@@ -51,17 +52,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import {
aabbForElement,
clamp,
distanceSq2d,
getCenterForBounds,
getCenterForElement,
pointInsideBounds,
pointToVector,
rotatePoint,
} from "../math";
import { import {
compareHeading, compareHeading,
HEADING_DOWN, HEADING_DOWN,
@@ -72,7 +63,18 @@ import {
vectorToHeading, vectorToHeading,
type Heading, type Heading,
} from "./heading"; } from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry"; import type { LocalPoint, Radians } from "../../math";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
vectorFromPoint,
pointFromPair,
pointDistanceSq,
clamp,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@@ -649,7 +651,7 @@ export const updateBoundElements = (
update, update,
): update is NonNullable<{ ): update is NonNullable<{
index: number; index: number;
point: Point; point: LocalPoint;
isDragging?: boolean; isDragging?: boolean;
}> => update !== null, }> => update !== null,
); );
@@ -695,14 +697,14 @@ const getSimultaneouslyUpdatedElementIds = (
}; };
export const getHeadingForElbowArrowSnap = ( export const getHeadingForElbowArrowSnap = (
point: Readonly<Point>, p: Readonly<GlobalPoint>,
otherPoint: Readonly<Point>, otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
elementsMap: ElementsMap, elementsMap: ElementsMap,
origPoint: Point, origPoint: GlobalPoint,
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
if (!bindableElement || !aabb) { if (!bindableElement || !aabb) {
return otherPointHeading; return otherPointHeading;
@@ -716,17 +718,23 @@ export const getHeadingForElbowArrowSnap = (
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
pointToVector(point, getCenterForElement(bindableElement)), vectorFromPoint(
p,
pointFrom<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; return pointHeading;
}; };
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<Point>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
@@ -745,89 +753,87 @@ const getDistanceForBinding = (
}; };
export const bindPointToSnapToElementOutline = ( export const bindPointToSnapToElementOutline = (
point: Readonly<Point>, p: Readonly<GlobalPoint>,
otherPoint: Readonly<Point>, otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined, bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point => { ): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement); const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) { if (bindableElement && aabb) {
// TODO: Dirty hacks until tangents are properly calculated // TODO: Dirty hacks until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point); const heading = headingForPointFromElement(bindableElement, aabb, p);
const intersections = [ const intersections = [
...intersectElementWithLine( ...(intersectElementWithLine(
bindableElement, bindableElement,
[point[0], point[1] - 2 * bindableElement.height], pointFrom(p[0], p[1] - 2 * bindableElement.height),
[point[0], point[1] + 2 * bindableElement.height], pointFrom(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
), ) ?? []),
...intersectElementWithLine( ...(intersectElementWithLine(
bindableElement, bindableElement,
[point[0] - 2 * bindableElement.width, point[1]], pointFrom(p[0] - 2 * bindableElement.width, p[1]),
[point[0] + 2 * bindableElement.width, point[1]], pointFrom(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
), ) ?? []),
]; ];
const isVertical = const isVertical =
compareHeading(heading, HEADING_LEFT) || compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT); compareHeading(heading, HEADING_RIGHT);
const dist = Math.abs( const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap), distanceToBindableElement(bindableElement, p, elementsMap),
); );
const isInner = isVertical const isInner = isVertical
? dist < bindableElement.width * -0.1 ? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1; : dist < bindableElement.height * -0.1;
intersections.sort( intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
return isInner return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb) ? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) => : intersections.filter((i) =>
isVertical isVertical
? Math.abs(point[1] - i[1]) < 0.1 ? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(point[0] - i[0]) < 0.1, : Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point; )[0] ?? p;
} }
return point; return p;
}; };
const headingToMidBindPoint = ( const headingToMidBindPoint = (
point: Point, p: GlobalPoint,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
aabb: Bounds, aabb: Bounds,
): Point => { ): GlobalPoint => {
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
const heading = vectorToHeading(pointToVector(point, center)); const heading = vectorToHeading(vectorFromPoint(p, center));
switch (true) { switch (true) {
case compareHeading(heading, HEADING_UP): case compareHeading(heading, HEADING_UP):
return rotatePoint( return pointRotateRads(
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]], pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center, center,
bindableElement.angle, bindableElement.angle,
); );
case compareHeading(heading, HEADING_RIGHT): case compareHeading(heading, HEADING_RIGHT):
return rotatePoint( return pointRotateRads(
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1], pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center, center,
bindableElement.angle, bindableElement.angle,
); );
case compareHeading(heading, HEADING_DOWN): case compareHeading(heading, HEADING_DOWN):
return rotatePoint( return pointRotateRads(
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]], pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center, center,
bindableElement.angle, bindableElement.angle,
); );
default: default:
return rotatePoint( return pointRotateRads(
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1], pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center, center,
bindableElement.angle, bindableElement.angle,
); );
@@ -836,22 +842,25 @@ const headingToMidBindPoint = (
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
p: Point, p: GlobalPoint,
): Point => { ): GlobalPoint => {
const center = getCenterForElement(element); const center = pointFrom<GlobalPoint>(
const nonRotatedPoint = rotatePoint(p, center, -element.angle); 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) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left // Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return rotatePoint( return pointRotateRads<GlobalPoint>(
[element.x - FIXED_BINDING_DISTANCE, element.y], pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
center, center,
element.angle, element.angle,
); );
} }
return rotatePoint( return pointRotateRads(
[element.x, element.y - FIXED_BINDING_DISTANCE], pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
center, center,
element.angle, element.angle,
); );
@@ -861,14 +870,17 @@ export const avoidRectangularCorner = (
) { ) {
// Bottom left // Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return rotatePoint( return pointRotateRads(
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE], pointFrom(
element.x,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
center, center,
element.angle, element.angle,
); );
} }
return rotatePoint( return pointRotateRads(
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height], pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center, center,
element.angle, element.angle,
); );
@@ -881,20 +893,20 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x < nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE element.width + FIXED_BINDING_DISTANCE
) { ) {
return rotatePoint( return pointRotateRads(
[ pointFrom(
element.x + element.width, element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE, element.y + element.height + FIXED_BINDING_DISTANCE,
], ),
center, center,
element.angle, element.angle,
); );
} }
return rotatePoint( return pointRotateRads(
[ pointFrom(
element.x + element.width + FIXED_BINDING_DISTANCE, element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height, element.y + element.height,
], ),
center, center,
element.angle, element.angle,
); );
@@ -907,14 +919,17 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x < nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE element.width + FIXED_BINDING_DISTANCE
) { ) {
return rotatePoint( return pointRotateRads(
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE], pointFrom(
element.x + element.width,
element.y - FIXED_BINDING_DISTANCE,
),
center, center,
element.angle, element.angle,
); );
} }
return rotatePoint( return pointRotateRads(
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y], pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center, center,
element.angle, element.angle,
); );
@@ -925,12 +940,15 @@ export const avoidRectangularCorner = (
export const snapToMid = ( export const snapToMid = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
p: Point, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): Point => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point; const center = pointFrom<GlobalPoint>(
const nonRotated = rotatePoint(p, center, -angle); 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 // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
@@ -943,22 +961,30 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThrehsold
) { ) {
// LEFT // LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle); return pointRotateRads(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThrehsold
) { ) {
// TOP // TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle); return pointRotateRads(
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThrehsold
) { ) {
// RIGHT // RIGHT
return rotatePoint( return pointRotateRads(
[x + width + FIXED_BINDING_DISTANCE, center[1]], pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@@ -968,8 +994,8 @@ export const snapToMid = (
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThrehsold
) { ) {
// DOWN // DOWN
return rotatePoint( return pointRotateRads(
[center[0], y + height + FIXED_BINDING_DISTANCE], pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@@ -984,7 +1010,7 @@ const updateBoundPoint = (
binding: PointBinding | null | undefined, binding: PointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point | null => { ): LocalPoint | null => {
if ( if (
binding == null || binding == null ||
// We only need to update the other end if this is a 2 point line element // We only need to update the other end if this is a 2 point line element
@@ -997,7 +1023,7 @@ const updateBoundPoint = (
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement)) { if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( calculateFixedPointForElbowArrowBinding(
@@ -1006,15 +1032,15 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap, elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = [ const globalMidPoint = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2, bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2, bindableElement.y + bindableElement.height / 2,
] as Point; );
const global = [ const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
] as Point; );
const rotatedGlobal = rotatePoint( const rotatedGlobal = pointRotateRads(
global, global,
globalMidPoint, globalMidPoint,
bindableElement.angle, bindableElement.angle,
@@ -1040,7 +1066,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
let newEdgePoint: Point; let newEdgePoint: GlobalPoint;
// The linear element was not originally pointing inside the bound shape, // The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point // we can point directly at the focus point
@@ -1054,7 +1080,7 @@ const updateBoundPoint = (
binding.gap, binding.gap,
elementsMap, elementsMap,
); );
if (intersections.length === 0) { if (!intersections || intersections.length === 0) {
// This should never happen, since focusPoint should always be // This should never happen, since focusPoint should always be
// inside the element, but just in case, bail out // inside the element, but just in case, bail out
newEdgePoint = focusPointAbsolute; newEdgePoint = focusPointAbsolute;
@@ -1101,15 +1127,15 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement, hoveredElement,
elementsMap, elementsMap,
); );
const globalMidPoint = [ const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point; );
const nonRotatedSnappedGlobalPoint = rotatePoint( const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint, snappedPoint,
globalMidPoint, globalMidPoint,
-hoveredElement.angle, -hoveredElement.angle as Radians,
) as Point; );
return { return {
fixedPoint: normalizeFixedPoint([ fixedPoint: normalizeFixedPoint([
@@ -1320,8 +1346,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap); const shape = getElementShape(element, elementsMap);
return ( return (
isPointOnShape([x, y], shape, threshold) || isPointOnShape(pointFrom(x, y), shape, threshold) ||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element))) (fullShape === true &&
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
); );
}; };
@@ -1339,7 +1366,7 @@ export const maxBindingGap = (
export const distanceToBindableElement = ( export const distanceToBindableElement = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
point: Point, point: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): number => { ): number => {
switch (element.type) { switch (element.type) {
@@ -1359,19 +1386,13 @@ export const distanceToBindableElement = (
}; };
const distanceToRectangle = ( const distanceToRectangle = (
element: element: ExcalidrawRectanguloidElement,
| ExcalidrawRectangleElement p: GlobalPoint,
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
point: Point,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement( const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element, element,
point, p,
elementsMap, elementsMap,
); );
return Math.max( return Math.max(
@@ -1382,7 +1403,7 @@ const distanceToRectangle = (
const distanceToDiamond = ( const distanceToDiamond = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
point: Point, point: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement( const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@@ -1396,7 +1417,7 @@ const distanceToDiamond = (
const distanceToEllipse = ( const distanceToEllipse = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
point: Point, point: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): number => { ): number => {
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
@@ -1405,7 +1426,7 @@ const distanceToEllipse = (
const ellipseParamsForTest = ( const ellipseParamsForTest = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
point: Point, point: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): [GA.Point, GA.Line] => { ): [GA.Point, GA.Line] => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement( const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@@ -1467,7 +1488,7 @@ const ellipseParamsForTest = (
// so we only need to perform hit tests for the positive quadrant. // so we only need to perform hit tests for the positive quadrant.
const pointRelativeToElement = ( const pointRelativeToElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
pointTuple: Point, pointTuple: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): [GA.Point, GA.Point, number, number] => { ): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple); const point = GAPoint.from(pointTuple);
@@ -1516,9 +1537,9 @@ const coordsCenter = (
const determineFocusDistance = ( const determineFocusDistance = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: Point, a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: Point, b: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): number => { ): number => {
const relateToCenter = relativizationToElementCenter(element, elementsMap); const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1559,13 +1580,13 @@ const determineFocusPoint = (
// The oriented, relative distance from the center of `element` of the // The oriented, relative distance from the center of `element` of the
// returned focusPoint // returned focusPoint
focus: number, focus: number,
adjecentPoint: Point, adjecentPoint: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point => { ): GlobalPoint => {
if (focus === 0) { if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const center = coordsCenter(x1, y1, x2, y2); const center = coordsCenter(x1, y1, x2, y2);
return GAPoint.toTuple(center); return pointFromPair(GAPoint.toTuple(center));
} }
const relateToCenter = relativizationToElementCenter(element, elementsMap); const relateToCenter = relativizationToElementCenter(element, elementsMap);
const adjecentPointRel = GATransform.apply( const adjecentPointRel = GATransform.apply(
@@ -1589,7 +1610,9 @@ const determineFocusPoint = (
point = findFocusPointForEllipse(element, focus, adjecentPointRel); point = findFocusPointForEllipse(element, focus, adjecentPointRel);
break; 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` // Returns 2 or 0 intersection points between line going through `a` and `b`
@@ -1597,15 +1620,15 @@ const determineFocusPoint = (
const intersectElementWithLine = ( const intersectElementWithLine = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: Point, a: GlobalPoint,
// Another point on the line, in absolute coordinates // Another point on the line, in absolute coordinates
b: Point, b: GlobalPoint,
// If given, the element is inflated by this value // If given, the element is inflated by this value
gap: number = 0, gap: number = 0,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point[] => { ): GlobalPoint[] | undefined => {
if (isRectangularElement(element)) { if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap); return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
} }
const relateToCenter = relativizationToElementCenter(element, elementsMap); const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1619,8 +1642,14 @@ const intersectElementWithLine = (
aRel, aRel,
gap, gap,
); );
return intersections.map((point) => return intersections.map(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), (point) =>
pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
),
// pointFromArray(
// ,
// ),
); );
}; };
@@ -2173,12 +2202,18 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
) => { ): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return rotatePoint( return pointRotateRads(
[element.x + element.width * fixedX, element.y + element.height * fixedY], pointFrom(
getCenterForElement(element), element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
),
element.angle, element.angle,
); );
}; };
@@ -2186,7 +2221,7 @@ export const getGlobalFixedPointForBindableElement = (
const getGlobalFixedPoints = ( const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): [GlobalPoint, GlobalPoint] => {
const startElement = const startElement =
arrow.startBinding && arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as (elementsMap.get(arrow.startBinding.elementId) as
@@ -2197,23 +2232,26 @@ const getGlobalFixedPoints = (
(elementsMap.get(arrow.endBinding.elementId) as (elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement | ExcalidrawBindableElement
| undefined); | undefined);
const startPoint: Point = const startPoint =
startElement && arrow.startBinding startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
) )
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]]; : pointFrom<GlobalPoint>(
const endPoint: Point = arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1],
);
const endPoint =
endElement && arrow.endBinding endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
) )
: [ : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1], arrow.y + arrow.points[arrow.points.length - 1][1],
]; );
return [startPoint, endPoint]; return [startPoint, endPoint];
}; };
+5 -3
View File
@@ -1,3 +1,5 @@
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -123,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416, a: 0.6447741904932416,
}), }),
points: [ points: [
[0, 0] as [number, number], pointFrom<LocalPoint>(0, 0),
[67.33984375, 92.48828125] as [number, number], pointFrom<LocalPoint>(67.33984375, 92.48828125),
[-102.7890625, 52.15625] as [number, number], pointFrom<LocalPoint>(-102.7890625, 52.15625),
], ],
} as ExcalidrawLinearElement; } as ExcalidrawLinearElement;
+187 -109
View File
@@ -7,10 +7,10 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ElementsMap, ElementsMap,
} from "./types"; } from "./types";
import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough"; 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 { Drawable, Op } from "roughjs/bin/core";
import type { AppState, Point } from "../types"; import type { AppState } from "../types";
import { generateRoughOptions } from "../scene/Shape"; import { generateRoughOptions } from "../scene/Shape";
import { import {
isArrowElement, isArrowElement,
@@ -22,9 +22,24 @@ import {
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; 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,
pointFrom,
pointDistance,
pointFromArray,
pointRotateRads,
} from "../../math";
import type { Mutable } from "../utility-types";
export type RectangleBox = { export type RectangleBox = {
x: number; x: number;
@@ -97,7 +112,11 @@ export class ElementBounds {
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints( const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) => element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle), pointRotateRads(
pointFrom(x, y),
pointFrom(cx - element.x, cy - element.y),
element.angle,
),
), ),
); );
@@ -110,10 +129,26 @@ export class ElementBounds {
} else if (isLinearElement(element)) { } else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle); const [x11, y11] = pointRotateRads(
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle); pointFrom(cx, y1),
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle); pointFrom(cx, cy),
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle); element.angle,
);
const [x12, y12] = pointRotateRads(
pointFrom(cx, y2),
pointFrom(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
pointFrom(x1, cy),
pointFrom(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
pointFrom(x2, cy),
pointFrom(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21); const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
@@ -128,10 +163,26 @@ export class ElementBounds {
const hh = Math.hypot(h * cos, w * sin); const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh]; bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else { } else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle); const [x11, y11] = pointRotateRads(
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle); pointFrom(x1, y1),
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle); pointFrom(cx, cy),
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle); element.angle,
);
const [x12, y12] = pointRotateRads(
pointFrom(x1, y2),
pointFrom(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
pointFrom(x2, y1),
pointFrom(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21); const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21); const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21); const maxX = Math.max(x11, x12, x22, x21);
@@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
? getContainerElement(element, elementsMap) ? getContainerElement(element, elementsMap)
: null; : null;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition( const { x, y } = LinearElementEditor.getBoundTextElementPosition(
container, container,
element as ExcalidrawTextElementWithContainer, element as ExcalidrawTextElementWithContainer,
elementsMap, elementsMap,
); );
return [ return [
coords.x, x,
coords.y, y,
coords.x + element.width, x + element.width,
coords.y + element.height, y + element.height,
coords.x + element.width / 2, x + element.width / 2,
coords.y + element.height / 2, y + element.height / 2,
]; ];
} }
} }
@@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
export const getElementLineSegments = ( export const getElementLineSegments = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): [Point, Point][] => { ): LineSegment<GlobalPoint>[] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
elementsMap, elementsMap,
); );
const center: Point = [cx, cy]; const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = []; const segments: LineSegment<GlobalPoint>[] = [];
let i = 0; let i = 0;
while (i < element.points.length - 1) { while (i < element.points.length - 1) {
segments.push([ segments.push(
rotatePoint( lineSegment(
[ pointRotateRads(
element.points[i][0] + element.x, pointFrom(
element.points[i][1] + element.y, element.points[i][0] + element.x,
] as Point, element.points[i][1] + element.y,
center, ),
element.angle, center,
element.angle,
),
pointRotateRads(
pointFrom(
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++; i++;
} }
@@ -246,40 +299,40 @@ export const getElementLineSegments = (
[cx, y2], [cx, y2],
[x1, cy], [x1, cy],
[x2, cy], [x2, cy],
] as Point[] ] as GlobalPoint[]
).map((point) => rotatePoint(point, center, element.angle)); ).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") { if (element.type === "diamond") {
return [ return [
[n, w], lineSegment(n, w),
[n, e], lineSegment(n, e),
[s, w], lineSegment(s, w),
[s, e], lineSegment(s, e),
]; ];
} }
if (element.type === "ellipse") { if (element.type === "ellipse") {
return [ return [
[n, w], lineSegment(n, w),
[n, e], lineSegment(n, e),
[s, w], lineSegment(s, w),
[s, e], lineSegment(s, e),
[n, w], lineSegment(n, w),
[n, e], lineSegment(n, e),
[s, w], lineSegment(s, w),
[s, e], lineSegment(s, e),
]; ];
} }
return [ return [
[nw, ne], lineSegment(nw, ne),
[sw, se], lineSegment(sw, se),
[nw, sw], lineSegment(nw, sw),
[ne, se], lineSegment(ne, se),
[nw, e], lineSegment(nw, e),
[sw, e], lineSegment(sw, e),
[ne, w], lineSegment(ne, w),
[se, w], lineSegment(se, w),
]; ];
}; };
@@ -386,10 +439,10 @@ const solveQuadratic = (
}; };
const getCubicBezierCurveBound = ( const getCubicBezierCurveBound = (
p0: Point, p0: GlobalPoint,
p1: Point, p1: GlobalPoint,
p2: Point, p2: GlobalPoint,
p3: Point, p3: GlobalPoint,
): Bounds => { ): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]); const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]); const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
@@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = ( export const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => { ): Bounds => {
let currentP: Point = [0, 0]; let currentP: GlobalPoint = pointFrom(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => { (limits, { op, data }) => {
@@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
// move, bcurveTo, lineTo, and curveTo // move, bcurveTo, lineTo, and curveTo
if (op === "move") { if (op === "move") {
// change starting point // 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 // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
const _p1 = [data[0], data[1]] as Point; const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
const _p2 = [data[2], data[3]] as Point; const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
const _p3 = [data[4], data[5]] as Point; const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(..._p1) : _p1; const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(..._p2) : _p2; const p2 = transformXY ? transformXY(_p2) : _p2;
const p3 = transformXY ? transformXY(..._p3) : _p3; const p3 = transformXY ? transformXY(_p3) : _p3;
const p0 = transformXY ? transformXY(...currentP) : currentP; const p0 = transformXY ? transformXY(currentP) : currentP;
currentP = _p3; currentP = _p3;
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound( const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
@@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
}; };
/** @returns number in degrees */ /** @returns number in degrees */
export const getArrowheadAngle = (arrowhead: Arrowhead): number => { export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
switch (arrowhead) { switch (arrowhead) {
case "bar": case "bar":
return 90; return 90 as Degrees;
case "arrow": case "arrow":
return 20; return 20 as Degrees;
default: default:
return 25; return 25 as Degrees;
} }
}; };
@@ -533,19 +588,24 @@ export const getArrowheadPoints = (
const index = position === "start" ? 1 : ops.length - 1; const index = position === "start" ? 1 : ops.length - 1;
const data = ops[index].data; const data = ops[index].data;
const p3 = [data[4], data[5]] as Point;
const p2 = [data[2], data[3]] as Point; invariant(data.length === 6, "Op data length is not 6");
const p1 = [data[0], data[1]] as Point;
const p3 = pointFrom(data[4], data[5]);
const p2 = pointFrom(data[2], data[3]);
const p1 = pointFrom(data[0], data[1]);
// We need to find p0 of the bezier curve. // We need to find p0 of the bezier curve.
// It is typically the last point of the previous // It is typically the last point of the previous
// curve; it can also be the position of moveTo operation. // curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1]; const prevOp = ops[index - 1];
let p0: Point = [0, 0]; let p0 = pointFrom(0, 0);
if (prevOp.op === "move") { 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") { } else if (prevOp.op === "bcurveTo") {
p0 = [prevOp.data[4], prevOp.data[5]]; p0 = pointFrom(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 // 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); const angle = getArrowheadAngle(arrowhead);
// Return points // Return points
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); const [x3, y3] = pointRotateRads(
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); pointFrom(xs, ys),
pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(x2, y2),
degreesToRadians(angle),
);
if (arrowhead === "diamond" || arrowhead === "diamond_outline") { if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point // point opposite to the arrowhead point
@@ -621,12 +689,10 @@ export const getArrowheadPoints = (
if (position === "start") { if (position === "start") {
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = rotate( [ox, oy] = pointRotateRads(
x2 + minSize * 2, pointFrom(x2 + minSize * 2, y2),
y2, pointFrom(x2, y2),
x2, Math.atan2(py - y2, px - x2) as Radians,
y2,
Math.atan2(py - y2, px - x2),
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -634,12 +700,10 @@ export const getArrowheadPoints = (
? element.points[element.points.length - 2] ? element.points[element.points.length - 2]
: [0, 0]; : [0, 0];
[ox, oy] = rotate( [ox, oy] = pointRotateRads(
x2 - minSize * 2, pointFrom(x2 - minSize * 2, y2),
y2, pointFrom(x2, y2),
x2, Math.atan2(y2 - py, x2 - px) as Radians,
y2,
Math.atan2(y2 - py, x2 - px),
); );
} }
@@ -665,7 +729,10 @@ const generateLinearElementShape = (
return "linearPath"; return "linearPath";
})(); })();
return generator[method](element.points as Mutable<Point>[], options); return generator[method](
element.points as Mutable<LocalPoint>[] as RoughPoint[],
options,
);
}; };
const getLinearElementRotatedBounds = ( const getLinearElementRotatedBounds = (
@@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) { if (element.points.length < 2) {
const [pointX, pointY] = element.points[0]; const [pointX, pointY] = element.points[0];
const [x, y] = rotate( const [x, y] = pointRotateRads(
element.x + pointX, pointFrom(element.x + pointX, element.y + pointY),
element.y + pointY, pointFrom(cx, cy),
cx,
cy,
element.angle, element.angle,
); );
@@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
const cachedShape = ShapeCache.get(element)?.[0]; const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element); const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape); const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) => const transformXY = ([x, y]: GlobalPoint) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle); pointRotateRads<GlobalPoint>(
pointFrom(element.x + x, element.y + y),
pointFrom(cx, cy),
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]]; let coords: Bounds = [res[0], res[1], res[2], res[3]];
if (boundTextElement) { if (boundTextElement) {
@@ -861,7 +930,10 @@ export const getClosestElementBounds = (
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMap(elements);
elements.forEach((element) => { elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y); const distance = pointDistance(
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
pointFrom(from.x, from.y),
);
if (distance < minDistance) { if (distance < minDistance) {
minDistance = distance; minDistance = distance;
@@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
-scrollY + height / zoom.value, -scrollY + height / zoom.value,
]; ];
}; };
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
+25 -19
View File
@@ -1,14 +1,11 @@
import { isPathALoop, isPointWithinBounds } from "../math";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
} from "./types"; } from "./types";
import { getElementBounds } from "./bounds"; import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types"; 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 { getPolygonShape } from "../../utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "../../utils/collision"; import { isPointInShape, isPointOnShape } from "../../utils/collision";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
@@ -18,7 +15,9 @@ import {
isImageElement, isImageElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getBoundTextShape } from "../shapes"; import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, pointFrom } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
@@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs = { export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number; x: number;
y: number; y: number;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape; shape: GeometricShape<Point>;
threshold?: number; threshold?: number;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
export const hitElementItself = ({ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x, x,
y, y,
element, element,
shape, shape,
threshold = 10, threshold = 10,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs) => { }: HitTestArgs<Point>) => {
let hit = shouldTestInside(element) let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold) isPointInShape(pointFrom(x, y), shape) ||
: isPointOnShape([x, y], shape, threshold); isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold);
// hit test against a frame's name // hit test against a frame's name
if (!hit && frameNameBound) { if (!hit && frameNameBound) {
hit = isPointInShape([x, y], { hit = isPointInShape(pointFrom(x, y), {
type: "polygon", type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon, .data as Polygon<Point>,
}); });
} }
@@ -89,11 +89,17 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds([x1, y1], [x, y], [x2, y2]); return isPointWithinBounds(
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = <
hitArgs: HitTestArgs, Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return ( return (
@@ -108,10 +114,10 @@ export const hitElementBoundingBoxOnly = (
); );
}; };
export const hitElementBoundText = ( export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number, x: number,
y: number, y: number,
textShape: GeometricShape | null, textShape: GeometricShape<Point> | null,
): boolean => { ): boolean => {
return !!textShape && isPointInShape([x, y], textShape); return !!textShape && isPointInShape(pointFrom(x, y), textShape);
}; };
+2 -9
View File
@@ -11,7 +11,6 @@ import type {
PointerDownState, PointerDownState,
} from "../types"; } from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { import {
isArrowElement, isArrowElement,
@@ -21,6 +20,7 @@ import {
} from "./typeChecks"; } from "./typeChecks";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@@ -35,7 +35,6 @@ export const dragSelectedElements = (
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) && isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding) (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) { ) {
@@ -43,13 +42,7 @@ export const dragSelectedElements = (
} }
const selectedElements = _selectedElements.filter( const selectedElements = _selectedElements.filter(
(el) => (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
); );
// we do not want a frame and its elements to be selected at the same time // we do not want a frame and its elements to be selected at the same time
+31
View File
@@ -45,6 +45,12 @@ const RE_GENERIC_EMBED =
const RE_GIPHY = const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/; /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const RE_REDDIT =
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -59,6 +65,7 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com", "stackblitz.com",
"val.town", "val.town",
"giphy.com", "giphy.com",
"reddit.com",
]); ]);
const ALLOW_SAME_ORIGIN = new Set([ const ALLOW_SAME_ORIGIN = new Set([
@@ -71,6 +78,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"x.com", "x.com",
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"reddit.com",
]); ]);
export const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
@@ -218,6 +226,24 @@ export const getEmbedLink = (
return ret; return ret;
} }
if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute(
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!; const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = sanitizeHTMLAttribute(
@@ -361,6 +387,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
return twitterMatch[1]; return twitterMatch[1];
} }
const redditMatch = str.match(RE_REDDIT_EMBED);
if (redditMatch && redditMatch.length === 2) {
return redditMatch[1];
}
const gistMatch = str.match(RE_GH_GIST_EMBED); const gistMatch = str.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) { if (gistMatch && gistMatch.length === 2) {
return gistMatch[1]; return gistMatch[1];
+8 -9
View File
@@ -10,7 +10,6 @@ import {
import { bindLinearElement } from "./binding"; import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement"; import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "../math";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement, ExcalidrawBindableElement,
@@ -20,7 +19,7 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "./types"; } from "./types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements, Point } from "../types"; import type { AppState, PendingExcalidrawElements } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame"; import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
import { import {
@@ -30,6 +29,8 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
} from "./typeChecks"; } from "./typeChecks";
import { invariant } from "../utils"; import { invariant } from "../utils";
import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
@@ -81,13 +82,14 @@ const getNodeRelatives = (
"not an ExcalidrawBindableElement", "not an ExcalidrawBindableElement",
); );
const edgePoint: Point = const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]; type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x, edgePoint[0] + el.x,
edgePoint[1] + el.y, edgePoint[1] + el.y,
]); ] as Readonly<LocalPoint>);
acc.push({ acc.push({
relative, relative,
@@ -419,10 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor, strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth, strokeWidth: appState.currentItemStrokeWidth,
points: [ points: [pointFrom(0, 0), pointFrom(endX, endY)],
[0, 0],
[endX, endY],
],
elbowed: true, elbowed: true,
}); });
+78 -46
View File
@@ -1,12 +1,18 @@
import { lineAngle } from "../../utils/geometry/geometry"; import type {
import type { Point, Vector } from "../../utils/geometry/shape"; LocalPoint,
GlobalPoint,
Triangle,
Vector,
Radians,
} from "../../math";
import { import {
getCenterForBounds, pointFrom,
PointInTriangle, pointRotateRads,
rotatePoint, pointScaleFromOrigin,
scalePointFromOrigin, radiansToDegrees,
} from "../math"; triangleIncludesPoint,
import type { Bounds } from "./bounds"; } from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types"; import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading; 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 const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = (a: Point, b: Point) => { export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
const angle = lineAngle([a, b]); a: Point,
b: Point,
) => {
const angle = radiansToDegrees(
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
);
if (angle >= 315 || angle < 45) { if (angle >= 315 || angle < 45) {
return HEADING_UP; return HEADING_UP;
} else if (angle >= 45 && angle < 135) { } 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 // 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 // close fitting bounding box, then creating 4 search cones around the center of
// the external bbox. // the external bbox.
export const headingForPointFromElement = ( export const headingForPointFromElement = <
Point extends GlobalPoint | LocalPoint,
>(
element: Readonly<ExcalidrawBindableElement>, element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>, aabb: Readonly<Bounds>,
point: Readonly<Point>, p: Readonly<LocalPoint | GlobalPoint>,
): Heading => { ): Heading => {
const SEARCH_CONE_MULTIPLIER = 2; const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb); const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") { if (element.type === "diamond") {
if (point[0] < element.x) { if (p[0] < element.x) {
return HEADING_LEFT; return HEADING_LEFT;
} else if (point[1] < element.y) { } else if (p[1] < element.y) {
return HEADING_UP; return HEADING_UP;
} else if (point[0] > element.x + element.width) { } else if (p[0] > element.x + element.width) {
return HEADING_RIGHT; return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) { } else if (p[1] > element.y + element.height) {
return HEADING_DOWN; return HEADING_DOWN;
} }
const top = rotatePoint( const top = pointRotateRads(
scalePointFromOrigin( pointScaleFromOrigin(
[element.x + element.width / 2, element.y], pointFrom(element.x + element.width / 2, element.y),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
midPoint, midPoint,
element.angle, element.angle,
); );
const right = rotatePoint( const right = pointRotateRads(
scalePointFromOrigin( pointScaleFromOrigin(
[element.x + element.width, element.y + element.height / 2], pointFrom(element.x + element.width, element.y + element.height / 2),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
midPoint, midPoint,
element.angle, element.angle,
); );
const bottom = rotatePoint( const bottom = pointRotateRads(
scalePointFromOrigin( pointScaleFromOrigin(
[element.x + element.width / 2, element.y + element.height], pointFrom(element.x + element.width / 2, element.y + element.height),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
midPoint, midPoint,
element.angle, element.angle,
); );
const left = rotatePoint( const left = pointRotateRads(
scalePointFromOrigin( pointScaleFromOrigin(
[element.x, element.y + element.height / 2], pointFrom(element.x, element.y + element.height / 2),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -104,43 +117,62 @@ export const headingForPointFromElement = (
element.angle, element.angle,
); );
if (PointInTriangle(point, top, right, midPoint)) { if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
return headingForDiamond(top, right); 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); 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(bottom, left);
} }
return headingForDiamond(left, top); return headingForDiamond(left, top);
} }
const topLeft = scalePointFromOrigin( const topLeft = pointScaleFromOrigin(
[aabb[0], aabb[1]], pointFrom(aabb[0], aabb[1]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
); ) as Point;
const topRight = scalePointFromOrigin( const topRight = pointScaleFromOrigin(
[aabb[2], aabb[1]], pointFrom(aabb[2], aabb[1]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
); ) as Point;
const bottomLeft = scalePointFromOrigin( const bottomLeft = pointScaleFromOrigin(
[aabb[0], aabb[3]], pointFrom(aabb[0], aabb[3]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
); ) as Point;
const bottomRight = scalePointFromOrigin( const bottomRight = pointScaleFromOrigin(
[aabb[2], aabb[3]], pointFrom(aabb[2], aabb[3]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
); ) as Point;
return PointInTriangle(point, topLeft, topRight, midPoint) return triangleIncludesPoint(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP ? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint) : triangleIncludesPoint(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT ? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint) : triangleIncludesPoint(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
? HEADING_DOWN ? HEADING_DOWN
: HEADING_LEFT; : 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;
+211 -178
View File
@@ -11,19 +11,6 @@ import type {
FixedPointBinding, FixedPointBinding,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
import {
distance2d,
rotate,
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
getControlPointsForBezierCurve,
getBezierXY,
getBezierCurveLength,
mapIntervalToBezierT,
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { import {
@@ -32,7 +19,6 @@ import {
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
} from "./bounds"; } from "./bounds";
import type { import type {
Point,
AppState, AppState,
PointerCoords, PointerCoords,
InteractiveCanvasAppState, InteractiveCanvasAppState,
@@ -46,7 +32,7 @@ import {
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { toBrandedType, tupleToCoors } from "../utils"; import { invariant, toBrandedType, tupleToCoors } from "../utils";
import { import {
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
@@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store"; import type { Store } from "../store";
import { mutateElbowArrow } from "./routing"; import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
pointCenter,
pointFrom,
pointRotateRads,
pointsEqual,
vector,
type GlobalPoint,
type LocalPoint,
pointDistance,
} from "../../math";
import {
getBezierCurveLength,
getBezierXY,
getControlPointsForBezierCurve,
isPathALoop,
mapIntervalToBezierT,
} from "../shapes";
import { getGridPoint } from "../snapping";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;
points: (Point | null)[]; points: (GlobalPoint | null)[];
zoom: number | null; zoom: number | null;
} = { version: null, points: [], zoom: null }; } = { version: null, points: [], zoom: null };
export class LinearElementEditor { export class LinearElementEditor {
@@ -80,7 +85,7 @@ export class LinearElementEditor {
lastClickedIsEndPoint: boolean; lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null; origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: { segmentMidpoint: {
value: Point | null; value: GlobalPoint | null;
index: number | null; index: number | null;
added: boolean; added: boolean;
}; };
@@ -88,7 +93,7 @@ export class LinearElementEditor {
/** whether you're dragging a point */ /** whether you're dragging a point */
public readonly isDragging: boolean; public readonly isDragging: boolean;
public readonly lastUncommittedPoint: Point | null; public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement: public readonly startBindingElement:
| ExcalidrawBindableElement | ExcalidrawBindableElement
@@ -96,13 +101,14 @@ export class LinearElementEditor {
| "keep"; | "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: Point | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>) { constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
if (!arePointsEqual(element.points[0], [0, 0])) { if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
} }
@@ -126,6 +132,7 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -280,7 +287,7 @@ export class LinearElementEditor {
element, element,
elementsMap, elementsMap,
referencePoint, referencePoint,
[scenePointerX, scenePointerY], pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
@@ -289,7 +296,10 @@ export class LinearElementEditor {
[ [
{ {
index: selectedIndex, index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]], point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint, isDragging: selectedIndex === lastClickedPoint,
}, },
], ],
@@ -310,7 +320,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition = const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt( ? LinearElementEditor.createPointAt(
element, element,
@@ -319,10 +329,10 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
) )
: ([ : pointFrom(
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY, element.points[pointIndex][1] + deltaY,
] as const); );
return { return {
index: pointIndex, index: pointIndex,
point: newPointPosition, point: newPointPosition,
@@ -515,7 +525,7 @@ export class LinearElementEditor {
); );
let index = 0; let index = 0;
const midpoints: (Point | null)[] = []; const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) { while (index < points.length - 1) {
if ( if (
LinearElementEditor.isSegmentTooShort( LinearElementEditor.isSegmentTooShort(
@@ -549,7 +559,7 @@ export class LinearElementEditor {
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
appState: AppState, appState: AppState,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): GlobalPoint | null => {
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
@@ -579,11 +589,12 @@ export class LinearElementEditor {
const existingSegmentMidpointHitCoords = const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords; linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) { if (existingSegmentMidpointHitCoords) {
const distance = distance2d( const distance = pointDistance(
existingSegmentMidpointHitCoords[0], pointFrom(
existingSegmentMidpointHitCoords[1], existingSegmentMidpointHitCoords[0],
scenePointer.x, existingSegmentMidpointHitCoords[1],
scenePointer.y, ),
pointFrom(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
return existingSegmentMidpointHitCoords; return existingSegmentMidpointHitCoords;
@@ -594,11 +605,9 @@ export class LinearElementEditor {
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
const distance = distance2d( const distance = pointDistance(
midPoints[index]![0], pointFrom(midPoints[index]![0], midPoints[index]![1]),
midPoints[index]![1], pointFrom(scenePointer.x, scenePointer.y),
scenePointer.x,
scenePointer.y,
); );
if (distance <= threshold) { if (distance <= threshold) {
return midPoints[index]; return midPoints[index];
@@ -612,15 +621,13 @@ export class LinearElementEditor {
static isSegmentTooShort( static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point, startPoint: GlobalPoint | LocalPoint,
endPoint: Point, endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"], zoom: AppState["zoom"],
) { ) {
let distance = distance2d( let distance = pointDistance(
startPoint[0], pointFrom(startPoint[0], startPoint[1]),
startPoint[1], pointFrom(endPoint[0], endPoint[1]),
endPoint[0],
endPoint[1],
); );
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); distance = getBezierCurveLength(element, endPoint);
@@ -631,12 +638,12 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point, startPoint: GlobalPoint,
endPoint: Point, endPoint: GlobalPoint,
endPointIndex: number, endPointIndex: number,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ): GlobalPoint {
let segmentMidPoint = centerPoint(startPoint, endPoint); let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve( const controlPoints = getControlPointsForBezierCurve(
element, element,
@@ -649,16 +656,15 @@ export class LinearElementEditor {
0.5, 0.5,
); );
const [tx, ty] = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element, element,
[tx, ty], getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap, elementsMap,
); );
} }
@@ -670,7 +676,7 @@ export class LinearElementEditor {
static getSegmentMidPointIndex( static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
appState: AppState, appState: AppState,
midPoint: Point, midPoint: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ) {
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
@@ -822,11 +828,12 @@ export class LinearElementEditor {
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const targetPoint = const targetPoint =
clickedPointIndex > -1 && clickedPointIndex > -1 &&
rotate( pointRotateRads(
element.x + element.points[clickedPointIndex][0], pointFrom(
element.y + element.points[clickedPointIndex][1], element.x + element.points[clickedPointIndex][0],
cx, element.y + element.points[clickedPointIndex][1],
cy, ),
pointFrom(cx, cy),
element.angle, element.angle,
); );
@@ -865,14 +872,17 @@ export class LinearElementEditor {
return ret; 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) { if (!point1 && !point2) {
return true; return true;
} }
if (!point1 || !point2) { if (!point1 || !point2) {
return false; return false;
} }
return arePointsEqual(point1, point2); return pointsEqual(point1, point2);
} }
static handlePointerMove( static handlePointerMove(
@@ -909,7 +919,7 @@ export class LinearElementEditor {
}; };
} }
let newPoint: Point; let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2]; const lastCommittedPoint = points[points.length - 2];
@@ -918,14 +928,14 @@ export class LinearElementEditor {
element, element,
elementsMap, elementsMap,
lastCommittedPoint, lastCommittedPoint,
[scenePointerX, scenePointerY], pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
newPoint = [ newPoint = pointFrom(
width + lastCommittedPoint[0], width + lastCommittedPoint[0],
height + lastCommittedPoint[1], height + lastCommittedPoint[1],
]; );
} else { } else {
newPoint = LinearElementEditor.createPointAt( newPoint = LinearElementEditor.createPointAt(
element, element,
@@ -965,30 +975,36 @@ export class LinearElementEditor {
/** scene coords */ /** scene coords */
static getPointGlobalCoordinates( static getPointGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
point: Point, p: LocalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ): GlobalPoint {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let { x, y } = element; const { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); return pointRotateRads(
return [x, y] as const; pointFrom(x + p[0], y + p[1]),
pointFrom(cx, cy),
element.angle,
);
} }
/** scene coords */ /** scene coords */
static getPointsGlobalCoordinates( static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point[] { ): GlobalPoint[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
return element.points.map((point) => { return element.points.map((p) => {
let { x, y } = element; const { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); return pointRotateRads(
return [x, y] as const; pointFrom(x + p[0], y + p[1]),
pointFrom(cx, cy),
element.angle,
);
}); });
} }
@@ -997,7 +1013,7 @@ export class LinearElementEditor {
indexMaybeFromEnd: number, // -1 for last element indexMaybeFromEnd: number, // -1 for last element
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point { ): GlobalPoint {
const index = const index =
indexMaybeFromEnd < 0 indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd ? element.points.length + indexMaybeFromEnd
@@ -1005,35 +1021,40 @@ export class LinearElementEditor {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const p = element.points[index];
const point = element.points[index];
const { x, y } = element; const { x, y } = element;
return point
? rotate(x + point[0], y + point[1], cx, cy, element.angle) return p
: rotate(x, y, cx, cy, element.angle); ? pointRotateRads(
pointFrom(x + p[0], y + p[1]),
pointFrom(cx, cy),
element.angle,
)
: pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
} }
static pointFromAbsoluteCoords( static pointFromAbsoluteCoords(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
absoluteCoords: Point, absoluteCoords: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point { ): LocalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
// No rotation for elbow arrows // No rotation for elbow arrows
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; return pointFrom(
absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y,
);
} }
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const [x, y] = rotate( const [x, y] = pointRotateRads(
absoluteCoords[0], pointFrom(absoluteCoords[0], absoluteCoords[1]),
absoluteCoords[1], pointFrom(cx, cy),
cx, -element.angle as Radians,
cy,
-element.angle,
); );
return [x - element.x, y - element.y]; return pointFrom(x - element.x, y - element.y);
} }
static getPointIndexUnderCursor( static getPointIndexUnderCursor(
@@ -1052,9 +1073,9 @@ export class LinearElementEditor {
// points on the left, thus should take precedence when clicking, if they // points on the left, thus should take precedence when clicking, if they
// overlap // overlap
while (--idx > -1) { while (--idx > -1) {
const point = pointHandles[idx]; const p = pointHandles[idx];
if ( if (
distance2d(x, y, point[0], point[1]) * zoom.value < pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke // +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1 LinearElementEditor.POINT_HANDLE_SIZE + 1
) { ) {
@@ -1070,20 +1091,18 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
gridSize: NullableGridSize, gridSize: NullableGridSize,
): Point { ): LocalPoint {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = rotate( const [rotatedX, rotatedY] = pointRotateRads(
pointerOnGrid[0], pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
pointerOnGrid[1], pointFrom(cx, cy),
cx, -element.angle as Radians,
cy,
-element.angle,
); );
return [rotatedX - element.x, rotatedY - element.y]; return pointFrom(rotatedX - element.x, rotatedY - element.y);
} }
/** /**
@@ -1091,15 +1110,19 @@ export class LinearElementEditor {
* expected in various parts of the codebase. Also returns new x/y to account * expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization. * for the potential normalization.
*/ */
static getNormalizedPoints(element: ExcalidrawLinearElement) { static getNormalizedPoints(element: ExcalidrawLinearElement): {
points: LocalPoint[];
x: number;
y: number;
} {
const { points } = element; const { points } = element;
const offsetX = points[0][0]; const offsetX = points[0][0];
const offsetY = points[0][1]; const offsetY = points[0][1];
return { return {
points: points.map((point) => { points: points.map((p) => {
return [point[0] - offsetX, point[1] - offsetY] as const; return pointFrom(p[0] - offsetX, p[1] - offsetY);
}), }),
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
@@ -1116,17 +1139,23 @@ export class LinearElementEditor {
static duplicateSelectedPoints( static duplicateSelectedPoints(
appState: AppState, appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) { ): AppState {
if (!appState.editingLinearElement) { invariant(
return false; appState.editingLinearElement,
} "Not currently editing a linear element",
);
const { selectedPointsIndices, elementId } = appState.editingLinearElement; const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element || selectedPointsIndices === null) { invariant(
return false; element,
} "The linear element does not exist in the provided Scene",
);
invariant(
selectedPointsIndices != null,
"There are no selected points to duplicate",
);
const { points } = element; const { points } = element;
@@ -1134,9 +1163,9 @@ export class LinearElementEditor {
let pointAddedToEnd = false; let pointAddedToEnd = false;
let indexCursor = -1; let indexCursor = -1;
const nextPoints = points.reduce((acc: Point[], point, index) => { const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
++indexCursor; ++indexCursor;
acc.push(point); acc.push(p);
const isSelected = selectedPointsIndices.includes(index); const isSelected = selectedPointsIndices.includes(index);
if (isSelected) { if (isSelected) {
@@ -1147,8 +1176,8 @@ export class LinearElementEditor {
} }
acc.push( acc.push(
nextPoint nextPoint
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2] ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: [point[0], point[1]], : pointFrom(p[0], p[1]),
); );
nextSelectedIndices.push(indexCursor + 1); nextSelectedIndices.push(indexCursor + 1);
@@ -1169,7 +1198,7 @@ export class LinearElementEditor {
[ [
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30], point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
}, },
], ],
elementsMap, elementsMap,
@@ -1177,12 +1206,10 @@ export class LinearElementEditor {
} }
return { return {
appState: { ...appState,
...appState, editingLinearElement: {
editingLinearElement: { ...appState.editingLinearElement,
...appState.editingLinearElement, selectedPointsIndices: nextSelectedIndices,
selectedPointsIndices: nextSelectedIndices,
},
}, },
}; };
} }
@@ -1209,10 +1236,12 @@ 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)) { if (!pointIndices.includes(idx)) {
acc.push( acc.push(
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY], !acc.length
? pointFrom(0, 0)
: pointFrom(p[0] - offsetX, p[1] - offsetY),
); );
} }
return acc; return acc;
@@ -1229,7 +1258,7 @@ export class LinearElementEditor {
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: Point }[], targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) { ) {
const offsetX = 0; const offsetX = 0;
@@ -1247,7 +1276,7 @@ export class LinearElementEditor {
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[], targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
@@ -1277,11 +1306,11 @@ export class LinearElementEditor {
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
} }
const nextPoints = points.map((point, idx) => { const nextPoints: LocalPoint[] = points.map((p, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx); const selectedPointData = targetPoints.find((t) => t.index === idx);
if (selectedPointData) { if (selectedPointData) {
if (selectedPointData.index === 0) { if (selectedPointData.index === 0) {
return point; return p;
} }
const deltaX = const deltaX =
@@ -1289,14 +1318,9 @@ export class LinearElementEditor {
const deltaY = const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1]; selectedPointData.point[1] - points[selectedPointData.index][1];
return [ return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
point[0] + deltaX - offsetX,
point[1] + deltaY - offsetY,
] as const;
} }
return offsetX || offsetY return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
}); });
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
@@ -1349,11 +1373,9 @@ export class LinearElementEditor {
} }
const origin = linearElementEditor.pointerDownState.origin!; const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d( const dist = pointDistance(
origin.x, pointFrom(origin.x, origin.y),
origin.y, pointFrom(pointerCoords.x, pointerCoords.y),
pointerCoords.x,
pointerCoords.y,
); );
if ( if (
!appState.editingLinearElement && !appState.editingLinearElement &&
@@ -1418,7 +1440,7 @@ export class LinearElementEditor {
private static _updatePoints( private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[], nextPoints: readonly LocalPoint[],
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
@@ -1461,9 +1483,11 @@ export class LinearElementEditor {
element, element,
mergedElementsMap, mergedElementsMap,
nextPoints, nextPoints,
[offsetX, offsetY], vector(offsetX, offsetY),
bindings, bindings,
options, {
isDragging: options?.isDragging,
},
); );
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
@@ -1474,7 +1498,11 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); const rotated = pointRotateRads(
pointFrom(offsetX, offsetY),
pointFrom(dX, dY),
element.angle,
);
mutateElement(element, { mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
@@ -1487,8 +1515,8 @@ export class LinearElementEditor {
private static _getShiftLockedDelta( private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
referencePoint: Point, referencePoint: LocalPoint,
scenePointer: Point, scenePointer: GlobalPoint,
gridSize: NullableGridSize, gridSize: NullableGridSize,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
@@ -1517,7 +1545,11 @@ export class LinearElementEditor {
gridY, gridY,
); );
return rotatePoint([width, height], [0, 0], -element.angle); return pointRotateRads(
pointFrom(width, height),
pointFrom(0, 0),
-element.angle as Radians,
);
} }
static getBoundTextElementPosition = ( static getBoundTextElementPosition = (
@@ -1548,7 +1580,7 @@ export class LinearElementEditor {
let midSegmentMidpoint = editorMidPointsCache.points[index]; let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) { if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]); midSegmentMidpoint = pointCenter(points[0], points[1]);
} }
if ( if (
!midSegmentMidpoint || !midSegmentMidpoint ||
@@ -1585,37 +1617,38 @@ export class LinearElementEditor {
); );
const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height; const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = pointFrom(cx, cy);
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle); const topLeftRotatedPoint = pointRotateRads(
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle); pointFrom(x1, y1),
centerPoint,
const counterRotateBoundTextTopLeft = rotatePoint( element.angle,
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
); );
const counterRotateBoundTextTopRight = rotatePoint( const topRightRotatedPoint = pointRotateRads(
[boundTextX2, boundTextY1], pointFrom(x2, y1),
centerPoint,
[cx, cy], element.angle,
-element.angle,
); );
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy], const counterRotateBoundTextTopLeft = pointRotateRads(
pointFrom(boundTextX1, boundTextY1),
-element.angle, centerPoint,
-element.angle as Radians,
); );
const counterRotateBoundTextBottomRight = rotatePoint( const counterRotateBoundTextTopRight = pointRotateRads(
[boundTextX2, boundTextY2], pointFrom(boundTextX2, boundTextY1),
centerPoint,
[cx, cy], -element.angle as Radians,
);
-element.angle, const counterRotateBoundTextBottomLeft = pointRotateRads(
pointFrom(boundTextX1, boundTextY2),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = pointRotateRads(
pointFrom(boundTextX2, boundTextY2),
centerPoint,
-element.angle as Radians,
); );
if ( if (
+28 -6
View File
@@ -2,16 +2,26 @@ import type { ExcalidrawElement } from "./types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import type { Point } from "../types";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { maybeGetSubtypeProps } from "./newElement";
import { getSubtypeMethods } from "./subtypes";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "version" | "versionNonce" | "updated"
>; >;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
const map = getSubtypeMethods(subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration. // This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in // The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you // the same drawing. Note: this will trigger the component to update. Make sure you
@@ -22,6 +32,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true, informMutation = true,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -59,8 +71,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
let didChangePoints = false; let didChangePoints = false;
let index = prevPoints.length; let index = prevPoints.length;
while (--index) { while (--index) {
const prevPoint: Point = prevPoints[index]; const prevPoint = prevPoints[index];
const nextPoint: Point = nextPoints[index]; const nextPoint = nextPoints[index];
if ( if (
prevPoint[0] !== nextPoint[0] || prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1] prevPoint[1] !== nextPoint[1]
@@ -70,6 +82,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
} }
if (!didChangePoints) { if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue; continue;
} }
} }
@@ -77,6 +90,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value; (element as any)[key] = value;
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -93,9 +107,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; if (increment) {
element.versionNonce = randomInteger(); element.version++;
element.updated = getUpdatedTimestamp(); element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) { if (informMutation) {
Scene.getScene(element)?.triggerUpdate(); Scene.getScene(element)?.triggerUpdate();
@@ -111,6 +127,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
force = false, force = false,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) { for (const key in updates) {
const value = (updates as any)[key]; const value = (updates as any)[key];
if (typeof value !== "undefined") { if (typeof value !== "undefined") {
@@ -122,6 +140,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue; continue;
} }
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -129,6 +148,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
if (!increment) {
return { ...element, ...updates };
}
return { return {
...element, ...element,
...updates, ...updates,
@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types"; import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
@@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" }; element.__proto__ = { hello: "world" };
mutateElement(element, { mutateElement(element, {
points: [ points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
[1, 2],
[3, 4],
],
}); });
const copy = duplicateElement(null, new Map(), element); const copy = duplicateElement(null, new Map(), element);
+108 -30
View File
@@ -19,23 +19,17 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
} from "./types"; } from "./types";
import { import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement"; import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
measureText, measureTextElement,
normalizeText, normalizeText,
wrapText, wrapTextElement,
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "./textElement"; } from "./textElement";
import { import {
@@ -48,6 +42,31 @@ import {
} from "../constants"; } from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import type { Radians } from "../../math";
import { getSubtypeMethods, isValidSubtype } from "./subtypes";
export const maybeGetSubtypeProps = (
obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
},
type: ExcalidrawElement["type"],
) => {
const data: typeof obj = {};
if ("subtype" in obj) {
data.subtype = obj.subtype;
}
if ("customData" in obj) {
data.customData = obj.customData;
}
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
delete data.subtype;
}
if (!("subtype" in data) && "customData" in data) {
delete data.customData;
}
return data as typeof obj;
};
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -62,6 +81,8 @@ export type ElementConstructorOpts = MarkOptional<
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link" | "link"
| "subtype"
| "customData"
| "strokeStyle" | "strokeStyle"
| "fillStyle" | "fillStyle"
| "strokeColor" | "strokeColor"
@@ -88,7 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity = DEFAULT_ELEMENT_PROPS.opacity, opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0, width = 0,
height = 0, height = 0,
angle = 0, angle = 0 as Radians,
groupIds = [], groupIds = [],
frameId = null, frameId = null,
index = null, index = null,
@@ -99,8 +120,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }, type),
id: rest.id || randomId(), id: rest.id || randomId(),
type, type,
x, x,
@@ -136,8 +159,11 @@ export const newElement = (
opts: { opts: {
type: ExcalidrawGenericElement["type"]; type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> => {
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
@@ -223,7 +249,6 @@ export const newTextElement = (
verticalAlign?: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
autoResize?: ExcalidrawTextElement["autoResize"]; autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
@@ -231,10 +256,12 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText( const metrics = measureTextElement(
text, { ...opts, fontSize, fontFamily, lineHeight },
getFontString({ fontFamily, fontSize }), {
lineHeight, text,
customData: opts.customData,
},
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -278,11 +305,9 @@ const getAdjustedDimensions = (
width: number; width: number;
height: number; height: number;
} => { } => {
let { width: nextWidth, height: nextHeight } = measureText( let { width: nextWidth, height: nextHeight } = measureTextElement(element, {
nextText, text: nextText,
getFontString(element), });
element.lineHeight,
);
// wrapped text // wrapped text
if (!element.autoResize) { if (!element.autoResize) {
@@ -298,11 +323,7 @@ const getAdjustedDimensions = (
!element.containerId && !element.containerId &&
element.autoResize element.autoResize
) { ) {
const prevMetrics = measureText( const prevMetrics = measureTextElement(element);
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,
@@ -348,6 +369,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 = ( export const refreshTextDimensions = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null, container: ExcalidrawTextContainer | null,
@@ -358,12 +426,14 @@ export const refreshTextDimensions = (
return; return;
} }
if (container || !textElement.autoResize) { if (container || !textElement.autoResize) {
text = wrapText( text = wrapTextElement(
text, textElement,
getFontString(textElement),
container container
? getBoundTextMaxWidth(container, textElement) ? getBoundTextMaxWidth(container, textElement)
: textElement.width, : textElement.width,
{
text,
},
); );
} }
const dimensions = getAdjustedDimensions(textElement, elementsMap, text); const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
@@ -378,6 +448,8 @@ export const newFreeDrawElement = (
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -393,6 +465,8 @@ export const newLinearElement = (
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -413,6 +487,8 @@ export const newArrowElement = (
elbowed?: boolean; elbowed?: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => { ): NonDeleted<ExcalidrawArrowElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -433,6 +509,8 @@ export const newImageElement = (
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => { ): NonDeleted<ExcalidrawImageElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawImageElement>("image", opts), ..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements, // in the future we'll support changing stroke color for some SVG elements,
+143 -101
View File
@@ -1,7 +1,5 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
import type { import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -11,6 +9,7 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -38,7 +37,7 @@ import type {
MaybeTransformHandleType, MaybeTransformHandleType,
TransformHandleDirection, TransformHandleDirection,
} from "./transformHandles"; } from "./transformHandles";
import type { Point, PointerDownState } from "../types"; import type { PointerDownState } from "../types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
getApproxMinLineWidth, getApproxMinLineWidth,
@@ -55,16 +54,15 @@ import {
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing"; import { mutateElbowArrow } from "./routing";
import type { GlobalPoint } from "../../math";
export const normalizeAngle = (angle: number): number => { import {
if (angle < 0) { pointCenter,
return angle + 2 * Math.PI; normalizeRadians,
} pointFrom,
if (angle >= 2 * Math.PI) { pointFromPair,
return angle - 2 * Math.PI; pointRotateRads,
} type Radians,
return angle; } from "../../math";
};
// Returns true when transform (resizing/rotation) happened // Returns true when transform (resizing/rotation) happened
export const transformElements = ( export const transformElements = (
@@ -158,16 +156,17 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle: number; let angle: Radians;
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
angle = 0; angle = 0 as Radians;
} else { } 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) { if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2; angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
angle -= angle % SHIFT_LOCKING_ANGLE; angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
} }
angle = normalizeAngle(angle); angle = normalizeRadians(angle as Radians);
} }
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
@@ -240,12 +239,10 @@ const resizeSingleTextElement = (
elementsMap, elementsMap,
); );
// rotation pointer with reverse angle // rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate( const [rotatedX, rotatedY] = pointRotateRads(
pointerX, pointFrom(pointerX, pointerY),
pointerY, pointFrom(cx, cy),
cx, -element.angle as Radians,
cy,
-element.angle,
); );
let scaleX = 0; let scaleX = 0;
let scaleY = 0; let scaleY = 0;
@@ -279,20 +276,26 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2]; const startBottomRight = [x2, y2];
const startCenter = [cx, cy]; const startCenter = [cx, cy];
let newTopLeft = [x1, y1] as [number, number]; let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) { if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [ newTopLeft = pointFrom<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth), startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight), startBottomRight[1] - Math.abs(nextHeight),
]; );
} }
if (transformHandleType === "ne") { if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]]; const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)]; newTopLeft = pointFrom<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
} }
if (transformHandleType === "sw") { if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]]; const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]]; newTopLeft = pointFrom<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
} }
if (["s", "n"].includes(transformHandleType)) { if (["s", "n"].includes(transformHandleType)) {
@@ -308,13 +311,25 @@ const resizeSingleTextElement = (
} }
const angle = element.angle; const angle = element.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle); const rotatedTopLeft = pointRotateRads(
const newCenter: Point = [ newTopLeft,
pointFrom(cx, cy),
angle,
);
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2, newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2, newTopLeft[1] + Math.abs(nextHeight) / 2,
]; );
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle); const rotatedNewCenter = pointRotateRads(
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft; const [nextX, nextY] = newTopLeft;
mutateElement(element, { mutateElement(element, {
@@ -334,14 +349,14 @@ const resizeSingleTextElement = (
stateAtResizeStart.height, stateAtResizeStart.height,
true, true,
); );
const startTopLeft: Point = [x1, y1]; const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
const startBottomRight: Point = [x2, y2]; const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
const startCenter: Point = centerPoint(startTopLeft, startBottomRight); const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = rotatePoint( const rotatedPointer = pointRotateRads(
[pointerX, pointerY], pointFrom(pointerX, pointerY),
startCenter, startCenter,
-stateAtResizeStart.angle, -stateAtResizeStart.angle as Radians,
); );
const [esx1, , esx2] = getResizedElementAbsoluteCoords( const [esx1, , esx2] = getResizedElementAbsoluteCoords(
@@ -407,13 +422,21 @@ const resizeSingleTextElement = (
// adjust topLeft to new rotation point // adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle; const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); const rotatedTopLeft = pointRotateRads(
const newCenter: Point = [ pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
]; );
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const resizedElement: Partial<ExcalidrawTextElement> = { const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth), width: Math.abs(newWidth),
@@ -446,15 +469,15 @@ export const resizeSingleElement = (
stateAtResizeStart.height, stateAtResizeStart.height,
true, true,
); );
const startTopLeft: Point = [x1, y1]; const startTopLeft = pointFrom(x1, y1);
const startBottomRight: Point = [x2, y2]; const startBottomRight = pointFrom(x2, y2);
const startCenter: Point = centerPoint(startTopLeft, startBottomRight); const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position // Calculate new dimensions based on cursor position
const rotatedPointer = rotatePoint( const rotatedPointer = pointRotateRads(
[pointerX, pointerY], pointFrom(pointerX, pointerY),
startCenter, startCenter,
-stateAtResizeStart.angle, -stateAtResizeStart.angle as Radians,
); );
// Get bounds corners rendered on screen // Get bounds corners rendered on screen
@@ -628,13 +651,21 @@ export const resizeSingleElement = (
// adjust topLeft to new rotation point // adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle; const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); const rotatedTopLeft = pointRotateRads(
const newCenter: Point = [ pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
]; );
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -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 // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be // So we need to readjust (x,y) to be where the first point should be
@@ -793,21 +824,21 @@ export const resizeMultipleElements = (
const direction = transformHandleType; const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, Point> = { const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
ne: [minX, maxY], ne: pointFrom(minX, maxY),
se: [minX, minY], se: pointFrom(minX, minY),
sw: [maxX, minY], sw: pointFrom(maxX, minY),
nw: [maxX, maxY], nw: pointFrom(maxX, maxY),
e: [minX, minY + height / 2], e: pointFrom(minX, minY + height / 2),
w: [maxX, minY + height / 2], w: pointFrom(maxX, minY + height / 2),
n: [minX + width / 2, maxY], n: pointFrom(minX + width / 2, maxY),
s: [minX + width / 2, minY], s: pointFrom(minX + width / 2, minY),
}; };
// anchor point must be on the opposite side of the dragged selection handle // anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter // or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter const [anchorX, anchorY] = shouldResizeFromCenter
? [midX, midY] ? pointFrom(midX, midY)
: anchorsMap[direction]; : anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -887,6 +918,8 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawArrowElement["startBinding"];
endBinding?: ExcalidrawArrowElement["endBinding"];
}; };
}[] = []; }[] = [];
@@ -898,7 +931,9 @@ export const resizeMultipleElements = (
const width = orig.width * scaleX; const width = orig.width * scaleX;
const height = orig.height * scaleY; 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 isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
const offsetX = orig.x - anchorX; const offsetX = orig.x - anchorX;
@@ -969,19 +1004,6 @@ export const resizeMultipleElements = (
mutateElement(element, update, false); mutateElement(element, update, false);
if (isArrowElement(element) && isElbowArrow(element)) {
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
);
}
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
oldSize: { width: oldWidth, height: oldHeight }, oldSize: { width: oldWidth, height: oldHeight },
@@ -1029,15 +1051,13 @@ const rotateMultipleElements = (
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const origAngle = const origAngle =
originalElements.get(element.id)?.angle ?? element.angle; originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = rotate( const [rotatedCX, rotatedCY] = pointRotateRads(
cx, pointFrom(cx, cy),
cy, pointFrom(centerX, centerY),
centerX, (centerAngle + origAngle - element.angle) as Radians,
centerY,
centerAngle + origAngle - element.angle,
); );
if (isArrowElement(element) && isElbowArrow(element)) { if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap); const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points); mutateElbowArrow(element, elementsMap, points);
} else { } else {
@@ -1046,7 +1066,7 @@ const rotateMultipleElements = (
{ {
x: element.x + (rotatedCX - cx), x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy), y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}, },
false, false,
); );
@@ -1063,7 +1083,7 @@ const rotateMultipleElements = (
{ {
x: boundText.x + (rotatedCX - cx), x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy), y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}, },
false, false,
); );
@@ -1086,25 +1106,47 @@ export const getResizeOffsetXY = (
: getCommonBounds(selectedElements); : getCommonBounds(selectedElements);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0; const angle = (
[x, y] = rotate(x, y, cx, cy, -angle); selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians;
[x, y] = pointRotateRads(
pointFrom(x, y),
pointFrom(cx, cy),
-angle as Radians,
);
switch (transformHandleType) { switch (transformHandleType) {
case "n": case "n":
return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle); return pointRotateRads(
pointFrom(x - (x1 + x2) / 2, y - y1),
pointFrom(0, 0),
angle,
);
case "s": case "s":
return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle); return pointRotateRads(
pointFrom(x - (x1 + x2) / 2, y - y2),
pointFrom(0, 0),
angle,
);
case "w": case "w":
return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle); return pointRotateRads(
pointFrom(x - x1, y - (y1 + y2) / 2),
pointFrom(0, 0),
angle,
);
case "e": case "e":
return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle); return pointRotateRads(
pointFrom(x - x2, y - (y1 + y2) / 2),
pointFrom(0, 0),
angle,
);
case "nw": case "nw":
return rotate(x - x1, y - y1, 0, 0, angle); return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
case "ne": case "ne":
return rotate(x - x2, y - y1, 0, 0, angle); return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
case "sw": case "sw":
return rotate(x - x1, y - y2, 0, 0, angle); return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
case "se": case "se":
return rotate(x - x2, y - y2, 0, 0, angle); return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
default: default:
return [0, 0]; return [0, 0];
} }
+40 -25
View File
@@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants"; 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 { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import {
pointFrom,
pointOnLineSegment,
pointRotateRads,
type Radians,
} from "../../math";
const isInsideTransformHandle = ( const isInsideTransformHandle = (
transformHandle: TransformHandle, transformHandle: TransformHandle,
@@ -38,7 +39,7 @@ const isInsideTransformHandle = (
y >= transformHandle[1] && y >= transformHandle[1] &&
y <= transformHandle[1] + transformHandle[3]; y <= transformHandle[1] + transformHandle[3];
export const resizeTest = ( export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: AppState, appState: AppState,
@@ -91,15 +92,21 @@ export const resizeTest = (
if (!(isLinearElement(element) && element.points.length <= 2)) { if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING], pointFrom(x1 - SPACING, y1 - SPACING),
[x2 + SPACING, y2 + SPACING], pointFrom(x2 + SPACING, y2 + SPACING),
[cx, cy], pointFrom(cx, cy),
angleToDegrees(element.angle), element.angle,
); );
for (const [dir, side] of Object.entries(sides)) { for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment // test to see if x, y are on the line segment
if (pointOnLine([x, y], side as Line, SPACING)) { if (
pointOnLineSegment(
pointFrom(x, y),
side as LineSegment<Point>,
SPACING,
)
) {
return dir as TransformHandleType; return dir as TransformHandleType;
} }
} }
@@ -137,7 +144,9 @@ export const getElementWithTransformHandleType = (
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
}; };
export const getTransformHandleTypeFromCoords = ( export const getTransformHandleTypeFromCoords = <
Point extends GlobalPoint | LocalPoint,
>(
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
@@ -147,7 +156,7 @@ export const getTransformHandleTypeFromCoords = (
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords( const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0, 0 as Radians,
zoom, zoom,
pointerType, pointerType,
getOmitSidesForDevice(device), getOmitSidesForDevice(device),
@@ -173,15 +182,21 @@ export const getTransformHandleTypeFromCoords = (
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING], pointFrom(x1 - SPACING, y1 - SPACING),
[x2 + SPACING, y2 + SPACING], pointFrom(x2 + SPACING, y2 + SPACING),
[cx, cy], pointFrom(cx, cy),
angleToDegrees(0), 0 as Radians,
); );
for (const [dir, side] of Object.entries(sides)) { for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment // test to see if x, y are on the line segment
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) { if (
pointOnLineSegment(
pointFrom(scenePointerX, scenePointerY),
side as LineSegment<Point>,
SPACING,
)
) {
return dir as TransformHandleType; return dir as TransformHandleType;
} }
} }
@@ -248,16 +263,16 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : ""; return cursor ? `${cursor}-resize` : "";
}; };
const getSelectionBorders = ( const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
[x1, y1]: Point, [x1, y1]: Point,
[x2, y2]: Point, [x2, y2]: Point,
center: Point, center: Point,
angleInDegrees: number, angle: Radians,
) => { ) => {
const topLeft = pointRotate([x1, y1], angleInDegrees, center); const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
const topRight = pointRotate([x2, y1], angleInDegrees, center); const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center); const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
return { return {
n: [topLeft, topRight], n: [topLeft, topRight],
+18 -22
View File
@@ -17,6 +17,7 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { ARROW_TYPE } from "../constants"; import { ARROW_TYPE } from "../constants";
import { pointFrom } from "../../math";
const { h } = window; const { h } = window;
@@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
[-45 - arrow.x, -100.1 - arrow.y], pointFrom(-45 - arrow.x, -100.1 - arrow.y),
[45 - arrow.x, 99.9 - arrow.y], pointFrom(45 - arrow.x, 99.9 - arrow.y),
]); ]);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1, y: -100.1,
width: 90, width: 90,
height: 200, height: 200,
points: [ points: [pointFrom(0, 0), pointFrom(90, 200)],
[0, 0],
[90, 200],
],
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1); scene.insertElement(rectangle1);
scene.insertElement(rectangle2); scene.insertElement(rectangle2);
@@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [ mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
[0, 0],
[90, 200],
]);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -99,7 +94,16 @@ describe("elbow arrow routing", () => {
describe("elbow arrow ui", () => { describe("elbow arrow ui", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
}); });
it("can follow bound shapes", async () => { it("can follow bound shapes", async () => {
@@ -135,8 +139,8 @@ describe("elbow arrow ui", () => {
expect(arrow.elbowed).toBe(true); expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[35, 0], [45, 0],
[35, 200], [45, 200],
[90, 200], [90, 200],
]); ]);
}); });
@@ -168,14 +172,6 @@ describe("elbow arrow ui", () => {
h.state, h.state,
)[0] as ExcalidrawArrowElement; )[0] as ExcalidrawArrowElement;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = UI.queryStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
@@ -187,8 +183,8 @@ describe("elbow arrow ui", () => {
[0, 0], [0, 0],
[35, 0], [35, 0],
[35, 90], [35, 90],
[25, 90], [35, 90], // Note that coordinates are rounded above!
[25, 165], [35, 165],
[103, 165], [103, 165],
]); ]);
}); });
+185 -106
View File
@@ -1,16 +1,19 @@
import { cross } from "../../utils/geometry/geometry"; import type { Radians } from "../../math";
import BinaryHeap from "../binaryheap";
import { import {
aabbForElement, pointFrom,
arePointsEqual, pointScaleFromOrigin,
pointInsideBounds, pointTranslate,
pointToVector, vector,
scalePointFromOrigin, vectorCross,
scaleVector, vectorFromPoint,
translatePoint, vectorScale,
} from "../math"; type GlobalPoint,
type LocalPoint,
type Vector,
} from "../../math";
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import type { Point } from "../types"; import { aabbForElement, pointInsideBounds } from "../shapes";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import { import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
@@ -25,22 +28,26 @@ import {
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
import { import {
compareHeading,
flipHeading,
HEADING_DOWN, HEADING_DOWN,
HEADING_LEFT, HEADING_LEFT,
HEADING_RIGHT, HEADING_RIGHT,
HEADING_UP, HEADING_UP,
vectorToHeading, vectorToHeading,
} from "./heading"; } from "./heading";
import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type { import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPointBinding,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
import type { ElementsMap, ExcalidrawBindableElement } from "./types"; import type { ElementsMap, ExcalidrawBindableElement } from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = { type Node = {
f: number; f: number;
g: number; g: number;
@@ -48,8 +55,8 @@ type Node = {
closed: boolean; closed: boolean;
visited: boolean; visited: boolean;
parent: Node | null; parent: Node | null;
pos: Point; pos: GlobalPoint;
addr: [number, number]; addr: GridAddress;
}; };
type Grid = { type Grid = {
@@ -63,26 +70,64 @@ const BASE_PADDING = 40;
export const mutateElbowArrow = ( export const mutateElbowArrow = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly Point[], nextPoints: readonly LocalPoint[],
offset?: Point, offset?: Vector,
otherUpdates?: { otherUpdates?: Omit<
startBinding?: FixedPointBinding | null; ElementUpdate<ExcalidrawElbowArrowElement>,
endBinding?: FixedPointBinding | null; "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
>,
options?: {
isDragging?: boolean;
informMutation?: boolean;
}, },
) => {
const update = updateElbowArrow(
arrow,
elementsMap,
nextPoints,
offset,
options,
);
if (update) {
mutateElement(
arrow,
{
...otherUpdates,
...update,
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
}
};
export const updateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[],
offset?: Vector,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
disableBinding?: boolean; disableBinding?: boolean;
informMutation?: boolean; informMutation?: boolean;
}, },
) => { ): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint = translatePoint(nextPoints[0], [ const origStartGlobalPoint: GlobalPoint = pointTranslate(
arrow.x + (offset ? offset[0] : 0), pointTranslate<LocalPoint, GlobalPoint>(
arrow.y + (offset ? offset[1] : 0), nextPoints[0],
]); vector(arrow.x, arrow.y),
const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [ ),
arrow.x + (offset ? offset[0] : 0), offset,
arrow.y + (offset ? offset[1] : 0), );
]); const origEndGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[nextPoints.length - 1],
vector(arrow.x, arrow.y),
),
offset,
);
const startElement = const startElement =
arrow.startBinding && arrow.startBinding &&
@@ -222,6 +267,8 @@ export const mutateElbowArrow = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement),
hoveredEndElement && aabbForElement(hoveredEndElement),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -275,22 +322,17 @@ export const mutateElbowArrow = (
); );
if (path) { 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); startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint); endDongle && points.push(endGlobalPoint);
mutateElement( return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
arrow,
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
} }
return null;
}; };
const offsetFromHeading = ( const offsetFromHeading = (
@@ -363,7 +405,7 @@ const astar = (
} }
// Intersect // Intersect
const neighborHalfPoint = scalePointFromOrigin( const neighborHalfPoint = pointScaleFromOrigin(
neighbor.pos, neighbor.pos,
current.pos, current.pos,
0.5, 0.5,
@@ -380,17 +422,17 @@ const astar = (
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. // 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 neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
const previousDirection = current.parent const previousDirection = current.parent
? vectorToHeading(pointToVector(current.pos, current.parent.pos)) ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
: startHeading; : startHeading;
// Do not allow going in reverse // Do not allow going in reverse
const reverseHeading = scaleVector(previousDirection, -1); const reverseHeading = flipHeading(previousDirection);
const neighborIsReverseRoute = const neighborIsReverseRoute =
arePointsEqual(reverseHeading, neighborHeading) || compareHeading(reverseHeading, neighborHeading) ||
(arePointsEqual(start.addr, neighbor.addr) && (gridAddressesEqual(start.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, startHeading)) || compareHeading(neighborHeading, startHeading)) ||
(arePointsEqual(end.addr, neighbor.addr) && (gridAddressesEqual(end.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, endHeading)); compareHeading(neighborHeading, endHeading));
if (neighborIsReverseRoute) { if (neighborIsReverseRoute) {
continue; continue;
} }
@@ -444,7 +486,7 @@ const pathTo = (start: Node, node: Node) => {
return path; 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]); Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
/** /**
@@ -459,7 +501,11 @@ const generateDynamicAABBs = (
startDifference?: [number, number, number, number], startDifference?: [number, number, number, number],
endDifference?: [number, number, number, number], endDifference?: [number, number, number, number],
disableSideHack?: boolean, disableSideHack?: boolean,
startElementBounds?: Bounds | null,
endElementBounds?: Bounds | null,
): Bounds[] => { ): Bounds[] => {
const startEl = startElementBounds ?? a;
const endEl = endElementBounds ?? b;
const [startUp, startRight, startDown, startLeft] = startDifference ?? [ const [startUp, startRight, startDown, startLeft] = startDifference ?? [
0, 0, 0, 0, 0, 0, 0, 0,
]; ];
@@ -468,29 +514,29 @@ const generateDynamicAABBs = (
const first = [ const first = [
a[0] > b[2] a[0] > b[2]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.min((a[0] + b[2]) / 2, a[0] - startLeft) ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
: (a[0] + b[2]) / 2 : (startEl[0] + endEl[2]) / 2
: a[0] > b[0] : a[0] > b[0]
? a[0] - startLeft ? a[0] - startLeft
: common[0] - startLeft, : common[0] - startLeft,
a[1] > b[3] a[1] > b[3]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.min((a[1] + b[3]) / 2, a[1] - startUp) ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
: (a[1] + b[3]) / 2 : (startEl[1] + endEl[3]) / 2
: a[1] > b[1] : a[1] > b[1]
? a[1] - startUp ? a[1] - startUp
: common[1] - startUp, : common[1] - startUp,
a[2] < b[0] a[2] < b[0]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.max((a[2] + b[0]) / 2, a[2] + startRight) ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
: (a[2] + b[0]) / 2 : (startEl[2] + endEl[0]) / 2
: a[2] < b[2] : a[2] < b[2]
? a[2] + startRight ? a[2] + startRight
: common[2] + startRight, : common[2] + startRight,
a[3] < b[1] a[3] < b[1]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.max((a[3] + b[1]) / 2, a[3] + startDown) ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
: (a[3] + b[1]) / 2 : (startEl[3] + endEl[1]) / 2
: a[3] < b[3] : a[3] < b[3]
? a[3] + startDown ? a[3] + startDown
: common[3] + startDown, : common[3] + startDown,
@@ -498,29 +544,29 @@ const generateDynamicAABBs = (
const second = [ const second = [
b[0] > a[2] b[0] > a[2]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.min((b[0] + a[2]) / 2, b[0] - endLeft) ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
: (b[0] + a[2]) / 2 : (endEl[0] + startEl[2]) / 2
: b[0] > a[0] : b[0] > a[0]
? b[0] - endLeft ? b[0] - endLeft
: common[0] - endLeft, : common[0] - endLeft,
b[1] > a[3] b[1] > a[3]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.min((b[1] + a[3]) / 2, b[1] - endUp) ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
: (b[1] + a[3]) / 2 : (endEl[1] + startEl[3]) / 2
: b[1] > a[1] : b[1] > a[1]
? b[1] - endUp ? b[1] - endUp
: common[1] - endUp, : common[1] - endUp,
b[2] < a[0] b[2] < a[0]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.max((b[2] + a[0]) / 2, b[2] + endRight) ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
: (b[2] + a[0]) / 2 : (endEl[2] + startEl[0]) / 2
: b[2] < a[2] : b[2] < a[2]
? b[2] + endRight ? b[2] + endRight
: common[2] + endRight, : common[2] + endRight,
b[3] < a[1] b[3] < a[1]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.max((b[3] + a[1]) / 2, b[3] + endDown) ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
: (b[3] + a[1]) / 2 : (endEl[3] + startEl[1]) / 2
: b[3] < a[3] : b[3] < a[3]
? b[3] + endDown ? b[3] + endDown
: common[3] + endDown, : common[3] + endDown,
@@ -541,7 +587,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2; const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 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 [ return [
[first[0], first[1], cX, first[3]], [first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]], [cX, second[1], second[2], second[3]],
@@ -557,7 +608,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2; const cX = first[2] + (second[0] - first[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 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 [ return [
[first[0], first[1], first[2], cY], [first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]], [second[0], cY, second[2], second[3]],
@@ -573,7 +629,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2; const cX = second[2] + (first[0] - second[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 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 [ return [
[cX, first[1], first[2], first[3]], [cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]], [second[0], second[1], cX, second[3]],
@@ -589,7 +650,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2; const cX = second[2] + (first[0] - second[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 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 [ return [
[cX, first[1], first[2], first[3]], [cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]], [second[0], second[1], cX, second[3]],
@@ -615,9 +681,9 @@ const generateDynamicAABBs = (
*/ */
const calculateGrid = ( const calculateGrid = (
aabbs: Bounds[], aabbs: Bounds[],
start: Point, start: GlobalPoint,
startHeading: Heading, startHeading: Heading,
end: Point, end: GlobalPoint,
endHeading: Heading, endHeading: Heading,
common: Bounds, common: Bounds,
): Grid => { ): Grid => {
@@ -662,8 +728,8 @@ const calculateGrid = (
closed: false, closed: false,
visited: false, visited: false,
parent: null, parent: null,
addr: [col, row] as [number, number], addr: [col, row] as GridAddress,
pos: [x, y] as Point, pos: [x, y] as GlobalPoint,
}), }),
), ),
), ),
@@ -673,17 +739,17 @@ const calculateGrid = (
const getDonglePosition = ( const getDonglePosition = (
bounds: Bounds, bounds: Bounds,
heading: Heading, heading: Heading,
point: Point, p: GlobalPoint,
): Point => { ): GlobalPoint => {
switch (heading) { switch (heading) {
case HEADING_UP: case HEADING_UP:
return [point[0], bounds[1]]; return pointFrom(p[0], bounds[1]);
case HEADING_RIGHT: case HEADING_RIGHT:
return [bounds[2], point[1]]; return pointFrom(bounds[2], p[1]);
case HEADING_DOWN: case HEADING_DOWN:
return [point[0], bounds[3]]; return pointFrom(p[0], bounds[3]);
} }
return [bounds[0], point[1]]; return pointFrom(bounds[0], p[1]);
}; };
const estimateSegmentCount = ( const estimateSegmentCount = (
@@ -826,7 +892,7 @@ const gridNodeFromAddr = (
/** /**
* Get node for global point on canvas (if exists) * 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 col = 0; col < grid.col; col++) {
for (let row = 0; row < grid.row; row++) { for (let row = 0; row < grid.row; row++) {
const candidate = gridNodeFromAddr([col, row], grid); const candidate = gridNodeFromAddr([col, row], grid);
@@ -865,15 +931,24 @@ const getBindableElementForId = (
}; };
const normalizedArrowElementUpdate = ( const normalizedArrowElementUpdate = (
global: Point[], global: GlobalPoint[],
externalOffsetX?: number, externalOffsetX?: number,
externalOffsetY?: number, externalOffsetY?: number,
) => { ): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
const points = global.map( const points = global.map((p) =>
(point) => [point[0] - offsetX, point[1] - offsetY] as const, pointTranslate<GlobalPoint, LocalPoint>(
p,
vectorScale(vectorFromPoint(global[0]), -1),
),
); );
return { return {
@@ -885,19 +960,22 @@ const normalizedArrowElementUpdate = (
}; };
/// If last and current segments have the same heading, skip the middle point /// If last and current segments have the same heading, skip the middle point
const simplifyElbowArrowPoints = (points: Point[]): Point[] => const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
points points
.slice(2) .slice(2)
.reduce( .reduce(
(result, point) => (result, p) =>
arePointsEqual( compareHeading(
vectorToHeading( 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.slice(0, -1), p]
: [...result, point], : [...result, p],
[points[0] ?? [0, 0], points[1] ?? [1, 0]], [points[0] ?? [0, 0], points[1] ?? [1, 0]],
); );
@@ -915,13 +993,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
const getGlobalPoint = ( const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: Point, initialPoint: GlobalPoint,
otherPoint: Point, otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
boundElement?: ExcalidrawBindableElement | null, boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null, hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean, isDragging?: boolean,
): Point => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (hoveredElement) { if (hoveredElement) {
const snapPoint = getSnapPoint( const snapPoint = getSnapPoint(
@@ -956,36 +1034,34 @@ const getGlobalPoint = (
}; };
const getSnapPoint = ( const getSnapPoint = (
point: Point, p: GlobalPoint,
otherPoint: Point, otherPoint: GlobalPoint,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => ) =>
bindPointToSnapToElementOutline( bindPointToSnapToElementOutline(
isRectanguloidElement(element) isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
? avoidRectangularCorner(element, point)
: point,
otherPoint, otherPoint,
element, element,
elementsMap, elementsMap,
); );
const getBindPointHeading = ( const getBindPointHeading = (
point: Point, p: GlobalPoint,
otherPoint: Point, otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: Point, origPoint: GlobalPoint,
) => ) =>
getHeadingForElbowArrowSnap( getHeadingForElbowArrowSnap(
point, p,
otherPoint, otherPoint,
hoveredElement, hoveredElement,
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill( Array(4).fill(
distanceToBindableElement(hoveredElement, point, elementsMap), distanceToBindableElement(hoveredElement, p, elementsMap),
) as [number, number, number, number], ) as [number, number, number, number],
), ),
elementsMap, elementsMap,
@@ -993,8 +1069,8 @@ const getBindPointHeading = (
); );
const getHoveredElements = ( const getHoveredElements = (
origStartGlobalPoint: Point, origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: Point, origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => { ) => {
// TODO: Might be a performance bottleneck and the Map type // TODO: Might be a performance bottleneck and the Map type
@@ -1018,3 +1094,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 { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; 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 { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
scrollY: number; scrollY: number;
}, },
elementsMap: ElementsMap, elementsMap: ElementsMap,
padding?: Partial<{ padding?: Offsets,
top: number;
right: number;
bottom: number;
left: number;
}>,
) => { ) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords( const topLeftSceneCoords = viewportCoordsToSceneCoords(
@@ -0,0 +1,541 @@
import { useEffect } from "react";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawTextElement,
NonDeleted,
} from "../types";
import { getNonDeletedElements } from "../";
import { getSelectedElements } from "../../scene";
import type { AppState, ExcalidrawImperativeAPI, ToolType } from "../../types";
import type { LangLdr } from "../../i18n";
import { registerCustomLangData } from "../../i18n";
import type {
Action,
ActionName,
ActionPredicateFn,
CustomActionName,
} from "../../actions/types";
import { makeCustomActionName } from "../../actions/types";
import { registerCustomShortcuts } from "../../actions/shortcuts";
import { register } from "../../actions/register";
import { hasBoundTextElement, isTextElement } from "../typeChecks";
import {
getBoundTextElement,
getContainerElement,
redrawTextBoundingBox,
} from "../textElement";
import { ShapeCache } from "../../scene/ShapeCache";
import Scene from "../../scene/Scene";
// Use "let" instead of "const" so we can dynamically add subtypes
let subtypeNames: readonly Subtype[] = [];
let parentTypeMap: readonly {
subtype: Subtype;
parentType: ExcalidrawElement["type"];
}[] = [];
let subtypeActionMap: readonly {
subtype: Subtype;
actions: readonly ActionName[];
}[] = [];
let disabledActionMap: readonly {
subtype: Subtype;
actions: readonly DisabledActionName[];
}[] = [];
let alwaysEnabledMap: readonly {
subtype: Subtype;
actions: readonly SubtypeActionName[];
}[] = [];
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly (ExcalidrawElement["type"] & ToolType)[];
actionNames?: readonly SubtypeActionName[];
disabledNames?: readonly DisabledActionName[];
shortcutMap?: Record<string, string[]>;
alwaysEnabledNames?: readonly SubtypeActionName[];
}>;
// Subtype Names
export type Subtype = Required<ExcalidrawElement>["subtype"];
export const getSubtypeNames = (): readonly Subtype[] => {
return subtypeNames;
};
export const isValidSubtype = (s: any, t: any): s is Subtype =>
parentTypeMap.find(
(val) => (val.subtype as any) === s && (val.parentType as any) === t,
) !== undefined;
const isSubtypeName = (s: any): s is Subtype => subtypeNames.includes(s);
// Subtype Actions
// Used for context menus in the shape chooser
export const hasAlwaysEnabledActions = (s: any): boolean => {
if (!isSubtypeName(s)) {
return false;
}
return alwaysEnabledMap.some((value) => value.subtype === s);
};
type SubtypeActionName = string;
const isSubtypeActionName = (s: any): s is SubtypeActionName =>
subtypeActionMap.some((val) => val.actions.includes(s));
const addSubtypeAction = (action: Action) => {
if (isSubtypeActionName(action.name) || isSubtypeName(action.name)) {
register(action);
}
};
// Standard actions disabled by subtypes
type DisabledActionName = ActionName;
const isDisabledActionName = (s: any): s is DisabledActionName =>
disabledActionMap.some((val) => val.actions.includes(s));
// Is the `actionName` one of the subtype actions for `subtype`
// (if `isAdded` is true) or one of the standard actions disabled
// by `subtype` (if `isAdded` is false)?
const isForSubtype = (
subtype: ExcalidrawElement["subtype"],
actionName: ActionName,
isAdded: boolean,
) => {
const actions = isAdded ? subtypeActionMap : disabledActionMap;
const map = actions.find((value) => value.subtype === subtype);
if (map) {
return map.actions.includes(actionName);
}
return false;
};
export const isSubtypeAction: ActionPredicateFn = function (action) {
return isSubtypeActionName(action.name) && !isSubtypeName(action.name);
};
export const subtypeActionPredicate: ActionPredicateFn = function (
action,
elements,
appState,
app,
) {
// We always enable subtype actions. Also let through standard actions
// which no subtypes might have disabled.
if (
isSubtypeName(action.name) ||
(!isSubtypeActionName(action.name) && !isDisabledActionName(action.name))
) {
return true;
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const chosen = appState.editingTextElement
? [appState.editingTextElement, ...selectedElements]
: selectedElements;
// Now handle actions added by subtypes
if (isSubtypeActionName(action.name)) {
// Has any ExcalidrawElement enabled this actionName through having
// its subtype?
return (
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return isForSubtype(e.subtype, action.name, true);
}) ||
// Or has any active subtype enabled this actionName?
(appState.activeSubtypes !== undefined &&
appState.activeSubtypes?.some((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return false;
}
return isForSubtype(subtype, action.name, true);
})) ||
alwaysEnabledMap.some((value) => {
return value.actions.includes(action.name);
})
);
}
// Now handle standard actions disabled by subtypes
if (isDisabledActionName(action.name)) {
return (
// Has every ExcalidrawElement not disabled this actionName?
(chosen.every((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return !isForSubtype(e.subtype, action.name, false);
}) &&
// And has every active subtype not disabled this actionName?
(appState.activeSubtypes === undefined ||
appState.activeSubtypes?.every((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return true;
}
return !isForSubtype(subtype, action.name, false);
}))) ||
// Or can we find an ExcalidrawElement without a valid subtype
// which would disable this action if it had a valid subtype?
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return parentTypeMap.some(
(value) =>
value.parentType === e.type &&
!isValidSubtype(e.subtype, e.type) &&
isForSubtype(value.subtype, action.name, false),
);
}) ||
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return (
// Would the subtype of e by inself disable this action?
isForSubtype(e.subtype, action.name, false) &&
// Can we find an ExcalidrawElement which could have the same subtype
// as e but whose subtype does not disable this action?
chosen.some((el) => {
const e2 = hasBoundTextElement(el)
? getBoundTextElement(
el,
app.scene.getElementsMapIncludingDeleted(),
)!
: el;
return (
// Does e have a valid subtype whose parent types include the
// type of e2, and does the subtype of e2 not disable this action?
parentTypeMap
.filter((val) => val.subtype === e.subtype)
.some((val) => val.parentType === e2.type) &&
!isForSubtype(e2.subtype, action.name, false)
);
})
);
})
);
}
// Shouldn't happen
return true;
};
// Are any of the parent types of `subtype` shared by any subtype
// in the array?
export const subtypeCollides = (subtype: Subtype, subtypeArray: Subtype[]) => {
const subtypeParents = parentTypeMap
.filter((value) => value.subtype === subtype)
.map((value) => value.parentType);
const subtypeArrayParents = subtypeArray.flatMap((s) =>
parentTypeMap
.filter((value) => value.subtype === s)
.map((value) => value.parentType),
);
return subtypeParents.some((t) => subtypeArrayParents.includes(t));
};
// Subtype Methods
export type SubtypeMethods = {
clean: (
updates: Omit<
Partial<ExcalidrawElement>,
"id" | "version" | "versionNonce"
>,
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
ensureLoaded: (callback?: () => void) => Promise<void>;
measureText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => { width: number; height: number };
render: (
element: NonDeleted<ExcalidrawElement>,
elementsMap: ElementsMap,
context: CanvasRenderingContext2D,
) => void;
renderSvg: (
svgRoot: SVGElement,
addToRoot: (node: SVGElement, element: ExcalidrawElement) => void,
element: NonDeleted<ExcalidrawElement>,
elementsMap: ElementsMap,
opt?: { offsetX?: number; offsetY?: number },
) => void;
wrapText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>,
containerWidth: number,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => string;
};
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
const methodMaps = [] as Array<MethodMap>;
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
export const getSubtypeMethods = (
subtype: Subtype | undefined,
): Partial<SubtypeMethods> | undefined => {
const map = methodMaps.find((method) => method.subtype === subtype);
return map?.methods;
};
export const addSubtypeMethods = (
subtype: Subtype,
methods: Partial<SubtypeMethods>,
) => {
if (!methodMaps.find((method) => method.subtype === subtype)) {
methodMaps.push({ subtype, methods });
}
};
// For a given `ExcalidrawElement` type, return the active subtype
// and associated customData (if any) from the AppState. Assume
// only one subtype is active for a given `ExcalidrawElement` type
// at any given time.
export const selectSubtype = (
appState: {
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
},
type: ExcalidrawElement["type"],
): {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
} => {
if (appState.activeSubtypes === undefined) {
return {};
}
const subtype = appState.activeSubtypes.find((subtype) =>
isValidSubtype(subtype, type),
);
if (subtype === undefined) {
return {};
}
if (appState.customData === undefined || !(subtype in appState.customData)) {
return { subtype };
}
const customData = appState.customData[subtype];
return { subtype, customData };
};
// Callback to re-render subtyped `ExcalidrawElement`s after completing
// async loading of the subtype.
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
// Functions to prepare subtypes for use
export type SubtypePrepFn = (
addSubtypeAction: (action: Action) => void,
addLangData: (fallbackLangData: {}, setLanguageAux: LangLdr) => void,
onSubtypeLoaded?: SubtypeLoadedCb,
) => {
actions: Action[];
methods: Partial<SubtypeMethods>;
};
// This is the main method to set up the subtype. The optional
// `onSubtypeLoaded` callback may be used to re-render subtyped
// `ExcalidrawElement`s after the subtype has finished async loading.
// See the MathJax extension in `@excalidraw/extensions` for example.
export const prepareSubtype = (
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
onSubtypeLoaded?: SubtypeLoadedCb,
): { actions: readonly Action[] | null; methods: Partial<SubtypeMethods> } => {
const map = getSubtypeMethods(record.subtype);
if (map) {
return { actions: null, methods: map };
}
// Check for undefined/null subtypes and parentTypes
if (
record.subtype === undefined ||
record.subtype === "" ||
record.parents === undefined ||
record.parents.length === 0
) {
return { actions: null, methods: {} };
}
// Register the types
const subtype = record.subtype;
subtypeNames = [...subtypeNames, subtype];
record.parents.forEach((parentType) => {
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
});
if (record.actionNames) {
subtypeActionMap = [
...subtypeActionMap,
{
subtype,
actions: record.actionNames.map((actionName) =>
makeCustomActionName(actionName),
),
},
];
}
if (record.disabledNames) {
disabledActionMap = [
...disabledActionMap,
{ subtype, actions: record.disabledNames },
];
}
if (record.alwaysEnabledNames) {
alwaysEnabledMap = [
...alwaysEnabledMap,
{
subtype,
actions: record.alwaysEnabledNames.map((actionName) =>
makeCustomActionName(actionName),
),
},
];
}
const customShortcutMap = record.shortcutMap;
if (customShortcutMap) {
const shortcutMap: Record<CustomActionName, string[]> = {};
for (const key in customShortcutMap) {
shortcutMap[makeCustomActionName(key)] = customShortcutMap[key];
}
registerCustomShortcuts(shortcutMap);
}
// Prepare the subtype
const { actions, methods } = subtypePrepFn(
addSubtypeAction,
registerCustomLangData,
onSubtypeLoaded,
);
// Register the subtype's methods
addSubtypeMethods(record.subtype, methods);
return { actions, methods };
};
// Ensure all subtypes are loaded before continuing, eg to
// render SVG previews of new charts. Chart-relevant subtypes
// include math equations in titles or non hand-drawn line styles.
export const ensureSubtypesLoadedForElements = async (
elements: readonly ExcalidrawElement[],
callback?: () => void,
) => {
// Only ensure the loading of subtypes which are actually needed.
// We don't want to be held up by eg downloading the MathJax SVG fonts
// if we don't actually need them yet.
const subtypesUsed = [] as Subtype[];
elements.forEach((el) => {
if (
"subtype" in el &&
isValidSubtype(el.subtype, el.type) &&
!subtypesUsed.includes(el.subtype)
) {
subtypesUsed.push(el.subtype);
}
});
await ensureSubtypesLoaded(subtypesUsed, callback);
};
export const ensureSubtypesLoaded = async (
subtypes: Subtype[],
callback?: () => void,
) => {
// Use a for loop so we can do `await map.ensureLoaded()`
for (let i = 0; i < subtypes.length; i++) {
const subtype = subtypes[i];
// Should be defined if prepareSubtype() has run
const map = getSubtypeMethods(subtype);
if (map?.ensureLoaded) {
await map.ensureLoaded();
}
}
if (callback) {
callback();
}
};
// Call this method after finishing any async loading for
// subtypes of ExcalidrawElement if the newly loaded code
// would change the rendering.
export const checkRefreshOnSubtypeLoad = (
hasSubtype: SubtypeCheckFn,
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = new Map() as ElementsMap;
for (const element of elements) {
if (!element.isDeleted) {
elementsMap.set(element.id, element);
}
}
let refreshNeeded = false;
const scenes: Scene[] = [];
getNonDeletedElements(elements).forEach((element) => {
// If the element is of the subtype that was just
// registered, update the element's dimensions, mark the
// element for a re-render, and indicate the scene needs a refresh.
if (hasSubtype(element)) {
ShapeCache.delete(element);
if (isTextElement(element)) {
redrawTextBoundingBox(
element,
getContainerElement(element, elementsMap),
elementsMap,
false,
);
}
refreshNeeded = true;
const scene = Scene.getScene(element);
if (scene && !scenes.includes(scene)) {
// Store in case we have multiple scenes
scenes.push(scene);
}
}
});
// Only inform each scene once
scenes.forEach((scene) => scene.triggerUpdate());
return refreshNeeded;
};
export const useSubtype = (
api: ExcalidrawImperativeAPI | null,
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
) => {
useEffect(() => {
if (api) {
const prep = api.addSubtype(record, subtypePrepFn);
if (prep) {
addSubtypeMethods(record.subtype, prep.methods);
if (prep.actions) {
prep.actions.forEach((action) => api.registerAction(action));
}
}
}
}, [api, record, subtypePrepFn]);
};
@@ -0,0 +1,13 @@
import type { Theme } from "../../../element/types";
import { createIcon, iconFillColor } from "../../../components/icons";
// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
export const mathSubtypeIcon = ({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
// fa-square-root-variable-solid
d="M289 24.2C292.5 10 305.3 0 320 0H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H345L239 487.8c-3.2 13-14.2 22.6-27.6 24s-26.1-5.5-32.1-17.5L76.2 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H96c12.1 0 23.2 6.8 28.6 17.7l73.3 146.6L289 24.2zM393.4 233.4c12.5-12.5 32.8-12.5 45.3 0L480 274.7l41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L525.3 320l41.4 41.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L480 365.3l-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 320l-41.4-41.4c-12.5-12.5-12.5-32.8 0-45.3z"
/>,
{ width: 576, height: 512, mirror: true, strokeWidth: 1.25 },
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
import type { ExcalidrawImperativeAPI } from "../../../types";
import { useSubtype } from "../";
import { getMathSubtypeRecord } from "./types";
import { prepareMathSubtype } from "./implementation";
declare global {
module SREfeature {
function custom(locale: string): Promise<string>;
}
}
// The main hook to use the MathJax subtype
export const useMathSubtype = (api: ExcalidrawImperativeAPI | null) => {
useSubtype(api, getMathSubtypeRecord(), prepareMathSubtype);
};
@@ -0,0 +1,15 @@
{
"labels": {
"changeMathOnly": "Math display",
"mathOnlyTrue": "Math only",
"mathOnlyFalse": "Mixed text",
"resetUseTex": "Reset math input type",
"useTexTrueActive": "✔ Standard input",
"useTexTrueInactive": "Standard input",
"useTexFalseActive": "✔ Simplified input",
"useTexFalseInactive": "Simplified input"
},
"toolBar": {
"math": "Math"
}
}
@@ -0,0 +1,76 @@
import { vi } from "vitest";
import { render } from "../../../../tests/test-utils";
import { API } from "../../../../tests/helpers/api";
import { Excalidraw } from "../../../../index";
import { measureTextElement } from "../../../textElement";
import { ensureSubtypesLoaded } from "../../";
import { getMathSubtypeRecord } from "../types";
import { prepareMathSubtype } from "../implementation";
describe("mathjax loaded", () => {
beforeEach(async () => {
await render(<Excalidraw />);
API.addSubtype(getMathSubtypeRecord(), prepareMathSubtype);
await ensureSubtypesLoaded(["math"]);
});
it("text-only measurements match", async () => {
const text = "A quick brown fox jumps over the lazy dog.";
const elements = [
API.createElement({ type: "text", id: "A", text, subtype: "math" }),
API.createElement({ type: "text", id: "B", text }),
];
const metrics1 = measureTextElement(elements[0]);
const metrics2 = measureTextElement(elements[1]);
expect(metrics1).toStrictEqual(metrics2);
});
it("minimum height remains", async () => {
const elements = [
API.createElement({ type: "text", id: "A", text: "a" }),
API.createElement({
type: "text",
id: "B",
text: "\\(\\alpha\\)",
subtype: "math",
customData: { useTex: true },
}),
API.createElement({
type: "text",
id: "C",
text: "`beta`",
subtype: "math",
customData: { useTex: false },
}),
];
const height = measureTextElement(elements[0]).height;
const height1 = measureTextElement(elements[1]).height;
const height2 = measureTextElement(elements[2]).height;
expect(height).toEqual(height1);
expect(height).toEqual(height2);
});
it("converts math to svgs", async () => {
const svgDim = 42;
vi.spyOn(SVGElement.prototype, "getBoundingClientRect").mockImplementation(
() => new DOMRect(0, 0, svgDim, svgDim),
);
const elements = [];
const type = "text";
const subtype = "math";
let text = "Math ";
elements.push(API.createElement({ type, text }));
text = "Math \\(\\alpha\\)";
elements.push(
API.createElement({ type, subtype, text, customData: { useTex: true } }),
);
text = "Math `beta`";
elements.push(
API.createElement({ type, subtype, text, customData: { useTex: false } }),
);
const metrics = {
width: measureTextElement(elements[0]).width + svgDim,
height: svgDim,
};
expect(measureTextElement(elements[1])).toStrictEqual(metrics);
expect(measureTextElement(elements[2])).toStrictEqual(metrics);
});
});
@@ -0,0 +1,17 @@
import { getShortcutKey } from "../../../utils";
import type { SubtypeRecord } from "../";
// Exports
export const getMathSubtypeRecord = () => mathSubtype;
// Use `getMathSubtype` so we don't have to export this
const mathSubtype: SubtypeRecord = {
subtype: "math",
parents: ["text"],
actionNames: ["useTexTrue", "useTexFalse", "resetUseTex", "changeMathOnly"],
disabledNames: ["changeFontFamily"],
shortcutMap: {
resetUseTex: [getShortcutKey("Shift+R")],
},
alwaysEnabledNames: ["useTexTrue", "useTexFalse"],
};
+44 -23
View File
@@ -1,3 +1,5 @@
import type { SubtypeMethods } from "./subtypes";
import { getSubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
import type { import type {
ElementsMap, ElementsMap,
@@ -30,6 +32,30 @@ import {
} from "./containerCache"; } from "./containerCache";
import type { ExtractSetType } from "../utility-types"; import type { ExtractSetType } from "../utility-types";
export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, element.lineHeight);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.wrapText) {
return map.wrapText(element, containerWidth, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.originalText;
return wrapText(text, font, containerWidth);
} as SubtypeMethods["wrapText"];
export const normalizeText = (text: string) => { export const normalizeText = (text: string) => {
return ( return (
normalizeEOL(text) normalizeEOL(text)
@@ -64,18 +90,12 @@ export const redrawTextBoundingBox = (
maxWidth = container maxWidth = container
? getBoundTextMaxWidth(container, textElement) ? getBoundTextMaxWidth(container, textElement)
: textElement.width; : textElement.width;
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
textElement.originalText,
getFontString(textElement),
maxWidth,
);
} }
const metrics = measureText( const metrics = measureTextElement(textElement, {
boundTextUpdates.text, text: boundTextUpdates.text,
getFontString(textElement), });
textElement.lineHeight,
);
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
if (textElement.autoResize) { if (textElement.autoResize) {
@@ -83,6 +103,14 @@ export const redrawTextBoundingBox = (
} }
boundTextUpdates.height = metrics.height; boundTextUpdates.height = metrics.height;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
}
}
if (container) { if (container) {
const maxContainerHeight = getBoundTextMaxHeight( const maxContainerHeight = getBoundTextMaxHeight(
container, container,
@@ -191,17 +219,9 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s") (transformHandleType !== "n" && transformHandleType !== "s")
) { ) {
if (text) { if (text) {
text = wrapText( text = wrapTextElement(textElement, maxWidth);
textElement.originalText,
getFontString(textElement),
maxWidth,
);
} }
const metrics = measureText( const metrics = measureTextElement(textElement, { text });
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = metrics.height; nextHeight = metrics.height;
nextWidth = metrics.width; nextWidth = metrics.width;
} }
@@ -284,16 +304,17 @@ export const measureText = (
text: string, text: string,
font: FontString, font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => { ) => {
text = text const _text = text
.split("\n") .split("\n")
// replace empty lines with single space because leading/trailing empty // replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .join("\n");
const fontSize = parseFloat(font); const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight); const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(text, font); const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height }; return { width, height };
}; };
@@ -19,6 +19,7 @@ import type {
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
import { pointFrom } from "../../math";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
type: "line", type: "line",
width: 100, width: 100,
height: 0, height: 0,
points: [ points: [pointFrom(0, 0), pointFrom(100, 0)],
[0, 0],
[100, 0],
],
}); });
const textSize = 20; const textSize = 20;
const text = API.createElement({ const text = API.createElement({
+59 -7
View File
@@ -25,6 +25,7 @@ import {
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
measureText,
normalizeText, normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
wrapText, wrapText,
@@ -46,12 +47,15 @@ import {
import type App from "../components/App"; import type App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
import type { SubtypeMethods } from "./subtypes";
import { getSubtypeMethods } from "./subtypes";
import { import {
originalContainerCache, originalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
const getTransform = ( const getTransform = (
offsetX: number,
width: number, width: number,
height: number, height: number,
angle: number, angle: number,
@@ -69,9 +73,18 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) { if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2; translateY = (maxHeight * (zoom.value - 1)) / 2;
} }
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; const offset = offsetX !== 0 ? ` translate(${offsetX}px, 0px)` : "";
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)${offset}`;
}; };
const getEditorStyle = function (element) {
const map = getSubtypeMethods(element.subtype);
if (map?.getEditorStyle) {
return map.getEditorStyle(element);
}
return {};
} as SubtypeMethods["getEditorStyle"];
export const textWysiwyg = ({ export const textWysiwyg = ({
id, id,
onChange, onChange,
@@ -137,14 +150,27 @@ export const textWysiwyg = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
let width = updatedTextElement.width; // Editing metrics
const eMetrics = measureText(
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
getBoundTextMaxWidth(container, updatedTextElement),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
// set to element height by default since that's let width = Math.max(updatedTextElement.width, eMetrics.width);
// Set to element height by default since that's
// what is going to be used for unbounded text // what is going to be used for unbounded text
let height = updatedTextElement.height; let height = Math.max(updatedTextElement.height, eMetrics.height);
let maxWidth = updatedTextElement.width; let maxWidth = width;
let maxHeight = updatedTextElement.height; let maxHeight = height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) { if (isArrowElement(container)) {
@@ -240,14 +266,37 @@ export const textWysiwyg = ({
width += 0.5; width += 0.5;
} }
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
const offWidth = container
? Math.min(
0,
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
)
: Math.min(maxWidth, updatedTextElement.width) -
Math.min(maxWidth, eMetrics.width);
const offsetX =
textAlign === "right"
? offWidth
: textAlign === "center"
? offWidth / 2
: 0;
let { width: w, height: h } = updatedTextElement;
// add 5% buffer otherwise it causes wysiwyg to jump // add 5% buffer otherwise it causes wysiwyg to jump
height *= 1.05; height *= 1.05;
h *= 1.05;
const transformOrigin =
updatedTextElement.width !== eMetrics.width ||
updatedTextElement.height !== eMetrics.height
? { transformOrigin: `${w / 2}px ${h / 2}px` }
: {};
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari) // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
const padding = !isSafari const padding = !isSafari
? Math.ceil(updatedTextElement.fontSize / 2) ? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
: 0; : 0;
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
@@ -261,7 +310,9 @@ export const textWysiwyg = ({
height: `${height}px`, height: `${height}px`,
left: `${viewportX - padding}px`, left: `${viewportX - padding}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
...transformOrigin,
transform: getTransform( transform: getTransform(
offsetX,
width, width,
height, height,
getTextElementAngle(updatedTextElement, container), getTextElementAngle(updatedTextElement, container),
@@ -322,6 +373,7 @@ export const textWysiwyg = ({
whiteSpace, whiteSpace,
overflowWrap: "break-word", overflowWrap: "break-word",
boxSizing: "content-box", boxSizing: "content-box",
...getEditorStyle(element),
}); });
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
@@ -7,7 +7,6 @@ import type {
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { import {
isElbowArrow, isElbowArrow,
@@ -19,6 +18,8 @@ import {
isAndroid, isAndroid,
isIOS, isIOS,
} from "../constants"; } from "../constants";
import type { Radians } from "../../math";
import { pointFrom, pointRotateRads } from "../../math";
export type TransformHandleDirection = export type TransformHandleDirection =
| "n" | "n"
@@ -91,9 +92,13 @@ const generateTransformHandle = (
height: number, height: number,
cx: number, cx: number,
cy: number, cy: number,
angle: number, angle: Radians,
): TransformHandle => { ): TransformHandle => {
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle); const [xx, yy] = pointRotateRads(
pointFrom(x + width / 2, y + height / 2),
pointFrom(cx, cy),
angle,
);
return [xx - width / 2, yy - height / 2, width, height]; return [xx - width / 2, yy - height / 2, width, height];
}; };
@@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
export const getTransformHandlesFromCoords = ( export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number], [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number, angle: Radians,
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {}, omitSides: { [T in TransformHandleType]?: boolean } = {},
+8 -16
View File
@@ -1,6 +1,5 @@
import type { LineSegment } from "../../utils";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import type { ElementOrToolType, Point } from "../types"; import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types"; import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils"; import { assertNever } from "../utils";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
@@ -191,7 +190,8 @@ export const isRectangularElement = (
element.type === "iframe" || element.type === "iframe" ||
element.type === "embeddable" || element.type === "embeddable" ||
element.type === "frame" || element.type === "frame" ||
element.type === "magicframe") element.type === "magicframe" ||
element.type === "freedraw")
); );
}; };
@@ -320,15 +320,14 @@ export const getDefaultRoundnessTypeForElement = (
}; };
export const isFixedPointBinding = ( export const isFixedPointBinding = (
binding: PointBinding, binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => { ): binding is FixedPointBinding => {
return binding.fixedPoint != null; return (
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null
);
}; };
// TODO: Move this to @excalidraw/math
export const isPoint = (point: unknown): point is Point =>
Array.isArray(point) && point.length === 2;
// TODO: Move this to @excalidraw/math // TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds => export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) && Array.isArray(box) &&
@@ -337,10 +336,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "number"; typeof box[3] === "number";
// TODO: Move this to @excalidraw/math
export const isLineSegment = (segment: unknown): segment is LineSegment =>
Array.isArray(segment) &&
segment.length === 2 &&
isPoint(segment[0]) &&
isPoint(segment[0]);
+28 -13
View File
@@ -1,4 +1,4 @@
import type { Point } from "../types"; import type { LocalPoint, Radians } from "../../math";
import type { import type {
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
@@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
opacity: number; opacity: number;
width: number; width: number;
height: number; height: number;
angle: number; angle: Radians;
/** Random integer used to seed shape generation so that the roughjs shape /** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */ doesn't differ across renders. */
seed: number; seed: number;
@@ -76,6 +76,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number; updated: number;
link: string | null; link: string | null;
locked: boolean; locked: boolean;
subtype?: string;
customData?: Record<string, any>; customData?: Record<string, any>;
}>; }>;
@@ -175,6 +176,15 @@ export type ExcalidrawFlowchartNodeElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement; | ExcalidrawEllipseElement;
export type ExcalidrawRectanguloidElement =
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable * no computed data. The list of all ExcalidrawElements should be shareable
@@ -184,6 +194,7 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement | ExcalidrawGenericElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawArrowElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
@@ -259,15 +270,19 @@ export type PointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number; focus: number;
gap: number; gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
}; };
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>; export type FixedPointBinding = Merge<
PointBinding,
{
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
@@ -283,8 +298,8 @@ export type Arrowhead =
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "line" | "arrow"; type: "line" | "arrow";
points: readonly Point[]; points: readonly LocalPoint[];
lastCommittedPoint: Point | null; lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null; startBinding: PointBinding | null;
endBinding: PointBinding | null; endBinding: PointBinding | null;
startArrowhead: Arrowhead | null; startArrowhead: Arrowhead | null;
@@ -309,10 +324,10 @@ export type ExcalidrawElbowArrowElement = Merge<
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "freedraw"; type: "freedraw";
points: readonly Point[]; points: readonly LocalPoint[];
pressures: readonly number[]; pressures: readonly number[];
simulatePressure: boolean; simulatePressure: boolean;
lastCommittedPoint: Point | null; lastCommittedPoint: LocalPoint | null;
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
+6 -5
View File
@@ -1,4 +1,8 @@
import { stringToBase64, toByteString } from "../data/encode"; import {
base64ToArrayBuffer,
stringToBase64,
toByteString,
} from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata"; import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader"; import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader"; import loadHbSubset from "./wasm/hb-subset.loader";
@@ -49,10 +53,7 @@ export class ExcalidrawFont implements Font {
// it's dataurl (server), the font is inlined as base64, no need to fetch // it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") { if (url.protocol === "data:") {
const arrayBuffer = Buffer.from( const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer, arrayBuffer,

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