Compare commits

...

66 Commits

Author SHA1 Message Date
dwelle a22927d4d1 DEBUG 2025-01-07 18:28:01 +01:00
dwelle ca9b7a505e flake 2025-01-07 18:04:43 +01:00
dwelle 36b387f973 feat: add timeout on doublick pointerup 2025-01-07 18:00:22 +01:00
Marcel Mraz 2ac55067cd fix: package build fails on worker chunks (#8990) 2025-01-07 11:22:36 +00:00
David Luzar 78ab12c7e6 fix: z-index clash in mobile UI (#8985) 2025-01-06 21:21:11 +01:00
David Luzar f2f8219917 feat: reintroduce .excalidraw.png default when embedding scene (#8979) 2025-01-05 22:21:39 +01:00
한별 12c39d1034 feat: add mimeTypes on file save (#8946) 2025-01-05 21:12:07 +00:00
Ryan Di d33e42e3a1 feat: add crowfoot to arrowheads (#8942)
* crowfoot many

* crowfoot one

* one or many

* add icons for crowfoot

* add crowfoot icons

* adjust arrowhead selection popover

* make options collapsible

* swap triangle and bar

* switch to radix popover

* put triangle outline in the first row

* align shadow with new design spec

* remove unused flag

* swap order

* tweak labels

* handle shift+tab

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Jakub Królak <108676707+j-krolak@users.noreply.github.com>
2025-01-05 21:50:24 +01:00
zsviczian 3b9ffd9586 fix: elbow arrows do not work within frames (issue: #8964) (#8969)
check for !isFrameLikeElement
2025-01-05 21:47:20 +01:00
David Luzar b63689c230 feat: make HTML attribute sanitization stricter (#8977)
* feat: make HTML attribute sanitization stricter

* fix double escape
2025-01-05 21:45:04 +01:00
David Luzar c84babf574 feat: validate library install urls (#8976) 2025-01-05 17:10:55 +01:00
David Luzar 36274f1f3e feat: cleanup svg export and move payload to <metadata> (#8975) 2025-01-05 16:53:05 +01:00
Aakansha Doshi 798c795405 docs: add demo link for browser integration (#8956) 2024-12-27 14:39:08 +09:00
Ryan Di 107eae3916 refactor: separate resizing logic from pointer (#8155)
* separate resizing logic for a single element

* replace resize logic in stats

* do not recompute width and height from points when they're already given

* correctly update linear elements' position when resized

* update snapshots

* lint

* simplify linear resizing logic

* fix initial scale for aspect ratio

* update tests for linear elements

* test typo

* separate pointer from resizing for multiple elements

* lint and simplify

* fix tests

* lint

* provide scene in param instead

* type

* refactor code

* fix floating in tests

* remove restrictions/checks on width & height

* update pointer to dimension to prevent regression

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-23 11:10:35 +01:00
zsviczian 56fca30bd0 fix: normalizeSVG width and height from viewbox when size includes decimal points (#8939)
Update image.ts
2024-12-22 23:10:11 +01:00
Ryan Di 1e3399eac8 fix: make arrow binding area adapt to zoom levels (#8927)
* make binding area adapt to zoom

* revert stroke color

* normalize binding gap

* reduce normalized gap
2024-12-22 22:55:50 +01:00
David Luzar 873698a1a2 fix: robust state.editingFrame teardown (#8941) 2024-12-22 22:47:39 +01:00
Ryan Di 606ac6c743 fix: regression on dragging a selected frame by its name (#8924)
fix hit element check for a selected frame's name
2024-12-22 22:47:21 +01:00
Ryan Di d99e4a23ca feat: use stats panel to crop (#8848)
* feat: use stats panel to crop

* fix: test flake

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-17 13:15:30 +01:00
Ryan Di 551bae07a7 feat: snap when cropping as well (#8831)
* crop with snap

* make crop snap work with cmd as well

* turn off grid with cmd as well in crop
2024-12-16 18:31:33 +08:00
Shreyansh Jain 2af3221974 fix: right-click paste for images in clipboard (Issue #8826) (#8845)
* Fix right-click paste command for images (Issue #8826)

* Fix clipboard logic for multiple paste types

* fix: remove unused code

* refactor & robustness

* fix: creating paste event with image files

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-10 20:10:34 +00:00
Antonio Della Fortuna 9b401f6ea3 fix: fixed image transparency by adding alpha option to preserve image alpha channel (#8895)
added alpha option to preserve image alpha channel
2024-12-10 13:41:10 +01:00
Marcel Mraz 8a1152ce36 fix: Flush pending DOM updates before .focus() (#8901) 2024-12-09 21:57:37 +01:00
Ryan Di b5652b8e36 fix: normalize svg using only absolute sizing (#8854) 2024-11-27 13:09:44 +01:00
David Luzar 31e2a0cb4a fix: element link selector dialog z-index & positioning (#8853) 2024-11-26 23:18:20 +01:00
Ryan Di c0b80a03bd feat: in canvas links between shapes (#8812)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-26 18:53:25 +01:00
David Luzar a758aaf8f6 fix: update old blog links & add canonical url (#8846) 2024-11-26 17:42:25 +01:00
Márk Tolmács b2a6a87b10 chore: Remove @tldraw/vec (#8762)
Not needed.
2024-11-21 15:19:20 +01:00
Márk Tolmács ab8b3537b3 fix: Optimize frameToHighlight state change and snapLines state change (#8763)
Fix case when frame interactions recursively call setState() without any change.
2024-11-21 15:19:00 +01:00
Márk Tolmács d21e0008dd fix: Make some events expllicitly active to avoid console warnings (#8757)
Avoid chrome/edge reporting of by-default blocking event handlers
2024-11-21 15:18:18 +01:00
David Luzar 840f1428c4 chore: bump @excalidraw/mermaid-to-excalidraw@1.1.2 (#8830) 2024-11-20 13:10:07 +01:00
Márk Tolmács 2db5bbcb29 fix: Unify binding update options for updateBoundElements() (#8832)
Fix insonsistent naming for option newSize/oldSize for updateBoundElements()
2024-11-20 11:46:45 +01:00
David Luzar 0927431d0d chore: bump @excalidraw/mermaid-to-excalidraw (#8829) 2024-11-19 20:46:55 +01:00
dependabot[bot] 98c0a67333 build(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#8824)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 20:34:35 +01:00
Aakansha Doshi 57cf577376 fix: cleanup scripts and support upto node 22 (#8794) 2024-11-11 23:56:00 +05:30
Hamir Mahal 6e0ee89ee4 fix: usage of node12 which is deprecated (#8791) 2024-11-11 12:05:55 +01:00
David Luzar 35f778a734 build: set PWA flag in dev to false (#8788) 2024-11-09 21:04:50 +01:00
Aakansha Doshi ee091d0dbd build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment (#8784)
* build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment

* fix

* set VITE_ENABLE_PWA to false in .env.development
2024-11-09 21:45:37 +05:30
Aakansha Doshi ef9ea14a75 fix: remove manifest.json (#8783)
* fix: remove manifest.json

* disable pwa in dev
2024-11-09 13:42:11 +05:30
Aakansha Doshi df168a6883 fix: load env vars correctly and set debug and linter flags to false explicitly in prod mode (#8770)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-07 21:14:49 +00:00
David Luzar 798f5f4dfb feat: update blog url (#8767) 2024-11-06 16:41:41 +00:00
Barnabás Molnár d9ad7c039b feat: export scene to e+ on workspace creation/redemption (#8514)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-04 22:35:45 +00:00
Márk Tolmács 7c0239e693 fix: Console error in dev mode due to missing font path in non-prod (#8756)
Fix console error due to missing font path in dev mode reported by Firefox.
2024-11-04 17:54:00 +01:00
Aakansha Doshi da33481fa3 chore: support upto node 22.x.x (#8755) 2024-11-04 18:57:40 +05:30
Marcel Mraz 70e0e8dc29 fix: text pushes UI due to padding (#8745) 2024-11-01 23:43:48 +02:00
Marcel Mraz 2734e646ca chore: simplify line-break regexes, separate text wrapping (#8715) 2024-10-30 14:24:12 +01:00
Marcel Mraz dfaaff4432 fix: fix trailing line whitespaces layout shift (#8714) 2024-10-30 14:11:13 +01:00
Marcel Mraz 03028eaa8c fix: load font faces in Safari manually (#8693) 2024-10-30 12:40:24 +01:00
David Luzar 79b181bcdc fix: restore svg image DataURL dimensions (#8730) 2024-10-29 22:40:24 +01:00
David Luzar f9815b8b4f fix: image cropping svg + compat mode (#8710)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2024-10-28 10:08:05 +01:00
codeman 96ed8a4331 chore: remove duplicated meta tag (#8718)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2024-10-25 16:29:48 +00:00
Hamir Mahal 33b01d4e80 fix: usage of node12 which is deprecated (#8709) 2024-10-25 10:39:44 +05:30
Milos Vetesnik 7d52176fea feat: added sitemap & fixed robot txt (#8699) 2024-10-24 12:49:33 +02:00
David Luzar 958e03fcc6 fix: image render perf (#8697) 2024-10-23 11:58:43 +02:00
David Luzar 4cedf3d966 feat: do not strip unknown element properties on restore (#8682) 2024-10-21 22:56:22 +02:00
Ryan Di e957c8e9ee feat: image cropping (#8613)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-10-21 22:26:52 +02:00
Denis Mishankov eb09b48ae6 fix: undo/redo action for international keyboard layouts (#8649)
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2024-10-21 17:08:39 +02:00
Marcel Mraz 61623bbeba fix: Comic Shanns issues, new fonts structure (#8641) 2024-10-21 00:11:53 +02:00
David Luzar 15ca182333 fix: remove export-to-clip-as-svg shortcut for now (#8660) 2024-10-17 20:47:05 +02:00
Marcel Mraz b479f3bd65 feat: add first-class support for CJK (#8530) 2024-10-17 20:14:17 +02:00
Mathis Beauville 21815fb930 fix: text disappearing on edit (#8558) (#8624) 2024-10-17 13:11:48 +02:00
David Luzar 47ee8a0094 refactor: point() -> pointFrom() to fix compiler issue (#8578) 2024-10-01 21:27:17 +02:00
Subhadeep Sengupta 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 Doshi 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 Mraz a80cb5896a feat: self-hosting existing google fonts (#8540) 2024-09-24 17:30:21 +02:00
David Luzar 6dfa18414a test: decrease min coverage thresholds (#8541) 2024-09-24 12:01:28 +00:00
450 changed files with 25087 additions and 4378 deletions
+12 -1
View File
@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3015
@@ -37,3 +37,14 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
# Enable PWA in dev server
VITE_APP_ENABLE_PWA=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
+15
View File
@@ -15,3 +15,18 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_ENABLE_TRACKING=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
PQIDAQAB'
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
VITE_APP_COLLAPSE_OVERLAY=false
# Enable eslint in dev server
VITE_APP_ENABLE_ESLINT=false
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v3.0.0
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -7,7 +7,7 @@
<h4 align="center">
<a href="https://excalidraw.com">Excalidraw Editor</a> |
<a href="https://blog.excalidraw.com">Blog</a> |
<a href="https://plus.excalidraw.com/blog">Blog</a> |
<a href="https://docs.excalidraw.com">Documentation</a> |
<a href="https://plus.excalidraw.com">Excalidraw+</a>
</h4>
@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img
src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch handrawn like diagrams."
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
/>
### Center
@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description |
| ----------- | ---------------------- |
| `Virgil` | The `handwritten` font |
| `Virgil` | The `Hand-drawn` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |
@@ -3,31 +3,32 @@
All `props` are _optional_.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | _ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | \_ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | \_ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | \_ | This prop if passed gets triggered on pointer down events |
| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | _ | Render function that renders custom UI in top right corner |
| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | _ | Render function that can be used to render custom stats on the stats dialog. |
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | _ | This indicates if the app is in `view` mode. |
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | _ | This indicates if the `zen` mode is enabled |
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | _ | This indicates if the `grid` mode is enabled |
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner |
| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | \_ | Render function that can be used to render custom stats on the stats dialog. |
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | \_ | This indicates if the app is in `view` mode. |
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | \_ | This indicates if the `zen` mode is enabled |
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | \_ | This indicates if the `grid` mode is enabled |
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | \_ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component |
| [`name`](#name) | `string` | | Name of the drawing |
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
@@ -93,9 +94,8 @@ This callback is triggered when mouse pointer is updated.
This prop if passed will be triggered on pointer down events and has the below signature.
<pre>
(activeTool:{" "}
(activeTool:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115">
{" "}
AppState["activeTool"]
@@ -143,6 +143,14 @@ This callback if supplied will get triggered when the library is updated and has
It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage.
### generateLinkForSelection
This prop if passed will be used to replace the default link generation function. The idea is that the host app can take over the creation of element links, which can be used to navigate to a particular element or a group. If the host app chooses a different key for element link id, then the host app should also take care of the handling and the navigation in `onLinkOpen`.
```tsx
(id: string, type: "element" | "group") => string;
```
### onLinkOpen
This prop if passed will be triggered when clicked on `link`. To handle the redirect yourself (such as when using your own router for internal links), you must call `event.preventDefault()`.
@@ -207,8 +215,7 @@ This prop indicates whether the shows the grid. When supplied, the value takes p
### libraryReturnUrl
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com).
Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
### theme
@@ -220,7 +227,6 @@ You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify t
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over _intialData.appState.name_, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
### detectScroll
Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method).
@@ -12,7 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
Throughout the documentation we use live, editable Excalidraw examples like the one shown below.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
:::
@@ -70,9 +70,9 @@ If you are using `pages router` then importing the wrapper dynamically would wor
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
</div>
);
};
export default ExcalidrawWrapper;
@@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
export default function Page() {
return (
<ExcalidrawWrapper />
<ExcalidrawWrapper />
);
}
```
@@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -153,7 +153,7 @@ Since Vite removes env variables by default, you can update the vite config to e
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
:::
## Browser
@@ -235,3 +235,5 @@ root.render(React.createElement(App));
</TabItem>
</Tabs>
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
+2 -2
View File
@@ -66,7 +66,7 @@ const config = {
label: "Docs",
},
{
to: "https://blog.excalidraw.com",
to: "https://plus.excalidraw.com/blog",
label: "Blog",
position: "left",
},
@@ -111,7 +111,7 @@ const config = {
items: [
{
label: "Blog",
to: "https://blog.excalidraw.com",
to: "https://plus.excalidraw.com/blog",
},
{
label: "GitHub",
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts
# copied assets
public/*.woff2
public/**/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
@@ -1,2 +1,2 @@
# copied assets
public/*.woff2
public/**/*.woff2
@@ -13,7 +13,7 @@
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002"
+14
View File
@@ -126,6 +126,8 @@ import DebugCanvas, {
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { isElementLink } from "../packages/excalidraw/element/elementLink";
polyfill();
@@ -847,6 +849,12 @@ const ExcalidrawWrapper = () => {
</div>
);
}}
onLinkOpen={(element, event) => {
if (element.link && isElementLink(element.link)) {
event.preventDefault();
excalidrawAPI?.scrollToContent(element.link, { animate: true });
}
}}
>
<AppMainMenu
onCollabDialogOpen={onCollabDialogOpen}
@@ -1125,6 +1133,12 @@ const ExcalidrawWrapper = () => {
};
const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
@@ -0,0 +1,222 @@
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "../packages/excalidraw/element/types";
import type { AppState, BinaryFileData } from "../packages/excalidraw/types";
import { ExcalidrawError } from "../packages/excalidraw/errors";
import { base64urlToString } from "../packages/excalidraw/data/encode";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
// -----------------------------------------------------------------------------
// outgoing message
// -----------------------------------------------------------------------------
type MESSAGE_REQUEST_SCENE = {
type: "REQUEST_SCENE";
jwt: string;
};
type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
// incoming messages
// -----------------------------------------------------------------------------
type MESSAGE_READY = { type: "READY" };
type MESSAGE_ERROR = { type: "ERROR"; message: string };
type MESSAGE_SCENE_DATA = {
type: "SCENE_DATA";
elements: OrderedExcalidrawElement[];
appState: Pick<AppState, "viewBackgroundColor">;
files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
};
type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
// -----------------------------------------------------------------------------
const parseSceneData = async ({
rawElementsString,
rawAppStateString,
}: {
rawElementsString: string | null;
rawAppStateString: string | null;
}): Promise<MESSAGE_SCENE_DATA> => {
if (!rawElementsString || !rawAppStateString) {
throw new ExcalidrawError("Elements or appstate is missing.");
}
try {
const elements = JSON.parse(
rawElementsString,
) as OrderedExcalidrawElement[];
if (!elements.length) {
throw new ExcalidrawError("Scene is empty, nothing to export.");
}
const appState = JSON.parse(rawAppStateString) as Pick<
AppState,
"viewBackgroundColor"
>;
const fileIds = elements.reduce((acc, el) => {
if ("fileId" in el && el.fileId) {
acc.push(el.fileId);
}
return acc;
}, [] as FileId[]);
const files = await LocalData.fileStorage.getFiles(fileIds);
return {
type: "SCENE_DATA",
elements,
appState,
files,
};
} catch (error: any) {
throw error instanceof ExcalidrawError
? error
: new ExcalidrawError("Failed to parse scene data.");
}
};
const verifyJWT = async ({
token,
publicKey,
}: {
token: string;
publicKey: string;
}) => {
try {
if (!publicKey) {
throw new ExcalidrawError("Public key is undefined");
}
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
throw new ExcalidrawError("Invalid JWT format");
}
// JWT is using Base64URL encoding
const decodedPayload = base64urlToString(payload);
const decodedSignature = base64urlToString(signature);
const data = `${header}.${payload}`;
const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
c.charCodeAt(0),
);
const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
c.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
"spki",
keyArrayBuffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"],
);
const isValid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
signatureArrayBuffer,
new TextEncoder().encode(data),
);
if (!isValid) {
throw new Error("Invalid JWT");
}
const parsedPayload = JSON.parse(decodedPayload);
// Check for expiration
const currentTime = Math.floor(Date.now() / 1000);
if (parsedPayload.exp && parsedPayload.exp < currentTime) {
throw new Error("JWT has expired");
}
} catch (error) {
console.error("Failed to verify JWT:", error);
throw new Error(error instanceof Error ? error.message : "Invalid JWT");
}
};
export const ExcalidrawPlusIframeExport = () => {
const readyRef = useRef(false);
useLayoutEffect(() => {
const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
throw new ExcalidrawError("Invalid origin");
}
if (event.data.type === EVENT_REQUEST_SCENE) {
if (!event.data.jwt) {
throw new ExcalidrawError("JWT is missing");
}
try {
try {
await verifyJWT({
token: event.data.jwt,
publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
});
} catch (error: any) {
console.error(`Failed to verify JWT: ${error.message}`);
throw new ExcalidrawError("Failed to verify JWT");
}
const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
rawAppStateString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
),
rawElementsString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
),
});
event.source!.postMessage(parsedSceneData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
} catch (error) {
const responseData: MESSAGE_ERROR = {
type: "ERROR",
message:
error instanceof ExcalidrawError
? error.message
: "Failed to export scene data",
};
event.source!.postMessage(responseData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
}
}
};
window.addEventListener("message", handleMessage);
// so we don't send twice in StrictMode
if (!readyRef.current) {
readyRef.current = true;
const message: MESSAGE_FROM_EDITOR = { type: "READY" };
window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
}
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
// Since this component is expected to run in a hidden iframe on Excaildraw+,
// it doesn't need to render anything. All the data we need is available in
// LocalStorage and IndexedDB. It only needs to handle the messaging between
// the parent window and the iframe with the relevant data.
return null;
};
+27 -2
View File
@@ -1,6 +1,7 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
@@ -9,6 +10,7 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
@@ -157,7 +159,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
throw new AbortError();
}
return saveFilesToFirebase({
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
@@ -165,6 +167,29 @@ class Collab extends PureComponent<CollabProps, CollabState> {
maxBytes: FILE_UPLOAD_MAX_BYTES,
}),
});
return {
savedFiles: savedFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
erroredFiles: erroredFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
};
},
});
this.excalidrawAPI = props.excalidrawAPI;
@@ -394,7 +419,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileHandled(element.fileId) &&
!this.fileManager.isFileTracked(element.fileId) &&
!element.isDeleted &&
(opts.forceFetchFiles
? element.status !== "pending" ||
+1 -1
View File
@@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
return (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
+50 -21
View File
@@ -16,14 +16,26 @@ import type {
BinaryFiles,
} from "../../packages/excalidraw/types";
type FileVersion = Required<BinaryFileData>["version"];
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_fetch = new Map<
ExcalidrawImageElement["fileId"],
true
>();
/** files being saved */
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private savingFiles = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
private erroredFiles_save = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
private _getFiles;
private _saveFiles;
@@ -37,8 +49,8 @@ export class FileManager {
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
}>;
}) {
this._getFiles = getFiles;
@@ -46,19 +58,28 @@ export class FileManager {
}
/**
* returns whether file is already saved or being processed
* returns whether file is saved/errored, or being processed
*/
isFileHandled = (id: FileId) => {
isFileTracked = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
this.fetchingFiles.has(id) ||
this.erroredFiles_fetch.has(id) ||
this.erroredFiles_save.has(id)
);
};
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
const fileVersion = this.getFileVersion(file);
return (
this.savedFiles.get(file.id) === fileVersion ||
this.savingFiles.get(file.id) === fileVersion
);
};
getFileVersion = (file: BinaryFileData) => {
return file.version ?? 1;
};
saveFiles = async ({
@@ -71,13 +92,16 @@ export class FileManager {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
fileData &&
// NOTE if errored during save, won't retry due to this check
!this.isFileSavedOrBeingSaved(fileData)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, true);
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
}
}
@@ -86,8 +110,12 @@ export class FileManager {
addedFiles,
});
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
for (const [fileId, fileData] of savedFiles) {
this.savedFiles.set(fileId, this.getFileVersion(fileData));
}
for (const [fileId, fileData] of erroredFiles) {
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
}
return {
@@ -121,10 +149,10 @@ export class FileManager {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, true);
this.savedFiles.set(file.id, this.getFileVersion(file));
}
for (const [fileId] of erroredFiles) {
this.erroredFiles.set(fileId, true);
this.erroredFiles_fetch.set(fileId, true);
}
return { loadedFiles, erroredFiles };
@@ -160,7 +188,7 @@ export class FileManager {
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.isFileSaved(element.fileId) &&
this.savedFiles.has(element.fileId) &&
element.status === "pending"
);
};
@@ -169,7 +197,8 @@ export class FileManager {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles.clear();
this.erroredFiles_fetch.clear();
this.erroredFiles_save.clear();
}
}
+4 -4
View File
@@ -183,8 +183,8 @@ export class LocalData {
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, BinaryFileData>();
const erroredFiles = new Map<FileId, BinaryFileData>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
@@ -195,10 +195,10 @@ export class LocalData {
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
savedFiles.set(id, fileData);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
erroredFiles.set(id, fileData);
}
}),
);
+4 -4
View File
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = [];
await Promise.all(
files.map(async ({ id, buffer }) => {
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.set(id, true);
savedFiles.push(id);
} catch (error: any) {
erroredFiles.set(id, true);
erroredFiles.push(id);
}
}),
);
+10 -21
View File
@@ -54,11 +54,7 @@
content="https://excalidraw.com/og-image-3.png"
/>
<!-- General tags -->
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<link rel="canonical" href="https://excalidraw.com" />
<!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled -->
@@ -124,28 +120,19 @@
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/fonts.css"
type="text/css"
/>
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</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 -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -227,6 +214,7 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@@ -259,5 +247,6 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+10 -7
View File
@@ -23,20 +23,20 @@
]
},
"engines": {
"node": ">=18.0.0"
"node": "18.0.0 - 22.x.x"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
"socket.io-client": "4.7.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
@@ -49,5 +49,8 @@
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
}
}
+3
View File
@@ -29,6 +29,9 @@ interface ImportMetaEnv {
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string;
+225 -203
View File
@@ -5,215 +5,237 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
const envVars = loadEnv("", `../`);
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
},
export default defineConfig(({ mode }) => {
// To load .env variables
const envVars = loadEnv(mode, `../`);
// https://vitejs.dev/config/
return {
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
sourcemap: true,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: false,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
const family = chunkInfo.name.split("-")[0];
return `fonts/${family}/[name][extname]`;
}
workbox: {
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
return "assets/[name]-[hash][extname]";
},
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
},
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
},
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
},
plugins: [
Sitemap({
hostname: "https://excalidraw.com",
outDir: "build",
changefreq: "monthly",
// its static in public folder
generateRobotsTxt: false,
}),
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
},
workbox: {
// don't precache fonts, locales and separate chunks
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
runtimeCaching: [
{
urlPattern: new RegExp(".+.woff2"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
},
cacheableResponse: {
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
statuses: [0, 200],
},
},
},
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
},
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
};
});
+10 -10
View File
@@ -45,7 +45,7 @@
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": "18.0.0 - 20.x.x"
"node": "18.0.0 - 22.x.x"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
@@ -55,15 +55,9 @@
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -74,9 +68,15 @@
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
+4
View File
@@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section.
### Features
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
- 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)
- 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)
+2 -1
View File
@@ -87,7 +87,8 @@ export const actionClearCanvas = register({
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector"
);
},
perform: (elements, appState, _, app) => {
@@ -147,14 +147,32 @@ export const actionCopyAsSvg = register({
name: app.getName(),
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
@@ -0,0 +1,55 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } from "../store";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks";
import type { ExcalidrawImageElement } from "../element/types";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<ToolButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});
@@ -161,6 +161,7 @@ export const actionDeleteSelected = register({
element,
selectedPointsIndices,
elementsMap,
appState.zoom,
);
return {
@@ -0,0 +1,105 @@
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import {
canCreateLinkFromElements,
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "../element/elementLink";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionCopyElementLink = register({
name: "copyElementLink",
label: "labels.copyElementLink",
icon: copyIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
try {
if (window.location) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState,
);
if (idAndType) {
await copyTextToSystemClipboard(
app.props.generateLinkForSelection
? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
: defaultGetElementLinkFromSelection(
idAndType.id,
idAndType.type,
),
);
return {
appState: {
toast: {
message: t("toast.elementLinkCopied"),
closable: true,
},
},
storeAction: StoreAction.NONE,
};
}
return {
appState,
elements,
app,
storeAction: StoreAction.NONE,
};
}
} catch (error: any) {
console.error(error);
}
return {
appState,
elements,
app,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState) =>
canCreateLinkFromElements(getSelectedElements(elements, appState)),
});
export const actionLinkToElement = register({
name: "linkToElement",
label: "labels.linkToElement",
icon: elementLinkIcon,
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
if (
selectedElements.length !== 1 ||
!canCreateLinkFromElements(selectedElements)
) {
return { elements, appState, app, storeAction: StoreAction.NONE };
}
return {
appState: {
...appState,
openDialog: {
name: "elementLinkSelector",
sourceElementId: getSelectedElements(elements, appState)[0].id,
},
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, appProps, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
appState.openDialog?.name !== "elementLinkSelector" &&
selectedElements.length === 1 &&
canCreateLinkFromElements(selectedElements)
);
},
trackEvent: false,
});
@@ -15,7 +15,7 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
import { point } from "../../math";
import { pointFrom } from "../../math";
import { isPathALoop } from "../shapes";
export const actionFinalize = register({
@@ -115,7 +115,7 @@ export const actionFinalize = register({
mutateElement(multiPointElement, {
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? point(firstPoint[0], firstPoint[1])
? pointFrom(firstPoint[0], firstPoint[1])
: p,
),
});
@@ -2,7 +2,7 @@ import React from "react";
import { Excalidraw } from "../index";
import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { point } from "../../math";
import { pointFrom } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
@@ -50,11 +50,11 @@ describe("flipping re-centers selection", () => {
startArrowhead: null,
endArrowhead: "arrow",
points: [
point(0, 0),
point(0, -35),
point(-90.9, -35),
point(-90.9, 204.9),
point(65.1, 204.9),
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
],
elbowed: true,
}),
+9 -13
View File
@@ -12,7 +12,6 @@ import { resizeMultipleElements } from "../element/resizeElements";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindLinearElements,
isBindingEnabled,
@@ -27,6 +26,7 @@ import {
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -132,19 +132,14 @@ const flipElements = (
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(
elementsMap,
selectedElements,
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
});
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
@@ -153,6 +148,7 @@ const flipElements = (
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
);
// ---------------------------------------------------------------------------
@@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types";
import { KEYS } from "../keys";
import { KEYS, matchKey } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types";
@@ -63,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
@@ -104,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
@@ -53,6 +53,9 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons";
import {
ARROW_TYPE,
@@ -116,7 +119,7 @@ import {
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { point, vector } from "../../math";
import { pointFrom, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -1405,59 +1408,65 @@ const getArrowheadOptions = (flip: boolean) => {
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "dot",
text: t("labels.arrowhead_circle"),
keyBinding: null,
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "r",
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: null,
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
showInPicker: false,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "t",
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "r",
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
] as const;
};
@@ -1521,6 +1530,7 @@ export const actionChangeArrowhead = register({
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
label="arrowhead_end"
@@ -1537,6 +1547,7 @@ export const actionChangeArrowhead = register({
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</div>
</fieldset>
@@ -1591,6 +1602,7 @@ export const actionChangeArrowType = register({
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const endHoveredElement =
@@ -1599,6 +1611,7 @@ export const actionChangeArrowType = register({
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const startElement = startHoveredElement
@@ -1651,7 +1664,7 @@ export const actionChangeArrowType = register({
elementsMap,
[finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
point(p[0] - newElement.x, p[1] - newElement.y),
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(0, 0),
{
+2
View File
@@ -88,3 +88,5 @@ export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";
-2
View File
@@ -23,7 +23,6 @@ export type ShortcutName =
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "gridMode"
@@ -88,7 +87,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
+5 -6
View File
@@ -10,7 +10,6 @@ import type {
BinaryFiles,
UIAppState,
} from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource =
@@ -24,10 +23,7 @@ export type ActionSource =
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
appState?: Partial<AppState> | null;
files?: BinaryFiles | null;
storeAction: StoreActionType;
replaceFiles?: boolean;
@@ -138,7 +134,10 @@ export type ActionName =
| "commandPalette"
| "autoResize"
| "elementStats"
| "searchMenu";
| "searchMenu"
| "copyElementLink"
| "linkToElement"
| "cropEditor";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
+6
View File
@@ -84,6 +84,7 @@ export const getDefaultAppState = (): Omit<
scrollX: 0,
scrollY: 0,
selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null,
@@ -116,6 +117,8 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
};
};
@@ -208,6 +211,7 @@ const APP_STATE_STORAGE_CONF = (<
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
hoveredElementIds: { browser: false, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,
@@ -237,6 +241,8 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});
+32 -1
View File
@@ -17,13 +17,16 @@ import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isImageElement,
isTextElement,
} from "./element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./element/types";
@@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
);
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];
@@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;
@@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
}
}
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
});
}
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
+5 -5
View File
@@ -1,5 +1,5 @@
import type { Radians } from "../math";
import { point } from "../math";
import { pointFrom } from "../math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
@@ -260,7 +260,7 @@ const chartLines = (
x,
y,
width: chartWidth,
points: [point(0, 0), point(chartWidth, 0)],
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
const yLine = newLinearElement({
@@ -271,7 +271,7 @@ const chartLines = (
x,
y,
height: chartHeight,
points: [point(0, 0), point(0, -chartHeight)],
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
});
const maxLine = newLinearElement({
@@ -284,7 +284,7 @@ const chartLines = (
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [point(0, 0), point(chartWidth, 0)],
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
@@ -441,7 +441,7 @@ const chartTypeLine = (
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [point(0, 0), point(0, cy)],
points: [pointFrom(0, 0), pointFrom(0, cy)],
});
});
+65 -35
View File
@@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -39,7 +41,7 @@ export interface ClipboardData {
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEvent =
type ParsedClipboardEventTextData =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
@@ -75,7 +77,7 @@ export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
types?: { [key in AllowedPasteMimeTypes]?: string | File };
files?: File[];
}) => {
if (!types && !files) {
@@ -88,6 +90,11 @@ export const createPasteEvent = ({
if (types) {
for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
@@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, "text/html");
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
const content = parseHTMLTree(doc.body);
@@ -238,34 +245,44 @@ const maybeParseHTMLPaste = (
return null;
};
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
try {
if (navigator.clipboard?.readText) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
const readText = await navigator.clipboard?.readText();
if (readText) {
return { [MIME_TYPES.text]: readText };
}
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
}
throw error;
}
@@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
types[type] = await (await item.getType(type)).text();
} else if (isSupportedImageFileType(type)) {
const imageBlob = await item.getType(type);
const file = createFile(imageBlob, type, undefined);
types[type] = file;
} else {
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
}
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
error instanceof ExcalidrawError
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
@@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEvent = async (
const parseClipboardEventTextData = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
): Promise<ParsedClipboardEventTextData> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
@@ -308,7 +335,7 @@ const parseClipboardEvent = async (
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value
.map((item) => item.value)
.join("\n")
@@ -319,7 +346,7 @@ const parseClipboardEvent = async (
return mixedContent;
}
const text = event.clipboardData?.getData("text/plain");
const text = event.clipboardData?.getData(MIME_TYPES.text);
return { type: "text", value: (text || "").trim() };
} catch {
@@ -328,13 +355,16 @@ const parseClipboardEvent = async (
};
/**
* Attempts to parse clipboard. Prefers system clipboard.
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
const parsedEventData = await parseClipboardEventTextData(
event,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") {
return {
@@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
@@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -127,6 +128,11 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return (
<div className="panelColumn">
<div>
@@ -245,6 +251,7 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,10 @@ import { trackEvent } from "../../analytics";
import { useStable } from "../../hooks/useStable";
import "./CommandPalette.scss";
import {
actionCopyElementLink,
actionLinkToElement,
} from "../../actions/actionElementLink";
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
@@ -279,7 +283,10 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
actionCopyElementLink,
actionLinkToElement,
].map((action: Action) =>
actionToCommand(
action,
@@ -1,3 +1,4 @@
import { flushSync } from "react-dom";
import { t } from "../i18n";
import type { DialogProps } from "./Dialog";
import { Dialog } from "./Dialog";
@@ -43,7 +44,14 @@ const ConfirmDialog = (props: Props) => {
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onCancel();
// flush any pending updates synchronously,
// otherwise it could lead to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onCancel();
});
container?.focus();
}}
/>
@@ -52,7 +60,14 @@ const ConfirmDialog = (props: Props) => {
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
onConfirm();
// flush any pending updates synchronously,
// otherwise it leads to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onConfirm();
});
container?.focus();
}}
actionType="danger"
@@ -0,0 +1,87 @@
@import "../css/variables.module.scss";
.excalidraw {
.ElementLinkDialog {
position: absolute;
top: var(--editor-container-padding);
left: var(--editor-container-padding);
z-index: var(--zIndex-modal);
border-radius: 10px;
padding: 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: var(--shadow-island);
background-color: var(--island-bg-color);
@include isMobile {
left: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
width: calc(100% - 1rem);
box-sizing: border-box;
z-index: 5;
}
.ElementLinkDialog__header {
h2 {
margin-top: 0;
margin-bottom: 0.5rem;
@include isMobile {
font-size: 1.25rem;
}
}
p {
margin: 0;
@include isMobile {
font-size: 0.875rem;
}
}
margin-bottom: 1.5rem;
@include isMobile {
margin-bottom: 1rem;
}
}
.ElementLinkDialog__input {
display: flex;
.ElementLinkDialog__input-field {
flex: 1;
}
.ElementLinkDialog__remove {
color: $oc-red-9;
margin-left: 1rem;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
.ToolIcon__icon svg {
color: $oc-red-6;
}
}
}
.ElementLinkDialog__actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
@include isMobile {
font-size: 0.875rem;
margin-top: 1rem;
}
}
}
}
@@ -0,0 +1,174 @@
import { TextField } from "./TextField";
import type { AppProps, AppState, UIAppState } from "../types";
import DialogActionButton from "./DialogActionButton";
import { getSelectedElements } from "../scene";
import {
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "../element/elementLink";
import { mutateElement } from "../element/mutateElement";
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { ElementsMap, ExcalidrawElement } from "../element/types";
import { ToolButton } from "./ToolButton";
import { TrashIcon } from "./icons";
import { KEYS } from "../keys";
import "./ElementLinkDialog.scss";
import { normalizeLink } from "../data/url";
const ElementLinkDialog = ({
sourceElementId,
onClose,
elementsMap,
appState,
generateLinkForSelection = defaultGetElementLinkFromSelection,
}: {
sourceElementId: ExcalidrawElement["id"];
elementsMap: ElementsMap;
appState: UIAppState;
onClose?: () => void;
generateLinkForSelection: AppProps["generateLinkForSelection"];
}) => {
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
const [nextLink, setNextLink] = useState<string | null>(originalLink);
const [linkEdited, setLinkEdited] = useState(false);
useEffect(() => {
const selectedElements = getSelectedElements(elementsMap, appState);
let nextLink = originalLink;
if (selectedElements.length > 0 && generateLinkForSelection) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState as AppState,
);
if (idAndType) {
nextLink = normalizeLink(
generateLinkForSelection(idAndType.id, idAndType.type),
);
}
}
setNextLink(nextLink);
}, [
elementsMap,
appState,
appState.selectedElementIds,
originalLink,
generateLinkForSelection,
]);
const handleConfirm = useCallback(() => {
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
link: nextLink,
});
}
if (!nextLink && linkEdited && sourceElementId) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
link: null,
});
}
onClose?.();
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ENTER
) {
handleConfirm();
}
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ESCAPE
) {
onClose?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [appState, onClose, handleConfirm]);
return (
<div className="ElementLinkDialog">
<div className="ElementLinkDialog__header">
<h2>{t("elementLink.title")}</h2>
<p>{t("elementLink.desc")}</p>
</div>
<div className="ElementLinkDialog__input">
<TextField
value={nextLink ?? ""}
onChange={(value) => {
if (!linkEdited) {
setLinkEdited(true);
}
setNextLink(value);
}}
onKeyDown={(event) => {
if (event.key === KEYS.ENTER) {
handleConfirm();
}
}}
className="ElementLinkDialog__input-field"
selectOnRender
/>
{originalLink && nextLink && (
<ToolButton
type="button"
title={t("buttons.remove")}
aria-label={t("buttons.remove")}
label={t("buttons.remove")}
onClick={() => {
// removes the link from the input
// but doesn't update the element
// when confirmed, will remove the link from the element
setNextLink(null);
setLinkEdited(true);
}}
className="ElementLinkDialog__remove"
icon={TrashIcon}
/>
)}
</div>
<div className="ElementLinkDialog__actions">
<DialogActionButton
label={t("buttons.cancel")}
onClick={() => {
onClose?.();
}}
style={{
marginRight: 10,
}}
/>
<DialogActionButton
label={t("buttons.confirm")}
onClick={handleConfirm}
actionType="primary"
/>
</div>
</div>
);
};
export default ElementLinkDialog;
@@ -15,7 +15,6 @@
top: var(--editor-container-padding);
right: var(--editor-container-padding);
bottom: var(--editor-container-padding);
z-index: 2;
}
.FixedSideContainer_side_top.zen-mode {
@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-handrawn",
testId: "font-family-hand-drawn",
},
{
value: FONT_FAMILY.Nunito,
@@ -21,6 +21,7 @@ import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor {
value: number;
@@ -62,12 +63,14 @@ export const FontPickerList = React.memo(
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
.filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon,
text: fonts[0].fontFace.family,
icon: metadata.icon ?? FontFamilyNormalIcon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
};
if (metadata.deprecated) {
@@ -89,7 +92,7 @@ export const FontPickerList = React.memo(
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()),
() => new Set(fonts.getSceneFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
+11 -1
View File
@@ -22,7 +22,7 @@ const Header = () => (
</a>
<a
className="HelpDialog__btn"
href="https://blog.excalidraw.com"
href="https://plus.excalidraw.com/blog"
target="_blank"
rel="noopener noreferrer"
>
@@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.cropStart")}
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.cropFinish")}
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut
label={t("helpDialog.preventBinding")}
@@ -100,6 +100,14 @@ const getHints = ({
return t("hints.text_editing");
}
if (appState.croppingElementId) {
return t("hints.leaveCropEditor");
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor");
}
if (activeTool.type === "selection") {
if (
appState.selectionElement &&
+11 -45
View File
@@ -1,19 +1,16 @@
@import "../css/variables.module.scss";
.excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker {
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
// ˇˇ yeah, i dunno, open to suggestions here :D
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
box-shadow: var(--shadow-island);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-container button,
@@ -55,47 +52,16 @@
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
}
.picker-triangle {
width: 0;
height: 0;
position: relative;
top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
z-index: 10;
&:before {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
&:after {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
}
}
.picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(3, auto);
grid-template-columns: repeat(4, auto);
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-collapsible {
font-size: 0.75rem;
padding: 0.5rem 0;
}
.picker-keybinding {
+131 -98
View File
@@ -1,10 +1,23 @@
import React from "react";
import { Popover } from "./Popover";
import React, { useEffect } from "react";
import * as Popover from "@radix-ui/react-popover";
import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n";
import { getLanguage, t } from "../i18n";
import clsx from "clsx";
import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { useDevice } from "..";
const moreOptionsAtom = atom(false);
type Option<T> = {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
};
function Picker<T>({
options,
@@ -12,30 +25,16 @@ function Picker<T>({
label,
onChange,
onClose,
numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
options: {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
options: readonly Option<T>[];
onChange: (value: T) => void;
onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) {
const rFirstItem = React.useRef<HTMLButtonElement>();
const rActiveItem = React.useRef<HTMLButtonElement>();
const rGallery = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// After the component is first mounted focus on first input
if (rActiveItem.current) {
rActiveItem.current.focus();
} else if (rGallery.current) {
rGallery.current.focus();
}
}, []);
const device = useDevice();
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
@@ -44,28 +43,19 @@ function Picker<T>({
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
const index = options.indexOf(pressedOption);
(rGallery!.current!.children![index] as any).focus();
onChange(pressedOption.value);
event.preventDefault();
} else if (event.key === KEYS.TAB) {
// Tab navigation cycle through options. If the user tabs
// away from the picker, close the picker. We need to use
// a timeout here to let the stack clear before checking.
setTimeout(() => {
const active = rActiveItem.current;
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
const index = options.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length
: (index + 1) % options.length;
onChange(options[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl;
const index = Array.prototype.indexOf.call(
rGallery!.current!.children,
activeElement,
);
const index = options.findIndex((option) => option.value === value);
if (index !== -1) {
const length = options.length;
let nextIndex = index;
@@ -73,19 +63,26 @@ function Picker<T>({
switch (event.key) {
// Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length;
break;
}
// Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
case KEYS.ARROW_UP: {
nextIndex = (length + index - 1) % length;
break;
// Go the next row
case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
nextIndex =
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
}
(rGallery.current!.children![nextIndex] as any).focus();
onChange(options[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -97,15 +94,29 @@ function Picker<T>({
event.stopPropagation();
};
return (
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={handleKeyDown}
>
<div className="picker-content" ref={rGallery}>
const [showMoreOptions, setShowMoreOptions] = useAtom(
moreOptionsAtom,
jotaiScope,
);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option, i) => (
<button
type="button"
@@ -113,7 +124,6 @@ function Picker<T>({
active: value === option.value,
})}
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value);
}}
title={`${option.text} ${
@@ -122,16 +132,13 @@ function Picker<T>({
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
ref={(el) => {
if (el && i === 0) {
rFirstItem.current = el;
ref={(ref) => {
if (value === option.value) {
// Use a timeout here to render focus properly
setTimeout(() => {
ref?.focus();
}, 0);
}
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}}
>
{option.icon}
@@ -141,7 +148,43 @@ function Picker<T>({
</button>
))}
</div>
</div>
);
};
return (
<Popover.Content
side={
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start"
sideOffset={12}
style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown}
>
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
openTrigger={() => {
setShowMoreOptions((value) => !value);
}}
className="picker-collapsible"
>
{renderOptions(moreOptions)}
</Collapsible>
)}
</div>
</Popover.Content>
);
}
@@ -151,6 +194,7 @@ export function IconPicker<T>({
options,
onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
@@ -159,51 +203,40 @@ export function IconPicker<T>({
text: string;
icon: JSX.Element;
keyBinding: string | null;
showInPicker?: boolean;
}[];
onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return (
<div>
<button
name={group}
type="button"
className={isActive ? "active" : ""}
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
>
{options.find((option) => option.value === value)?.icon}
</button>
<React.Suspense fallback="">
{isActive ? (
<>
<Popover
onCloseRequest={(event) =>
event.target !== rPickerButton.current && setActive(false)
}
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
>
<Picker
options={options.filter((opt) => opt.showInPicker !== false)}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
name={group}
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""}
>
{options.find((option) => option.value === value)?.icon}
</Popover.Trigger>
{isActive && (
<Picker
options={options}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
</Popover.Root>
</div>
);
}
+97 -78
View File
@@ -60,6 +60,7 @@ import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import ElementLinkDialog from "./ElementLinkDialog";
import "./LayerUI.scss";
import "./Toolbar.scss";
@@ -84,6 +85,7 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
}
const DefaultMainMenu: React.FC<{
@@ -141,6 +143,7 @@ const LayerUI = ({
children,
app,
isCollaborating,
generateLinkForSelection,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -232,7 +235,8 @@ const LayerUI = ({
const shouldShowStats =
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled;
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
return (
<FixedSideContainer side="top">
@@ -241,90 +245,91 @@ const LayerUI = ({
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
{!appState.viewModeEnabled && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Island
padding={1}
className={clsx("App-toolbar", {
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<HintViewer
appState={appState}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
})}
>
<HintViewer
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
</Stack.Row>
</Island>
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
{heading}
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
@@ -341,6 +346,7 @@ const LayerUI = ({
)}
{renderTopRightUI?.(device.editor.isMobile, appState)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked
(!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
@@ -471,6 +477,19 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
{appState.openDialog?.name === "elementLinkSelector" && (
<ElementLinkDialog
sourceElementId={appState.openDialog.sourceElementId}
onClose={() => {
setAppState({
openDialog: null,
});
}}
elementsMap={app.scene.getNonDeletedElementsMap()}
appState={appState}
generateLinkForSelection={generateLinkForSelection}
/>
)}
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
+12 -5
View File
@@ -91,9 +91,10 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && (
<DefaultSidebarTriggerTunnel.Out />
)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<DefaultSidebarTriggerTunnel.Out />
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
@@ -129,7 +130,10 @@ export const MobileMenu = ({
};
const renderAppToolbar = () => {
if (appState.viewModeEnabled) {
if (
appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector"
) {
return (
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
@@ -154,7 +158,9 @@ export const MobileMenu = ({
return (
<>
{renderSidebars()}
{!appState.viewModeEnabled && renderToolbar()}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
renderToolbar()}
<div
className="App-bottom-bar"
style={{
@@ -166,6 +172,7 @@ export const MobileMenu = ({
<Island padding={0}>
{appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
@@ -48,6 +48,9 @@ const ChartPreviewBtn = (props: {
viewBackgroundColor: oc.white,
},
null, // files
{
skipInliningFonts: true,
},
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
@@ -294,6 +294,7 @@ export const SearchMenu = () => {
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
passive: false,
});
}, [setAppState, stableState, app]);
@@ -9,6 +9,7 @@ interface CollapsibleProps {
open: boolean;
openTrigger: () => void;
children: React.ReactNode;
className?: string;
}
const Collapsible = ({
@@ -16,6 +17,7 @@ const Collapsible = ({
open,
openTrigger,
children,
className,
}: CollapsibleProps) => {
return (
<>
@@ -26,6 +28,7 @@ const Collapsible = ({
justifyContent: "space-between",
alignItems: "center",
}}
className={className}
onClick={openTrigger}
>
{label}
@@ -1,10 +1,18 @@
import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { resizeSingleElement } from "../../element/resizeElements";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isImageElement } from "../../element/typeChecks";
import {
MINIMAL_CROP_SIZE,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
import { clamp, round } from "../../../math";
interface DimensionDragInputProps {
property: "width" | "height";
@@ -23,20 +31,124 @@ const handleDimensionChange: DragInputCallbackType<
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
property,
originalAppState,
instantChange,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (origElement && latestElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height;
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = { ...crop };
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth;
const naturalToUncroppedHeightRatio =
crop.naturalHeight / uncroppedHeight;
const MAX_POSSIBLE_WIDTH = isFlippedByX
? crop.width + crop.x
: crop.naturalWidth - crop.x;
const MAX_POSSIBLE_HEIGHT = isFlippedByY
? crop.height + crop.y
: crop.naturalHeight - crop.y;
const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio;
const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio;
if (nextValue !== undefined) {
if (property === "width") {
const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio;
const nextCropWidth = clamp(
nextValueInNatural,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
nextCrop = {
...nextCrop,
width: nextCropWidth,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
};
} else if (property === "height") {
const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio;
const nextCropHeight = clamp(
nextValueInNatural,
MIN_HEIGHT,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...nextCrop,
height: nextCropHeight,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
};
}
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
const changeInWidth = property === "width" ? instantChange : 0;
const changeInHeight = property === "height" ? instantChange : 0;
const nextCropWidth = clamp(
crop.width + changeInWidth,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
const nextCropHeight = clamp(
crop.height + changeInHeight,
MIN_WIDTH,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...crop,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
width: nextCropWidth,
height: nextCropHeight,
};
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
@@ -55,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType<
MIN_WIDTH_OR_HEIGHT,
);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
return;
@@ -99,14 +214,17 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
}
};
@@ -117,9 +235,25 @@ const DimensionDragInput = ({
scene,
appState,
}: DimensionDragInputProps) => {
const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
100;
let value = round(property === "width" ? element.width : element.height, 2);
if (
appState.croppingElementId &&
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (property === "width") {
const ratio = uncroppedWidth / element.crop.naturalWidth;
value = round(element.crop.width * ratio, 2);
}
if (property === "height") {
const ratio = uncroppedHeight / element.crop.naturalHeight;
value = round(element.crop.height * ratio, 2);
}
}
return (
<DragInput
@@ -2,7 +2,10 @@ import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
rescalePointsInElement,
resizeSingleElement,
} from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
@@ -17,10 +20,10 @@ import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { point, type GlobalPoint } from "../../../math";
import { pointFrom, type GlobalPoint } from "../../../math";
interface MultiDimensionProps {
property: "width" | "height";
@@ -69,7 +72,6 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
@@ -79,7 +81,7 @@ const resizeElementInGroup = (
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -151,7 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
@@ -182,7 +183,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
point(x1, y1),
pointFrom(x1, y1),
property,
latestElements,
originalElements,
@@ -224,15 +225,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
false,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -287,7 +290,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
point(x1, y1),
pointFrom(x1, y1),
property,
latestElements,
originalElements,
@@ -325,14 +328,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -13,7 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math";
interface MultiPositionProps {
property: "x" | "y";
@@ -44,8 +44,8 @@ const moveElements = (
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
pointFrom(origElement.x, origElement.y),
pointFrom(cx, cy),
origElement.angle,
);
@@ -97,8 +97,8 @@ const moveGroupTo = (
];
const [topLeftX, topLeftY] = pointRotateRads(
point(latestElement.x, latestElement.y),
point(cx, cy),
pointFrom(latestElement.x, latestElement.y),
pointFrom(cx, cy),
latestElement.angle,
);
@@ -171,8 +171,8 @@ const handlePositionChange: DragInputCallbackType<
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
pointFrom(origElement.x, origElement.y),
pointFrom(cx, cy),
origElement.angle,
);
@@ -241,8 +241,8 @@ const MultiPosition = ({
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = pointRotateRads(
point(el.x, el.y),
point(cx, cy),
pointFrom(el.x, el.y),
pointFrom(cx, cy),
el.angle,
);
@@ -4,7 +4,13 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math";
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
import { isImageElement } from "../../element/typeChecks";
import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
interface PositionProps {
property: "x" | "y";
@@ -18,12 +24,14 @@ const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
@@ -33,11 +41,87 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
pointFrom(origElement.x, origElement.y),
pointFrom(cx, cy),
origElement.angle,
);
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = crop;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (nextValue !== undefined) {
if (property === "x") {
const nextValueInNatural =
nextValue * (crop.naturalWidth / uncroppedWidth);
if (isFlippedByX) {
nextCrop = {
...crop,
x: clamp(
crop.naturalWidth - nextValueInNatural - crop.width,
0,
crop.naturalWidth - crop.width,
),
};
} else {
nextCrop = {
...crop,
x: clamp(
nextValue * (crop.naturalWidth / uncroppedWidth),
0,
crop.naturalWidth - crop.width,
),
};
}
}
if (property === "y") {
nextCrop = {
...crop,
y: clamp(
nextValue * (crop.naturalHeight / uncroppedHeight),
0,
crop.naturalHeight - crop.height,
),
};
}
mutateElement(element, {
crop: nextCrop,
});
return;
}
const changeInX =
(property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1);
const changeInY =
(property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1);
nextCrop = {
...crop,
x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width),
y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
};
mutateElement(element, {
crop: nextCrop,
});
return;
}
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -93,12 +177,26 @@ const Position = ({
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = pointRotateRads(
point(element.x, element.y),
point(element.x + element.width / 2, element.y + element.height / 2),
pointFrom(element.x, element.y),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
let value = round(property === "x" ? topLeftX : topLeftY, 2);
if (
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const flipAdjustedPosition = getFlipAdjustedCropPosition(element);
if (flipAdjustedPosition) {
value = round(
property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y,
2,
);
}
}
return (
<StatsDragInput
+39 -3
View File
@@ -23,12 +23,14 @@ import Collapsible from "./Collapsible";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import { isElbowArrow, isImageElement } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math";
interface StatsProps {
app: AppClassProperties;
@@ -128,6 +130,13 @@ export const StatsInner = memo(
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const cropMode =
appState.croppingElementId && isImageElement(singleElement);
const unCroppedDimension = cropMode
? getUncroppedWidthAndHeight(singleElement)
: null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
@@ -244,8 +253,34 @@ export const StatsInner = memo(
<StatsRows>
{singleElement && (
<>
{cropMode && (
<StatsRow heading>
{t("labels.unCroppedDimension")}
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.width")}</div>
<div>{round(unCroppedDimension.width, 2)}</div>
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.height")}</div>
<div>{round(unCroppedDimension.height, 2)}</div>
</StatsRow>
)}
<StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)}
{appState.croppingElementId
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}
</StatsRow>
<StatsRow>
@@ -387,7 +422,8 @@ export const StatsInner = memo(
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
prev.appState.gridStep === next.appState.gridStep &&
prev.appState.croppingElementId === next.appState.croppingElementId
);
},
);
@@ -25,7 +25,7 @@ import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
import type { Degrees } from "../../../math";
import { degreesToRadians, point, pointRotateRads } from "../../../math";
import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -264,8 +264,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
pointFrom(rectangle.x, rectangle.y),
pointFrom(cx, cy),
rectangle.angle,
);
@@ -283,8 +283,8 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
pointFrom(rectangle.x, rectangle.y),
pointFrom(cx, cy),
rectangle.angle,
);
@@ -294,8 +294,8 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
pointFrom(rectangle.x, rectangle.y),
pointFrom(cx, cy),
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -311,8 +311,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
pointFrom(rectangle.x, rectangle.y),
pointFrom(cx, cy),
rectangle.angle,
);
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -321,8 +321,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2,
];
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
pointFrom(rectangle.x, rectangle.y),
pointFrom(cx, cy),
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -334,8 +334,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2,
];
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
pointFrom(rectangle.x, rectangle.y),
pointFrom(cx, cy),
rectangle.angle,
);
+8 -108
View File
@@ -1,21 +1,11 @@
import type { Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getBoundTextElement } from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
@@ -34,7 +24,6 @@ import {
} from "../../groups";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
@@ -121,97 +110,6 @@ export const newOrigin = (
};
};
export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(
latestElement,
{
...newOrigin(
latestElement.x,
latestElement.y,
latestElement.width,
latestElement.height,
nextWidth,
nextHeight,
latestElement.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
}
}
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
};
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
@@ -231,8 +129,8 @@ export const moveElement = (
originalElement.y + originalElement.height / 2,
];
const [topLeftX, topLeftY] = pointRotateRads(
point(originalElement.x, originalElement.y),
point(cx, cy),
pointFrom(originalElement.x, originalElement.y),
pointFrom(cx, cy),
originalElement.angle,
);
@@ -240,8 +138,8 @@ export const moveElement = (
const changeInY = newTopLeftY - topLeftY;
const [x, y] = pointRotateRads(
point(newTopLeftX, newTopLeftY),
point(cx + changeInX, cy + changeInY),
pointFrom(newTopLeftX, newTopLeftY),
pointFrom(cx + changeInX, cy + changeInY),
-originalElement.angle as Radians,
);
@@ -302,6 +200,7 @@ export const updateBindings = (
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
@@ -312,6 +211,7 @@ export const updateBindings = (
scene,
true,
[],
options?.zoom,
);
} else {
updateBoundElements(latestElement, elementsMap, options);
@@ -182,6 +182,7 @@ const getRelevantAppStateProps = (
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds,
@@ -203,6 +204,8 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement,
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
});
@@ -92,6 +92,8 @@ const getRelevantAppStateProps = (
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,
@@ -107,6 +109,7 @@ const getRelevantAppStateProps = (
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
});
const areEqual = (
@@ -13,7 +13,7 @@ import type {
} from "../../element/types";
import { ToolButton } from "../ToolButton";
import { FreedrawIcon, TrashIcon } from "../icons";
import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons";
import { t } from "../../i18n";
import {
useCallback,
@@ -30,18 +30,19 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
import { getSelectedElements } from "../../scene";
import { hitElementBoundingBox } from "../../element/collision";
import { isLocalLink, normalizeLink } from "../../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App";
import { useAppProps, useDevice, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
import { point, type GlobalPoint } from "../../../math";
import { pointFrom, type GlobalPoint } from "../../../math";
import { isElementLink } from "../../element/elementLink";
const CONTAINER_WIDTH = 320;
import "./Hyperlink.scss";
const POPUP_WIDTH = 380;
const POPUP_HEIGHT = 42;
const POPUP_PADDING = 5;
const SPACE_BOTTOM = 85;
const CONTAINER_PADDING = 5;
const CONTAINER_HEIGHT = 42;
const AUTO_HIDE_TIMEOUT = 500;
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
@@ -73,6 +74,7 @@ export const Hyperlink = ({
}) => {
const appState = useExcalidrawAppState();
const appProps = useAppProps();
const device = useDevice();
const linkVal = element.link || "";
@@ -170,6 +172,15 @@ export const Hyperlink = ({
useEffect(() => {
let timeoutId: number | null = null;
if (
inputRef &&
inputRef.current &&
!(device.viewport.isMobile || device.isTouchScreen)
) {
inputRef.current.select();
}
const handlePointerMove = (event: PointerEvent) => {
if (isEditing) {
return;
@@ -181,7 +192,7 @@ export const Hyperlink = ({
element,
elementsMap,
appState,
point(event.clientX, event.clientY),
pointFrom(event.clientX, event.clientY),
) as boolean;
if (shouldHide) {
timeoutId = window.setTimeout(() => {
@@ -196,16 +207,21 @@ export const Hyperlink = ({
clearTimeout(timeoutId);
}
};
}, [appState, element, isEditing, setAppState, elementsMap]);
}, [
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete");
mutateElement(element, { link: null });
if (isEditing) {
inputRef.current!.value = "";
}
setAppState({ showHyperlinkPopup: false });
}, [setAppState, element, isEditing]);
}, [setAppState, element]);
const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui");
@@ -229,19 +245,14 @@ export const Hyperlink = ({
style={{
top: `${y}px`,
left: `${x}px`,
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
onClick={() => {
if (!element.link && !isEditing) {
setAppState({ showHyperlinkPopup: "editor" });
}
width: POPUP_WIDTH,
padding: POPUP_PADDING,
}}
>
{isEditing ? (
<input
className={clsx("excalidraw-hyperlinkContainer-input")}
placeholder="Type or paste your link here"
placeholder={t("labels.link.hint")}
ref={inputRef}
value={inputVal}
onChange={(event) => setInputVal(event.target.value)}
@@ -302,6 +313,21 @@ export const Hyperlink = ({
icon={FreedrawIcon}
/>
)}
<ToolButton
type="button"
title={t("labels.linkToElement")}
aria-label={t("labels.linkToElement")}
label={t("labels.linkToElement")}
onClick={() => {
setAppState({
openDialog: {
name: "elementLinkSelector",
sourceElementId: element.id,
},
});
}}
icon={elementLinkIcon}
/>
{linkVal && !isEmbeddableElement(element) && (
<ToolButton
type="button"
@@ -328,7 +354,7 @@ const getCoordsForPopover = (
{ sceneX: x1 + element.width / 2, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2;
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
return { x, y };
};
@@ -338,12 +364,10 @@ export const getContextMenuLabel = (
appState: UIAppState,
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]?.link
? isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: "labels.link.edit"
: isEmbeddableElement(selectedElements[0])
? "labels.link.createEmbed"
const label = isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: selectedElements[0]?.link
? "labels.link.edit"
: "labels.link.create";
return label;
};
@@ -376,7 +400,9 @@ const renderTooltip = (
tooltipDiv.classList.add("excalidraw-tooltip--visible");
tooltipDiv.style.maxWidth = "20rem";
tooltipDiv.textContent = element.link;
tooltipDiv.textContent = isElementLink(element.link)
? t("labels.link.goToElement")
: element.link;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -450,9 +476,9 @@ const shouldHideLinkPopup = (
if (
clientX >= popoverX - threshold &&
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold &&
clientY >= popoverY - threshold &&
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT
) {
return false;
}
@@ -1,5 +1,5 @@
import type { GlobalPoint, Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -16,6 +16,11 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
)}`;
export const ELEMENT_LINK_IMG = document.createElement("img");
ELEMENT_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-big-right-line"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 9v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-6v-6h6z" /><path d="M3 9v6" /></svg>`,
)}`;
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: Radians,
@@ -35,8 +40,8 @@ export const getLinkHandleFromCoords = (
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = pointRotateRads(
point(x + linkWidth / 2, y + linkHeight / 2),
point(centerX, centerY),
pointFrom(x + linkWidth / 2, y + linkHeight / 2),
pointFrom(centerX, centerY),
angle,
);
return [
@@ -85,5 +90,10 @@ export const isPointHittingLink = (
) {
return true;
}
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
return isPointHittingLinkIcon(
element,
elementsMap,
appState,
pointFrom(x, y),
);
};
+72
View File
@@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
),
);
export const ArrowheadCrowfootIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const FontSizeSmallIcon = createIcon(
<>
<g clipPath="url(#a)">
@@ -2147,3 +2195,27 @@ export const upIcon = createIcon(
</g>,
tablerIconProps,
);
export const cropIcon = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 5v10a1 1 0 0 0 1 1h10" />
<path d="M5 8h10a1 1 0 0 1 1 1v10" />
</g>,
tablerIconProps,
);
export const elementLinkIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 7l0 10" />
<path d="M7 5l10 0" />
<path d="M7 19l10 0" />
<path d="M19 7l0 10" />
</g>,
tablerIconProps,
);
+39 -4
View File
@@ -2,6 +2,7 @@ import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
@@ -116,6 +117,9 @@ export const CLASSES = {
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
@@ -136,6 +140,22 @@ export const FONT_FAMILY = {
"Liberation Sans": 9,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
}
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
@@ -157,8 +177,6 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
@@ -196,9 +214,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
@@ -212,6 +230,12 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES,
} as const;
export const ALLOWED_PASTE_MIME_TYPES = [
MIME_TYPES.text,
MIME_TYPES.html,
...Object.values(IMAGE_MIME_TYPES),
] as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
@@ -231,6 +255,14 @@ export const EXPORT_SOURCE =
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
/**
* The time the user has from 2nd pointerdown to following pointerup
* before it's not considered a double click.
*
* Helps prevent cases where you double-click by mistake but then drag/keep
* the pointer down for to cancel the double click or do another action.
*/
export const DOUBLE_CLICK_POINTERUP_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000;
@@ -431,3 +463,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
round: "round",
elbow: "elbow",
};
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2",
"boundElements": [
{
"id": "id45",
"id": "id47",
"type": "arrow",
},
{
"id": "id46",
"id": "id48",
"type": "arrow",
},
],
@@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id46",
"id": "id48",
"type": "arrow",
},
],
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 35,
"height": 33.519031369643244,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5,
],
[
394.5,
34.5,
382.47606040672997,
34.019031369643244,
],
],
"roughness": 1,
@@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
"elementId": "id49",
"fixedPoint": null,
"focus": -0.08139534883720931,
"gap": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 7,
"versionNonce": Any<Number>,
"width": 395,
"width": 381.97606040672997,
"x": 247,
"y": 420,
}
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0,
],
[
399.5,
389.5,
0,
],
],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 400,
"x": 227,
"width": 390,
"x": 237,
"y": 450,
}
`;
@@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id45",
"id": "id47",
"type": "arrow",
},
],
@@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id48",
"id": "id50",
"type": "arrow",
},
],
@@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id48",
"id": "id50",
"type": "arrow",
},
],
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top",
"width": 100,
"x": 560,
"y": 226.5,
"y": 236.95454545454544,
}
`;
@@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id49",
"id": "id51",
"type": "text",
},
],
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
"focus": 1.625925925925924,
"gap": 14,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"height": 18.278619528619487,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [
[
0.5,
0,
-0.5,
],
[
99.5,
0,
357.2037037037038,
-17.778619528619487,
],
],
"roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
"y": 239,
"width": 357.7037037037038,
"x": 171,
"y": 249.45454545454544,
}
`;
@@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id48",
"containerId": "id50",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id38",
"id": "id40",
"type": "text",
},
],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id40",
"elementId": "id42",
"fixedPoint": null,
"focus": 0,
"gap": 1,
@@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id39",
"elementId": "id41",
"fixedPoint": null,
"focus": 0,
"gap": 1,
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
@@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id37",
"containerId": "id39",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id37",
"id": "id39",
"type": "arrow",
},
],
@@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id37",
"id": "id39",
"type": "arrow",
},
],
@@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id42",
"id": "id44",
"type": "text",
},
],
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id44",
"elementId": "id46",
"fixedPoint": null,
"focus": 0,
"gap": 1,
@@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id43",
"elementId": "id45",
"fixedPoint": null,
"focus": 0,
"gap": 1,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 100,
"x": 255,
@@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id41",
"containerId": "id43",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id41",
"id": "id43",
"type": "arrow",
},
],
@@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id41",
"id": "id43",
"type": "arrow",
},
],
@@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id54",
"id": "id56",
"type": "text",
},
{
@@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id55",
"id": "id57",
"type": "text",
},
],
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id56",
"id": "id58",
"type": "text",
},
{
@@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id57",
"id": "id59",
"type": "text",
},
{
@@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id58",
"id": "id60",
"type": "text",
},
],
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0,
],
[
272.485,
270.98528125,
0,
],
],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 7,
"versionNonce": Any<Number>,
"width": 272.985,
"x": 111.262,
"width": 270.48528125,
"x": 112.76171875,
"y": 57,
}
`;
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id59",
"id": "id61",
"type": "text",
},
],
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"version": 6,
"versionNonce": Any<Number>,
"width": 0,
"x": 77.017,
"y": 79,
"x": 83.015625,
"y": 81.5,
}
`;
@@ -2171,7 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
"text": "ANOTHER STYLED
LABELLED ARROW",
"textAlign": "center",
"type": "text",
@@ -2179,8 +2179,8 @@ LABELLED ARROW",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 75,
"width": 140,
"x": 80,
"y": 275,
}
`;
@@ -2213,7 +2213,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#099268",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
"text": "ANOTHER STYLED
LABELLED ARROW",
"textAlign": "center",
"type": "text",
@@ -2221,8 +2221,8 @@ LABELLED ARROW",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 75,
"width": 140,
"x": 80,
"y": 375,
}
`;
@@ -2518,7 +2518,7 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ELLIPSE TEXT
"text": "ELLIPSE TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2526,8 +2526,8 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 534.7893218813452,
"width": 120,
"x": 539.7893218813452,
"y": 117.44796179957173,
}
`;
@@ -2562,7 +2562,7 @@ TEXT CONTAINER",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "DIAMOND
TEXT
TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2646,8 +2646,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "TOP LEFT ALIGNED
RECTANGLE TEXT
"text": "TOP LEFT ALIGNED
RECTANGLE TEXT
CONTAINER",
"textAlign": "left",
"type": "text",
@@ -2655,7 +2655,7 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 170,
"width": 160,
"x": 505,
"y": 305,
}
@@ -2689,8 +2689,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "STYLED
ELLIPSE TEXT
"text": "STYLED
ELLIPSE TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2698,8 +2698,8 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 534.7893218813452,
"width": 120,
"x": 539.7893218813452,
"y": 522.5735931288071,
}
`;
+25 -6
View File
@@ -5,16 +5,18 @@ import { clearElementsForExport } from "../element";
import type { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils";
import { base64ToString, stringToBase64, toByteString } from "./encode";
import type { FileSystemHandle } from "./filesystem";
import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
const parseFileContents = async (blob: Blob | File): Promise<string> => {
let contents: string;
if (blob.type === MIME_TYPES.png) {
@@ -46,9 +48,7 @@ const parseFileContents = async (blob: Blob | File) => {
}
if (blob.type === MIME_TYPES.svg) {
try {
return await (
await import("./image")
).decodeSvgMetadata({
return decodeSvgBase64Payload({
svg: contents,
});
} catch (error: any) {
@@ -107,11 +107,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg";
};
export const isSupportedImageFileType = (type: string | null | undefined) => {
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
return isSupportedImageFileType(type);
};
export const loadSceneOrLibraryFromBlob = async (
@@ -249,6 +253,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
}
};
/** async. For sync variant, use getDataURL_sync */
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -261,6 +266,16 @@ export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
});
};
export const getDataURL_sync = (
data: string | Uint8Array | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>,
): DataURL => {
return `data:${mimeType};base64,${stringToBase64(
toByteString(data),
true,
)}` as DataURL;
};
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1));
@@ -274,6 +289,10 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
return new File([ab], filename, { type: mimeType });
};
export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
};
export const resizeImageFile = async (
file: File,
opts: {
@@ -315,7 +334,7 @@ export const resizeImageFile = async (
}
return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
file.name,
{
type: opts.outputType || file.type,
+40 -27
View File
@@ -5,24 +5,23 @@ import { encryptData, decryptData } from "./encryption";
// byte (binary) strings
// -----------------------------------------------------------------------------
// fast, Buffer-compatible implem
export const toByteString = (
data: string | Uint8Array | ArrayBuffer,
): Promise<string> => {
return new Promise((resolve, reject) => {
const blob =
typeof data === "string"
? new Blob([new TextEncoder().encode(data)])
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target || typeof event.target.result !== "string") {
return reject(new Error("couldn't convert to byte string"));
}
resolve(event.target.result);
};
reader.readAsBinaryString(blob);
});
// Buffer-compatible implem.
//
// Note that in V8, spreading the uint8array (by chunks) into
// `String.fromCharCode(...uint8array)` tends to be faster for large
// strings/buffers, in case perf is needed in the future.
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
const bytes =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: new Uint8Array(data);
let bstring = "";
for (const byte of bytes) {
bstring += String.fromCharCode(byte);
}
return bstring;
};
const byteStringToArrayBuffer = (byteString: string) => {
@@ -46,12 +45,12 @@ const byteStringToString = (byteString: string) => {
* @param isByteString set to true if already byte string to prevent bloat
* due to reencoding
*/
export const stringToBase64 = async (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
export const stringToBase64 = (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
};
// async to align with stringToBase64
export const base64ToString = async (base64: string, isByteString = false) => {
export const base64ToString = (base64: string, isByteString = false) => {
return isByteString
? window.atob(base64)
: byteStringToString(window.atob(base64));
@@ -66,6 +65,20 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
return byteStringToArrayBuffer(atob(base64));
};
// -----------------------------------------------------------------------------
// base64url
// -----------------------------------------------------------------------------
export const base64urlToString = (str: string) => {
return window.atob(
// normalize base64URL to base64
str
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
);
};
// -----------------------------------------------------------------------------
// text encoding
// -----------------------------------------------------------------------------
@@ -82,18 +95,18 @@ type EncodedData = {
/**
* Encodes (and potentially compresses via zlib) text to byte string
*/
export const encode = async ({
export const encode = ({
text,
compress,
}: {
text: string;
/** defaults to `true`. If compression fails, falls back to bstring alone. */
compress?: boolean;
}): Promise<EncodedData> => {
}): EncodedData => {
let deflated!: string;
if (compress !== false) {
try {
deflated = await toByteString(deflate(text));
deflated = toByteString(deflate(text));
} catch (error: any) {
console.error("encode: cannot deflate", error);
}
@@ -102,11 +115,11 @@ export const encode = async ({
version: "1",
encoding: "bstring",
compressed: !!deflated,
encoded: deflated || (await toByteString(text)),
encoded: deflated || toByteString(text),
};
};
export const decode = async (data: EncodedData): Promise<string> => {
export const decode = (data: EncodedData): string => {
let decoded: string;
switch (data.encoding) {
@@ -114,7 +127,7 @@ export const decode = async (data: EncodedData): Promise<string> => {
// if compressed, do not double decode the bstring
decoded = data.compressed
? data.encoded
: await byteStringToString(data.encoded);
: byteStringToString(data.encoded);
break;
default:
throw new Error(`decode: unknown encoding "${data.encoding}"`);
+3 -1
View File
@@ -82,6 +82,7 @@ export const fileSave = (
name: string;
/** file extension */
extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
@@ -93,10 +94,11 @@ export const fileSave = (
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
},
opts.fileHandle,
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };
export type { FileSystemHandle };
+3 -56
View File
@@ -1,7 +1,7 @@
import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { encode, decode } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob";
@@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw,
JSON.stringify(
await encode({
encode({
text: metadata,
compress: true,
}),
@@ -59,60 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => {
}
throw new Error("FAILED");
}
return await decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
// -----------------------------------------------------------------------------
// SVG
// -----------------------------------------------------------------------------
export const encodeSvgMetadata = async ({ text }: { text: string }) => {
const base64 = await stringToBase64(
JSON.stringify(await encode({ text })),
true /* is already byte string */,
);
let metadata = "";
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
metadata += `<!-- payload-version:2 -->`;
metadata += "<!-- payload-start -->";
metadata += base64;
metadata += "<!-- payload-end -->";
return metadata;
};
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = await base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return await decode(encodedData);
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
+6 -5
View File
@@ -5,6 +5,7 @@ import {
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox,
MIME_TYPES,
} from "../constants";
@@ -15,8 +16,9 @@ import type {
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementsOverlappingFrame } from "../frame";
import { t } from "../i18n";
import { isSomeElementSelected, getSelectedElements } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
@@ -25,7 +27,6 @@ import { canvasToBlob } from "./blob";
import type { FileSystemHandle } from "./filesystem";
import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json";
import { getElementsOverlappingFrame } from "../frame";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
@@ -130,6 +131,7 @@ export const exportCanvas = async (
description: "Export to SVG",
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle,
},
);
@@ -168,9 +170,8 @@ export const exportCanvas = async (
return fileSave(blob, {
description: "Export to PNG",
name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
mimeTypes: [IMAGE_MIME_TYPES.png],
fileHandle,
});
} else if (type === "clipboard") {
+43 -2
View File
@@ -35,6 +35,9 @@ import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter";
import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */
@@ -467,6 +470,28 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements;
};
const validateLibraryUrl = (
libraryUrl: string,
/**
* If supplied, takes precedence over the default whitelist.
* Return `true` if the URL is valid.
*/
validator?: (libraryUrl: string) => boolean,
): boolean => {
if (
validator
? validator(libraryUrl)
: ALLOWED_LIBRARY_HOSTNAMES.includes(
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
)
) {
return true;
}
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
@@ -608,6 +633,11 @@ const persistLibraryUpdate = async (
export const useHandleLibrary = (
opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
/**
* Return `true` if the library install url should be allowed.
* If not supplied, only the excalidraw.com base domain is allowed.
*/
validateLibraryUrl?: (libraryUrl: string) => boolean;
} & (
| {
/** @deprecated we recommend using `opts.adapter` instead */
@@ -650,7 +680,13 @@ export const useHandleLibrary = (
}) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
libraryUrl = decodeURIComponent(libraryUrl);
libraryUrl = toValidURL(libraryUrl);
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
const request = await fetch(libraryUrl);
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
@@ -678,7 +714,12 @@ export const useHandleLibrary = (
defaultStatus: "published",
openLibraryMenu: true,
});
} catch (error) {
} catch (error: any) {
excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
throw error;
} finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
+9 -3
View File
@@ -57,7 +57,7 @@ import {
getNormalizedZoom,
} from "../scene";
import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, point } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math";
type RestoredAppState = Omit<
AppState,
@@ -190,6 +190,10 @@ const restoreElementWithProperties = <
}
return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base,
...getNormalizedDimensions(base),
...extra,
@@ -258,6 +262,7 @@ const restoreElement = (
status: element.status || "pending",
fileId: element.fileId,
scale: element.scale || [1, 1],
crop: element.crop ?? null,
});
case "line":
// @ts-ignore LEGACY type
@@ -268,7 +273,7 @@ const restoreElement = (
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [point(0, 0), point(element.width, element.height)]
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -296,7 +301,7 @@ const restoreElement = (
let y: number | undefined = element.y;
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [point(0, 0), point(element.width, element.height)]
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -634,6 +639,7 @@ export const restoreAppState = (
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
editingFrame: null,
};
};
+51 -46
View File
@@ -2,7 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types";
import { point } from "../../math";
import { pointFrom } from "../../math";
const opts = { regenerateIds: false };
@@ -309,28 +309,32 @@ describe("Test Transform", () => {
});
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", () => {
const elementsSkeleton: 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",
},
...elements,
{
type: "frame",
children: ["1", "2"],
@@ -352,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[] = [
{
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",
},
...elements,
{
type: "frame",
children: ["1", "2"],
@@ -388,7 +373,27 @@ describe("Test Transform", () => {
);
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
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);
});
});
@@ -774,7 +779,7 @@ describe("Test Transform", () => {
elementId: "rect-1",
fixedPoint: null,
focus: 0,
gap: 205,
gap: 14,
});
expect(rect.boundElements).toStrictEqual([
{
@@ -912,7 +917,7 @@ describe("Test Transform", () => {
x: 111.262,
y: 57,
strokeWidth: 2,
points: [point(0, 0), point(272.985, 0)],
points: [pointFrom(0, 0), pointFrom(272.985, 0)],
label: {
text: "How are you?",
fontSize: 20,
@@ -935,7 +940,7 @@ describe("Test Transform", () => {
x: 77.017,
y: 79,
strokeWidth: 2,
points: [point(0, 0)],
points: [pointFrom(0, 0)],
label: {
text: "Friendship",
fontSize: 20,
+25 -8
View File
@@ -46,6 +46,7 @@ import {
assertNever,
cloneJSON,
getFontString,
isDevEnv,
toBrandedType,
} from "../utils";
import { getSizeFromPoints } from "../points";
@@ -53,7 +54,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
import { point, type LocalPoint } from "../../math";
import { pointFrom, type LocalPoint } from "../../math";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -536,7 +537,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({
width,
height,
points: [point(0, 0), point(width, height)],
points: [pointFrom(0, 0), pointFrom(width, height)],
...element,
});
@@ -549,7 +550,7 @@ export const convertToExcalidrawElements = (
width,
height,
endArrowhead: "arrow",
points: [point(0, 0), point(width, height)],
points: [pointFrom(0, 0), pointFrom(width, height)],
...element,
type: "arrow",
});
@@ -717,7 +718,7 @@ export const convertToExcalidrawElements = (
}
// 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.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame" && element.type !== "magicframe") {
@@ -764,10 +765,26 @@ export const convertToExcalidrawElements = (
maxX = maxX + PADDING;
maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher
const width = Math.max(frame?.width, maxX - minX);
const height = Math.max(frame?.height, maxY - minY);
Object.assign(frame, { x: minX, y: minY, width, height });
const frameX = frame?.x || minX;
const frameY = frame?.y || minY;
const frameWidth = frame?.width || maxX - minX;
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();
+2 -1
View File
@@ -25,6 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&amp;");
});
});
+1 -4
View File
@@ -1,8 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
import { sanitizeHTMLAttribute } from "../utils";
export const normalizeLink = (link: string) => {
link = link.trim();
+126 -46
View File
@@ -66,7 +66,7 @@ import {
import type { LocalPoint, Radians } from "../../math";
import {
lineSegment,
point,
pointFrom,
pointRotateRads,
type GlobalPoint,
vectorFromPoint,
@@ -97,6 +97,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
};
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = (
scene: Scene,
@@ -213,6 +215,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
@@ -223,7 +226,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
@@ -235,12 +238,14 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement,
edge as "start" | "end",
elementsMap,
zoom,
),
);
@@ -250,6 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0;
const endIdx = selectedElement.points.length - 1;
@@ -262,6 +268,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -270,6 +277,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
);
const end = endDragged
? isBindingEnabled
@@ -278,6 +286,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -286,6 +295,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
);
return [start, end];
@@ -296,10 +306,12 @@ const getBindingStrategyForDraggingArrowOrJoints = (
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement,
elementsMap,
zoom,
);
const start = startIsClose
? isBindingEnabled
@@ -308,6 +320,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"start",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -318,6 +331,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"end",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -332,6 +346,7 @@ export const bindOrUnbindLinearElements = (
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
const [start, end] = draggingPoints?.length
@@ -342,6 +357,7 @@ export const bindOrUnbindLinearElements = (
draggingPoints ?? [],
elementsMap,
elements,
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
@@ -349,6 +365,7 @@ export const bindOrUnbindLinearElements = (
elementsMap,
elements,
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
@@ -358,6 +375,7 @@ export const bindOrUnbindLinearElements = (
export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
@@ -368,7 +386,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements
.filter(isLinearElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -406,6 +424,7 @@ export const maybeBindLinearElement = (
pointerCoords,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement),
);
@@ -422,6 +441,26 @@ export const maybeBindLinearElement = (
}
};
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
@@ -433,11 +472,14 @@ export const bindLinearElement = (
}
const binding: PointBinding = {
elementId: hoveredElement.id,
...calculateFocusAndGap(
linearElement,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
startOrEnd,
elementsMap,
),
...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
@@ -462,6 +504,12 @@ export const bindLinearElement = (
}),
});
}
// update bound elements to make sure the binding tips are in sync with
// the normalized gap from above
if (!isElbowArrow(linearElement)) {
updateBoundElements(hoveredElement, elementsMap);
}
};
// Don't bind both ends of a simple segment
@@ -514,6 +562,7 @@ export const getHoveredElementForBinding = (
},
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
@@ -524,11 +573,13 @@ export const getHoveredElementForBinding = (
element,
pointerCoords,
elementsMap,
zoom,
// disable fullshape snapping for frame elements so we
// can bind to frame children
fullShape && !isFrameLikeElement(element),
),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -576,11 +627,13 @@ export const updateBoundElements = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
oldSize?: { width: number; height: number };
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
},
) => {
const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@@ -603,12 +656,12 @@ export const updateBoundElements = (
startBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.startBinding,
oldSize,
newSize,
),
endBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.endBinding,
oldSize,
newSize,
),
};
@@ -670,6 +723,7 @@ export const updateBoundElements = (
},
{
changedElements,
zoom,
},
);
@@ -703,6 +757,7 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -714,13 +769,14 @@ export const getHeadingForElbowArrowSnap = (
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) {
return vectorToHeading(
vectorFromPoint(
p,
point<GlobalPoint>(
pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
),
@@ -737,6 +793,7 @@ const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(
bindableElement,
@@ -747,6 +804,7 @@ const getDistanceForBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
return distance > bindDistance ? null : distance;
@@ -766,15 +824,15 @@ export const bindPointToSnapToElementOutline = (
const intersections = [
...(intersectElementWithLine(
bindableElement,
point(p[0], p[1] - 2 * bindableElement.height),
point(p[0], p[1] + 2 * bindableElement.height),
pointFrom(p[0], p[1] - 2 * bindableElement.height),
pointFrom(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE,
elementsMap,
) ?? []),
...(intersectElementWithLine(
bindableElement,
point(p[0] - 2 * bindableElement.width, p[1]),
point(p[0] + 2 * bindableElement.width, p[1]),
pointFrom(p[0] - 2 * bindableElement.width, p[1]),
pointFrom(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE,
elementsMap,
) ?? []),
@@ -815,25 +873,25 @@ const headingToMidBindPoint = (
switch (true) {
case compareHeading(heading, HEADING_UP):
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return pointRotateRads(
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center,
bindableElement.angle,
);
default:
return pointRotateRads(
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center,
bindableElement.angle,
);
@@ -844,7 +902,7 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: GlobalPoint,
): GlobalPoint => {
const center = point<GlobalPoint>(
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
@@ -854,13 +912,13 @@ export const avoidRectangularCorner = (
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return pointRotateRads<GlobalPoint>(
point(element.x - FIXED_BINDING_DISTANCE, element.y),
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
}
return pointRotateRads(
point(element.x, element.y - FIXED_BINDING_DISTANCE),
pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
@@ -871,13 +929,16 @@ export const avoidRectangularCorner = (
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return pointRotateRads(
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
pointFrom(
element.x,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
center,
element.angle,
);
}
return pointRotateRads(
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center,
element.angle,
);
@@ -891,7 +952,7 @@ export const avoidRectangularCorner = (
element.width + FIXED_BINDING_DISTANCE
) {
return pointRotateRads(
point(
pointFrom(
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
@@ -900,7 +961,7 @@ export const avoidRectangularCorner = (
);
}
return pointRotateRads(
point(
pointFrom(
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
),
@@ -917,13 +978,16 @@ export const avoidRectangularCorner = (
element.width + FIXED_BINDING_DISTANCE
) {
return pointRotateRads(
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
pointFrom(
element.x + element.width,
element.y - FIXED_BINDING_DISTANCE,
),
center,
element.angle,
);
}
return pointRotateRads(
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
@@ -938,7 +1002,10 @@ export const snapToMid = (
tolerance: number = 0.05,
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
const center = pointFrom<GlobalPoint>(
x + width / 2 - 0.1,
y + height / 2 - 0.1,
);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
@@ -953,7 +1020,7 @@ export const snapToMid = (
) {
// LEFT
return pointRotateRads(
point(x - FIXED_BINDING_DISTANCE, center[1]),
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
@@ -964,7 +1031,7 @@ export const snapToMid = (
) {
// TOP
return pointRotateRads(
point(center[0], y - FIXED_BINDING_DISTANCE),
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
@@ -975,7 +1042,7 @@ export const snapToMid = (
) {
// RIGHT
return pointRotateRads(
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
@@ -986,7 +1053,7 @@ export const snapToMid = (
) {
// DOWN
return pointRotateRads(
point(center[0], y + height + FIXED_BINDING_DISTANCE),
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
center,
angle,
);
@@ -1023,11 +1090,11 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = point<GlobalPoint>(
const globalMidPoint = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const global = point<GlobalPoint>(
const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
);
@@ -1118,7 +1185,7 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement,
elementsMap,
);
const globalMidPoint = point(
const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
@@ -1165,11 +1232,13 @@ const getElligibleElementForBindingElement = (
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements,
elementsMap,
zoom,
);
};
@@ -1332,14 +1401,16 @@ export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height);
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape(point(x, y), shape, threshold) ||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(point(x, y), aabbForElement(element)))
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
);
};
@@ -1347,12 +1418,21 @@ export const maxBindingGap = (
element: ExcalidrawElement,
elementWidth: number,
elementHeight: number,
zoom?: AppState["zoom"],
): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
// We make the bindable boundary bigger for bigger elements
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
return Math.max(
16,
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
);
};
export const distanceToBindableElement = (
@@ -2197,11 +2277,11 @@ export const getGlobalFixedPointForBindableElement = (
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return pointRotateRads(
point(
pointFrom(
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
point<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
),
@@ -2229,7 +2309,7 @@ const getGlobalFixedPoints = (
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
)
: point<GlobalPoint>(
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1],
);
@@ -2239,7 +2319,7 @@ const getGlobalFixedPoints = (
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
)
: point<GlobalPoint>(
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
);
+4 -4
View File
@@ -1,5 +1,5 @@
import type { LocalPoint } from "../../math";
import { point } from "../../math";
import { pointFrom } from "../../math";
import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -125,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416,
}),
points: [
point<LocalPoint>(0, 0),
point<LocalPoint>(67.33984375, 92.48828125),
point<LocalPoint>(-102.7890625, 52.15625),
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(67.33984375, 92.48828125),
pointFrom<LocalPoint>(-102.7890625, 52.15625),
],
} as ExcalidrawLinearElement;
+65 -46
View File
@@ -34,7 +34,7 @@ import type {
import {
degreesToRadians,
lineSegment,
point,
pointFrom,
pointDistance,
pointFromArray,
pointRotateRads,
@@ -113,8 +113,8 @@ export class ElementBounds {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
pointRotateRads(
point(x, y),
point(cx - element.x, cy - element.y),
pointFrom(x, y),
pointFrom(cx - element.x, cy - element.y),
element.angle,
),
),
@@ -130,23 +130,23 @@ export class ElementBounds {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads(
point(cx, y1),
point(cx, cy),
pointFrom(cx, y1),
pointFrom(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(cx, y2),
point(cx, cy),
pointFrom(cx, y2),
pointFrom(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x1, cy),
point(cx, cy),
pointFrom(x1, cy),
pointFrom(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, cy),
point(cx, cy),
pointFrom(x2, cy),
pointFrom(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
@@ -164,23 +164,23 @@ export class ElementBounds {
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = pointRotateRads(
point(x1, y1),
point(cx, cy),
pointFrom(x1, y1),
pointFrom(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(x1, y2),
point(cx, cy),
pointFrom(x1, y2),
pointFrom(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x2, y2),
point(cx, cy),
pointFrom(x2, y2),
pointFrom(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, y1),
point(cx, cy),
pointFrom(x2, y1),
pointFrom(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
@@ -255,7 +255,7 @@ export const getElementLineSegments = (
elementsMap,
);
const center: GlobalPoint = point(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = [];
@@ -266,7 +266,7 @@ export const getElementLineSegments = (
segments.push(
lineSegment(
pointRotateRads(
point(
pointFrom(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
@@ -274,7 +274,7 @@ export const getElementLineSegments = (
element.angle,
),
pointRotateRads(
point(
pointFrom(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
@@ -470,7 +470,7 @@ export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => {
let currentP: GlobalPoint = point(0, 0);
let currentP: GlobalPoint = pointFrom(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => {
@@ -484,9 +484,9 @@ export const getMinMaxXYFromCurvePathOps = (
// move operation does not draw anything; so, it always
// returns false
} else if (op === "bcurveTo") {
const _p1 = point<GlobalPoint>(data[0], data[1]);
const _p2 = point<GlobalPoint>(data[2], data[3]);
const _p3 = point<GlobalPoint>(data[4], data[5]);
const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(_p2) : _p2;
@@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default:
return 15;
}
@@ -591,21 +595,21 @@ export const getArrowheadPoints = (
invariant(data.length === 6, "Op data length is not 6");
const p3 = point(data[4], data[5]);
const p2 = point(data[2], data[3]);
const p1 = point(data[0], data[1]);
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.
// It is typically the last point of the previous
// curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1];
let p0 = point(0, 0);
let p0 = pointFrom(0, 0);
if (prevOp.op === "move") {
const p = pointFromArray(prevOp.data);
invariant(p != null, "Op data is not a point");
p0 = p;
} else if (prevOp.op === "bcurveTo") {
p0 = point(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
@@ -669,15 +673,30 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(angle),
);
return [xs, ys, x3, y3, x4, y4];
}
// Return points
const [x3, y3] = pointRotateRads(
point(xs, ys),
point(x2, y2),
pointFrom(xs, ys),
pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
point(xs, ys),
point(x2, y2),
pointFrom(xs, ys),
pointFrom(x2, y2),
degreesToRadians(angle),
);
@@ -690,8 +709,8 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
point(x2 + minSize * 2, y2),
point(x2, y2),
pointFrom(x2 + minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
);
} else {
@@ -701,8 +720,8 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
point(x2 - minSize * 2, y2),
point(x2, y2),
pointFrom(x2 - minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
);
}
@@ -746,8 +765,8 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = pointRotateRads(
point(element.x + pointX, element.y + pointY),
point(cx, cy),
pointFrom(element.x + pointX, element.y + pointY),
pointFrom(cx, cy),
element.angle,
);
@@ -775,8 +794,8 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>(
point(element.x + x, element.y + y),
point(cx, cy),
pointFrom(element.x + x, element.y + y),
pointFrom(cx, cy),
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
@@ -931,8 +950,8 @@ export const getClosestElementBounds = (
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = pointDistance(
point((x1 + x2) / 2, (y1 + y2) / 2),
point(from.x, from.y),
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
pointFrom(from.x, from.y),
);
if (distance < minDistance) {
@@ -990,7 +1009,7 @@ export const getVisibleSceneBounds = ({
};
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
point(
pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
+11 -7
View File
@@ -17,7 +17,7 @@ import {
} from "./typeChecks";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, point } from "../../math";
import { isPointWithinBounds, pointFrom } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@@ -61,13 +61,13 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape(point(x, y), shape) ||
isPointOnShape(point(x, y), shape, threshold)
: isPointOnShape(point(x, y), shape, threshold);
isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape(point(x, y), {
hit = isPointInShape(pointFrom(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon<Point>,
@@ -89,7 +89,11 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
return isPointWithinBounds(
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
};
export const hitElementBoundingBoxOnly = <
@@ -115,5 +119,5 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
y: number,
textShape: GeometricShape<Point> | null,
): boolean => {
return !!textShape && isPointInShape(point(x, y), textShape);
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
};
+625
View File
@@ -0,0 +1,625 @@
import { type Point } from "points-on-curve";
import {
type Radians,
pointFrom,
pointCenter,
pointRotateRads,
vectorFromPoint,
vectorNormalize,
vectorSubtract,
vectorAdd,
vectorScale,
pointFromVector,
clamp,
isCloseTo,
} from "../../math";
import type { TransformHandleType } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawImageElement,
ImageCrop,
NonDeleted,
} from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
export const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
transformHandle: TransformHandleType,
naturalWidth: number,
naturalHeight: number,
pointerX: number,
pointerY: number,
widthAspectRatio?: number,
) => {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
/**
* uncropped width
* **
* | (x,y) (natural) |
* | ** |
* | |///////| height | uncropped height
* | ** |
* | width (natural) |
* **
*/
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
-element.angle as Radians,
);
pointerX = rotatedPointer[0];
pointerY = rotatedPointer[1];
let nextWidth = element.width;
let nextHeight = element.height;
let crop: ImageCrop | null = element.crop ?? {
x: 0,
y: 0,
width: naturalWidth,
height: naturalHeight,
naturalWidth,
naturalHeight,
};
const previousCropHeight = crop.height;
const previousCropWidth = crop.width;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let changeInHeight = pointerY - element.y;
let changeInWidth = pointerX - element.x;
if (transformHandle.includes("n")) {
nextHeight = clamp(
element.height - changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
);
}
if (transformHandle.includes("s")) {
changeInHeight = pointerY - element.y - element.height;
nextHeight = clamp(
element.height + changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
);
}
if (transformHandle.includes("e")) {
changeInWidth = pointerX - element.x - element.width;
nextWidth = clamp(
element.width + changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
);
}
if (transformHandle.includes("w")) {
nextWidth = clamp(
element.width - changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
);
}
const updateCropWidthAndHeight = (crop: ImageCrop) => {
crop.height = nextHeight * naturalHeightToUncropped;
crop.width = nextWidth * naturalWidthToUncropped;
};
updateCropWidthAndHeight(crop);
const adjustFlipForHandle = (
handle: TransformHandleType,
crop: ImageCrop,
) => {
updateCropWidthAndHeight(crop);
if (handle.includes("n")) {
if (!isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("s")) {
if (isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("e")) {
if (isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
if (handle.includes("w")) {
if (!isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
};
switch (transformHandle) {
case "n": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "s": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "w": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "e": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "ne": {
if (widthAspectRatio) {
if (changeInWidth > -changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "nw": {
if (widthAspectRatio) {
if (changeInWidth < changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "se": {
if (widthAspectRatio) {
if (changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "sw": {
if (widthAspectRatio) {
if (-changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
default:
break;
}
const newOrigin = recomputeOrigin(
element,
transformHandle,
nextWidth,
nextHeight,
!!widthAspectRatio,
);
// reset crop to null if we're back to orig size
if (
isCloseTo(crop.width, crop.naturalWidth) &&
isCloseTo(crop.height, crop.naturalHeight)
) {
crop = null;
}
return {
x: newOrigin[0],
y: newOrigin[1],
width: nextWidth,
height: nextHeight,
crop,
};
};
const recomputeOrigin = (
stateAtCropStart: NonDeleted<ExcalidrawElement>,
transformHandle: TransformHandleType,
width: number,
height: number,
shouldMaintainAspectRatio?: boolean,
) => {
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtCropStart,
stateAtCropStart.width,
stateAtCropStart.height,
true,
);
const startTopLeft = pointFrom(x1, y1);
const startBottomRight = pointFrom(x2, y2);
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandle)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandle === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandle === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandle)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandle)) {
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// adjust topLeft to new rotation point
const angle = stateAtCropStart.angle;
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
return newOrigin;
};
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
export const getUncroppedImageElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
) => {
if (element.crop) {
const { width, height } = getUncroppedWidthAndHeight(element);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const topLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
);
const topRightVector = vectorFromPoint(
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
);
const topEdgeNormalized = vectorNormalize(
vectorSubtract(topRightVector, topLeftVector),
);
const bottomLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
);
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
const rotatedTopLeft = vectorAdd(
vectorAdd(
topLeftVector,
vectorScale(
topEdgeNormalized,
(-cropX * width) / element.crop.naturalWidth,
),
),
vectorScale(
leftEdgeNormalized,
(-cropY * height) / element.crop.naturalHeight,
),
);
const center = pointFromVector(
vectorAdd(
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
vectorScale(leftEdgeNormalized, height / 2),
),
);
const unrotatedTopLeft = pointRotateRads(
pointFromVector(rotatedTopLeft),
center,
-element.angle as Radians,
);
const uncroppedElement: ExcalidrawImageElement = {
...element,
x: unrotatedTopLeft[0],
y: unrotatedTopLeft[1],
width,
height,
crop: null,
};
return uncroppedElement;
}
return element;
};
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
if (element.crop) {
const width =
element.width / (element.crop.width / element.crop.naturalWidth);
const height =
element.height / (element.crop.height / element.crop.naturalHeight);
return {
width,
height,
};
}
return {
width: element.width,
height: element.height,
};
};
const adjustCropPosition = (
crop: ImageCrop,
scale: ExcalidrawImageElement["scale"],
) => {
let cropX = crop.x;
let cropY = crop.y;
const flipX = scale[0] === -1;
const flipY = scale[1] === -1;
if (flipX) {
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
}
if (flipY) {
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
}
return {
cropX,
cropY,
};
};
export const getFlipAdjustedCropPosition = (
element: ExcalidrawImageElement,
natural = false,
) => {
const crop = element.crop;
if (!crop) {
return null;
}
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let cropX = crop.x;
let cropY = crop.y;
if (isFlippedByX) {
cropX = crop.naturalWidth - crop.width - crop.x;
}
if (isFlippedByY) {
cropY = crop.naturalHeight - crop.height - crop.y;
}
if (natural) {
return {
x: cropX,
y: cropY,
};
}
const { width, height } = getUncroppedWidthAndHeight(element);
return {
x: cropX / (crop.naturalWidth / width),
y: cropY / (crop.naturalHeight / height),
};
};
@@ -16,6 +16,7 @@ import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import { getFontString } from "../utils";
@@ -251,6 +252,14 @@ export const dragNewElement = ({
}
if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
imageInitialDimension = {
initialWidth: width,
initialHeight: height,
};
}
mutateElement(
newElement,
{
@@ -259,6 +268,7 @@ export const dragNewElement = ({
width,
height,
...textAutoResize,
...imageInitialDimension,
},
informMutation,
);
+102
View File
@@ -0,0 +1,102 @@
/**
* Create and link between shapes.
*/
import { ELEMENT_LINK_KEY } from "../constants";
import { normalizeLink } from "../data/url";
import { elementsAreInSameGroup } from "../groups";
import type { AppProps, AppState } from "../types";
import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude<
AppProps["generateLinkForSelection"],
undefined
> = (id, type) => {
const url = window.location.href;
try {
const link = new URL(url);
link.searchParams.set(ELEMENT_LINK_KEY, id);
return normalizeLink(link.toString());
} catch (error) {
console.error(error);
}
return normalizeLink(url);
};
export const getLinkIdAndTypeFromSelection = (
selectedElements: ExcalidrawElement[],
appState: AppState,
): {
id: string;
type: "element" | "group";
} | null => {
if (
selectedElements.length > 0 &&
canCreateLinkFromElements(selectedElements)
) {
if (selectedElements.length === 1) {
return {
id: selectedElements[0].id,
type: "element",
};
}
if (selectedElements.length > 1) {
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
if (selectedGroupId) {
return {
id: selectedGroupId,
type: "group",
};
}
return {
id: selectedElements[0].groupIds[0],
type: "group",
};
}
}
return null;
};
export const canCreateLinkFromElements = (
selectedElements: ExcalidrawElement[],
) => {
if (selectedElements.length === 1) {
return true;
}
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
return true;
}
return false;
};
export const isElementLink = (url: string) => {
try {
const _url = new URL(url);
return (
_url.searchParams.has(ELEMENT_LINK_KEY) &&
_url.host === window.location.host
);
} catch (error) {
return false;
}
};
export const parseElementLinkFromURL = (url: string) => {
try {
const { searchParams } = new URL(url);
if (searchParams.has(ELEMENT_LINK_KEY)) {
const id = searchParams.get(ELEMENT_LINK_KEY);
return id;
}
} catch {}
return null;
};
+37 -3
View File
@@ -1,17 +1,20 @@
import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils";
import {
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { wrapText } from "./textElement";
import { wrapText } from "./textWrapping";
import { isIframeElement } from "./typeChecks";
import type {
ExcalidrawElement,
ExcalidrawIframeLikeElement,
IframeData,
} from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import type { MarkRequired } from "../utility-types";
import { StoreAction } from "../store";
@@ -45,6 +48,12 @@ const RE_GENERIC_EMBED =
const RE_GIPHY =
/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([
"youtube.com",
"youtu.be",
@@ -59,6 +68,7 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com",
"val.town",
"giphy.com",
"reddit.com",
]);
const ALLOW_SAME_ORIGIN = new Set([
@@ -71,6 +81,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"x.com",
"*.simplepdf.eu",
"stackblitz.com",
"reddit.com",
]);
export const createSrcDoc = (body: string) => {
@@ -218,6 +229,24 @@ export const getEmbedLink = (
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)) {
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute(
@@ -361,6 +390,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
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);
if (gistMatch && gistMatch.length === 2) {
return gistMatch[1];
+2 -2
View File
@@ -29,7 +29,7 @@ import {
isFlowchartNodeElement,
} from "./typeChecks";
import { invariant } from "../utils";
import { point, type LocalPoint } from "../../math";
import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left";
@@ -421,7 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth,
points: [point(0, 0), point(endX, endY)],
points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true,
});
+9 -9
View File
@@ -6,7 +6,7 @@ import type {
Radians,
} from "../../math";
import {
point,
pointFrom,
pointRotateRads,
pointScaleFromOrigin,
radiansToDegrees,
@@ -82,7 +82,7 @@ export const headingForPointFromElement = <
const top = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y),
pointFrom(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@@ -91,7 +91,7 @@ export const headingForPointFromElement = <
);
const right = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width, element.y + element.height / 2),
pointFrom(element.x + element.width, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@@ -100,7 +100,7 @@ export const headingForPointFromElement = <
);
const bottom = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y + element.height),
pointFrom(element.x + element.width / 2, element.y + element.height),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@@ -109,7 +109,7 @@ export const headingForPointFromElement = <
);
const left = pointRotateRads(
pointScaleFromOrigin(
point(element.x, element.y + element.height / 2),
pointFrom(element.x, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@@ -133,22 +133,22 @@ export const headingForPointFromElement = <
}
const topLeft = pointScaleFromOrigin(
point(aabb[0], aabb[1]),
pointFrom(aabb[0], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const topRight = pointScaleFromOrigin(
point(aabb[2], aabb[1]),
pointFrom(aabb[2], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const bottomLeft = pointScaleFromOrigin(
point(aabb[0], aabb[3]),
pointFrom(aabb[0], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const bottomRight = pointScaleFromOrigin(
point(aabb[2], aabb[3]),
pointFrom(aabb[2], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
+28 -6
View File
@@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
return node?.nodeName.toLowerCase() === "svg";
};
export const normalizeSVG = async (SVGString: string) => {
export const normalizeSVG = (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
@@ -105,20 +105,42 @@ export const normalizeSVG = async (SVGString: string) => {
svg.setAttribute("xmlns", SVG_NS);
}
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
const viewBox = svg.getAttribute("viewBox");
let width = svg.getAttribute("width") || "50";
let height = svg.getAttribute("height") || "50";
let width = svg.getAttribute("width");
let height = svg.getAttribute("height");
// Do not use % or auto values for width/height
// to avoid scaling issues when rendering at different sizes/zoom levels
if (width?.includes("%") || width === "auto") {
width = null;
}
if (height?.includes("%") || height === "auto") {
height = null;
}
const viewBox = svg.getAttribute("viewBox");
if (!width || !height) {
width = width || "50";
height = height || "50";
if (viewBox) {
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
const match = viewBox.match(
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) {
[, width, height] = match;
}
}
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
// Make sure viewBox is set
if (!viewBox) {
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
return svg.outerHTML;
}
};
@@ -49,7 +49,7 @@ import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
pointCenter,
point,
pointFrom,
pointRotateRads,
pointsEqual,
vector,
@@ -108,7 +108,7 @@ export class LinearElementEditor {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!pointsEqual(element.points[0], point(0, 0))) {
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
}
@@ -287,7 +287,7 @@ export class LinearElementEditor {
element,
elementsMap,
referencePoint,
point(scenePointerX, scenePointerY),
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
@@ -296,7 +296,7 @@ export class LinearElementEditor {
[
{
index: selectedIndex,
point: point(
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
@@ -329,7 +329,7 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
: point(
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
@@ -448,6 +448,7 @@ export class LinearElementEditor {
),
elements,
elementsMap,
appState.zoom,
)
: null;
@@ -590,11 +591,11 @@ export class LinearElementEditor {
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
const distance = pointDistance(
point(
pointFrom(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
),
point(scenePointer.x, scenePointer.y),
pointFrom(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
@@ -606,8 +607,8 @@ export class LinearElementEditor {
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = pointDistance(
point(midPoints[index]![0], midPoints[index]![1]),
point(scenePointer.x, scenePointer.y),
pointFrom(midPoints[index]![0], midPoints[index]![1]),
pointFrom(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return midPoints[index];
@@ -626,8 +627,8 @@ export class LinearElementEditor {
zoom: AppState["zoom"],
) {
let distance = pointDistance(
point(startPoint[0], startPoint[1]),
point(endPoint[0], endPoint[1]),
pointFrom(startPoint[0], startPoint[1]),
pointFrom(endPoint[0], endPoint[1]),
);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
@@ -787,6 +788,7 @@ export class LinearElementEditor {
scenePointer,
elements,
elementsMap,
app.state.zoom,
),
};
@@ -829,11 +831,11 @@ export class LinearElementEditor {
const targetPoint =
clickedPointIndex > -1 &&
pointRotateRads(
point(
pointFrom(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
),
point(cx, cy),
pointFrom(cx, cy),
element.angle,
);
@@ -911,6 +913,7 @@ export class LinearElementEditor {
element,
[points.length - 1],
elementsMap,
app.state.zoom,
);
}
return {
@@ -928,11 +931,11 @@ export class LinearElementEditor {
element,
elementsMap,
lastCommittedPoint,
point(scenePointerX, scenePointerY),
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
newPoint = point(
newPoint = pointFrom(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
);
@@ -964,6 +967,7 @@ export class LinearElementEditor {
element,
[{ point: newPoint }],
elementsMap,
app.state.zoom,
);
}
return {
@@ -984,8 +988,8 @@ export class LinearElementEditor {
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
pointFrom(x + p[0], y + p[1]),
pointFrom(cx, cy),
element.angle,
);
}
@@ -1001,8 +1005,8 @@ export class LinearElementEditor {
return element.points.map((p) => {
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
pointFrom(x + p[0], y + p[1]),
pointFrom(cx, cy),
element.angle,
);
});
@@ -1025,8 +1029,12 @@ export class LinearElementEditor {
const { x, y } = element;
return p
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
: pointRotateRads(point(x, y), point(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(
@@ -1036,7 +1044,7 @@ export class LinearElementEditor {
): LocalPoint {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
return point(
return pointFrom(
absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y,
);
@@ -1046,11 +1054,11 @@ export class LinearElementEditor {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [x, y] = pointRotateRads(
point(absoluteCoords[0], absoluteCoords[1]),
point(cx, cy),
pointFrom(absoluteCoords[0], absoluteCoords[1]),
pointFrom(cx, cy),
-element.angle as Radians,
);
return point(x - element.x, y - element.y);
return pointFrom(x - element.x, y - element.y);
}
static getPointIndexUnderCursor(
@@ -1071,7 +1079,7 @@ export class LinearElementEditor {
while (--idx > -1) {
const p = pointHandles[idx];
if (
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
@@ -1093,12 +1101,12 @@ export class LinearElementEditor {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerOnGrid[0], pointerOnGrid[1]),
point(cx, cy),
pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
pointFrom(cx, cy),
-element.angle as Radians,
);
return point(rotatedX - element.x, rotatedY - element.y);
return pointFrom(rotatedX - element.x, rotatedY - element.y);
}
/**
@@ -1118,7 +1126,7 @@ export class LinearElementEditor {
return {
points: points.map((p) => {
return point(p[0] - offsetX, p[1] - offsetY);
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX,
y: element.y + offsetY,
@@ -1172,8 +1180,8 @@ export class LinearElementEditor {
}
acc.push(
nextPoint
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: point(p[0], p[1]),
? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: pointFrom(p[0], p[1]),
);
nextSelectedIndices.push(indexCursor + 1);
@@ -1194,7 +1202,7 @@ export class LinearElementEditor {
[
{
index: element.points.length - 1,
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
],
elementsMap,
@@ -1214,6 +1222,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
let offsetX = 0;
let offsetY = 0;
@@ -1235,7 +1244,9 @@ export class LinearElementEditor {
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
!acc.length
? pointFrom(0, 0)
: pointFrom(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
@@ -1254,6 +1265,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
const offsetX = 0;
const offsetY = 0;
@@ -1279,6 +1291,7 @@ export class LinearElementEditor {
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
const { points } = element;
@@ -1312,9 +1325,9 @@ export class LinearElementEditor {
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
}
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
});
LinearElementEditor._updatePoints(
@@ -1331,6 +1344,7 @@ export class LinearElementEditor {
false,
),
changedElements: options?.changedElements,
zoom: options?.zoom,
},
);
}
@@ -1368,8 +1382,8 @@ export class LinearElementEditor {
const origin = linearElementEditor.pointerDownState.origin!;
const dist = pointDistance(
point(origin.x, origin.y),
point(pointerCoords.x, pointerCoords.y),
pointFrom(origin.x, origin.y),
pointFrom(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
@@ -1445,6 +1459,7 @@ export class LinearElementEditor {
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
if (isElbowArrow(element)) {
@@ -1481,6 +1496,7 @@ export class LinearElementEditor {
bindings,
{
isDragging: options?.isDragging,
zoom: options?.zoom,
},
);
} else {
@@ -1493,8 +1509,8 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads(
point(offsetX, offsetY),
point(dX, dY),
pointFrom(offsetX, offsetY),
pointFrom(dX, dY),
element.angle,
);
mutateElement(element, {
@@ -1540,8 +1556,8 @@ export class LinearElementEditor {
);
return pointRotateRads(
point(width, height),
point(0, 0),
pointFrom(width, height),
pointFrom(0, 0),
-element.angle as Radians,
);
}
@@ -1611,36 +1627,36 @@ export class LinearElementEditor {
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = point(cx, cy);
const centerPoint = pointFrom(cx, cy);
const topLeftRotatedPoint = pointRotateRads(
point(x1, y1),
pointFrom(x1, y1),
centerPoint,
element.angle,
);
const topRightRotatedPoint = pointRotateRads(
point(x2, y1),
pointFrom(x2, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextTopLeft = pointRotateRads(
point(boundTextX1, boundTextY1),
pointFrom(boundTextX1, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextTopRight = pointRotateRads(
point(boundTextX2, boundTextY1),
pointFrom(boundTextX2, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomLeft = pointRotateRads(
point(boundTextX1, boundTextY2),
pointFrom(boundTextX1, boundTextY2),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = pointRotateRads(
point(boundTextX2, boundTextY2),
pointFrom(boundTextX2, boundTextY2),
centerPoint,
-element.angle as Radians,
);
@@ -5,7 +5,7 @@ import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math";
import { point } from "../../math";
import { pointFrom } from "../../math";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
@@ -38,7 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
});
const copy = duplicateElement(null, new Map(), element);

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