Compare commits

..

100 Commits

Author SHA1 Message Date
Daniel J. Geiger c93e2fa9ce Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-10-06 19:09:35 -05:00
Daniel J. Geiger 039562cd61 Fix test failures from May merge. 2024-07-26 20:03:02 -05:00
Daniel J. Geiger 629cd307fd Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-07-26 20:02:06 -05:00
Daniel J. Geiger 81e3dd5406 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-05-08 18:41:06 -05:00
Daniel J. Geiger 704bbd6e0f fix: Missed in merging 2024-02-18 18:53:25 -06:00
Daniel J. Geiger bff220e0f5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2024-02-18 18:52:00 -06:00
Daniel J. Geiger ce595ff18c chore: Clean up src/i18n.ts changes 2023-12-02 19:07:48 -06:00
Daniel J. Geiger 1dfadb4d26 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-12-02 14:50:21 -06:00
Daniel J. Geiger 3fb902f1d8 Resolve merge conflicts 2023-11-22 16:18:53 -06:00
DanielJGeiger 63a91a883f feat: Expose ActionManager.registerAction through ExcalidrawImperativeAPI (#6995)
* feat: Expose `ActionManager` through `ExcalidrawImperativeAPI`

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

`@excalidraw/extensions` mostly contained boilerplate and obscured the
main new features here: `ExcalidrawElement` subtypes and MathJax support.
2023-09-01 13:40:27 -05:00
Daniel J. Geiger e8a6053251 Revert "Add a semicolon."
This reverts commit 456433e8f0.
2023-08-24 11:11:11 -05:00
Daniel J. Geiger 456433e8f0 Add a semicolon. 2023-08-24 10:35:12 -05:00
Daniel J. Geiger 38e3a4e8e1 fix: Further patch AsciiMath to work with Vite in production mode also. 2023-08-24 10:16:04 -05:00
Daniel J. Geiger 27a8cda8fd fix: Patch AsciiMath to work with Vite.
Incorporates PR mathjax/MathJax-src#854 by @masx200.
2023-08-23 11:27:12 -05:00
Daniel J. Geiger dd5053149a @excalidraw/extensions: Fixes for Vite. 2023-08-22 16:18:28 -05:00
Daniel J. Geiger 40ec02b280 chore: Update @excalidraw/extensions configs. 2023-08-22 09:54:13 -05:00
Daniel J. Geiger b81aa19ff9 fix: Migrate @excalidraw/extensions environment variable names to Vite. 2023-08-22 08:51:01 -05:00
Daniel J. Geiger e4ddd08bb1 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-08-21 16:09:37 -05:00
Daniel J. Geiger 795176b256 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-06-15 14:36:09 -05:00
Daniel J. Geiger be057bde39 MathJax: Use $ as LaTeX delimiters. Fall back to \( and \) if detected. Interpret \$ as a text literal "$" sign. 2023-06-15 13:56:33 -05:00
Daniel J. Geiger 94f4b727bb Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-05-17 14:36:53 -05:00
Daniel J. Geiger 63698572db Subtypes: add another test. 2023-04-28 13:03:03 -05:00
Daniel J. Geiger ab3467973f fix: No more debounced refresh() for subtypes. 2023-04-28 09:47:03 -05:00
Daniel J. Geiger 91fe07d9c5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-26 16:43:42 -05:00
Daniel J. Geiger 28cc821047 Fix a merge lint issue 2023-04-24 15:29:55 -05:00
Daniel J. Geiger 7dc728a459 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-24 13:08:44 -05:00
Daniel J. Geiger 12c651af6d Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-20 18:52:45 -05:00
Daniel J. Geiger 9d0cafe10b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-14 18:34:08 -05:00
Daniel J. Geiger fb24221587 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-10 20:24:03 -05:00
Daniel J. Geiger ef347cc685 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-08 09:52:03 -05:00
Daniel J. Geiger 2d3b9e0c66 fix: Properly avoid concurrent invocations of loadMathJax(). 2023-03-18 09:52:44 -05:00
Daniel J. Geiger bdb0dd064b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-17 11:19:18 -05:00
Daniel J. Geiger b17ed4dc29 fix: Don't cache wrapped text before MathJax finishes loading. 2023-03-13 13:01:52 -05:00
Daniel J. Geiger b988f67759 fix: Better legibility when editing some math elements. 2023-03-13 12:57:33 -05:00
Daniel J. Geiger 089aaa8792 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-13 11:33:26 -05:00
Daniel J. Geiger 28261c4b29 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-06 09:06:55 -06:00
Daniel J. Geiger 3fbed86d3e Fixes for math element dimensions before/upon loading MathJax. 2023-02-27 15:32:36 -06:00
Daniel J. Geiger 38b3d90fa6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 15:32:15 -06:00
Daniel J. Geiger 82b597ab8b fix: Catch MathML errors and render the "ERR" block instead. 2023-02-27 14:19:06 -06:00
Daniel J. Geiger 4c939cefad Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 14:18:41 -06:00
Daniel J. Geiger 8f0d9f5230 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-19 16:02:22 -06:00
Daniel J. Geiger fcde0ac3de Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-02-07 21:10:26 -06:00
Daniel J. Geiger b07dfba4b8 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-03 17:49:32 -06:00
Daniel J. Geiger 1089cdb278 Refactor: Modify fewer components. 2023-02-01 21:25:04 -06:00
Daniel J. Geiger 7246a6b17a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-01 17:34:12 -06:00
Daniel J. Geiger 04a96caf78 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-31 15:26:03 -06:00
Daniel J. Geiger 14c6ea938a Refactor: Drop isActionName and convert getCustomActions to
`filterActions`.
2023-01-28 21:27:25 -06:00
Daniel J. Geiger 87aba3f619 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-28 18:44:03 -06:00
Daniel J. Geiger c8d4e8c421 Simplify custom Actions: universal Action predicates instead of
action-specific guards.
2023-01-27 13:23:40 -06:00
Daniel J. Geiger 512e506798 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-26 17:38:48 -06:00
Daniel J. Geiger b4e742bda0 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-25 16:54:14 -06:00
Daniel J. Geiger 5a3f4fd08f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-24 19:27:05 -06:00
Daniel J. Geiger 34515f2952 Fixes. 2023-01-24 19:09:07 -06:00
Daniel J. Geiger 08f430b3ac Fix tests. 2023-01-23 20:23:51 -06:00
Daniel J. Geiger 59e74f94e6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-17 15:12:39 -06:00
Daniel J. Geiger ddc393bd9d Make filtering of custom actions optional. 2023-01-08 19:30:01 -06:00
Daniel J. Geiger 9e5948ac28 Filter all context menu items (standard and custom) through
`isActionEnabled`.
2023-01-08 17:37:32 -06:00
Daniel J. Geiger f86d0f9102 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-08 17:06:23 -06:00
Daniel J. Geiger ace031e992 Update to latest Action changes. 2023-01-07 15:47:19 -06:00
Daniel J. Geiger 45faf7d58f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-07 11:58:15 -06:00
Daniel J. Geiger 8c558a0f33 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-05 11:36:23 -06:00
Daniel J. Geiger 65059cb166 Fix tests introduced by the arrow labels feature. 2023-01-02 13:30:11 -06:00
Daniel J. Geiger 9158e2d989 fix: Remove leftovers from a merge. 2023-01-02 12:48:53 -06:00
Daniel J. Geiger 12da1862a0 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-02 12:45:33 -06:00
Daniel J. Geiger 67fb3210ab fix: Correct existing subtypes test coverage; add test coverage for
subtype actions; and a subtype action fix.
2023-01-02 12:43:19 -06:00
Daniel J. Geiger 13d69d8cef Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2022-12-31 16:00:26 -06:00
Daniel J. Geiger 0f6ad916c0 fix: Cache SVGs separately for mixed-text and math-only modes. 2022-12-30 10:48:47 -06:00
Daniel J. Geiger 9ee2bf36cf Render LaTeX matrices correctly in math-only mode. 2022-12-30 10:26:46 -06:00
Daniel J. Geiger 86f5c2ebcf feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com 2022-12-27 15:11:52 -06:00
438 changed files with 7673 additions and 24214 deletions
+1 -12
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_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000 VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015 VITE_APP_AI_BACKEND=http://localhost:3015
@@ -37,14 +37,3 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint # Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true 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,18 +15,3 @@ 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_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_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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v3.0.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -7,7 +7,7 @@
<h4 align="center"> <h4 align="center">
<a href="https://excalidraw.com">Excalidraw Editor</a> | <a href="https://excalidraw.com">Excalidraw Editor</a> |
<a href="https://plus.excalidraw.com/blog">Blog</a> | <a href="https://blog.excalidraw.com">Blog</a> |
<a href="https://docs.excalidraw.com">Documentation</a> | <a href="https://docs.excalidraw.com">Documentation</a> |
<a href="https://plus.excalidraw.com">Excalidraw+</a> <a href="https://plus.excalidraw.com">Excalidraw+</a>
</h4> </h4>
@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img <img
src={require("@site/static/img/welcome-screen-overview.png").default} src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch hand-drawn like diagrams." alt="Excalidraw logo: Sketch handrawn like diagrams."
/> />
### Center ### Center
@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description | | Font Family | Description |
| ----------- | ---------------------- | | ----------- | ---------------------- |
| `Virgil` | The `Hand-drawn` font | | `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font | | `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font | | `Cascadia` | The `Code` Font |
@@ -3,32 +3,31 @@
All `props` are _optional_. All `props` are _optional_.
| Name | Type | Default | Description | | 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. | | [`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 | | [`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 | | [`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`. | | [`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. | | [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | \_ | This prop if passed gets triggered on pointer down events | | [`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. | | [`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 | | [`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. | | [`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. |
| [`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 | | [`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 | | [`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. | | [`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. | | [`viewModeEnabled`](#viewmodeenabled) | `boolean` | _ | This indicates if the app is in `view` mode. |
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | \_ | This indicates if the `zen` mode is enabled | | [`zenModeEnabled`](#zenmodeenabled) | `boolean` | _ | This indicates if the `zen` mode is enabled |
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | \_ | This indicates if the `grid` 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 | | [`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 | | [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component |
| [`name`](#name) | `string` | | Name of the drawing | | [`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) | | [`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. | | [`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. | | [`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 | | [`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 | | [`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>` | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
@@ -94,8 +93,9 @@ 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. This prop if passed will be triggered on pointer down events and has the below signature.
<pre> <pre>
(activeTool:{" "} (activeTool:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115">
{" "} {" "}
AppState["activeTool"] AppState["activeTool"]
@@ -143,14 +143,6 @@ 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. 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 ### 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()`. 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()`.
@@ -215,7 +207,8 @@ This prop indicates whether the shows the grid. When supplied, the value takes p
### libraryReturnUrl ### 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 ### theme
@@ -227,6 +220,7 @@ 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. 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 ### 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). 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).
@@ -235,5 +235,3 @@ root.render(React.createElement(App));
</TabItem> </TabItem>
</Tabs> </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", label: "Docs",
}, },
{ {
to: "https://plus.excalidraw.com/blog", to: "https://blog.excalidraw.com",
label: "Blog", label: "Blog",
position: "left", position: "left",
}, },
@@ -111,7 +111,7 @@ const config = {
items: [ items: [
{ {
label: "Blog", label: "Blog",
to: "https://plus.excalidraw.com/blog", to: "https://blog.excalidraw.com",
}, },
{ {
label: "GitHub", label: "GitHub",
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
# copied assets # copied assets
public/**/*.woff2 public/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public", "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005", "dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build", "build": "yarn build:workspace && next build",
"start": "next start -p 3006", "start": "next start -p 3006",
@@ -1,2 +1,2 @@
# copied assets # copied assets
public/**/*.woff2 public/*.woff2
@@ -13,7 +13,7 @@
}, },
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public", "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite", "start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build", "build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002" "build:preview": "yarn build && vite preview --port 5002"
+3 -14
View File
@@ -4,6 +4,7 @@ import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState"; import { getDefaultAppState } from "../packages/excalidraw/appState";
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary"; import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { useMathSubtype } from "../packages/excalidraw/element/subtypes/mathjax";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
@@ -126,8 +127,6 @@ import DebugCanvas, {
loadSavedDebugState, loadSavedDebugState,
} from "./components/DebugCanvas"; } from "./components/DebugCanvas";
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { isElementLink } from "../packages/excalidraw/element/elementLink";
polyfill(); polyfill();
@@ -357,6 +356,8 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] = const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>(); useCallbackRefState<ExcalidrawImperativeAPI>();
useMathSubtype(excalidrawAPI);
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -849,12 +850,6 @@ const ExcalidrawWrapper = () => {
</div> </div>
); );
}} }}
onLinkOpen={(element, event) => {
if (element.link && isElementLink(element.link)) {
event.preventDefault();
excalidrawAPI?.scrollToContent(element.link, { animate: true });
}
}}
> >
<AppMainMenu <AppMainMenu
onCollabDialogOpen={onCollabDialogOpen} onCollabDialogOpen={onCollabDialogOpen}
@@ -1133,12 +1128,6 @@ const ExcalidrawWrapper = () => {
}; };
const ExcalidrawApp = () => { const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}> <Provider unstable_createStore={() => appJotaiStore}>
@@ -1,222 +0,0 @@
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;
};
+2 -27
View File
@@ -1,7 +1,6 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import type { import type {
BinaryFileData,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
SocketId, SocketId,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
@@ -10,7 +9,6 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
@@ -159,7 +157,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
throw new AbortError(); throw new AbortError();
} }
const { savedFiles, erroredFiles } = await saveFilesToFirebase({ return saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`, prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({ files: await encodeFilesForUpload({
files: addedFiles, files: addedFiles,
@@ -167,29 +165,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
maxBytes: FILE_UPLOAD_MAX_BYTES, 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; this.excalidrawAPI = props.excalidrawAPI;
@@ -419,7 +394,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
.filter((element) => { .filter((element) => {
return ( return (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
!this.fileManager.isFileTracked(element.fileId) && !this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted && !element.isDeleted &&
(opts.forceFetchFiles (opts.forceFetchFiles
? element.status !== "pending" || ? element.status !== "pending" ||
+1 -1
View File
@@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
return ( return (
<a <a
className="encrypted-icon tooltip" className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption" href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={t("encrypted.link")} aria-label={t("encrypted.link")}
+21 -50
View File
@@ -16,26 +16,14 @@ import type {
BinaryFiles, BinaryFiles,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
type FileVersion = Required<BinaryFileData>["version"];
export class FileManager { export class FileManager {
/** files being fetched */ /** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>(); private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_fetch = new Map<
ExcalidrawImageElement["fileId"],
true
>();
/** files being saved */ /** files being saved */
private savingFiles = new Map< private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
ExcalidrawImageElement["fileId"],
FileVersion
>();
/* files already saved to persistent storage */ /* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>(); private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_save = new Map< private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
ExcalidrawImageElement["fileId"],
FileVersion
>();
private _getFiles; private _getFiles;
private _saveFiles; private _saveFiles;
@@ -49,8 +37,8 @@ export class FileManager {
erroredFiles: Map<FileId, true>; erroredFiles: Map<FileId, true>;
}>; }>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{ saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, BinaryFileData>; savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, BinaryFileData>; erroredFiles: Map<FileId, true>;
}>; }>;
}) { }) {
this._getFiles = getFiles; this._getFiles = getFiles;
@@ -58,28 +46,19 @@ export class FileManager {
} }
/** /**
* returns whether file is saved/errored, or being processed * returns whether file is already saved or being processed
*/ */
isFileTracked = (id: FileId) => { isFileHandled = (id: FileId) => {
return ( return (
this.savedFiles.has(id) || this.savedFiles.has(id) ||
this.savingFiles.has(id) ||
this.fetchingFiles.has(id) || this.fetchingFiles.has(id) ||
this.erroredFiles_fetch.has(id) || this.savingFiles.has(id) ||
this.erroredFiles_save.has(id) this.erroredFiles.has(id)
); );
}; };
isFileSavedOrBeingSaved = (file: BinaryFileData) => { isFileSaved = (id: FileId) => {
const fileVersion = this.getFileVersion(file); return this.savedFiles.has(id);
return (
this.savedFiles.get(file.id) === fileVersion ||
this.savingFiles.get(file.id) === fileVersion
);
};
getFileVersion = (file: BinaryFileData) => {
return file.version ?? 1;
}; };
saveFiles = async ({ saveFiles = async ({
@@ -92,16 +71,13 @@ export class FileManager {
const addedFiles: Map<FileId, BinaryFileData> = new Map(); const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) { for (const element of elements) {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if ( if (
fileData && isInitializedImageElement(element) &&
// NOTE if errored during save, won't retry due to this check files[element.fileId] &&
!this.isFileSavedOrBeingSaved(fileData) !this.isFileHandled(element.fileId)
) { ) {
addedFiles.set(element.fileId, files[element.fileId]); addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, this.getFileVersion(fileData)); this.savingFiles.set(element.fileId, true);
} }
} }
@@ -110,12 +86,8 @@ export class FileManager {
addedFiles, addedFiles,
}); });
for (const [fileId, fileData] of savedFiles) { for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, this.getFileVersion(fileData)); this.savedFiles.set(fileId, true);
}
for (const [fileId, fileData] of erroredFiles) {
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
} }
return { return {
@@ -149,10 +121,10 @@ export class FileManager {
const { loadedFiles, erroredFiles } = await this._getFiles(ids); const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) { for (const file of loadedFiles) {
this.savedFiles.set(file.id, this.getFileVersion(file)); this.savedFiles.set(file.id, true);
} }
for (const [fileId] of erroredFiles) { for (const [fileId] of erroredFiles) {
this.erroredFiles_fetch.set(fileId, true); this.erroredFiles.set(fileId, true);
} }
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
@@ -188,7 +160,7 @@ export class FileManager {
): element is InitializedExcalidrawImageElement => { ): element is InitializedExcalidrawImageElement => {
return ( return (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
this.savedFiles.has(element.fileId) && this.isFileSaved(element.fileId) &&
element.status === "pending" element.status === "pending"
); );
}; };
@@ -197,8 +169,7 @@ export class FileManager {
this.fetchingFiles.clear(); this.fetchingFiles.clear();
this.savingFiles.clear(); this.savingFiles.clear();
this.savedFiles.clear(); this.savedFiles.clear();
this.erroredFiles_fetch.clear(); this.erroredFiles.clear();
this.erroredFiles_save.clear();
} }
} }
+4 -4
View File
@@ -183,8 +183,8 @@ export class LocalData {
); );
}, },
async saveFiles({ addedFiles }) { async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, BinaryFileData>(); const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, BinaryFileData>(); const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag // before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed // optimistically. Hopefully nothing fails, and an IDB read executed
@@ -195,10 +195,10 @@ export class LocalData {
[...addedFiles].map(async ([id, fileData]) => { [...addedFiles].map(async ([id, fileData]) => {
try { try {
await set(id, fileData, filesStore); await set(id, fileData, filesStore);
savedFiles.set(id, fileData); savedFiles.set(id, true);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
erroredFiles.set(id, fileData); erroredFiles.set(id, true);
} }
}), }),
); );
+4 -4
View File
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
}) => { }) => {
const firebase = await loadFirebaseStorage(); const firebase = await loadFirebaseStorage();
const erroredFiles: FileId[] = []; const erroredFiles = new Map<FileId, true>();
const savedFiles: FileId[] = []; const savedFiles = new Map<FileId, true>();
await Promise.all( await Promise.all(
files.map(async ({ id, buffer }) => { files.map(async ({ id, buffer }) => {
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`, cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
}, },
); );
savedFiles.push(id); savedFiles.set(id, true);
} catch (error: any) { } catch (error: any) {
erroredFiles.push(id); erroredFiles.set(id, true);
} }
}), }),
); );
+12 -10
View File
@@ -54,7 +54,11 @@
content="https://excalidraw.com/og-image-3.png" content="https://excalidraw.com/og-image-3.png"
/> />
<link rel="canonical" href="https://excalidraw.com" /> <!-- 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."
/>
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled --> <!-- to minimize white flash on load when user has dark mode enabled -->
@@ -120,19 +124,19 @@
<!-- Following placeholder is replaced during the build step --> <!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS --> <!-- 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 { %> <% } else { %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = window.origin; window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<% } %> <% } %>
<!-- 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="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="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -214,7 +218,6 @@
</header> </header>
<div id="root"></div> <div id="root"></div>
<script type="module" src="index.tsx"></script> <script type="module" src="index.tsx"></script>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<!-- 100% privacy friendly analytics --> <!-- 100% privacy friendly analytics -->
<script> <script>
// need to load this script dynamically bcs. of iframe embed tracking // need to load this script dynamically bcs. of iframe embed tracking
@@ -247,6 +250,5 @@
} }
</script> </script>
<!-- end LEGACY GOOGLE ANALYTICS --> <!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body> </body>
</html> </html>
+7 -10
View File
@@ -23,20 +23,20 @@
] ]
}, },
"engines": { "engines": {
"node": "18.0.0 - 22.x.x" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"jotai": "1.13.1", "jotai": "1.13.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"socket.io-client": "4.7.2", "vite-plugin-html": "3.2.2",
"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"
}, },
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
@@ -49,8 +49,5 @@
"start:production": "yarn build && yarn serve", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o", "serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
} }
} }
-3
View File
@@ -29,9 +29,6 @@ interface ImportMetaEnv {
// Enable eslint in dev server // Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string; VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
VITE_APP_PLUS_LP: string; VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string; VITE_APP_PLUS_APP: string;
+198 -218
View File
@@ -5,237 +5,217 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
export default defineConfig(({ mode }) => { // To load .env.local variables
// To load .env variables const envVars = loadEnv("", `../`);
const envVars = loadEnv(mode, `../`); // https://vitejs.dev/config/
// https://vitejs.dev/config/ export default defineConfig({
return { server: {
server: { port: Number(envVars.VITE_APP_PORT || 3000),
port: Number(envVars.VITE_APP_PORT || 3000), // open the browser
// open the browser open: true,
open: true, },
}, // We need to specify the envDir since now there are no
// 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
//more located in parallel with the vite.config.ts file but in parent dir envDir: "../",
envDir: "../", build: {
build: { outDir: "build",
outDir: "build", rollupOptions: {
rollupOptions: { output: {
output: { assetFileNames(chunkInfo) {
assetFileNames(chunkInfo) { if (chunkInfo?.name?.endsWith(".woff2")) {
if (chunkInfo?.name?.endsWith(".woff2")) { // put on root so we are flexible about the CDN path
const family = chunkInfo.name.split("-")[0]; return "[name]-[hash][extname]";
return `fonts/${family}/[name][extname]`; }
}
return "assets/[name]-[hash][extname]"; return "assets/[name]-[hash][extname]";
}, },
// Creating separate chunk for locales except for en and percentages.json so they // Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with // can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load // 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. // 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) { manualChunks(id) {
if ( if (
id.includes("packages/excalidraw/locales") && id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null id.match(/en.json|percentages.json/) === null
) { ) {
const index = id.indexOf("locales/"); const index = id.indexOf("locales/");
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
},
}, },
}, },
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
}, },
plugins: [ sourcemap: true,
Sitemap({ // don't auto-inline small assets (i.e. fonts hosted on CDN)
hostname: "https://excalidraw.com", assetsInlineLimit: 0,
outDir: "build", },
changefreq: "monthly", plugins: [
// its static in public folder woff2BrowserPlugin(),
generateRobotsTxt: false, react(),
}), checker({
woff2BrowserPlugin(), typescript: true,
react(), eslint:
checker({ envVars.VITE_APP_ENABLE_ESLINT === "false"
typescript: true, ? undefined
eslint: : { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
envVars.VITE_APP_ENABLE_ESLINT === "false" overlay: {
? undefined initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' }, badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
overlay: { },
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false", }),
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem", svgrPlugin(),
}, ViteEjsPlugin(),
}), VitePWA({
svgrPlugin(), registerType: "autoUpdate",
ViteEjsPlugin(), devOptions: {
VitePWA({ /* set this flag to true to enable in Development mode */
registerType: "autoUpdate", enabled: false,
devOptions: { },
/* set this flag to true to enable in Development mode */
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
},
workbox: { workbox: {
// don't precache fonts, locales and separate chunks // Don't push fonts, locales and wasm to app precache
globIgnores: [ globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
"fonts.css", runtimeCaching: [
"**/locales/**", {
"service-worker.js", urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
"**/*.chunk-*.js", handler: "CacheFirst",
], options: {
runtimeCaching: [ cacheName: "fonts",
{ expiration: {
urlPattern: new RegExp(".+.woff2"), maxEntries: 50,
handler: "CacheFirst", maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
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: [ {
{ urlPattern: new RegExp("fonts.css"),
src: "/screenshots/virtual-whiteboard.png", handler: "StaleWhileRevalidate",
type: "image/png", options: {
sizes: "462x945", cacheName: "fonts",
expiration: {
maxEntries: 50,
},
}, },
{ },
src: "/screenshots/wireframe.png", {
type: "image/png", urlPattern: new RegExp("locales/[^/]+.js"),
sizes: "462x945", handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
}, },
{ },
src: "/screenshots/illustration.png", {
type: "image/png", urlPattern: new RegExp(".wasm-.+.js"),
sizes: "462x945", handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
}, },
{ },
src: "/screenshots/shapes.png", ],
type: "image/png", },
sizes: "462x945", 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"],
}, },
{ },
src: "/screenshots/collaboration.png", ],
type: "image/png", share_target: {
sizes: "462x945", action: "/web-share-target",
}, method: "POST",
{ enctype: "multipart/form-data",
src: "/screenshots/export.png", params: {
type: "image/png", files: [
sizes: "462x945", {
}, name: "file",
], accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
}, },
}), screenshots: [
createHtmlPlugin({ {
minify: true, src: "/screenshots/virtual-whiteboard.png",
}), type: "image/png",
], sizes: "462x945",
publicDir: "../public", },
}; {
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",
}); });
+13 -10
View File
@@ -32,7 +32,9 @@
"husky": "7.0.4", "husky": "7.0.4",
"jsdom": "22.1.0", "jsdom": "22.1.0",
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
"patch-package": "8.0.0",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"postinstall-postinstall": "2.1.0",
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
@@ -45,7 +47,7 @@
"vitest-canvas-mock": "0.3.3" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": "18.0.0 - 22.x.x" "node": "18.0.0 - 20.x.x"
}, },
"homepage": ".", "homepage": ".",
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
@@ -55,9 +57,16 @@
"build:app": "yarn --cwd ./excalidraw-app build:app", "build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version", "build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build", "build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview", "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",
"postinstall": "patch-package",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start", "start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production", "start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest", "test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -68,15 +77,9 @@
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch", "test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true", "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", "autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js", "prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js", "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: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", "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
+1 -2
View File
@@ -15,8 +15,6 @@ Please add the latest change on the top under the correct section.
### Features ### 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) - Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135) - `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
@@ -307,6 +305,7 @@ define: {
## 0.16.0 (2023-09-19) ## 0.16.0 (2023-09-19)
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText, measureTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@@ -31,7 +31,7 @@ import type {
} from "../element/types"; } from "../element/types";
import type { AppState } from "../types"; import type { AppState } from "../types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -51,11 +51,9 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) { if (boundTextElement) {
const { width, height } = measureText( const { width, height } = measureTextElement(boundTextElement, {
boundTextElement.originalText, text: boundTextElement.originalText,
getFontString(boundTextElement), });
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
+1 -2
View File
@@ -87,8 +87,7 @@ export const actionClearCanvas = register({
predicate: (elements, appState, props, app) => { predicate: (elements, appState, props, app) => {
return ( return (
!!app.props.UIOptions.canvasActions.clearCanvas && !!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled && !appState.viewModeEnabled
appState.openDialog?.name !== "elementLinkSelector"
); );
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@@ -147,32 +147,14 @@ export const actionCopyAsSvg = register({
name: app.getName(), name: app.getName(),
}, },
); );
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return { 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, storeAction: StoreAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -1,55 +0,0 @@
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,7 +161,6 @@ export const actionDeleteSelected = register({
element, element,
selectedPointsIndices, selectedPointsIndices,
elementsMap, elementsMap,
appState.zoom,
); );
return { return {
@@ -1,105 +0,0 @@
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,
});
+13 -9
View File
@@ -12,6 +12,7 @@ import { resizeMultipleElements } from "../element/resizeElements";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
isBindingEnabled, isBindingEnabled,
@@ -26,7 +27,6 @@ import {
} from "../element/typeChecks"; } from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -132,14 +132,19 @@ const flipElements = (
}); });
} }
const { midX, midY } = getCommonBoundingBox(selectedElements); const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, { resizeMultipleElements(
flipByX: flipDirection === "horizontal", elementsMap,
flipByY: flipDirection === "vertical", selectedElements,
shouldResizeFromCenter: true, elementsMap,
shouldMaintainAspectRatio: true, "nw",
}); true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
@@ -148,7 +153,6 @@ const flipElements = (
app.scene, app.scene,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
appState.zoom,
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history"; import type { History } from "../history";
import { HistoryChangedEvent } from "../history"; import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { KEYS, matchKey } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants"; import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types"; import type { SceneElementsMap } from "../element/types";
@@ -63,7 +63,9 @@ export const createUndoAction: ActionCreator = (history, store) => ({
), ),
), ),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -102,8 +104,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
), ),
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] &&
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -53,9 +53,6 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
ARROW_TYPE, ARROW_TYPE,
@@ -1408,65 +1405,59 @@ const getArrowheadOptions = (flip: boolean) => {
keyBinding: "w", keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />, 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", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />, icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e", keyBinding: "t",
}, },
{ {
value: "triangle_outline", value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"), text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r", keyBinding: null,
}, showInPicker: false,
{
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", value: "diamond",
text: t("labels.arrowhead_diamond"), text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d", keyBinding: null,
showInPicker: false,
}, },
{ {
value: "diamond_outline", value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"), text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f", keyBinding: null,
}, showInPicker: false,
{
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; ] as const;
}; };
@@ -1530,7 +1521,6 @@ export const actionChangeArrowhead = register({
appState.currentItemStartArrowhead, appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
label="arrowhead_end" label="arrowhead_end"
@@ -1547,7 +1537,6 @@ export const actionChangeArrowhead = register({
appState.currentItemEndArrowhead, appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1602,7 +1591,6 @@ export const actionChangeArrowType = register({
tupleToCoors(startGlobalPoint), tupleToCoors(startGlobalPoint),
elements, elements,
elementsMap, elementsMap,
appState.zoom,
true, true,
); );
const endHoveredElement = const endHoveredElement =
@@ -1611,7 +1599,6 @@ export const actionChangeArrowType = register({
tupleToCoors(endGlobalPoint), tupleToCoors(endGlobalPoint),
elements, elements,
elementsMap, elementsMap,
appState.zoom,
true, true,
); );
const startElement = startHoveredElement const startElement = startHoveredElement
-2
View File
@@ -88,5 +88,3 @@ export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";
+60 -8
View File
@@ -6,6 +6,7 @@ import type {
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource, ActionSource,
ActionPredicateFn,
} from "./types"; } from "./types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -45,6 +46,7 @@ const trackAction = (
export class ActionManager { export class ActionManager {
actions = {} as Record<ActionName, Action>; actions = {} as Record<ActionName, Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -72,6 +74,37 @@ export class ActionManager {
this.app = app; this.app = app;
} }
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
filterActions(
filter: ActionPredicateFn,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
},
): Action[] {
// For testing
if (this === undefined) {
return [];
}
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
const actions: Action[] = [];
for (const key in this.actions) {
const action = this.actions[key as ActionName];
if (filter(action, elements, appState, this.app, data)) {
actions.push(action);
}
}
return actions;
}
registerAction(action: Action) { registerAction(action: Action) {
this.actions[action.name] = action; this.actions[action.name] = action;
} }
@@ -88,7 +121,7 @@ export class ActionManager {
(action) => (action) =>
(action.name in canvasActions (action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions] ? canvasActions[action.name as keyof typeof canvasActions]
: true) && : this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
@@ -147,7 +180,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] && "PanelComponent" in this.actions[name] &&
(name in canvasActions (name in canvasActions
? canvasActions[name as keyof typeof canvasActions] ? canvasActions[name as keyof typeof canvasActions]
: true) : this.isActionEnabled(this.actions[name], { noPredicates: true }))
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
@@ -169,6 +202,7 @@ export class ActionManager {
return ( return (
<PanelComponent <PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()} elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
@@ -182,13 +216,31 @@ export class ActionManager {
return null; return null;
}; };
isActionEnabled = (action: Action) => { isActionEnabled = (
const elements = this.getElementsIncludingDeleted(); action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const data = opts?.data;
return ( if (
!action.predicate || !opts?.noPredicates &&
action.predicate(elements, appState, this.app.props, this.app) action.predicate &&
); !action.predicate(elements, appState, this.app.props, this.app, data)
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, this.app, data)) {
enabled = false;
}
});
return enabled;
}; };
} }
+13 -1
View File
@@ -2,11 +2,12 @@ import { isDarwin } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import type { SubtypeOf } from "../utility-types"; import type { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import type { ActionName } from "./types"; import type { ActionName, CustomActionName } from "./types";
export type ShortcutName = export type ShortcutName =
| SubtypeOf< | SubtypeOf<
ActionName, ActionName,
| CustomActionName
| "toggleTheme" | "toggleTheme"
| "loadScene" | "loadScene"
| "clearCanvas" | "clearCanvas"
@@ -23,6 +24,7 @@ export type ShortcutName =
| "sendToBack" | "sendToBack"
| "bringToFront" | "bringToFront"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg"
| "group" | "group"
| "ungroup" | "ungroup"
| "gridMode" | "gridMode"
@@ -53,6 +55,15 @@ export type ShortcutName =
| "commandPalette" | "commandPalette"
| "searchMenu"; | "searchMenu";
export const registerCustomShortcuts = (
shortcuts: Record<CustomActionName, string[]>,
) => {
for (const key in shortcuts) {
const shortcut = key as CustomActionName;
shortcutMap[shortcut] = shortcuts[shortcut];
}
};
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")], saveScene: [getShortcutKey("CtrlOrCmd+S")],
@@ -87,6 +98,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"), : getShortcutKey("CtrlOrCmd+Shift+]"),
], ],
copyAsPng: [getShortcutKey("Shift+Alt+C")], copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")], group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")], gridMode: [getShortcutKey("CtrlOrCmd+'")],
+21 -5
View File
@@ -10,6 +10,7 @@ import type {
BinaryFiles, BinaryFiles,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store"; import type { StoreActionType } from "../store";
export type ActionSource = export type ActionSource =
@@ -23,7 +24,10 @@ export type ActionSource =
export type ActionResult = export type ActionResult =
| { | {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null; appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null; files?: BinaryFiles | null;
storeAction: StoreActionType; storeAction: StoreActionType;
replaceFiles?: boolean; replaceFiles?: boolean;
@@ -37,10 +41,24 @@ type ActionFn = (
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
export const makeCustomActionName = (name: string) =>
`custom.${name}` as CustomActionName;
export type CustomActionName = `custom.${string}`;
export type ActionName = export type ActionName =
| CustomActionName
| "copy" | "copy"
| "cut" | "cut"
| "paste" | "paste"
@@ -134,10 +152,7 @@ export type ActionName =
| "commandPalette" | "commandPalette"
| "autoResize" | "autoResize"
| "elementStats" | "elementStats"
| "searchMenu" | "searchMenu";
| "copyElementLink"
| "linkToElement"
| "cropEditor";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -178,6 +193,7 @@ export interface Action {
appState: AppState, appState: AppState,
appProps: ExcalidrawProps, appProps: ExcalidrawProps,
app: AppClassProperties, app: AppClassProperties,
data?: Record<string, any>,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
+2 -6
View File
@@ -84,7 +84,6 @@ export const getDefaultAppState = (): Omit<
scrollX: 0, scrollX: 0,
scrollY: 0, scrollY: 0,
selectedElementIds: {}, selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
selectionElement: null, selectionElement: null,
@@ -117,8 +116,6 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
userToFollow: null, userToFollow: null,
followedBy: new Set(), followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [], searchMatches: [],
}; };
}; };
@@ -173,6 +170,8 @@ const APP_STATE_STORAGE_CONF = (<
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
activeSubtypes: { browser: true, export: false, server: false },
customData: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
@@ -211,7 +210,6 @@ const APP_STATE_STORAGE_CONF = (<
scrollX: { browser: true, export: false, server: false }, scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false, server: false },
selectedElementIds: { 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 }, selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: { selectedElementsAreBeingDragged: {
browser: false, browser: false,
@@ -241,8 +239,6 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false }, userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
}); });
+1 -32
View File
@@ -17,16 +17,13 @@ import {
hasBoundTextElement, hasBoundTextElement,
isBindableElement, isBindableElement,
isBoundToContainer, isBoundToContainer,
isImageElement,
isTextElement, isTextElement,
} from "./element/typeChecks"; } from "./element/typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
Ordered,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap, SceneElementsMap,
} from "./element/types"; } from "./element/types";
@@ -629,18 +626,6 @@ export class AppStateChange implements Change<AppState> {
); );
break; 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": case "editingGroupId":
const editingGroupId = nextAppState[key]; const editingGroupId = nextAppState[key];
@@ -771,7 +756,6 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds, selectedElementIds,
editingLinearElementId, editingLinearElementId,
selectedLinearElementId, selectedLinearElementId,
croppingElementId,
...standaloneProps ...standaloneProps
} = delta as ObservedAppState; } = delta as ObservedAppState;
@@ -795,10 +779,7 @@ export class AppStateChange implements Change<AppState> {
} }
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
ElementUpdate<Ordered<T>>,
"seed"
>;
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1235,18 +1216,6 @@ 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) { if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change // strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
+21 -1
View File
@@ -13,6 +13,8 @@ import {
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import type { NonDeletedExcalidrawElement } from "./element/types"; import type { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
import type { AppState } from "./types";
import { selectSubtype } from "./element/subtypes";
export type ChartElements = readonly NonDeletedExcalidrawElement[]; export type ChartElements = readonly NonDeletedExcalidrawElement[];
@@ -25,6 +27,8 @@ export interface Spreadsheet {
title: string | null; title: string | null;
labels: string[] | null; labels: string[] | null;
values: number[]; values: number[];
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
} }
export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
@@ -195,13 +199,17 @@ const chartXLabels = (
groupId: string, groupId: string,
backgroundColor: string, backgroundColor: string,
): ChartElements => { ): ChartElements => {
const custom = selectSubtype(spreadsheet, "text");
return ( return (
spreadsheet.labels?.map((label, index) => { spreadsheet.labels?.map((label, index) => {
return newTextElement({ return newTextElement({
groupIds: [groupId], groupIds: [groupId],
backgroundColor, backgroundColor,
...commonProps, ...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label, text:
label.length > 8 && custom.subtype === undefined
? `${label.slice(0, 5)}...`
: label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
@@ -209,6 +217,7 @@ const chartXLabels = (
fontSize: 16, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
...custom,
}); });
}) || [] }) || []
); );
@@ -229,6 +238,7 @@ const chartYLabels = (
y: y - BAR_GAP, y: y - BAR_GAP,
text: "0", text: "0",
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
const maxYLabel = newTextElement({ const maxYLabel = newTextElement({
@@ -239,6 +249,7 @@ const chartYLabels = (
y: y - BAR_HEIGHT - minYLabel.height / 2, y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(), text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
return [minYLabel, maxYLabel]; return [minYLabel, maxYLabel];
@@ -261,6 +272,7 @@ const chartLines = (
y, y,
width: chartWidth, width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
...selectSubtype(spreadsheet, "line"),
}); });
const yLine = newLinearElement({ const yLine = newLinearElement({
@@ -272,6 +284,7 @@ const chartLines = (
y, y,
height: chartHeight, height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
...selectSubtype(spreadsheet, "line"),
}); });
const maxLine = newLinearElement({ const maxLine = newLinearElement({
@@ -285,6 +298,7 @@ const chartLines = (
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
...selectSubtype(spreadsheet, "line"),
}); });
return [xLine, yLine, maxLine]; return [xLine, yLine, maxLine];
@@ -311,6 +325,7 @@ const chartBaseElements = (
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, roundness: null,
textAlign: "center", textAlign: "center",
...selectSubtype(spreadsheet, "text"),
}) })
: null; : null;
@@ -327,6 +342,7 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
...selectSubtype(spreadsheet, "rectangle"),
}) })
: null; : null;
@@ -359,6 +375,7 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP, y: y - barHeight - BAR_GAP,
width: BAR_WIDTH, width: BAR_WIDTH,
height: barHeight, height: barHeight,
...selectSubtype(spreadsheet, "rectangle"),
}); });
}); });
@@ -409,6 +426,7 @@ const chartTypeLine = (
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
points: points as any, points: points as any,
...selectSubtype(spreadsheet, "line"),
}); });
const dots = spreadsheet.values.map((value, index) => { const dots = spreadsheet.values.map((value, index) => {
@@ -425,6 +443,7 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2, y: y + cy - BAR_GAP * 2,
width: BAR_GAP, width: BAR_GAP,
height: BAR_GAP, height: BAR_GAP,
...selectSubtype(spreadsheet, "ellipse"),
}); });
}); });
@@ -442,6 +461,7 @@ const chartTypeLine = (
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(0, cy)], points: [pointFrom(0, 0), pointFrom(0, cy)],
...selectSubtype(spreadsheet, "line"),
}); });
}); });
+41 -66
View File
@@ -2,7 +2,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import type { BinaryFiles } from "./types"; import type { AppState, BinaryFiles } from "./types";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import { import {
@@ -18,8 +18,6 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -41,7 +39,7 @@ export interface ClipboardData {
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number]; type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEventTextData = type ParsedClipboardEvent =
| { type: "text"; value: string } | { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent }; | { type: "mixedContent"; value: PastedMixedContent };
@@ -77,7 +75,7 @@ export const createPasteEvent = ({
types, types,
files, files,
}: { }: {
types?: { [key in AllowedPasteMimeTypes]?: string | File }; types?: { [key in AllowedPasteMimeTypes]?: string };
files?: File[]; files?: File[];
}) => { }) => {
if (!types && !files) { if (!types && !files) {
@@ -90,11 +88,6 @@ export const createPasteEvent = ({
if (types) { if (types) {
for (const [type, value] of Object.entries(types)) { for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try { try {
event.clipboardData?.setData(type, value); event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
@@ -224,14 +217,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = ( const maybeParseHTMLPaste = (
event: ClipboardEvent, event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData(MIME_TYPES.html); const html = event.clipboardData?.getData("text/html");
if (!html) { if (!html) {
return null; return null;
} }
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, "text/html");
const content = parseHTMLTree(doc.body); const content = parseHTMLTree(doc.body);
@@ -245,44 +238,34 @@ const maybeParseHTMLPaste = (
return null; return null;
}; };
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => { export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {}; 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;
}
}
let clipboardItems: ClipboardItems; let clipboardItems: ClipboardItems;
try { try {
clipboardItems = await navigator.clipboard?.read(); clipboardItems = await navigator.clipboard?.read();
} catch (error: any) { } catch (error: any) {
try { if (error.name === "DataError") {
if (navigator.clipboard?.readText) { console.warn(
console.warn( `navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`, );
); return types;
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; throw error;
} }
@@ -293,20 +276,10 @@ export const readSystemClipboard = async () => {
continue; continue;
} }
try { try {
if (type === MIME_TYPES.text || type === MIME_TYPES.html) { types[type] = await (await item.getType(type)).text();
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) { } catch (error: any) {
console.warn( console.warn(
error instanceof ExcalidrawError `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
); );
} }
} }
@@ -323,10 +296,10 @@ export const readSystemClipboard = async () => {
/** /**
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEvent = async (
event: ClipboardEvent, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEvent> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
@@ -335,7 +308,7 @@ const parseClipboardEventTextData = async (
return { return {
type: "text", type: "text",
value: value:
event.clipboardData?.getData(MIME_TYPES.text) || event.clipboardData?.getData("text/plain") ||
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@@ -346,7 +319,7 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
const text = event.clipboardData?.getData(MIME_TYPES.text); const text = event.clipboardData?.getData("text/plain");
return { type: "text", value: (text || "").trim() }; return { type: "text", value: (text || "").trim() };
} catch { } catch {
@@ -355,16 +328,14 @@ const parseClipboardEventTextData = async (
}; };
/** /**
* Attempts to parse clipboard event. * Attempts to parse clipboard. Prefers system clipboard.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
appState?: AppState,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
event,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") { if (parsedEventData.type === "mixedContent") {
return { return {
@@ -379,6 +350,10 @@ export const parseClipboard = async (
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value); !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
if (spreadsheetResult) { if (spreadsheetResult) {
if ("spreadsheet" in spreadsheetResult) {
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
spreadsheetResult.spreadsheet.customData = appState?.customData;
}
return spreadsheetResult; return spreadsheetResult;
} }
} catch (error: any) { } catch (error: any) {
@@ -453,8 +428,8 @@ export const copyTextToSystemClipboard = async (
// (2) if fails and we have access to ClipboardEvent, use plain old setData() // (2) if fails and we have access to ClipboardEvent, use plain old setData()
try { try {
if (clipboardEvent) { if (clipboardEvent) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || ""); clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) { if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
throw new Error("Failed to setData on clipboardEvent"); throw new Error("Failed to setData on clipboardEvent");
} }
return; return;
+2 -7
View File
@@ -21,12 +21,12 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { SubtypeShapeActions } from "./Subtypes";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons"; import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow, isElbowArrow,
isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@@ -128,11 +128,6 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) && isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]); !isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -142,6 +137,7 @@ export const SelectedShapeActions = ({
{canChangeBackgroundColor(appState, targetElements) && ( {canChangeBackgroundColor(appState, targetElements) && (
<div>{renderAction("changeBackgroundColor")}</div> <div>{renderAction("changeBackgroundColor")}</div>
)} )}
<SubtypeShapeActions elements={targetElements} />
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(appState.activeTool.type) ||
@@ -251,7 +247,6 @@ export const SelectedShapeActions = ({
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")} {showLineEditorAction && renderAction("toggleLinearEditor")}
</div> </div>
</fieldset> </fieldset>
File diff suppressed because it is too large Load Diff
@@ -56,10 +56,6 @@ import { trackEvent } from "../../analytics";
import { useStable } from "../../hooks/useStable"; import { useStable } from "../../hooks/useStable";
import "./CommandPalette.scss"; import "./CommandPalette.scss";
import {
actionCopyElementLink,
actionLinkToElement,
} from "../../actions/actionElementLink";
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null); const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
@@ -283,10 +279,7 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize, actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize, actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor, actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink, actionLink,
actionCopyElementLink,
actionLinkToElement,
].map((action: Action) => ].map((action: Action) =>
actionToCommand( actionToCommand(
action, action,
@@ -1,4 +1,3 @@
import { flushSync } from "react-dom";
import { t } from "../i18n"; import { t } from "../i18n";
import type { DialogProps } from "./Dialog"; import type { DialogProps } from "./Dialog";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -44,14 +43,7 @@ const ConfirmDialog = (props: Props) => {
onClick={() => { onClick={() => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
// flush any pending updates synchronously, onCancel();
// 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(); container?.focus();
}} }}
/> />
@@ -60,14 +52,7 @@ const ConfirmDialog = (props: Props) => {
onClick={() => { onClick={() => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
// flush any pending updates synchronously, onConfirm();
// 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(); container?.focus();
}} }}
actionType="danger" actionType="danger"
@@ -1,87 +0,0 @@
@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;
}
}
}
}
@@ -1,174 +0,0 @@
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,6 +15,7 @@
top: var(--editor-container-padding); top: var(--editor-container-padding);
right: var(--editor-container-padding); right: var(--editor-container-padding);
bottom: var(--editor-container-padding); bottom: var(--editor-container-padding);
z-index: 2;
} }
.FixedSideContainer_side_top.zen-mode { .FixedSideContainer_side_top.zen-mode {
@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont, value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon, icon: FreedrawIcon,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
testId: "font-family-hand-drawn", testId: "font-family-handrawn",
}, },
{ {
value: FONT_FAMILY.Nunito, value: FONT_FAMILY.Nunito,
@@ -21,7 +21,6 @@ import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts"; import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types"; import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor { export interface FontDescriptor {
value: number; value: number;
@@ -63,14 +62,12 @@ export const FontPickerList = React.memo(
const allFonts = useMemo( const allFonts = useMemo(
() => () =>
Array.from(Fonts.registered.entries()) Array.from(Fonts.registered.entries())
.filter( .filter(([_, { metadata }]) => !metadata.serverSide)
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback, .map(([familyId, { metadata, fonts }]) => {
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: metadata.icon ?? FontFamilyNormalIcon, icon: metadata.icon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown", text: fonts[0].fontFace.family,
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -92,7 +89,7 @@ export const FontPickerList = React.memo(
); );
const sceneFamilies = useMemo( const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFamilies()), () => new Set(fonts.getSceneFontFamilies()),
// cache per selected font family, so hover re-render won't mess it up // cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily], [selectedFontFamily],
+1 -11
View File
@@ -22,7 +22,7 @@ const Header = () => (
</a> </a>
<a <a
className="HelpDialog__btn" className="HelpDialog__btn"
href="https://plus.excalidraw.com/blog" href="https://blog.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -222,16 +222,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]} ]}
isOr={false} 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("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
@@ -100,14 +100,6 @@ const getHints = ({
return t("hints.text_editing"); 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 (activeTool.type === "selection") {
if ( if (
appState.selectionElement && appState.selectionElement &&
+47 -13
View File
@@ -1,16 +1,19 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker { .picker {
padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: var(--shadow-island); // ˇˇ yeah, i dunno, open to suggestions here :D
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
} }
.picker-container button, .picker-container button,
@@ -52,16 +55,47 @@
padding: 0.25rem 0.28rem 0.35rem 0.25rem; padding: 0.25rem 0.28rem 0.35rem 0.25rem;
} }
.picker-content { .picker-triangle {
display: grid; width: 0;
grid-template-columns: repeat(4, auto); height: 0;
grid-gap: 0.5rem; position: relative;
border-radius: 4px; 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-collapsible { .picker-content {
font-size: 0.75rem; padding: 0.5rem;
padding: 0.5rem 0; display: grid;
grid-template-columns: repeat(3, auto);
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
} }
.picker-keybinding { .picker-keybinding {
+98 -131
View File
@@ -1,23 +1,10 @@
import React, { useEffect } from "react"; import React from "react";
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "./Popover";
import "./IconPicker.scss"; import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage, t } from "../i18n"; import { getLanguage } from "../i18n";
import clsx from "clsx"; 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>({ function Picker<T>({
options, options,
@@ -25,16 +12,30 @@ function Picker<T>({
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly Option<T>[]; options: {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const device = useDevice(); 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 handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = options.find(
@@ -43,19 +44,28 @@ function Picker<T>({
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
onChange(pressedOption.value); const index = options.indexOf(pressedOption);
(rGallery!.current!.children![index] as any).focus();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value); // Tab navigation cycle through options. If the user tabs
const nextIndex = event.shiftKey // away from the picker, close the picker. We need to use
? (options.length + index - 1) % options.length // a timeout here to let the stack clear before checking.
: (index + 1) % options.length; setTimeout(() => {
onChange(options[nextIndex].value); const active = rActiveItem.current;
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value); const index = Array.prototype.indexOf.call(
rGallery!.current!.children,
activeElement,
);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = options.length;
let nextIndex = index; let nextIndex = index;
@@ -63,26 +73,19 @@ function Picker<T>({
switch (event.key) { switch (event.key) {
// Select the next option // Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT: case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length; nextIndex = (index + 1) % length;
break; break;
}
// Select the previous option // Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT: case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
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: { case KEYS.ARROW_UP: {
nextIndex = nextIndex = (length + index - 1) % length;
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
break; break;
} }
} }
onChange(options[nextIndex].value); (rGallery.current!.children![nextIndex] as any).focus();
} }
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -94,29 +97,15 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom( return (
moreOptionsAtom, <div
jotaiScope, className={`picker`}
); role="dialog"
aria-modal="true"
const alwaysVisibleOptions = React.useMemo( aria-label={label}
() => options.slice(0, numberOfOptionsToAlwaysShow), onKeyDown={handleKeyDown}
[options, numberOfOptionsToAlwaysShow], >
); <div className="picker-content" ref={rGallery}>
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) => ( {options.map((option, i) => (
<button <button
type="button" type="button"
@@ -124,6 +113,7 @@ function Picker<T>({
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={`${option.text} ${
@@ -132,13 +122,16 @@ function Picker<T>({
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
ref={(ref) => { ref={(el) => {
if (value === option.value) { if (el && i === 0) {
// Use a timeout here to render focus properly rFirstItem.current = el;
setTimeout(() => {
ref?.focus();
}, 0);
} }
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}} }}
> >
{option.icon} {option.icon}
@@ -148,43 +141,7 @@ function Picker<T>({
</button> </button>
))} ))}
</div> </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>
); );
} }
@@ -194,7 +151,6 @@ export function IconPicker<T>({
options, options,
onChange, onChange,
group = "", group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
@@ -203,40 +159,51 @@ export function IconPicker<T>({
text: string; text: string;
icon: JSX.Element; icon: JSX.Element;
keyBinding: string | null; keyBinding: string | null;
showInPicker?: boolean;
}[]; }[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string; group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return ( return (
<div> <div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}> <button
<Popover.Trigger name={group}
name={group} type="button"
type="button" className={isActive ? "active" : ""}
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton} ref={rPickerButton}
className={isActive ? "active" : ""} >
> {options.find((option) => option.value === value)?.icon}
{options.find((option) => option.value === value)?.icon} </button>
</Popover.Trigger> <React.Suspense fallback="">
{isActive && ( {isActive ? (
<Picker <>
options={options} <Popover
value={value} onCloseRequest={(event) =>
label={label} event.target !== rPickerButton.current && setActive(false)
onChange={onChange} }
onClose={() => { {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
setActive(false); >
}} <Picker
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow} options={options.filter((opt) => opt.showInPicker !== false)}
/> value={value}
)} label={label}
</Popover.Root> onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
</div> </div>
); );
} }
+80 -97
View File
@@ -56,11 +56,11 @@ import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { SubtypeToggles } from "./Subtypes";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import ElementLinkDialog from "./ElementLinkDialog";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
@@ -85,7 +85,6 @@ interface LayerUIProps {
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean; isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@@ -143,7 +142,6 @@ const LayerUI = ({
children, children,
app, app,
isCollaborating, isCollaborating,
generateLinkForSelection,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@@ -235,8 +233,7 @@ const LayerUI = ({
const shouldShowStats = const shouldShowStats =
appState.stats.open && appState.stats.open &&
!appState.zenModeEnabled && !appState.zenModeEnabled &&
!appState.viewModeEnabled && !appState.viewModeEnabled;
appState.openDialog?.name !== "elementLinkSelector";
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
@@ -245,91 +242,91 @@ const LayerUI = ({
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && {!appState.viewModeEnabled && (
appState.openDialog?.name !== "elementLinkSelector" && ( <Section heading="shapes" className="shapes-section">
<Section heading="shapes" className="shapes-section"> {(heading: React.ReactNode) => (
{(heading: React.ReactNode) => ( <div style={{ position: "relative" }}>
<div style={{ position: "relative" }}> {renderWelcomeScreen && (
{renderWelcomeScreen && ( <tunnels.WelcomeScreenToolbarHintTunnel.Out />
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> )}
)} <Stack.Col gap={4} align="start">
<Stack.Col gap={4} align="start"> <Stack.Row
<Stack.Row gap={1}
gap={1} className={clsx("App-toolbar-container", {
className={clsx("App-toolbar-container", { "zen-mode": appState.zenModeEnabled,
})}
>
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<Island <HintViewer
padding={1} appState={appState}
className={clsx("App-toolbar", { isMobile={device.editor.isMobile}
"zen-mode": appState.zenModeEnabled, device={device}
})} app={app}
> />
<HintViewer {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} appState={appState}
isMobile={device.editor.isMobile} activeTool={appState.activeTool}
device={device} UIOptions={UIOptions}
app={app} app={app}
/> />
{heading} </Stack.Row>
<Stack.Row gap={1}> </Island>
<PenModeButton <SubtypeToggles />
zenModeEnabled={appState.zenModeEnabled} {isCollaborating && (
checked={appState.penMode} <Island
onChange={() => onPenModeToggle(null)} style={{
title={t("toolBar.penMode")} marginLeft: 8,
penDetected={appState.penDetected} alignSelf: "center",
/> height: "fit-content",
<LockButton }}
checked={appState.activeTool.locked} >
onChange={onLockToggle} <LaserPointerButton
title={t("toolBar.lock")} title={t("toolBar.laser")}
/> checked={
appState.activeTool.type === TOOL_TYPE.laser
<div className="App-toolbar__divider" /> }
onChange={() =>
<HandButton app.setActiveTool({ type: TOOL_TYPE.laser })
checked={isHandToolActive(appState)} }
onChange={() => onHandToolToggle()} isMobile
title={t("toolBar.hand")} />
isMobile
/>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island> </Island>
{isCollaborating && ( )}
<Island </Stack.Row>
style={{ </Stack.Col>
marginLeft: 8, </div>
alignSelf: "center", )}
height: "fit-content", </Section>
}} )}
>
<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 <div
className={clsx( className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
@@ -346,7 +343,6 @@ const LayerUI = ({
)} )}
{renderTopRightUI?.(device.editor.isMobile, appState)} {renderTopRightUI?.(device.editor.isMobile, appState)}
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked // hide button when sidebar docked
(!isSidebarDocked || (!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
@@ -477,19 +473,6 @@ const LayerUI = ({
/> />
)} )}
<ActiveConfirmDialog /> <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 /> <tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()} {renderImageExportDialog()}
{renderJSONExportDialog()} {renderJSONExportDialog()}
+7 -12
View File
@@ -24,6 +24,7 @@ import { PenModeButton } from "./PenModeButton";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { SubtypeToggles } from "./Subtypes";
type MobileMenuProps = { type MobileMenuProps = {
appState: UIAppState; appState: UIAppState;
@@ -89,12 +90,12 @@ export const MobileMenu = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<SubtypeToggles />
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && {!appState.viewModeEnabled && (
appState.openDialog?.name !== "elementLinkSelector" && ( <DefaultSidebarTriggerTunnel.Out />
<DefaultSidebarTriggerTunnel.Out /> )}
)}
<PenModeButton <PenModeButton
checked={appState.penMode} checked={appState.penMode}
onChange={() => onPenModeToggle(null)} onChange={() => onPenModeToggle(null)}
@@ -130,10 +131,7 @@ export const MobileMenu = ({
}; };
const renderAppToolbar = () => { const renderAppToolbar = () => {
if ( if (appState.viewModeEnabled) {
appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector"
) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<MainMenuTunnel.Out /> <MainMenuTunnel.Out />
@@ -158,9 +156,7 @@ export const MobileMenu = ({
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
{!appState.viewModeEnabled && {!appState.viewModeEnabled && renderToolbar()}
appState.openDialog?.name !== "elementLinkSelector" &&
renderToolbar()}
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
@@ -172,7 +168,6 @@ export const MobileMenu = ({
<Island padding={0}> <Island padding={0}>
{appState.openMenu === "shape" && {appState.openMenu === "shape" &&
!appState.viewModeEnabled && !appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
showSelectedShapeActions(appState, elements) ? ( showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions"> <Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions <SelectedShapeActions
@@ -3,7 +3,7 @@ import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts"; import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts"; import { renderSpreadsheet } from "../charts";
import type { ChartType } from "../element/types"; import type { ChartType, ElementsMap } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types"; import type { UIAppState } from "../types";
@@ -11,6 +11,12 @@ import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; import "./PasteChartDialog.scss";
import { ensureSubtypesLoaded } from "../element/subtypes";
import { isTextElement } from "../element";
import {
getContainerElement,
redrawTextBoundingBox,
} from "../element/textElement";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -26,44 +32,64 @@ const ChartPreviewBtn = (props: {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
if (!props.spreadsheet) {
return;
}
const elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
setChartElements(elements);
let svg: SVGSVGElement; let svg: SVGSVGElement;
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg( (async () => {
elements, let elements: ChartElements;
{ await ensureSubtypesLoaded(
exportBackground: false, props.spreadsheet?.activeSubtypes ?? [],
viewBackgroundColor: oc.white, () => {
}, if (!props.spreadsheet) {
null, // files return;
{ }
skipInliningFonts: true,
},
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) { elements = renderSpreadsheet(
(previewNode.parentNode as HTMLDivElement).focus(); props.chartType,
} props.spreadsheet,
0,
0,
);
const elementsMap = new Map() as ElementsMap;
for (const element of elements) {
if (!element.isDeleted) {
elementsMap.set(element.id, element);
}
}
elements.forEach(
(el) =>
isTextElement(el) &&
redrawTextBoundingBox(
el,
getContainerElement(el, elementsMap),
elementsMap,
),
);
setChartElements(elements);
},
).then(async () => {
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
});
})();
return () => {
previewNode.replaceChildren();
};
})(); })();
return () => {
previewNode.replaceChildren();
};
}, [props.spreadsheet, props.chartType, props.selected]); }, [props.spreadsheet, props.chartType, props.selected]);
return ( return (
@@ -294,7 +294,6 @@ export const SearchMenu = () => {
// as well as to handle events before App ones // as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, { return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true, capture: true,
passive: false,
}); });
}, [setAppState, stableState, app]); }, [setAppState, stableState, app]);
@@ -9,7 +9,6 @@ interface CollapsibleProps {
open: boolean; open: boolean;
openTrigger: () => void; openTrigger: () => void;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
const Collapsible = ({ const Collapsible = ({
@@ -17,7 +16,6 @@ const Collapsible = ({
open, open,
openTrigger, openTrigger,
children, children,
className,
}: CollapsibleProps) => { }: CollapsibleProps) => {
return ( return (
<> <>
@@ -28,7 +26,6 @@ const Collapsible = ({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}} }}
className={className}
onClick={openTrigger} onClick={openTrigger}
> >
{label} {label}
@@ -1,18 +1,10 @@
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { resizeSingleElement } from "../../element/resizeElements";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; 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 { interface DimensionDragInputProps {
property: "width" | "height"; property: "width" | "height";
@@ -31,124 +23,20 @@ const handleDimensionChange: DragInputCallbackType<
> = ({ > = ({
accumulatedChange, accumulatedChange,
originalElements, originalElements,
originalElementsMap,
shouldKeepAspectRatio, shouldKeepAspectRatio,
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
property, property,
originalAppState,
instantChange,
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
const latestElement = elementsMap.get(origElement.id); if (origElement) {
if (origElement && latestElement) {
const keepAspectRatio = const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height; 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) { if (nextValue !== undefined) {
const nextWidth = Math.max( const nextWidth = Math.max(
property === "width" property === "width"
@@ -167,17 +55,14 @@ const handleDimensionChange: DragInputCallbackType<
MIN_WIDTH_OR_HEIGHT, MIN_WIDTH_OR_HEIGHT,
); );
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{
shouldMaintainAspectRatio: keepAspectRatio,
},
); );
return; return;
@@ -214,17 +99,14 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{
shouldMaintainAspectRatio: keepAspectRatio,
},
); );
} }
}; };
@@ -235,25 +117,9 @@ const DimensionDragInput = ({
scene, scene,
appState, appState,
}: DimensionDragInputProps) => { }: DimensionDragInputProps) => {
let value = round(property === "width" ? element.width : element.height, 2); const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
if ( 100;
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 ( return (
<DragInput <DragInput
@@ -2,10 +2,7 @@ import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element"; import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding"; import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { import { rescalePointsInElement } from "../../element/resizeElements";
rescalePointsInElement,
resizeSingleElement,
} from "../../element/resizeElements";
import { import {
getBoundTextElement, getBoundTextElement,
handleBindTextResize, handleBindTextResize,
@@ -20,7 +17,7 @@ import type { AppState } from "../../types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math"; import { pointFrom, type GlobalPoint } from "../../../math";
@@ -72,6 +69,7 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false); mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
@@ -81,7 +79,7 @@ const resizeElementInGroup = (
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height }, oldSize: { width: oldWidth, height: oldHeight },
}); });
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -153,6 +151,7 @@ const handleDimensionChange: DragInputCallbackType<
property, property,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState); const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) { for (const atomicUnit of atomicUnits) {
@@ -225,17 +224,15 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, false,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{ false,
shouldInformMutation: false,
},
); );
} }
} }
@@ -328,17 +325,14 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeSingleElement( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
latestElement, false,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, elements,
property === "width" ? "e" : "s", scene,
{
shouldInformMutation: false,
},
); );
} }
} }
@@ -4,13 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { clamp, pointFrom, pointRotateRads, round } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math";
import { isImageElement } from "../../element/typeChecks";
import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -24,14 +18,12 @@ const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange, accumulatedChange,
instantChange,
originalElements, originalElements,
originalElementsMap, originalElementsMap,
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
property, property,
scene, scene,
originalAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
@@ -46,82 +38,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.angle, 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) { if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX; const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY; const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -181,22 +97,8 @@ const Position = ({
pointFrom(element.x + element.width / 2, element.y + element.height / 2), pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle, element.angle,
); );
let value = round(property === "x" ? topLeftX : topLeftY, 2); const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
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 ( return (
<StatsDragInput <StatsDragInput
+3 -39
View File
@@ -23,14 +23,12 @@ import Collapsible from "./Collapsible";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
import { isElbowArrow, isImageElement } from "../../element/typeChecks"; import { isElbowArrow } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid"; import CanvasGrid from "./CanvasGrid";
import clsx from "clsx"; import clsx from "clsx";
import "./Stats.scss"; import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping"; import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math";
interface StatsProps { interface StatsProps {
app: AppClassProperties; app: AppClassProperties;
@@ -130,13 +128,6 @@ export const StatsInner = memo(
const multipleElements = const multipleElements =
selectedElements.length > 1 ? selectedElements : null; selectedElements.length > 1 ? selectedElements : null;
const cropMode =
appState.croppingElementId && isImageElement(singleElement);
const unCroppedDimension = cropMode
? getUncroppedWidthAndHeight(singleElement)
: null;
const [sceneDimension, setSceneDimension] = useState<{ const [sceneDimension, setSceneDimension] = useState<{
width: number; width: number;
height: number; height: number;
@@ -253,34 +244,8 @@ export const StatsInner = memo(
<StatsRows> <StatsRows>
{singleElement && ( {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"> <StatsRow heading data-testid="stats-element-type">
{appState.croppingElementId {t(`element.${singleElement.type}`)}
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}
</StatsRow> </StatsRow>
<StatsRow> <StatsRow>
@@ -422,8 +387,7 @@ export const StatsInner = memo(
prev.selectedElements === next.selectedElements && prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels && prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled && prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep && prev.appState.gridStep === next.appState.gridStep
prev.appState.croppingElementId === next.appState.croppingElementId
); );
}, },
); );
+103 -3
View File
@@ -5,7 +5,17 @@ import {
updateBoundElements, updateBoundElements,
} from "../../element/binding"; } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { import {
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
@@ -24,6 +34,7 @@ import {
} from "../../groups"; } from "../../groups";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty = export type StatsInputProperty =
| "x" | "x"
@@ -110,6 +121,97 @@ 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 = ( export const moveElement = (
newTopLeftX: number, newTopLeftX: number,
newTopLeftY: number, newTopLeftY: number,
@@ -200,7 +302,6 @@ export const updateBindings = (
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
}, },
) => { ) => {
if (isLinearElement(latestElement)) { if (isLinearElement(latestElement)) {
@@ -211,7 +312,6 @@ export const updateBindings = (
scene, scene,
true, true,
[], [],
options?.zoom,
); );
} else { } else {
updateBoundElements(latestElement, elementsMap, options); updateBoundElements(latestElement, elementsMap, options);
+188
View File
@@ -0,0 +1,188 @@
import { getShortcutKey, updateActiveTool } from "../utils";
import { t } from "../i18n";
import type { Action } from "../actions/types";
import { makeCustomActionName } from "../actions/types";
import clsx from "clsx";
import type { Subtype, SubtypeRecord } from "../element/subtypes";
import {
getSubtypeNames,
hasAlwaysEnabledActions,
isSubtypeAction,
isValidSubtype,
subtypeCollides,
} from "../element/subtypes";
import type { ExcalidrawElement, Theme } from "../element/types";
import {
useExcalidrawActionManager,
useExcalidrawContainer,
useExcalidrawSetAppState,
} from "./App";
import type { ContextMenuItems } from "./ContextMenu";
import { Island } from "./Island";
export const SubtypeButton = (
subtype: Subtype,
parentType: SubtypeRecord["parents"][number],
icon: ({ theme }: { theme: Theme }) => JSX.Element,
key?: string,
) => {
const title = key !== undefined ? ` - ${getShortcutKey(key)}` : "";
const keyTest: Action["keyTest"] =
key !== undefined ? (event) => event.code === `Key${key}` : undefined;
const subtypeAction: Action = {
name: makeCustomActionName(subtype),
label: t(`toolBar.${subtype}`),
trackEvent: false,
predicate: (...rest) => rest[4]?.subtype === subtype,
perform: (elements, appState) => {
const inactive = !appState.activeSubtypes?.includes(subtype) ?? true;
const activeSubtypes: Subtype[] = [];
if (appState.activeSubtypes) {
activeSubtypes.push(...appState.activeSubtypes);
}
let activated = false;
if (inactive) {
// Ensure `element.subtype` is well-defined
if (!subtypeCollides(subtype, activeSubtypes)) {
activeSubtypes.push(subtype);
activated = true;
}
} else {
// Can only be active if appState.activeSubtypes is defined
// and contains subtype.
activeSubtypes.splice(activeSubtypes.indexOf(subtype), 1);
}
const type =
appState.activeTool.type !== "custom" &&
isValidSubtype(subtype, appState.activeTool.type)
? appState.activeTool.type
: parentType;
const activeTool = !inactive
? appState.activeTool
: updateActiveTool(appState, { type });
const selectedElementIds = activated ? {} : appState.selectedElementIds;
const selectedGroupIds = activated ? {} : appState.selectedGroupIds;
return {
appState: {
...appState,
activeSubtypes,
selectedElementIds,
selectedGroupIds,
activeTool,
},
storeAction: "capture",
};
},
keyTest,
PanelComponent: ({ elements, appState, updateData, data }) => (
<button
className={clsx("ToolIcon_type_button", "ToolIcon_type_button--show", {
ToolIcon: true,
"ToolIcon--selected":
appState.activeSubtypes !== undefined &&
appState.activeSubtypes.includes(subtype),
"ToolIcon--plain": true,
})}
title={`${t(`toolBar.${subtype}`)}${title}`}
aria-label={t(`toolBar.${subtype}`)}
onClick={() => {
updateData(null);
}}
onContextMenu={
data && "onContextMenu" in data
? (event: React.MouseEvent) => {
if (
appState.activeSubtypes === undefined ||
(appState.activeSubtypes !== undefined &&
!appState.activeSubtypes.includes(subtype))
) {
updateData(null);
}
data.onContextMenu(event, subtype);
}
: undefined
}
>
{
<div className="ToolIcon__icon" aria-hidden="true">
{icon.call(this, { theme: appState.theme })}
</div>
}
</button>
),
};
if (key === "") {
delete subtypeAction.keyTest;
}
return subtypeAction;
};
export const SubtypeToggles = () => {
const am = useExcalidrawActionManager();
const { container } = useExcalidrawContainer();
const setAppState = useExcalidrawSetAppState();
const onContextMenu = (
event: React.MouseEvent<HTMLButtonElement>,
subtype: string,
) => {
event.preventDefault();
const { top: offsetTop, left: offsetLeft } =
container!.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
const items: ContextMenuItems = [];
am.filterActions(isSubtypeAction).forEach(
(action) =>
am.isActionEnabled(action, { data: { subtype } }) && items.push(action),
);
setAppState({}, () => {
setAppState({
contextMenu: { top, left, items },
});
});
};
// Only render if one or more subtypes are registered
if (getSubtypeNames().length === 0) {
return <></>;
}
return (
<>
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
{getSubtypeNames().map((subtype) =>
am.renderAction(
makeCustomActionName(subtype),
hasAlwaysEnabledActions(subtype) ? { onContextMenu } : {},
),
)}
</Island>
</>
);
};
SubtypeToggles.displayName = "SubtypeToggles";
export const SubtypeShapeActions = (props: {
elements: readonly ExcalidrawElement[];
}) => {
const am = useExcalidrawActionManager();
return (
<>
{am
.filterActions(isSubtypeAction, { elements: props.elements })
.map((action) => am.renderAction(action.name))}
</>
);
};
SubtypeShapeActions.displayName = "SubtypeShapeActions";
@@ -182,7 +182,6 @@ const getRelevantAppStateProps = (
width: appState.width, width: appState.width,
height: appState.height, height: appState.height,
viewModeEnabled: appState.viewModeEnabled, viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement, editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
@@ -204,8 +203,6 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement, editingTextElement: appState.editingTextElement,
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches, searchMatches: appState.searchMatches,
}); });
@@ -92,8 +92,6 @@ const getRelevantAppStateProps = (
width: appState.width, width: appState.width,
height: appState.height, height: appState.height,
viewModeEnabled: appState.viewModeEnabled, viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
@@ -109,7 +107,6 @@ const getRelevantAppStateProps = (
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily, currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
}); });
const areEqual = ( const areEqual = (
@@ -13,7 +13,7 @@ import type {
} from "../../element/types"; } from "../../element/types";
import { ToolButton } from "../ToolButton"; import { ToolButton } from "../ToolButton";
import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons"; import { FreedrawIcon, TrashIcon } from "../icons";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { import {
useCallback, useCallback,
@@ -30,19 +30,18 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
import { getSelectedElements } from "../../scene"; import { getSelectedElements } from "../../scene";
import { hitElementBoundingBox } from "../../element/collision"; import { hitElementBoundingBox } from "../../element/collision";
import { isLocalLink, normalizeLink } from "../../data/url"; import { isLocalLink, normalizeLink } from "../../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { useAppProps, useDevice, useExcalidrawAppState } from "../App"; import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks"; import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers"; import { getLinkHandleFromCoords } from "./helpers";
import { pointFrom, type GlobalPoint } from "../../../math"; import { pointFrom, type GlobalPoint } from "../../../math";
import { isElementLink } from "../../element/elementLink";
import "./Hyperlink.scss"; const CONTAINER_WIDTH = 320;
const POPUP_WIDTH = 380;
const POPUP_HEIGHT = 42;
const POPUP_PADDING = 5;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
const CONTAINER_PADDING = 5;
const CONTAINER_HEIGHT = 42;
const AUTO_HIDE_TIMEOUT = 500; const AUTO_HIDE_TIMEOUT = 500;
let IS_HYPERLINK_TOOLTIP_VISIBLE = false; let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
@@ -74,7 +73,6 @@ export const Hyperlink = ({
}) => { }) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const appProps = useAppProps(); const appProps = useAppProps();
const device = useDevice();
const linkVal = element.link || ""; const linkVal = element.link || "";
@@ -172,15 +170,6 @@ export const Hyperlink = ({
useEffect(() => { useEffect(() => {
let timeoutId: number | null = null; let timeoutId: number | null = null;
if (
inputRef &&
inputRef.current &&
!(device.viewport.isMobile || device.isTouchScreen)
) {
inputRef.current.select();
}
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (isEditing) { if (isEditing) {
return; return;
@@ -207,21 +196,16 @@ export const Hyperlink = ({
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
}, [ }, [appState, element, isEditing, setAppState, elementsMap]);
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete"); trackEvent("hyperlink", "delete");
mutateElement(element, { link: null }); mutateElement(element, { link: null });
if (isEditing) {
inputRef.current!.value = "";
}
setAppState({ showHyperlinkPopup: false }); setAppState({ showHyperlinkPopup: false });
}, [setAppState, element]); }, [setAppState, element, isEditing]);
const onEdit = () => { const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui"); trackEvent("hyperlink", "edit", "popup-ui");
@@ -245,14 +229,19 @@ export const Hyperlink = ({
style={{ style={{
top: `${y}px`, top: `${y}px`,
left: `${x}px`, left: `${x}px`,
width: POPUP_WIDTH, width: CONTAINER_WIDTH,
padding: POPUP_PADDING, padding: CONTAINER_PADDING,
}}
onClick={() => {
if (!element.link && !isEditing) {
setAppState({ showHyperlinkPopup: "editor" });
}
}} }}
> >
{isEditing ? ( {isEditing ? (
<input <input
className={clsx("excalidraw-hyperlinkContainer-input")} className={clsx("excalidraw-hyperlinkContainer-input")}
placeholder={t("labels.link.hint")} placeholder="Type or paste your link here"
ref={inputRef} ref={inputRef}
value={inputVal} value={inputVal}
onChange={(event) => setInputVal(event.target.value)} onChange={(event) => setInputVal(event.target.value)}
@@ -313,21 +302,6 @@ export const Hyperlink = ({
icon={FreedrawIcon} 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) && ( {linkVal && !isEmbeddableElement(element) && (
<ToolButton <ToolButton
type="button" type="button"
@@ -354,7 +328,7 @@ const getCoordsForPopover = (
{ sceneX: x1 + element.width / 2, sceneY: y1 }, { sceneX: x1 + element.width / 2, sceneY: y1 },
appState, appState,
); );
const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2; const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
const y = viewportY - appState.offsetTop - SPACE_BOTTOM; const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
return { x, y }; return { x, y };
}; };
@@ -364,10 +338,12 @@ export const getContextMenuLabel = (
appState: UIAppState, appState: UIAppState,
) => { ) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const label = isEmbeddableElement(selectedElements[0]) const label = selectedElements[0]?.link
? "labels.link.editEmbed" ? isEmbeddableElement(selectedElements[0])
: selectedElements[0]?.link ? "labels.link.editEmbed"
? "labels.link.edit" : "labels.link.edit"
: isEmbeddableElement(selectedElements[0])
? "labels.link.createEmbed"
: "labels.link.create"; : "labels.link.create";
return label; return label;
}; };
@@ -400,9 +376,7 @@ const renderTooltip = (
tooltipDiv.classList.add("excalidraw-tooltip--visible"); tooltipDiv.classList.add("excalidraw-tooltip--visible");
tooltipDiv.style.maxWidth = "20rem"; tooltipDiv.style.maxWidth = "20rem";
tooltipDiv.textContent = isElementLink(element.link) tooltipDiv.textContent = element.link;
? t("labels.link.goToElement")
: element.link;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -476,9 +450,9 @@ const shouldHideLinkPopup = (
if ( if (
clientX >= popoverX - threshold && clientX >= popoverX - threshold &&
clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold && clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
clientY >= popoverY - threshold && clientY >= popoverY - threshold &&
clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
) { ) {
return false; return false;
} }
@@ -16,11 +16,6 @@ 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>`, `<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 = ( export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: Radians, angle: Radians,
-72
View File
@@ -1352,54 +1352,6 @@ 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( export const FontSizeSmallIcon = createIcon(
<> <>
<g clipPath="url(#a)"> <g clipPath="url(#a)">
@@ -2195,27 +2147,3 @@ export const upIcon = createIcon(
</g>, </g>,
tablerIconProps, 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,
);
+4 -39
View File
@@ -2,7 +2,6 @@ import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types"; import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
@@ -117,9 +116,6 @@ export const CLASSES = {
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", 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. * // 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.
* *
@@ -140,22 +136,6 @@ export const FONT_FAMILY = {
"Liberation Sans": 9, "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 = { export const THEME = {
LIGHT: "light", LIGHT: "light",
DARK: "dark", DARK: "dark",
@@ -177,6 +157,8 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25, nameLineHeight: 1.25,
}; };
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1; export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
@@ -214,9 +196,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = { export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
@@ -230,12 +212,6 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES, ...IMAGE_MIME_TYPES,
} as const; } 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 = { export const EXPORT_IMAGE_TYPES = {
png: "png", png: "png",
svg: "svg", svg: "svg",
@@ -255,14 +231,6 @@ export const EXPORT_SOURCE =
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; 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 TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
@@ -463,6 +431,3 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
round: "round", round: "round",
elbow: "elbow", elbow: "elbow",
}; };
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 33.519031369643244, "height": 35,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5, 0.5,
], ],
[ [
382.47606040672997, 394.5,
34.019031369643244, 34.5,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 381.97606040672997, "width": 395,
"x": 247, "x": 247,
"y": 420, "y": 420,
} }
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0, 0,
], ],
[ [
389.5, 399.5,
0, 0,
], ],
], ],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 390, "width": 400,
"x": 237, "x": 227,
"y": 450, "y": 450,
} }
`; `;
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 560, "x": 560,
"y": 236.95454545454544, "y": 226.5,
} }
`; `;
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": null, "fixedPoint": null,
"focus": 1.625925925925924, "focus": 0,
"gap": 14, "gap": 205,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 18.278619528619487, "height": 0,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [ "points": [
[ [
0.5, 0.5,
-0.5, 0,
], ],
[ [
357.2037037037038, 99.5,
-17.778619528619487, 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 357.7037037037038, "width": 100,
"x": 171, "x": 255,
"y": 249.45454545454544, "y": 239,
} }
`; `;
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0, 0,
], ],
[ [
270.98528125, 272.485,
0, 0,
], ],
], ],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 270.48528125, "width": 272.985,
"x": 112.76171875, "x": 111.262,
"y": 57, "y": 57,
} }
`; `;
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 0, "width": 0,
"x": 83.015625, "x": 77.017,
"y": 81.5, "y": 79,
} }
`; `;
@@ -2179,8 +2179,8 @@ LABELLED ARROW",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 140, "width": 150,
"x": 80, "x": 75,
"y": 275, "y": 275,
} }
`; `;
@@ -2221,8 +2221,8 @@ LABELLED ARROW",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 140, "width": 150,
"x": 80, "x": 75,
"y": 375, "y": 375,
} }
`; `;
@@ -2526,8 +2526,8 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 130,
"x": 539.7893218813452, "x": 534.7893218813452,
"y": 117.44796179957173, "y": 117.44796179957173,
} }
`; `;
@@ -2655,7 +2655,7 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 160, "width": 170,
"x": 505, "x": 505,
"y": 305, "y": 305,
} }
@@ -2698,8 +2698,8 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 130,
"x": 539.7893218813452, "x": 534.7893218813452,
"y": 522.5735931288071, "y": 522.5735931288071,
} }
`; `;
+6 -25
View File
@@ -5,18 +5,16 @@ import { clearElementsForExport } from "../element";
import type { ExcalidrawElement, FileId } from "../element/types"; import type { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors"; import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types"; import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils"; import { bytesToHexString, isPromiseLike } from "../utils";
import { base64ToString, stringToBase64, toByteString } from "./encode";
import type { FileSystemHandle } from "./filesystem"; import type { FileSystemHandle } from "./filesystem";
import { nativeFileSystemSupported } from "./filesystem"; import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json"; import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore"; import { restore, restoreLibraryItems } from "./restore";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File) => {
let contents: string; let contents: string;
if (blob.type === MIME_TYPES.png) { if (blob.type === MIME_TYPES.png) {
@@ -48,7 +46,9 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
} }
if (blob.type === MIME_TYPES.svg) { if (blob.type === MIME_TYPES.svg) {
try { try {
return decodeSvgBase64Payload({ return await (
await import("./image")
).decodeSvgMetadata({
svg: contents, svg: contents,
}); });
} catch (error: any) { } catch (error: any) {
@@ -107,15 +107,11 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg"; 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 = ( export const isSupportedImageFile = (
blob: Blob | null | undefined, blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => { ): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {}; const { type } = blob || {};
return isSupportedImageFileType(type); return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
}; };
export const loadSceneOrLibraryFromBlob = async ( export const loadSceneOrLibraryFromBlob = async (
@@ -253,7 +249,6 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
} }
}; };
/** async. For sync variant, use getDataURL_sync */
export const getDataURL = async (file: Blob | File): Promise<DataURL> => { export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -266,16 +261,6 @@ 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 = "") => { export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(","); const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1)); const byteString = atob(dataURL.slice(dataIndexStart + 1));
@@ -289,10 +274,6 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
return new File([ab], filename, { type: mimeType }); return new File([ab], filename, { type: mimeType });
}; };
export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
};
export const resizeImageFile = async ( export const resizeImageFile = async (
file: File, file: File,
opts: { opts: {
@@ -334,7 +315,7 @@ export const resizeImageFile = async (
} }
return new File( return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })], [await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
file.name, file.name,
{ {
type: opts.outputType || file.type, type: opts.outputType || file.type,
+27 -40
View File
@@ -5,23 +5,24 @@ import { encryptData, decryptData } from "./encryption";
// byte (binary) strings // byte (binary) strings
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Buffer-compatible implem. // fast, Buffer-compatible implem
// export const toByteString = (
// Note that in V8, spreading the uint8array (by chunks) into data: string | Uint8Array | ArrayBuffer,
// `String.fromCharCode(...uint8array)` tends to be faster for large ): Promise<string> => {
// strings/buffers, in case perf is needed in the future. return new Promise((resolve, reject) => {
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => { const blob =
const bytes = typeof data === "string"
typeof data === "string" ? new Blob([new TextEncoder().encode(data)])
? new TextEncoder().encode(data) : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
: data instanceof Uint8Array const reader = new FileReader();
? data reader.onload = (event) => {
: new Uint8Array(data); if (!event.target || typeof event.target.result !== "string") {
let bstring = ""; return reject(new Error("couldn't convert to byte string"));
for (const byte of bytes) { }
bstring += String.fromCharCode(byte); resolve(event.target.result);
} };
return bstring; reader.readAsBinaryString(blob);
});
}; };
const byteStringToArrayBuffer = (byteString: string) => { const byteStringToArrayBuffer = (byteString: string) => {
@@ -45,12 +46,12 @@ const byteStringToString = (byteString: string) => {
* @param isByteString set to true if already byte string to prevent bloat * @param isByteString set to true if already byte string to prevent bloat
* due to reencoding * due to reencoding
*/ */
export const stringToBase64 = (str: string, isByteString = false) => { export const stringToBase64 = async (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(toByteString(str)); return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
}; };
// async to align with stringToBase64 // async to align with stringToBase64
export const base64ToString = (base64: string, isByteString = false) => { export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString return isByteString
? window.atob(base64) ? window.atob(base64)
: byteStringToString(window.atob(base64)); : byteStringToString(window.atob(base64));
@@ -65,20 +66,6 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
return byteStringToArrayBuffer(atob(base64)); 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 // text encoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -95,18 +82,18 @@ type EncodedData = {
/** /**
* Encodes (and potentially compresses via zlib) text to byte string * Encodes (and potentially compresses via zlib) text to byte string
*/ */
export const encode = ({ export const encode = async ({
text, text,
compress, compress,
}: { }: {
text: string; text: string;
/** defaults to `true`. If compression fails, falls back to bstring alone. */ /** defaults to `true`. If compression fails, falls back to bstring alone. */
compress?: boolean; compress?: boolean;
}): EncodedData => { }): Promise<EncodedData> => {
let deflated!: string; let deflated!: string;
if (compress !== false) { if (compress !== false) {
try { try {
deflated = toByteString(deflate(text)); deflated = await toByteString(deflate(text));
} catch (error: any) { } catch (error: any) {
console.error("encode: cannot deflate", error); console.error("encode: cannot deflate", error);
} }
@@ -115,11 +102,11 @@ export const encode = ({
version: "1", version: "1",
encoding: "bstring", encoding: "bstring",
compressed: !!deflated, compressed: !!deflated,
encoded: deflated || toByteString(text), encoded: deflated || (await toByteString(text)),
}; };
}; };
export const decode = (data: EncodedData): string => { export const decode = async (data: EncodedData): Promise<string> => {
let decoded: string; let decoded: string;
switch (data.encoding) { switch (data.encoding) {
@@ -127,7 +114,7 @@ export const decode = (data: EncodedData): string => {
// if compressed, do not double decode the bstring // if compressed, do not double decode the bstring
decoded = data.compressed decoded = data.compressed
? data.encoded ? data.encoded
: byteStringToString(data.encoded); : await byteStringToString(data.encoded);
break; break;
default: default:
throw new Error(`decode: unknown encoding "${data.encoding}"`); throw new Error(`decode: unknown encoding "${data.encoding}"`);
+1 -3
View File
@@ -82,7 +82,6 @@ export const fileSave = (
name: string; name: string;
/** file extension */ /** file extension */
extension: FILE_EXTENSION; extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string; description: string;
/** existing FileSystemHandle */ /** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
@@ -94,11 +93,10 @@ export const fileSave = (
fileName: `${opts.name}.${opts.extension}`, fileName: `${opts.name}.${opts.extension}`,
description: opts.description, description: opts.description,
extensions: [`.${opts.extension}`], extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
}, },
opts.fileHandle, opts.fileHandle,
); );
}; };
export { nativeFileSystemSupported };
export type { FileSystemHandle }; export type { FileSystemHandle };
export { nativeFileSystemSupported };
+56 -3
View File
@@ -1,7 +1,7 @@
import decodePng from "png-chunks-extract"; import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text"; import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode"; import encodePng from "png-chunks-encode";
import { encode, decode } from "./encode"; import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob"; import { blobToArrayBuffer } from "./blob";
@@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
const metadataChunk = tEXt.encode( const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw, MIME_TYPES.excalidraw,
JSON.stringify( JSON.stringify(
encode({ await encode({
text: metadata, text: metadata,
compress: true, compress: true,
}), }),
@@ -59,7 +59,60 @@ export const decodePngMetadata = async (blob: Blob) => {
} }
throw new Error("FAILED"); throw new Error("FAILED");
} }
return decode(encodedData); 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);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
throw new Error("FAILED"); throw new Error("FAILED");
+5 -6
View File
@@ -5,7 +5,6 @@ import {
import { import {
DEFAULT_EXPORT_PADDING, DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME, DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
} from "../constants"; } from "../constants";
@@ -16,9 +15,8 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { getElementsOverlappingFrame } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected, getSelectedElements } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export"; import { exportToCanvas, exportToSvg } from "../scene/export";
import type { ExportType } from "../scene/types"; import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
@@ -27,6 +25,7 @@ import { canvasToBlob } from "./blob";
import type { FileSystemHandle } from "./filesystem"; import type { FileSystemHandle } from "./filesystem";
import { fileSave } from "./filesystem"; import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
import { getElementsOverlappingFrame } from "../frame";
export { loadFromBlob } from "./blob"; export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json"; export { loadFromJSON, saveAsJSON } from "./json";
@@ -131,7 +130,6 @@ export const exportCanvas = async (
description: "Export to SVG", description: "Export to SVG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle, fileHandle,
}, },
); );
@@ -170,8 +168,9 @@ export const exportCanvas = async (
return fileSave(blob, { return fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png", // FIXME reintroduce `excalidraw.png` when most people upgrade away
mimeTypes: [IMAGE_MIME_TYPES.png], // from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
+2 -43
View File
@@ -35,9 +35,6 @@ import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter"; import { Emitter } from "../emitter";
import { Queue } from "../queue"; import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -470,28 +467,6 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; 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 = () => { export const parseLibraryTokensFromUrl = () => {
const libraryUrl = const libraryUrl =
// current // current
@@ -633,11 +608,6 @@ const persistLibraryUpdate = async (
export const useHandleLibrary = ( export const useHandleLibrary = (
opts: { opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null; 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 */ /** @deprecated we recommend using `opts.adapter` instead */
@@ -680,13 +650,7 @@ export const useHandleLibrary = (
}) => { }) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => { const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try { try {
libraryUrl = decodeURIComponent(libraryUrl); const request = await fetch(decodeURIComponent(libraryUrl));
libraryUrl = toValidURL(libraryUrl);
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
const request = await fetch(libraryUrl);
const blob = await request.blob(); const blob = await request.blob();
resolve(blob); resolve(blob);
} catch (error: any) { } catch (error: any) {
@@ -714,12 +678,7 @@ export const useHandleLibrary = (
defaultStatus: "published", defaultStatus: "published",
openLibraryMenu: true, openLibraryMenu: true,
}); });
} catch (error: any) { } catch (error) {
excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
throw error; throw error;
} finally { } finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
+11 -7
View File
@@ -121,7 +121,8 @@ const repairBinding = (
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & { T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -184,16 +185,15 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element || "customData" in extra) { if ("customData" in element || "customData" in extra) {
base.customData = base.customData =
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
} }
return { return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
@@ -262,7 +262,6 @@ const restoreElement = (
status: element.status || "pending", status: element.status || "pending",
fileId: element.fileId, fileId: element.fileId,
scale: element.scale || [1, 1], scale: element.scale || [1, 1],
crop: element.crop ?? null,
}); });
case "line": case "line":
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
@@ -602,6 +601,12 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
if ("activeSubtypes" in appState) {
nextAppState.activeSubtypes = appState.activeSubtypes;
}
if ("customData" in appState) {
nextAppState.customData = appState.customData;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
@@ -639,7 +644,6 @@ export const restoreAppState = (
gridStep: getNormalizedGridStep( gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP, isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
), ),
editingFrame: null,
}; };
}; };
+1 -1
View File
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
elementId: "rect-1", elementId: "rect-1",
fixedPoint: null, fixedPoint: null,
focus: 0, focus: 0,
gap: 14, gap: 205,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {
+1 -2
View File
@@ -25,7 +25,6 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://"); expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); 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>");
expect(normalizeLink("test&")).toBe("test&amp;");
}); });
}); });
+4 -1
View File
@@ -1,5 +1,8 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();
+13 -84
View File
@@ -97,8 +97,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
}; };
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@@ -215,7 +213,6 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end", edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => { ): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId = const elementId =
@@ -226,7 +223,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const element = elementsMap.get(elementId); const element = elementsMap.get(elementId);
if ( if (
isBindableElement(element) && isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom) bindingBorderTest(element, coors, elementsMap)
) { ) {
return element; return element;
} }
@@ -238,14 +235,12 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => ["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge( getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement, linearElement,
edge as "start" | "end", edge as "start" | "end",
elementsMap, elementsMap,
zoom,
), ),
); );
@@ -255,7 +250,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
draggingPoints: readonly number[], draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0; const startIdx = 0;
const endIdx = selectedElement.points.length - 1; const endIdx = selectedElement.points.length - 1;
@@ -268,7 +262,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start", "start",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null // If binding is disabled and start is dragged, break all binds : 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 : // We have to update the focus and gap of the binding, so let's rebind
@@ -277,7 +270,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start", "start",
elementsMap, elementsMap,
elements, elements,
zoom,
); );
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
@@ -286,7 +278,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end", "end",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null // If binding is disabled and end is dragged, break all binds : 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 : // We have to update the focus and gap of the binding, so let's rebind
@@ -295,7 +286,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end", "end",
elementsMap, elementsMap,
elements, elements,
zoom,
); );
return [start, end]; return [start, end];
@@ -306,12 +296,10 @@ const getBindingStrategyForDraggingArrowOrJoints = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean, isBindingEnabled: boolean,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement, selectedElement,
elementsMap, elementsMap,
zoom,
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
@@ -320,7 +308,6 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"start", "start",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null : null
: null; : null;
@@ -331,7 +318,6 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"end", "end",
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: null : null
: null; : null;
@@ -346,7 +332,6 @@ export const bindOrUnbindLinearElements = (
scene: Scene, scene: Scene,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[] | null, draggingPoints: readonly number[] | null,
zoom?: AppState["zoom"],
): void => { ): void => {
selectedElements.forEach((selectedElement) => { selectedElements.forEach((selectedElement) => {
const [start, end] = draggingPoints?.length const [start, end] = draggingPoints?.length
@@ -357,7 +342,6 @@ export const bindOrUnbindLinearElements = (
draggingPoints ?? [], draggingPoints ?? [],
elementsMap, elementsMap,
elements, elements,
zoom,
) )
: // The arrow itself (the shaft) or the inner joins are dragged : // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints( getBindingStrategyForDraggingArrowOrJoints(
@@ -365,7 +349,6 @@ export const bindOrUnbindLinearElements = (
elementsMap, elementsMap,
elements, elements,
isBindingEnabled, isBindingEnabled,
zoom,
); );
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene); bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
@@ -375,7 +358,6 @@ export const bindOrUnbindLinearElements = (
export const getSuggestedBindingsForArrows = ( export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[], selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => { ): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large // HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) { if (selectedElements.length > 50) {
@@ -386,7 +368,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements selectedElements
.filter(isLinearElement) .filter(isLinearElement)
.flatMap((element) => .flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
) )
.filter( .filter(
(element): element is NonDeleted<ExcalidrawBindableElement> => (element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -424,7 +406,6 @@ export const maybeBindLinearElement = (
pointerCoords, pointerCoords,
elements, elements,
elementsMap, elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement), isElbowArrow(linearElement) && isElbowArrow(linearElement),
); );
@@ -441,26 +422,6 @@ 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 = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
@@ -472,14 +433,11 @@ export const bindLinearElement = (
} }
const binding: PointBinding = { const binding: PointBinding = {
elementId: hoveredElement.id, elementId: hoveredElement.id,
...normalizePointBinding( ...calculateFocusAndGap(
calculateFocusAndGap( linearElement,
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement, hoveredElement,
startOrEnd,
elementsMap,
), ),
...(isElbowArrow(linearElement) ...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding( ? calculateFixedPointForElbowArrowBinding(
@@ -504,12 +462,6 @@ 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 // Don't bind both ends of a simple segment
@@ -562,7 +514,6 @@ export const getHoveredElementForBinding = (
}, },
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
@@ -573,13 +524,11 @@ export const getHoveredElementForBinding = (
element, element,
pointerCoords, pointerCoords,
elementsMap, elementsMap,
zoom,
// disable fullshape snapping for frame elements so we // disable fullshape snapping for frame elements so we
// can bind to frame children // can bind to frame children
fullShape && !isFrameLikeElement(element), fullShape && !isFrameLikeElement(element),
), ),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@@ -627,13 +576,11 @@ export const updateBoundElements = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; oldSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated, changedElements, zoom } = const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@@ -656,12 +603,12 @@ export const updateBoundElements = (
startBinding: maybeCalculateNewGapWhenScaling( startBinding: maybeCalculateNewGapWhenScaling(
changedElement, changedElement,
element.startBinding, element.startBinding,
newSize, oldSize,
), ),
endBinding: maybeCalculateNewGapWhenScaling( endBinding: maybeCalculateNewGapWhenScaling(
changedElement, changedElement,
element.endBinding, element.endBinding,
newSize, oldSize,
), ),
}; };
@@ -723,7 +670,6 @@ export const updateBoundElements = (
}, },
{ {
changedElements, changedElements,
zoom,
}, },
); );
@@ -757,7 +703,6 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
elementsMap: ElementsMap, elementsMap: ElementsMap,
origPoint: GlobalPoint, origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -769,7 +714,6 @@ export const getHeadingForElbowArrowSnap = (
origPoint, origPoint,
bindableElement, bindableElement,
elementsMap, elementsMap,
zoom,
); );
if (!distance) { if (!distance) {
@@ -793,7 +737,6 @@ const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement( const distance = distanceToBindableElement(
bindableElement, bindableElement,
@@ -804,7 +747,6 @@ const getDistanceForBinding = (
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
bindableElement.height, bindableElement.height,
zoom,
); );
return distance > bindDistance ? null : distance; return distance > bindDistance ? null : distance;
@@ -1232,13 +1174,11 @@ const getElligibleElementForBindingElement = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements, elements,
elementsMap, elementsMap,
zoom,
); );
}; };
@@ -1401,11 +1341,9 @@ export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number }, { x, y }: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap); const shape = getElementShape(element, elementsMap);
return ( return (
isPointOnShape(pointFrom(x, y), shape, threshold) || isPointOnShape(pointFrom(x, y), shape, threshold) ||
@@ -1418,21 +1356,12 @@ export const maxBindingGap = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementWidth: number, elementWidth: number,
elementHeight: number, elementHeight: number,
zoom?: AppState["zoom"],
): number => { ): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles // Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
// We make the bindable boundary bigger for bigger elements
return Math.max( return Math.max(16, Math.min(0.25 * smallerDimension, 32));
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 = ( export const distanceToBindableElement = (
-19
View File
@@ -556,10 +556,6 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default: default:
return 15; return 15;
} }
@@ -673,21 +669,6 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead); 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 // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
-625
View File
@@ -1,625 +0,0 @@
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,7 +16,6 @@ import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
@@ -252,14 +251,6 @@ export const dragNewElement = ({
} }
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
imageInitialDimension = {
initialWidth: width,
initialHeight: height,
};
}
mutateElement( mutateElement(
newElement, newElement,
{ {
@@ -268,7 +259,6 @@ export const dragNewElement = ({
width, width,
height, height,
...textAutoResize, ...textAutoResize,
...imageInitialDimension,
}, },
informMutation, informMutation,
); );
-102
View File
@@ -1,102 +0,0 @@
/**
* 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;
};
+3 -6
View File
@@ -1,20 +1,17 @@
import { register } from "../actions/register"; import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types"; import type { ExcalidrawProps } from "../types";
import { import { getFontString, updateActiveTool } from "../utils";
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textElement";
import { isIframeElement } from "./typeChecks"; import { isIframeElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
IframeData, IframeData,
} from "./types"; } from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import type { MarkRequired } from "../utility-types"; import type { MarkRequired } from "../utility-types";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
+6 -28
View File
@@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
return node?.nodeName.toLowerCase() === "svg"; return node?.nodeName.toLowerCase() === "svg";
}; };
export const normalizeSVG = (SVGString: string) => { export const normalizeSVG = async (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg); const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg"); const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror"); const errorNode = doc.querySelector("parsererror");
@@ -105,42 +105,20 @@ export const normalizeSVG = (SVGString: string) => {
svg.setAttribute("xmlns", SVG_NS); svg.setAttribute("xmlns", SVG_NS);
} }
let width = svg.getAttribute("width"); if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
let height = svg.getAttribute("height"); const viewBox = svg.getAttribute("viewBox");
let width = svg.getAttribute("width") || "50";
// Do not use % or auto values for width/height let height = svg.getAttribute("height") || "50";
// 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) { if (viewBox) {
const match = viewBox.match( const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) { if (match) {
[, width, height] = match; [, width, height] = match;
} }
} }
svg.setAttribute("width", width); svg.setAttribute("width", width);
svg.setAttribute("height", height); svg.setAttribute("height", height);
} }
// Make sure viewBox is set
if (!viewBox) {
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
return svg.outerHTML; return svg.outerHTML;
} }
}; };
@@ -448,7 +448,6 @@ export class LinearElementEditor {
), ),
elements, elements,
elementsMap, elementsMap,
appState.zoom,
) )
: null; : null;
@@ -788,7 +787,6 @@ export class LinearElementEditor {
scenePointer, scenePointer,
elements, elements,
elementsMap, elementsMap,
app.state.zoom,
), ),
}; };
@@ -913,7 +911,6 @@ export class LinearElementEditor {
element, element,
[points.length - 1], [points.length - 1],
elementsMap, elementsMap,
app.state.zoom,
); );
} }
return { return {
@@ -967,7 +964,6 @@ export class LinearElementEditor {
element, element,
[{ point: newPoint }], [{ point: newPoint }],
elementsMap, elementsMap,
app.state.zoom,
); );
} }
return { return {
@@ -1222,7 +1218,6 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[], pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
@@ -1265,7 +1260,6 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[], targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
const offsetX = 0; const offsetX = 0;
const offsetY = 0; const offsetY = 0;
@@ -1291,7 +1285,6 @@ export class LinearElementEditor {
options?: { options?: {
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"];
}, },
) { ) {
const { points } = element; const { points } = element;
@@ -1344,7 +1337,6 @@ export class LinearElementEditor {
false, false,
), ),
changedElements: options?.changedElements, changedElements: options?.changedElements,
zoom: options?.zoom,
}, },
); );
} }
@@ -1459,7 +1451,6 @@ export class LinearElementEditor {
options?: { options?: {
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"];
}, },
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
@@ -1496,7 +1487,6 @@ export class LinearElementEditor {
bindings, bindings,
{ {
isDragging: options?.isDragging, isDragging: options?.isDragging,
zoom: options?.zoom,
}, },
); );
} else { } else {
+26 -3
View File
@@ -5,12 +5,23 @@ import { randomInteger } from "../random";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { maybeGetSubtypeProps } from "./newElement";
import { getSubtypeMethods } from "./subtypes";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "version" | "versionNonce" | "updated"
>; >;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
const map = getSubtypeMethods(subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration. // This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in // The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you // the same drawing. Note: this will trigger the component to update. Make sure you
@@ -21,6 +32,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true, informMutation = true,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -69,6 +82,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
} }
if (!didChangePoints) { if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue; continue;
} }
} }
@@ -76,6 +90,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value; (element as any)[key] = value;
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -92,9 +107,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; if (increment) {
element.versionNonce = randomInteger(); element.version++;
element.updated = getUpdatedTimestamp(); element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) { if (informMutation) {
Scene.getScene(element)?.triggerUpdate(); Scene.getScene(element)?.triggerUpdate();
@@ -110,6 +127,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
force = false, force = false,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) { for (const key in updates) {
const value = (updates as any)[key]; const value = (updates as any)[key];
if (typeof value !== "undefined") { if (typeof value !== "undefined") {
@@ -121,6 +140,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue; continue;
} }
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -128,6 +148,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
if (!increment) {
return { ...element, ...updates };
}
return { return {
...element, ...element,
...updates, ...updates,
+59 -29
View File
@@ -19,12 +19,7 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
} from "./types"; } from "./types";
import { import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement"; import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
@@ -32,11 +27,11 @@ import type { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
measureText, measureTextElement,
normalizeText, normalizeText,
wrapTextElement,
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "./textElement"; } from "./textElement";
import { wrapText } from "./textWrapping";
import { import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@@ -48,6 +43,30 @@ import {
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { getSubtypeMethods, isValidSubtype } from "./subtypes";
export const maybeGetSubtypeProps = (
obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
},
type: ExcalidrawElement["type"],
) => {
const data: typeof obj = {};
if ("subtype" in obj) {
data.subtype = obj.subtype;
}
if ("customData" in obj) {
data.customData = obj.customData;
}
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
delete data.subtype;
}
if (!("subtype" in data) && "customData" in data) {
delete data.customData;
}
return data as typeof obj;
};
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -62,6 +81,8 @@ export type ElementConstructorOpts = MarkOptional<
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link" | "link"
| "subtype"
| "customData"
| "strokeStyle" | "strokeStyle"
| "fillStyle" | "fillStyle"
| "strokeColor" | "strokeColor"
@@ -99,8 +120,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }, type),
id: rest.id || randomId(), id: rest.id || randomId(),
type, type,
x, x,
@@ -136,8 +159,11 @@ export const newElement = (
opts: { opts: {
type: ExcalidrawGenericElement["type"]; type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> => {
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
@@ -230,10 +256,12 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText( const metrics = measureTextElement(
text, { ...opts, fontSize, fontFamily, lineHeight },
getFontString({ fontFamily, fontSize }), {
lineHeight, text,
customData: opts.customData,
},
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -277,11 +305,9 @@ const getAdjustedDimensions = (
width: number; width: number;
height: number; height: number;
} => { } => {
let { width: nextWidth, height: nextHeight } = measureText( let { width: nextWidth, height: nextHeight } = measureTextElement(element, {
nextText, text: nextText,
getFontString(element), });
element.lineHeight,
);
// wrapped text // wrapped text
if (!element.autoResize) { if (!element.autoResize) {
@@ -297,11 +323,7 @@ const getAdjustedDimensions = (
!element.containerId && !element.containerId &&
element.autoResize element.autoResize
) { ) {
const prevMetrics = measureText( const prevMetrics = measureTextElement(element);
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,
@@ -404,12 +426,14 @@ export const refreshTextDimensions = (
return; return;
} }
if (container || !textElement.autoResize) { if (container || !textElement.autoResize) {
text = wrapText( text = wrapTextElement(
text, textElement,
getFontString(textElement),
container container
? getBoundTextMaxWidth(container, textElement) ? getBoundTextMaxWidth(container, textElement)
: textElement.width, : textElement.width,
{
text,
},
); );
} }
const dimensions = getAdjustedDimensions(textElement, elementsMap, text); const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
@@ -424,6 +448,8 @@ export const newFreeDrawElement = (
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -439,6 +465,8 @@ export const newLinearElement = (
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -459,6 +487,8 @@ export const newArrowElement = (
elbowed?: boolean; elbowed?: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => { ): NonDeleted<ExcalidrawArrowElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -477,9 +507,10 @@ export const newImageElement = (
status?: ExcalidrawImageElement["status"]; status?: ExcalidrawImageElement["status"];
fileId?: ExcalidrawImageElement["fileId"]; fileId?: ExcalidrawImageElement["fileId"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
crop?: ExcalidrawImageElement["crop"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => { ): NonDeleted<ExcalidrawImageElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawImageElement>("image", opts), ..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements, // in the future we'll support changing stroke color for some SVG elements,
@@ -488,7 +519,6 @@ export const newImageElement = (
status: opts.status ?? "pending", status: opts.status ?? "pending",
fileId: opts.fileId ?? null, fileId: opts.fileId ?? null,
scale: opts.scale ?? [1, 1], scale: opts.scale ?? [1, 1],
crop: opts.crop ?? null,
}; };
}; };
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -20,7 +20,7 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants"; import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { isImageElement, isLinearElement } from "./typeChecks"; import { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math"; import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import { import {
pointFrom, pointFrom,
@@ -90,11 +90,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
// do not resize from the sides for linear elements with only two points // do not resize from the sides for linear elements with only two points
if (!(isLinearElement(element) && element.points.length <= 2)) { if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = isImageElement(element) const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
? 0
: SIDE_RESIZING_THRESHOLD / zoom.value;
const ZOOMED_SIDE_RESIZING_THRESHOLD =
SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
pointFrom(x1 - SPACING, y1 - SPACING), pointFrom(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING), pointFrom(x2 + SPACING, y2 + SPACING),
@@ -108,7 +104,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
pointOnLineSegment( pointOnLineSegment(
pointFrom(x, y), pointFrom(x, y),
side as LineSegment<Point>, side as LineSegment<Point>,
ZOOMED_SIDE_RESIZING_THRESHOLD, SPACING,
) )
) { ) {
return dir as TransformHandleType; return dir as TransformHandleType;
+1 -12
View File
@@ -14,7 +14,6 @@ import {
import BinaryHeap from "../binaryheap"; import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { aabbForElement, pointInsideBounds } from "../shapes"; import { aabbForElement, pointInsideBounds } from "../shapes";
import type { AppState } from "../types";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import { import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
@@ -80,7 +79,6 @@ export const mutateElbowArrow = (
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
informMutation?: boolean; informMutation?: boolean;
zoom?: AppState["zoom"];
}, },
) => { ) => {
const update = updateElbowArrow( const update = updateElbowArrow(
@@ -114,7 +112,6 @@ export const updateElbowArrow = (
isDragging?: boolean; isDragging?: boolean;
disableBinding?: boolean; disableBinding?: boolean;
informMutation?: boolean; informMutation?: boolean;
zoom?: AppState["zoom"];
}, },
): ElementUpdate<ExcalidrawElbowArrowElement> | null => { ): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint: GlobalPoint = pointTranslate( const origStartGlobalPoint: GlobalPoint = pointTranslate(
@@ -139,12 +136,7 @@ export const updateElbowArrow = (
arrow.endBinding && arrow.endBinding &&
getBindableElementForId(arrow.endBinding.elementId, elementsMap); getBindableElementForId(arrow.endBinding.elementId, elementsMap);
const [hoveredStartElement, hoveredEndElement] = options?.isDragging const [hoveredStartElement, hoveredEndElement] = options?.isDragging
? getHoveredElements( ? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
options?.zoom,
)
: [startElement, endElement]; : [startElement, endElement];
const startGlobalPoint = getGlobalPoint( const startGlobalPoint = getGlobalPoint(
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
@@ -1080,7 +1072,6 @@ const getHoveredElements = (
origStartGlobalPoint: GlobalPoint, origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint, origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom?: AppState["zoom"],
) => { ) => {
// TODO: Might be a performance bottleneck and the Map type // TODO: Might be a performance bottleneck and the Map type
// remembers the insertion order anyway... // remembers the insertion order anyway...
@@ -1093,14 +1084,12 @@ const getHoveredElements = (
tupleToCoors(origStartGlobalPoint), tupleToCoors(origStartGlobalPoint),
elements, elements,
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom,
true, true,
), ),
getHoveredElementForBinding( getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint), tupleToCoors(origEndGlobalPoint),
elements, elements,
nonDeletedSceneElementsMap, nonDeletedSceneElementsMap,
zoom,
true, true,
), ),
]; ];
@@ -8,7 +8,6 @@ export const showSelectedShapeActions = (
) => ) =>
Boolean( Boolean(
!appState.viewModeEnabled && !appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
((appState.activeTool.type !== "custom" && ((appState.activeTool.type !== "custom" &&
(appState.editingTextElement || (appState.editingTextElement ||
(appState.activeTool.type !== "selection" && (appState.activeTool.type !== "selection" &&
@@ -0,0 +1,541 @@
import { useEffect } from "react";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawTextElement,
NonDeleted,
} from "../types";
import { getNonDeletedElements } from "../";
import { getSelectedElements } from "../../scene";
import type { AppState, ExcalidrawImperativeAPI, ToolType } from "../../types";
import type { LangLdr } from "../../i18n";
import { registerCustomLangData } from "../../i18n";
import type {
Action,
ActionName,
ActionPredicateFn,
CustomActionName,
} from "../../actions/types";
import { makeCustomActionName } from "../../actions/types";
import { registerCustomShortcuts } from "../../actions/shortcuts";
import { register } from "../../actions/register";
import { hasBoundTextElement, isTextElement } from "../typeChecks";
import {
getBoundTextElement,
getContainerElement,
redrawTextBoundingBox,
} from "../textElement";
import { ShapeCache } from "../../scene/ShapeCache";
import Scene from "../../scene/Scene";
// Use "let" instead of "const" so we can dynamically add subtypes
let subtypeNames: readonly Subtype[] = [];
let parentTypeMap: readonly {
subtype: Subtype;
parentType: ExcalidrawElement["type"];
}[] = [];
let subtypeActionMap: readonly {
subtype: Subtype;
actions: readonly ActionName[];
}[] = [];
let disabledActionMap: readonly {
subtype: Subtype;
actions: readonly DisabledActionName[];
}[] = [];
let alwaysEnabledMap: readonly {
subtype: Subtype;
actions: readonly SubtypeActionName[];
}[] = [];
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly (ExcalidrawElement["type"] & ToolType)[];
actionNames?: readonly SubtypeActionName[];
disabledNames?: readonly DisabledActionName[];
shortcutMap?: Record<string, string[]>;
alwaysEnabledNames?: readonly SubtypeActionName[];
}>;
// Subtype Names
export type Subtype = Required<ExcalidrawElement>["subtype"];
export const getSubtypeNames = (): readonly Subtype[] => {
return subtypeNames;
};
export const isValidSubtype = (s: any, t: any): s is Subtype =>
parentTypeMap.find(
(val) => (val.subtype as any) === s && (val.parentType as any) === t,
) !== undefined;
const isSubtypeName = (s: any): s is Subtype => subtypeNames.includes(s);
// Subtype Actions
// Used for context menus in the shape chooser
export const hasAlwaysEnabledActions = (s: any): boolean => {
if (!isSubtypeName(s)) {
return false;
}
return alwaysEnabledMap.some((value) => value.subtype === s);
};
type SubtypeActionName = string;
const isSubtypeActionName = (s: any): s is SubtypeActionName =>
subtypeActionMap.some((val) => val.actions.includes(s));
const addSubtypeAction = (action: Action) => {
if (isSubtypeActionName(action.name) || isSubtypeName(action.name)) {
register(action);
}
};
// Standard actions disabled by subtypes
type DisabledActionName = ActionName;
const isDisabledActionName = (s: any): s is DisabledActionName =>
disabledActionMap.some((val) => val.actions.includes(s));
// Is the `actionName` one of the subtype actions for `subtype`
// (if `isAdded` is true) or one of the standard actions disabled
// by `subtype` (if `isAdded` is false)?
const isForSubtype = (
subtype: ExcalidrawElement["subtype"],
actionName: ActionName,
isAdded: boolean,
) => {
const actions = isAdded ? subtypeActionMap : disabledActionMap;
const map = actions.find((value) => value.subtype === subtype);
if (map) {
return map.actions.includes(actionName);
}
return false;
};
export const isSubtypeAction: ActionPredicateFn = function (action) {
return isSubtypeActionName(action.name) && !isSubtypeName(action.name);
};
export const subtypeActionPredicate: ActionPredicateFn = function (
action,
elements,
appState,
app,
) {
// We always enable subtype actions. Also let through standard actions
// which no subtypes might have disabled.
if (
isSubtypeName(action.name) ||
(!isSubtypeActionName(action.name) && !isDisabledActionName(action.name))
) {
return true;
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const chosen = appState.editingTextElement
? [appState.editingTextElement, ...selectedElements]
: selectedElements;
// Now handle actions added by subtypes
if (isSubtypeActionName(action.name)) {
// Has any ExcalidrawElement enabled this actionName through having
// its subtype?
return (
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return isForSubtype(e.subtype, action.name, true);
}) ||
// Or has any active subtype enabled this actionName?
(appState.activeSubtypes !== undefined &&
appState.activeSubtypes?.some((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return false;
}
return isForSubtype(subtype, action.name, true);
})) ||
alwaysEnabledMap.some((value) => {
return value.actions.includes(action.name);
})
);
}
// Now handle standard actions disabled by subtypes
if (isDisabledActionName(action.name)) {
return (
// Has every ExcalidrawElement not disabled this actionName?
(chosen.every((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return !isForSubtype(e.subtype, action.name, false);
}) &&
// And has every active subtype not disabled this actionName?
(appState.activeSubtypes === undefined ||
appState.activeSubtypes?.every((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return true;
}
return !isForSubtype(subtype, action.name, false);
}))) ||
// Or can we find an ExcalidrawElement without a valid subtype
// which would disable this action if it had a valid subtype?
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return parentTypeMap.some(
(value) =>
value.parentType === e.type &&
!isValidSubtype(e.subtype, e.type) &&
isForSubtype(value.subtype, action.name, false),
);
}) ||
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return (
// Would the subtype of e by inself disable this action?
isForSubtype(e.subtype, action.name, false) &&
// Can we find an ExcalidrawElement which could have the same subtype
// as e but whose subtype does not disable this action?
chosen.some((el) => {
const e2 = hasBoundTextElement(el)
? getBoundTextElement(
el,
app.scene.getElementsMapIncludingDeleted(),
)!
: el;
return (
// Does e have a valid subtype whose parent types include the
// type of e2, and does the subtype of e2 not disable this action?
parentTypeMap
.filter((val) => val.subtype === e.subtype)
.some((val) => val.parentType === e2.type) &&
!isForSubtype(e2.subtype, action.name, false)
);
})
);
})
);
}
// Shouldn't happen
return true;
};
// Are any of the parent types of `subtype` shared by any subtype
// in the array?
export const subtypeCollides = (subtype: Subtype, subtypeArray: Subtype[]) => {
const subtypeParents = parentTypeMap
.filter((value) => value.subtype === subtype)
.map((value) => value.parentType);
const subtypeArrayParents = subtypeArray.flatMap((s) =>
parentTypeMap
.filter((value) => value.subtype === s)
.map((value) => value.parentType),
);
return subtypeParents.some((t) => subtypeArrayParents.includes(t));
};
// Subtype Methods
export type SubtypeMethods = {
clean: (
updates: Omit<
Partial<ExcalidrawElement>,
"id" | "version" | "versionNonce"
>,
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
ensureLoaded: (callback?: () => void) => Promise<void>;
measureText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => { width: number; height: number };
render: (
element: NonDeleted<ExcalidrawElement>,
elementsMap: ElementsMap,
context: CanvasRenderingContext2D,
) => void;
renderSvg: (
svgRoot: SVGElement,
addToRoot: (node: SVGElement, element: ExcalidrawElement) => void,
element: NonDeleted<ExcalidrawElement>,
elementsMap: ElementsMap,
opt?: { offsetX?: number; offsetY?: number },
) => void;
wrapText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>,
containerWidth: number,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => string;
};
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
const methodMaps = [] as Array<MethodMap>;
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
export const getSubtypeMethods = (
subtype: Subtype | undefined,
): Partial<SubtypeMethods> | undefined => {
const map = methodMaps.find((method) => method.subtype === subtype);
return map?.methods;
};
export const addSubtypeMethods = (
subtype: Subtype,
methods: Partial<SubtypeMethods>,
) => {
if (!methodMaps.find((method) => method.subtype === subtype)) {
methodMaps.push({ subtype, methods });
}
};
// For a given `ExcalidrawElement` type, return the active subtype
// and associated customData (if any) from the AppState. Assume
// only one subtype is active for a given `ExcalidrawElement` type
// at any given time.
export const selectSubtype = (
appState: {
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
},
type: ExcalidrawElement["type"],
): {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
} => {
if (appState.activeSubtypes === undefined) {
return {};
}
const subtype = appState.activeSubtypes.find((subtype) =>
isValidSubtype(subtype, type),
);
if (subtype === undefined) {
return {};
}
if (appState.customData === undefined || !(subtype in appState.customData)) {
return { subtype };
}
const customData = appState.customData[subtype];
return { subtype, customData };
};
// Callback to re-render subtyped `ExcalidrawElement`s after completing
// async loading of the subtype.
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
// Functions to prepare subtypes for use
export type SubtypePrepFn = (
addSubtypeAction: (action: Action) => void,
addLangData: (fallbackLangData: {}, setLanguageAux: LangLdr) => void,
onSubtypeLoaded?: SubtypeLoadedCb,
) => {
actions: Action[];
methods: Partial<SubtypeMethods>;
};
// This is the main method to set up the subtype. The optional
// `onSubtypeLoaded` callback may be used to re-render subtyped
// `ExcalidrawElement`s after the subtype has finished async loading.
// See the MathJax extension in `@excalidraw/extensions` for example.
export const prepareSubtype = (
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
onSubtypeLoaded?: SubtypeLoadedCb,
): { actions: readonly Action[] | null; methods: Partial<SubtypeMethods> } => {
const map = getSubtypeMethods(record.subtype);
if (map) {
return { actions: null, methods: map };
}
// Check for undefined/null subtypes and parentTypes
if (
record.subtype === undefined ||
record.subtype === "" ||
record.parents === undefined ||
record.parents.length === 0
) {
return { actions: null, methods: {} };
}
// Register the types
const subtype = record.subtype;
subtypeNames = [...subtypeNames, subtype];
record.parents.forEach((parentType) => {
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
});
if (record.actionNames) {
subtypeActionMap = [
...subtypeActionMap,
{
subtype,
actions: record.actionNames.map((actionName) =>
makeCustomActionName(actionName),
),
},
];
}
if (record.disabledNames) {
disabledActionMap = [
...disabledActionMap,
{ subtype, actions: record.disabledNames },
];
}
if (record.alwaysEnabledNames) {
alwaysEnabledMap = [
...alwaysEnabledMap,
{
subtype,
actions: record.alwaysEnabledNames.map((actionName) =>
makeCustomActionName(actionName),
),
},
];
}
const customShortcutMap = record.shortcutMap;
if (customShortcutMap) {
const shortcutMap: Record<CustomActionName, string[]> = {};
for (const key in customShortcutMap) {
shortcutMap[makeCustomActionName(key)] = customShortcutMap[key];
}
registerCustomShortcuts(shortcutMap);
}
// Prepare the subtype
const { actions, methods } = subtypePrepFn(
addSubtypeAction,
registerCustomLangData,
onSubtypeLoaded,
);
// Register the subtype's methods
addSubtypeMethods(record.subtype, methods);
return { actions, methods };
};
// Ensure all subtypes are loaded before continuing, eg to
// render SVG previews of new charts. Chart-relevant subtypes
// include math equations in titles or non hand-drawn line styles.
export const ensureSubtypesLoadedForElements = async (
elements: readonly ExcalidrawElement[],
callback?: () => void,
) => {
// Only ensure the loading of subtypes which are actually needed.
// We don't want to be held up by eg downloading the MathJax SVG fonts
// if we don't actually need them yet.
const subtypesUsed = [] as Subtype[];
elements.forEach((el) => {
if (
"subtype" in el &&
isValidSubtype(el.subtype, el.type) &&
!subtypesUsed.includes(el.subtype)
) {
subtypesUsed.push(el.subtype);
}
});
await ensureSubtypesLoaded(subtypesUsed, callback);
};
export const ensureSubtypesLoaded = async (
subtypes: Subtype[],
callback?: () => void,
) => {
// Use a for loop so we can do `await map.ensureLoaded()`
for (let i = 0; i < subtypes.length; i++) {
const subtype = subtypes[i];
// Should be defined if prepareSubtype() has run
const map = getSubtypeMethods(subtype);
if (map?.ensureLoaded) {
await map.ensureLoaded();
}
}
if (callback) {
callback();
}
};
// Call this method after finishing any async loading for
// subtypes of ExcalidrawElement if the newly loaded code
// would change the rendering.
export const checkRefreshOnSubtypeLoad = (
hasSubtype: SubtypeCheckFn,
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = new Map() as ElementsMap;
for (const element of elements) {
if (!element.isDeleted) {
elementsMap.set(element.id, element);
}
}
let refreshNeeded = false;
const scenes: Scene[] = [];
getNonDeletedElements(elements).forEach((element) => {
// If the element is of the subtype that was just
// registered, update the element's dimensions, mark the
// element for a re-render, and indicate the scene needs a refresh.
if (hasSubtype(element)) {
ShapeCache.delete(element);
if (isTextElement(element)) {
redrawTextBoundingBox(
element,
getContainerElement(element, elementsMap),
elementsMap,
false,
);
}
refreshNeeded = true;
const scene = Scene.getScene(element);
if (scene && !scenes.includes(scene)) {
// Store in case we have multiple scenes
scenes.push(scene);
}
}
});
// Only inform each scene once
scenes.forEach((scene) => scene.triggerUpdate());
return refreshNeeded;
};
export const useSubtype = (
api: ExcalidrawImperativeAPI | null,
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
) => {
useEffect(() => {
if (api) {
const prep = api.addSubtype(record, subtypePrepFn);
if (prep) {
addSubtypeMethods(record.subtype, prep.methods);
if (prep.actions) {
prep.actions.forEach((action) => api.registerAction(action));
}
}
}
}, [api, record, subtypePrepFn]);
};

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