Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e43bf7c7 | |||
| 9d24003517 | |||
| 91ebf8b0ea | |||
| 8551823da9 | |||
| ae6bee3403 | |||
| 46f42ef8d7 | |||
| 00b5b0a0ca | |||
| c92f3bebf5 | |||
| 2ac55067cd | |||
| 78ab12c7e6 | |||
| f2f8219917 | |||
| 12c39d1034 | |||
| d33e42e3a1 | |||
| 3b9ffd9586 | |||
| b63689c230 | |||
| c84babf574 | |||
| 36274f1f3e | |||
| 798c795405 | |||
| 107eae3916 | |||
| 56fca30bd0 | |||
| 1e3399eac8 | |||
| 873698a1a2 | |||
| 606ac6c743 | |||
| d99e4a23ca | |||
| 551bae07a7 | |||
| 2af3221974 | |||
| 9b401f6ea3 | |||
| 8a1152ce36 | |||
| b5652b8e36 | |||
| 31e2a0cb4a | |||
| c0b80a03bd | |||
| a758aaf8f6 | |||
| b2a6a87b10 | |||
| ab8b3537b3 | |||
| d21e0008dd | |||
| 840f1428c4 | |||
| 2db5bbcb29 | |||
| 0927431d0d | |||
| 98c0a67333 | |||
| 57cf577376 | |||
| 6e0ee89ee4 | |||
| 35f778a734 | |||
| ee091d0dbd | |||
| ef9ea14a75 | |||
| df168a6883 | |||
| 798f5f4dfb | |||
| d9ad7c039b | |||
| 7c0239e693 | |||
| da33481fa3 | |||
| 70e0e8dc29 | |||
| 2734e646ca | |||
| dfaaff4432 | |||
| 03028eaa8c | |||
| 79b181bcdc | |||
| f9815b8b4f | |||
| 96ed8a4331 | |||
| 33b01d4e80 |
+12
-1
@@ -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=https://app.excalidraw.com
|
VITE_APP_PLUS_APP=http://localhost:3000
|
||||||
|
|
||||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||||
|
|
||||||
@@ -37,3 +37,14 @@ 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,3 +15,18 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
|||||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
VITE_APP_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
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -3,6 +3,20 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off",
|
||||||
"no-restricted-globals": "off",
|
"no-restricted-globals": "off",
|
||||||
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"prefer": "type-imports",
|
||||||
|
"disallowTypeAnnotations": false,
|
||||||
|
"fixStyle": "separate-type-imports"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"name": "jotai",
|
||||||
|
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ jobs:
|
|||||||
semantic:
|
semantic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v3.0.0
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -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://blog.excalidraw.com">Blog</a> |
|
<a href="https://plus.excalidraw.com/blog">Blog</a> |
|
||||||
<a href="https://docs.excalidraw.com">Documentation</a> |
|
<a href="https://docs.excalidraw.com">Documentation</a> |
|
||||||
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -3,31 +3,32 @@
|
|||||||
All `props` are _optional_.
|
All `props` are _optional_.
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | null></code> | `null` | The initial data with which app loads. |
|
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `null` | <code>Promise<object | 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. |
|
||||||
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
|
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
|
||||||
|
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
|
||||||
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
|
| [`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"` | `"dark"` | `"light"` | The theme of the Excalidraw component |
|
| [`theme`](#theme) | `"light"` | `"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>` |
|
||||||
|
|
||||||
@@ -93,9 +94,8 @@ This callback is triggered when mouse pointer is updated.
|
|||||||
|
|
||||||
This prop if passed will be triggered on pointer down events and has the below signature.
|
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,6 +143,14 @@ This callback if supplied will get triggered when the library is updated and has
|
|||||||
|
|
||||||
It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage.
|
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()`.
|
||||||
@@ -207,8 +215,7 @@ 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).
|
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.
|
||||||
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
|
||||||
|
|
||||||
@@ -220,7 +227,6 @@ You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify t
|
|||||||
|
|
||||||
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over _intialData.appState.name_, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
|
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,3 +235,5 @@ 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).
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const config = {
|
|||||||
label: "Docs",
|
label: "Docs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "https://blog.excalidraw.com",
|
to: "https://plus.excalidraw.com/blog",
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
position: "left",
|
position: "left",
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,7 @@ const config = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
to: "https://blog.excalidraw.com",
|
to: "https://plus.excalidraw.com/blog",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
|
|||||||
+24
-7
@@ -90,9 +90,13 @@ import {
|
|||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
import {
|
||||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
Provider,
|
||||||
import { appJotaiStore } from "./app-jotai";
|
useAtom,
|
||||||
|
useAtomValue,
|
||||||
|
useAtomWithInitialValue,
|
||||||
|
appJotaiStore,
|
||||||
|
} from "./app-jotai";
|
||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||||
@@ -117,7 +121,7 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
import DebugCanvas, {
|
import DebugCanvas, {
|
||||||
@@ -126,6 +130,8 @@ 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();
|
||||||
|
|
||||||
@@ -326,8 +332,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
|
||||||
const { editorTheme } = useHandleAppTheme();
|
|
||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
@@ -847,6 +852,12 @@ 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}
|
||||||
@@ -1125,9 +1136,15 @@ 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 store={appJotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</Provider>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
import { LocalData } from "./data/LocalData";
|
||||||
|
import type {
|
||||||
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../packages/excalidraw/element/types";
|
||||||
|
import type { AppState, BinaryFileData } from "../packages/excalidraw/types";
|
||||||
|
import { ExcalidrawError } from "../packages/excalidraw/errors";
|
||||||
|
import { base64urlToString } from "../packages/excalidraw/data/encode";
|
||||||
|
|
||||||
|
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
|
||||||
|
|
||||||
|
const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// outgoing message
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
type MESSAGE_REQUEST_SCENE = {
|
||||||
|
type: "REQUEST_SCENE";
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
|
||||||
|
|
||||||
|
// incoming messages
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
type MESSAGE_READY = { type: "READY" };
|
||||||
|
type MESSAGE_ERROR = { type: "ERROR"; message: string };
|
||||||
|
type MESSAGE_SCENE_DATA = {
|
||||||
|
type: "SCENE_DATA";
|
||||||
|
elements: OrderedExcalidrawElement[];
|
||||||
|
appState: Pick<AppState, "viewBackgroundColor">;
|
||||||
|
files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
|
||||||
|
};
|
||||||
|
|
||||||
|
type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const parseSceneData = async ({
|
||||||
|
rawElementsString,
|
||||||
|
rawAppStateString,
|
||||||
|
}: {
|
||||||
|
rawElementsString: string | null;
|
||||||
|
rawAppStateString: string | null;
|
||||||
|
}): Promise<MESSAGE_SCENE_DATA> => {
|
||||||
|
if (!rawElementsString || !rawAppStateString) {
|
||||||
|
throw new ExcalidrawError("Elements or appstate is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const elements = JSON.parse(
|
||||||
|
rawElementsString,
|
||||||
|
) as OrderedExcalidrawElement[];
|
||||||
|
|
||||||
|
if (!elements.length) {
|
||||||
|
throw new ExcalidrawError("Scene is empty, nothing to export.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const appState = JSON.parse(rawAppStateString) as Pick<
|
||||||
|
AppState,
|
||||||
|
"viewBackgroundColor"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const fileIds = elements.reduce((acc, el) => {
|
||||||
|
if ("fileId" in el && el.fileId) {
|
||||||
|
acc.push(el.fileId);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as FileId[]);
|
||||||
|
|
||||||
|
const files = await LocalData.fileStorage.getFiles(fileIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "SCENE_DATA",
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw error instanceof ExcalidrawError
|
||||||
|
? error
|
||||||
|
: new ExcalidrawError("Failed to parse scene data.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyJWT = async ({
|
||||||
|
token,
|
||||||
|
publicKey,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
publicKey: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new ExcalidrawError("Public key is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [header, payload, signature] = token.split(".");
|
||||||
|
|
||||||
|
if (!header || !payload || !signature) {
|
||||||
|
throw new ExcalidrawError("Invalid JWT format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT is using Base64URL encoding
|
||||||
|
const decodedPayload = base64urlToString(payload);
|
||||||
|
const decodedSignature = base64urlToString(signature);
|
||||||
|
|
||||||
|
const data = `${header}.${payload}`;
|
||||||
|
const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
|
||||||
|
c.charCodeAt(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
|
||||||
|
const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
|
||||||
|
c.charCodeAt(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
keyArrayBuffer,
|
||||||
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||||
|
true,
|
||||||
|
["verify"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = await crypto.subtle.verify(
|
||||||
|
"RSASSA-PKCS1-v1_5",
|
||||||
|
key,
|
||||||
|
signatureArrayBuffer,
|
||||||
|
new TextEncoder().encode(data),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("Invalid JWT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPayload = JSON.parse(decodedPayload);
|
||||||
|
|
||||||
|
// Check for expiration
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
if (parsedPayload.exp && parsedPayload.exp < currentTime) {
|
||||||
|
throw new Error("JWT has expired");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to verify JWT:", error);
|
||||||
|
throw new Error(error instanceof Error ? error.message : "Invalid JWT");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExcalidrawPlusIframeExport = () => {
|
||||||
|
const readyRef = useRef(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
|
||||||
|
if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
|
||||||
|
throw new ExcalidrawError("Invalid origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === EVENT_REQUEST_SCENE) {
|
||||||
|
if (!event.data.jwt) {
|
||||||
|
throw new ExcalidrawError("JWT is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await verifyJWT({
|
||||||
|
token: event.data.jwt,
|
||||||
|
publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Failed to verify JWT: ${error.message}`);
|
||||||
|
throw new ExcalidrawError("Failed to verify JWT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
|
||||||
|
rawAppStateString: localStorage.getItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||||
|
),
|
||||||
|
rawElementsString: localStorage.getItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
event.source!.postMessage(parsedSceneData, {
|
||||||
|
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const responseData: MESSAGE_ERROR = {
|
||||||
|
type: "ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof ExcalidrawError
|
||||||
|
? error.message
|
||||||
|
: "Failed to export scene data",
|
||||||
|
};
|
||||||
|
event.source!.postMessage(responseData, {
|
||||||
|
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
|
// so we don't send twice in StrictMode
|
||||||
|
if (!readyRef.current) {
|
||||||
|
readyRef.current = true;
|
||||||
|
const message: MESSAGE_FROM_EDITOR = { type: "READY" };
|
||||||
|
window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Since this component is expected to run in a hidden iframe on Excaildraw+,
|
||||||
|
// it doesn't need to render anything. All the data we need is available in
|
||||||
|
// LocalStorage and IndexedDB. It only needs to handle the messaging between
|
||||||
|
// the parent window and the iframe with the relevant data.
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,3 +1,37 @@
|
|||||||
import { unstable_createStore } from "jotai";
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import {
|
||||||
|
atom,
|
||||||
|
Provider,
|
||||||
|
useAtom,
|
||||||
|
useAtomValue,
|
||||||
|
useSetAtom,
|
||||||
|
createStore,
|
||||||
|
type PrimitiveAtom,
|
||||||
|
} from "jotai";
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
|
||||||
export const appJotaiStore = unstable_createStore();
|
export const appJotaiStore = createStore();
|
||||||
|
|
||||||
|
export { atom, Provider, useAtom, useAtomValue, useSetAtom };
|
||||||
|
|
||||||
|
export const useAtomWithInitialValue = <
|
||||||
|
T extends unknown,
|
||||||
|
A extends PrimitiveAtom<T>,
|
||||||
|
>(
|
||||||
|
atom: A,
|
||||||
|
initialValue: T | (() => T),
|
||||||
|
) => {
|
||||||
|
const [value, setValue] = useAtom(atom);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof initialValue === "function") {
|
||||||
|
// @ts-ignore
|
||||||
|
setValue(initialValue());
|
||||||
|
} else {
|
||||||
|
setValue(initialValue);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSetAtom } from "jotai";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||||
|
import { useSetAtom } from "../app-jotai";
|
||||||
import { appLangCodeAtom } from "./language-state";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { atom, useAtom } from "../app-jotai";
|
||||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||||
|
|
||||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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";
|
||||||
@@ -9,6 +10,7 @@ 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";
|
||||||
@@ -77,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
|
|||||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { atom } from "jotai";
|
import { appJotaiStore, atom } from "../app-jotai";
|
||||||
import { appJotaiStore } from "../app-jotai";
|
|
||||||
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
@@ -157,7 +158,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveFilesToFirebase({
|
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
|
||||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||||
files: await encodeFilesForUpload({
|
files: await encodeFilesForUpload({
|
||||||
files: addedFiles,
|
files: addedFiles,
|
||||||
@@ -165,6 +166,29 @@ 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;
|
||||||
@@ -394,7 +418,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
!this.fileManager.isFileHandled(element.fileId) &&
|
!this.fileManager.isFileTracked(element.fileId) &&
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
(opts.forceFetchFiles
|
(opts.forceFetchFiles
|
||||||
? element.status !== "pending" ||
|
? element.status !== "pending" ||
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
|||||||
import { warning } from "../../packages/excalidraw/components/icons";
|
import { warning } from "../../packages/excalidraw/components/icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { atom } from "../app-jotai";
|
||||||
|
|
||||||
import "./CollabError.scss";
|
import "./CollabError.scss";
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
type ErrorIndicator = {
|
type ErrorIndicator = {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ 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<ExcalidrawImageElement["fileId"], true>();
|
private savingFiles = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
/* files already saved to persistent storage */
|
/* files already saved to persistent storage */
|
||||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
|
||||||
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private erroredFiles_save = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
|
|
||||||
private _getFiles;
|
private _getFiles;
|
||||||
private _saveFiles;
|
private _saveFiles;
|
||||||
@@ -37,8 +49,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, true>;
|
savedFiles: Map<FileId, BinaryFileData>;
|
||||||
erroredFiles: Map<FileId, true>;
|
erroredFiles: Map<FileId, BinaryFileData>;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
this._getFiles = getFiles;
|
this._getFiles = getFiles;
|
||||||
@@ -46,19 +58,28 @@ export class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns whether file is already saved or being processed
|
* returns whether file is saved/errored, or being processed
|
||||||
*/
|
*/
|
||||||
isFileHandled = (id: FileId) => {
|
isFileTracked = (id: FileId) => {
|
||||||
return (
|
return (
|
||||||
this.savedFiles.has(id) ||
|
this.savedFiles.has(id) ||
|
||||||
this.fetchingFiles.has(id) ||
|
|
||||||
this.savingFiles.has(id) ||
|
this.savingFiles.has(id) ||
|
||||||
this.erroredFiles.has(id)
|
this.fetchingFiles.has(id) ||
|
||||||
|
this.erroredFiles_fetch.has(id) ||
|
||||||
|
this.erroredFiles_save.has(id)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
isFileSaved = (id: FileId) => {
|
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
|
||||||
return this.savedFiles.has(id);
|
const fileVersion = this.getFileVersion(file);
|
||||||
|
return (
|
||||||
|
this.savedFiles.get(file.id) === fileVersion ||
|
||||||
|
this.savingFiles.get(file.id) === fileVersion
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getFileVersion = (file: BinaryFileData) => {
|
||||||
|
return file.version ?? 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
saveFiles = async ({
|
saveFiles = async ({
|
||||||
@@ -71,13 +92,16 @@ 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 (
|
||||||
isInitializedImageElement(element) &&
|
fileData &&
|
||||||
files[element.fileId] &&
|
// NOTE if errored during save, won't retry due to this check
|
||||||
!this.isFileHandled(element.fileId)
|
!this.isFileSavedOrBeingSaved(fileData)
|
||||||
) {
|
) {
|
||||||
addedFiles.set(element.fileId, files[element.fileId]);
|
addedFiles.set(element.fileId, files[element.fileId]);
|
||||||
this.savingFiles.set(element.fileId, true);
|
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +110,12 @@ export class FileManager {
|
|||||||
addedFiles,
|
addedFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [fileId] of savedFiles) {
|
for (const [fileId, fileData] of savedFiles) {
|
||||||
this.savedFiles.set(fileId, true);
|
this.savedFiles.set(fileId, this.getFileVersion(fileData));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fileId, fileData] of erroredFiles) {
|
||||||
|
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -121,10 +149,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, true);
|
this.savedFiles.set(file.id, this.getFileVersion(file));
|
||||||
}
|
}
|
||||||
for (const [fileId] of erroredFiles) {
|
for (const [fileId] of erroredFiles) {
|
||||||
this.erroredFiles.set(fileId, true);
|
this.erroredFiles_fetch.set(fileId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loadedFiles, erroredFiles };
|
return { loadedFiles, erroredFiles };
|
||||||
@@ -160,7 +188,7 @@ export class FileManager {
|
|||||||
): element is InitializedExcalidrawImageElement => {
|
): element is InitializedExcalidrawImageElement => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
this.isFileSaved(element.fileId) &&
|
this.savedFiles.has(element.fileId) &&
|
||||||
element.status === "pending"
|
element.status === "pending"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -169,7 +197,8 @@ export class FileManager {
|
|||||||
this.fetchingFiles.clear();
|
this.fetchingFiles.clear();
|
||||||
this.savingFiles.clear();
|
this.savingFiles.clear();
|
||||||
this.savedFiles.clear();
|
this.savedFiles.clear();
|
||||||
this.erroredFiles.clear();
|
this.erroredFiles_fetch.clear();
|
||||||
|
this.erroredFiles_save.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ export class LocalData {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
async saveFiles({ addedFiles }) {
|
async saveFiles({ addedFiles }) {
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles = new Map<FileId, BinaryFileData>();
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles = new Map<FileId, BinaryFileData>();
|
||||||
|
|
||||||
// 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, true);
|
savedFiles.set(id, fileData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.set(id, fileData);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles: FileId[] = [];
|
||||||
|
|
||||||
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.set(id, true);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.push(id);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,11 +54,7 @@
|
|||||||
content="https://excalidraw.com/og-image-3.png"
|
content="https://excalidraw.com/og-image-3.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
<link rel="canonical" href="https://excalidraw.com" />
|
||||||
<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 -->
|
||||||
@@ -124,12 +120,6 @@
|
|||||||
<!-- Following placeholder is replaced during the build step -->
|
<!-- Following placeholder is replaced during the build step -->
|
||||||
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||||
|
|
||||||
<% } else { %>
|
|
||||||
<script>
|
|
||||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
|
||||||
</script>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
@@ -137,6 +127,12 @@
|
|||||||
type="text/css"
|
type="text/css"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<% } else { %>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<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" />
|
||||||
@@ -218,6 +214,7 @@
|
|||||||
</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
|
||||||
@@ -250,5 +247,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
|
<% } %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"jotai": "1.13.1",
|
"jotai": "2.11.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"socket.io-client": "4.7.2",
|
"socket.io-client": "4.7.2",
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
|
|||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
import type { CollabAPI } from "../collab/Collab";
|
import type { CollabAPI } from "../collab/Collab";
|
||||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
|
||||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||||
|
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||||
|
|
||||||
|
import "./ShareDialog.scss";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect, useLayoutEffect, useState } from "react";
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { THEME } from "../packages/excalidraw";
|
import { THEME } from "../packages/excalidraw";
|
||||||
import { EVENT } from "../packages/excalidraw/constants";
|
import { EVENT } from "../packages/excalidraw/constants";
|
||||||
@@ -6,18 +5,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
|
|||||||
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
||||||
import { STORAGE_KEYS } from "./app_constants";
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
|
||||||
export const appThemeAtom = atom<Theme | "system">(
|
|
||||||
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
|
||||||
| Theme
|
|
||||||
| "system"
|
|
||||||
| null) || THEME.LIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
||||||
window.matchMedia?.("(prefers-color-scheme: dark)");
|
window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
export const useHandleAppTheme = () => {
|
export const useHandleAppTheme = () => {
|
||||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
|
||||||
|
return (
|
||||||
|
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
||||||
|
| Theme
|
||||||
|
| "system"
|
||||||
|
| null) || THEME.LIGHT
|
||||||
|
);
|
||||||
|
});
|
||||||
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
|
|||||||
}
|
}
|
||||||
}, [appTheme]);
|
}, [appTheme]);
|
||||||
|
|
||||||
return { editorTheme };
|
return { editorTheme, appTheme, setAppTheme };
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+3
@@ -29,6 +29,9 @@ 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;
|
||||||
|
|||||||
+223
-220
@@ -8,231 +8,234 @@ import { createHtmlPlugin } from "vite-plugin-html";
|
|||||||
import Sitemap from "vite-plugin-sitemap";
|
import Sitemap from "vite-plugin-sitemap";
|
||||||
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||||
|
|
||||||
// To load .env.local variables
|
export default defineConfig(({ mode }) => {
|
||||||
const envVars = loadEnv("", `../`);
|
// To load .env variables
|
||||||
// https://vitejs.dev/config/
|
const envVars = loadEnv(mode, `../`);
|
||||||
export default defineConfig({
|
// https://vitejs.dev/config/
|
||||||
server: {
|
return {
|
||||||
port: Number(envVars.VITE_APP_PORT || 3000),
|
server: {
|
||||||
// open the browser
|
port: Number(envVars.VITE_APP_PORT || 3000),
|
||||||
open: true,
|
// open the browser
|
||||||
},
|
open: true,
|
||||||
// We need to specify the envDir since now there are no
|
|
||||||
//more located in parallel with the vite.config.ts file but in parent dir
|
|
||||||
envDir: "../",
|
|
||||||
build: {
|
|
||||||
outDir: "build",
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
assetFileNames(chunkInfo) {
|
|
||||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
|
||||||
const family = chunkInfo.name.split("-")[0];
|
|
||||||
return `fonts/${family}/[name][extname]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "assets/[name]-[hash][extname]";
|
|
||||||
},
|
|
||||||
// Creating separate chunk for locales except for en and percentages.json so they
|
|
||||||
// can be cached at runtime and not merged with
|
|
||||||
// app precache. en.json and percentages.json are needed for first load
|
|
||||||
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
|
||||||
manualChunks(id) {
|
|
||||||
if (
|
|
||||||
id.includes("packages/excalidraw/locales") &&
|
|
||||||
id.match(/en.json|percentages.json/) === null
|
|
||||||
) {
|
|
||||||
const index = id.indexOf("locales/");
|
|
||||||
// Taking the substring after "locales/"
|
|
||||||
return `locales/${id.substring(index + 8)}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
// We need to specify the envDir since now there are no
|
||||||
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
//more located in parallel with the vite.config.ts file but in parent dir
|
||||||
assetsInlineLimit: 0,
|
envDir: "../",
|
||||||
},
|
build: {
|
||||||
plugins: [
|
|
||||||
Sitemap({
|
|
||||||
hostname: "https://excalidraw.com",
|
|
||||||
outDir: "build",
|
outDir: "build",
|
||||||
changefreq: "monthly",
|
rollupOptions: {
|
||||||
// its static in public folder
|
output: {
|
||||||
generateRobotsTxt: false,
|
assetFileNames(chunkInfo) {
|
||||||
}),
|
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||||
woff2BrowserPlugin(),
|
const family = chunkInfo.name.split("-")[0];
|
||||||
react(),
|
return `fonts/${family}/[name][extname]`;
|
||||||
checker({
|
}
|
||||||
typescript: true,
|
|
||||||
eslint:
|
|
||||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
|
||||||
? undefined
|
|
||||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
|
||||||
overlay: {
|
|
||||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
|
||||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
svgrPlugin(),
|
|
||||||
ViteEjsPlugin(),
|
|
||||||
VitePWA({
|
|
||||||
registerType: "autoUpdate",
|
|
||||||
devOptions: {
|
|
||||||
/* set this flag to true to enable in Development mode */
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
workbox: {
|
return "assets/[name]-[hash][extname]";
|
||||||
// don't precache fonts, locales and separate chunks
|
|
||||||
globIgnores: [
|
|
||||||
"fonts.css",
|
|
||||||
"**/locales/**",
|
|
||||||
"service-worker.js",
|
|
||||||
"**/*.chunk-*.js",
|
|
||||||
],
|
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: new RegExp(".+.woff2"),
|
|
||||||
handler: "CacheFirst",
|
|
||||||
options: {
|
|
||||||
cacheName: "fonts",
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 1000,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
// Creating separate chunk for locales except for en and percentages.json so they
|
||||||
urlPattern: new RegExp("fonts.css"),
|
// can be cached at runtime and not merged with
|
||||||
handler: "StaleWhileRevalidate",
|
// app precache. en.json and percentages.json are needed for first load
|
||||||
options: {
|
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
||||||
cacheName: "fonts",
|
manualChunks(id) {
|
||||||
expiration: {
|
if (
|
||||||
maxEntries: 50,
|
id.includes("packages/excalidraw/locales") &&
|
||||||
},
|
id.match(/en.json|percentages.json/) === null
|
||||||
},
|
) {
|
||||||
},
|
const index = id.indexOf("locales/");
|
||||||
{
|
// Taking the substring after "locales/"
|
||||||
urlPattern: new RegExp("locales/[^/]+.js"),
|
return `locales/${id.substring(index + 8)}`;
|
||||||
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: "/",
|
|
||||||
display: "standalone",
|
|
||||||
theme_color: "#121212",
|
|
||||||
background_color: "#ffffff",
|
|
||||||
file_handlers: [
|
|
||||||
{
|
|
||||||
action: "/",
|
|
||||||
accept: {
|
|
||||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
share_target: {
|
|
||||||
action: "/web-share-target",
|
|
||||||
method: "POST",
|
|
||||||
enctype: "multipart/form-data",
|
|
||||||
params: {
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
name: "file",
|
|
||||||
accept: [
|
|
||||||
"application/vnd.excalidraw+json",
|
|
||||||
"application/json",
|
|
||||||
".excalidraw",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
screenshots: [
|
|
||||||
{
|
|
||||||
src: "/screenshots/virtual-whiteboard.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/wireframe.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/illustration.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/shapes.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/collaboration.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/export.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
sourcemap: true,
|
||||||
createHtmlPlugin({
|
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
||||||
minify: true,
|
assetsInlineLimit: 0,
|
||||||
}),
|
},
|
||||||
],
|
plugins: [
|
||||||
publicDir: "../public",
|
Sitemap({
|
||||||
|
hostname: "https://excalidraw.com",
|
||||||
|
outDir: "build",
|
||||||
|
changefreq: "monthly",
|
||||||
|
// its static in public folder
|
||||||
|
generateRobotsTxt: false,
|
||||||
|
}),
|
||||||
|
woff2BrowserPlugin(),
|
||||||
|
react(),
|
||||||
|
checker({
|
||||||
|
typescript: true,
|
||||||
|
eslint:
|
||||||
|
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||||
|
? undefined
|
||||||
|
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||||
|
overlay: {
|
||||||
|
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||||
|
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
svgrPlugin(),
|
||||||
|
ViteEjsPlugin(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
devOptions: {
|
||||||
|
/* set this flag to true to enable in Development mode */
|
||||||
|
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
|
||||||
|
},
|
||||||
|
|
||||||
|
workbox: {
|
||||||
|
// don't precache fonts, locales and separate chunks
|
||||||
|
globIgnores: [
|
||||||
|
"fonts.css",
|
||||||
|
"**/locales/**",
|
||||||
|
"service-worker.js",
|
||||||
|
"**/*.chunk-*.js",
|
||||||
|
],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".+.woff2"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "fonts",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1000,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp("fonts.css"),
|
||||||
|
handler: "StaleWhileRevalidate",
|
||||||
|
options: {
|
||||||
|
cacheName: "fonts",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "locales",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".chunk-.+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "chunk",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
short_name: "Excalidraw",
|
||||||
|
name: "Excalidraw",
|
||||||
|
description:
|
||||||
|
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "android-chrome-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "apple-touch-icon.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "favicon-32x32.png",
|
||||||
|
sizes: "32x32",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "favicon-16x16.png",
|
||||||
|
sizes: "16x16",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
start_url: "/",
|
||||||
|
id:"excalidraw",
|
||||||
|
display: "standalone",
|
||||||
|
theme_color: "#121212",
|
||||||
|
background_color: "#ffffff",
|
||||||
|
file_handlers: [
|
||||||
|
{
|
||||||
|
action: "/",
|
||||||
|
accept: {
|
||||||
|
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
share_target: {
|
||||||
|
action: "/web-share-target",
|
||||||
|
method: "POST",
|
||||||
|
enctype: "multipart/form-data",
|
||||||
|
params: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
accept: [
|
||||||
|
"application/vnd.excalidraw+json",
|
||||||
|
"application/json",
|
||||||
|
".excalidraw",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
screenshots: [
|
||||||
|
{
|
||||||
|
src: "/screenshots/virtual-whiteboard.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/wireframe.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/illustration.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/shapes.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/collaboration.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/export.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createHtmlPlugin({
|
||||||
|
minify: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
publicDir: "../public",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-10
@@ -45,7 +45,7 @@
|
|||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 20.x.x"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
@@ -55,15 +55,9 @@
|
|||||||
"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",
|
||||||
"fix:code": "yarn test:code --fix",
|
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||||
"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",
|
|
||||||
"start": "yarn --cwd ./excalidraw-app start",
|
"start": "yarn --cwd ./excalidraw-app start",
|
||||||
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||||
"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 .",
|
||||||
@@ -74,9 +68,15 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ 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) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
|
|||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import {
|
import {
|
||||||
@@ -18,14 +18,12 @@ import {
|
|||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
const framesToBeDeleted = new Set(
|
const framesToBeDeleted = new Set(
|
||||||
getSelectedElements(
|
getSelectedElements(
|
||||||
elements.filter((el) => isFrameLikeElement(el)),
|
elements.filter((el) => isFrameLikeElement(el)),
|
||||||
@@ -33,48 +31,99 @@ const deleteSelectedElements = (
|
|||||||
).map((el) => el.id),
|
).map((el) => el.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
||||||
elements: elements.map((el) => {
|
|
||||||
if (appState.selectedElementIds[el.id]) {
|
let shouldSelectEditingGroup = true;
|
||||||
if (el.boundElements) {
|
|
||||||
el.boundElements.forEach((candidate) => {
|
const nextElements = elements.map((el) => {
|
||||||
const bound = app.scene
|
if (appState.selectedElementIds[el.id]) {
|
||||||
.getNonDeletedElementsMap()
|
if (el.boundElements) {
|
||||||
.get(candidate.id);
|
el.boundElements.forEach((candidate) => {
|
||||||
if (bound && isElbowArrow(bound)) {
|
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||||
mutateElement(bound, {
|
if (bound && isElbowArrow(bound)) {
|
||||||
startBinding:
|
mutateElement(bound, {
|
||||||
el.id === bound.startBinding?.elementId
|
startBinding:
|
||||||
? null
|
el.id === bound.startBinding?.elementId
|
||||||
: bound.startBinding,
|
? null
|
||||||
endBinding:
|
: bound.startBinding,
|
||||||
el.id === bound.endBinding?.elementId
|
endBinding:
|
||||||
? null
|
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||||
: bound.endBinding,
|
});
|
||||||
});
|
mutateElement(bound, { points: bound.points });
|
||||||
mutateElbowArrow(bound, elementsMap, bound.points);
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// if deleting a frame, remove the children from it and select them
|
||||||
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
|
shouldSelectEditingGroup = false;
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
return newElementWith(el, { frameId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextEditingGroupId = appState.editingGroupId;
|
||||||
|
|
||||||
|
// select next eligible element in currently editing group or supergroup
|
||||||
|
if (shouldSelectEditingGroup && appState.editingGroupId) {
|
||||||
|
const elems = getElementsInGroup(
|
||||||
|
nextElements,
|
||||||
|
appState.editingGroupId,
|
||||||
|
).filter((el) => !el.isDeleted);
|
||||||
|
if (elems.length > 1) {
|
||||||
|
if (elems[0]) {
|
||||||
|
selectedElementIds[elems[0].id] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextEditingGroupId = null;
|
||||||
|
if (elems[0]) {
|
||||||
|
selectedElementIds[elems[0].id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastElementInGroup = elems[0];
|
||||||
|
if (lastElementInGroup) {
|
||||||
|
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
|
||||||
|
(groupId) => {
|
||||||
|
return groupId === appState.editingGroupId;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
|
||||||
|
if (superGroupId) {
|
||||||
|
const elems = getElementsInGroup(nextElements, superGroupId).filter(
|
||||||
|
(el) => !el.isDeleted,
|
||||||
|
);
|
||||||
|
if (elems.length > 1) {
|
||||||
|
nextEditingGroupId = superGroupId;
|
||||||
|
|
||||||
|
elems.forEach((el) => {
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
return {
|
||||||
return newElementWith(el, { isDeleted: true });
|
elements: nextElements,
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isBoundToContainer(el) &&
|
|
||||||
appState.selectedElementIds[el.containerId]
|
|
||||||
) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: {},
|
...selectGroupsForSelectedElements(
|
||||||
selectedGroupIds: {},
|
{
|
||||||
|
selectedElementIds,
|
||||||
|
editingGroupId: nextEditingGroupId,
|
||||||
|
},
|
||||||
|
nextElements,
|
||||||
|
appState,
|
||||||
|
null,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -157,11 +206,7 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
element,
|
|
||||||
selectedPointsIndices,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
|
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||||
|
import {
|
||||||
|
canCreateLinkFromElements,
|
||||||
|
defaultGetElementLinkFromSelection,
|
||||||
|
getLinkIdAndTypeFromSelection,
|
||||||
|
} from "../element/elementLink";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionCopyElementLink = register({
|
||||||
|
name: "copyElementLink",
|
||||||
|
label: "labels.copyElementLink",
|
||||||
|
icon: copyIcon,
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
perform: async (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.location) {
|
||||||
|
const idAndType = getLinkIdAndTypeFromSelection(
|
||||||
|
selectedElements,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idAndType) {
|
||||||
|
await copyTextToSystemClipboard(
|
||||||
|
app.props.generateLinkForSelection
|
||||||
|
? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
|
||||||
|
: defaultGetElementLinkFromSelection(
|
||||||
|
idAndType.id,
|
||||||
|
idAndType.type,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
toast: {
|
||||||
|
message: t("toast.elementLinkCopied"),
|
||||||
|
closable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
app,
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
app,
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
predicate: (elements, appState) =>
|
||||||
|
canCreateLinkFromElements(getSelectedElements(elements, appState)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionLinkToElement = register({
|
||||||
|
name: "linkToElement",
|
||||||
|
label: "labels.linkToElement",
|
||||||
|
icon: elementLinkIcon,
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedElements.length !== 1 ||
|
||||||
|
!canCreateLinkFromElements(selectedElements)
|
||||||
|
) {
|
||||||
|
return { elements, appState, app, storeAction: StoreAction.NONE };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
openDialog: {
|
||||||
|
name: "elementLinkSelector",
|
||||||
|
sourceElementId: getSelectedElements(elements, appState)[0].id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
predicate: (elements, appState, appProps, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
canCreateLinkFromElements(selectedElements)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
trackEvent: false,
|
||||||
|
});
|
||||||
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
|
|||||||
},
|
},
|
||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: "arrow",
|
endArrowhead: "arrow",
|
||||||
|
fixedSegments: null,
|
||||||
points: [
|
points: [
|
||||||
pointFrom(0, 0),
|
pointFrom(0, 0),
|
||||||
pointFrom(0, -35),
|
pointFrom(0, -35),
|
||||||
pointFrom(-90.9, -35),
|
pointFrom(-90, -35),
|
||||||
pointFrom(-90.9, 204.9),
|
pointFrom(-90, 204),
|
||||||
pointFrom(65.1, 204.9),
|
pointFrom(66, 204),
|
||||||
],
|
],
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
}),
|
}),
|
||||||
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
|
|||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||||
expect(rec1?.x).toBeCloseTo(100);
|
expect(rec1.x).toBeCloseTo(100, 0);
|
||||||
expect(rec1?.y).toBeCloseTo(100);
|
expect(rec1.y).toBeCloseTo(100, 0);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||||
expect(rec2?.x).toBeCloseTo(220);
|
expect(rec2.x).toBeCloseTo(220, 0);
|
||||||
expect(rec2?.y).toBeCloseTo(250);
|
expect(rec2.y).toBeCloseTo(250, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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,
|
||||||
@@ -25,8 +24,9 @@ import {
|
|||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@@ -132,18 +132,25 @@ const flipElements = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY, midX, midY } =
|
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||||
getCommonBoundingBox(selectedElements);
|
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
elementsMap,
|
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
"nw",
|
"nw",
|
||||||
true,
|
app.scene,
|
||||||
true,
|
new Map(
|
||||||
flipDirection === "horizontal" ? maxX : minX,
|
Array.from(elementsMap.values()).map((element) => [
|
||||||
flipDirection === "horizontal" ? minY : maxY,
|
element.id,
|
||||||
|
deepCopyElement(element),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
flipByX: flipDirection === "horizontal",
|
||||||
|
flipByY: flipDirection === "vertical",
|
||||||
|
shouldResizeFromCenter: true,
|
||||||
|
shouldMaintainAspectRatio: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
@@ -153,6 +160,7 @@ const flipElements = (
|
|||||||
app.scene,
|
app.scene,
|
||||||
isBindingEnabled(appState),
|
isBindingEnabled(appState),
|
||||||
[],
|
[],
|
||||||
|
appState.zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,16 +193,10 @@ const flipElements = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
elbowArrows.forEach((element) =>
|
elbowArrows.forEach((element) =>
|
||||||
mutateElbowArrow(
|
mutateElement(element, {
|
||||||
element,
|
x: element.x + diffX,
|
||||||
elementsMap,
|
y: element.y + diffY,
|
||||||
element.points,
|
}),
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
informMutation: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { removeAllElementsFromFrame } from "../frame";
|
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameChildren } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
@@ -10,6 +10,8 @@ import { register } from "./register";
|
|||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { newFrameElement } from "../element/newElement";
|
||||||
|
|
||||||
const isSingleFrameSelected = (
|
const isSingleFrameSelected = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -144,3 +146,46 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
event.key.toLocaleLowerCase() === KEYS.F,
|
event.key.toLocaleLowerCase() === KEYS.F,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionWrapSelectionInFrame = register({
|
||||||
|
name: "wrapSelectionInFrame",
|
||||||
|
label: "labels.wrapSelectionInFrame",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
predicate: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
!selectedElements.some((element) => isFrameLikeElement(element))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
const PADDING = 16;
|
||||||
|
const frame = newFrameElement({
|
||||||
|
x: x1 - PADDING,
|
||||||
|
y: y1 - PADDING,
|
||||||
|
width: x2 - x1 + PADDING * 2,
|
||||||
|
height: y2 - y1 + PADDING * 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextElements = addElementsToFrame(
|
||||||
|
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||||
|
selectedElements,
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: {
|
||||||
|
selectedElementIds: { [frame.id]: true },
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
sharpArrowIcon,
|
sharpArrowIcon,
|
||||||
roundArrowIcon,
|
roundArrowIcon,
|
||||||
elbowArrowIcon,
|
elbowArrowIcon,
|
||||||
|
ArrowheadCrowfootIcon,
|
||||||
|
ArrowheadCrowfootOneIcon,
|
||||||
|
ArrowheadCrowfootOneOrManyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
ARROW_TYPE,
|
ARROW_TYPE,
|
||||||
@@ -113,10 +116,9 @@ import {
|
|||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import type { LocalPoint } from "../../math";
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom, vector } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -1405,59 +1407,65 @@ 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: "t",
|
keyBinding: "e",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: null,
|
keyBinding: "r",
|
||||||
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: null,
|
keyBinding: "d",
|
||||||
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: null,
|
keyBinding: "f",
|
||||||
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;
|
||||||
};
|
};
|
||||||
@@ -1521,6 +1529,7 @@ 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"
|
||||||
@@ -1537,6 +1546,7 @@ 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>
|
||||||
@@ -1549,150 +1559,162 @@ export const actionChangeArrowType = register({
|
|||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
const newElements = changeProperty(elements, appState, (el) => {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
if (!isArrowElement(el)) {
|
||||||
if (!isArrowElement(el)) {
|
return el;
|
||||||
return el;
|
}
|
||||||
}
|
const newElement = newElementWith(el, {
|
||||||
const newElement = newElementWith(el, {
|
roundness:
|
||||||
roundness:
|
value === ARROW_TYPE.round
|
||||||
value === ARROW_TYPE.round
|
? {
|
||||||
? {
|
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
}
|
||||||
}
|
: null,
|
||||||
: null,
|
elbowed: value === ARROW_TYPE.elbow,
|
||||||
elbowed: value === ARROW_TYPE.elbow,
|
points:
|
||||||
points:
|
value === ARROW_TYPE.elbow || el.elbowed
|
||||||
value === ARROW_TYPE.elbow || el.elbowed
|
? [el.points[0], el.points[el.points.length - 1]]
|
||||||
? [el.points[0], el.points[el.points.length - 1]]
|
: el.points,
|
||||||
: el.points,
|
});
|
||||||
|
|
||||||
|
if (isElbowArrow(newElement)) {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
app.dismissLinearEditor();
|
||||||
|
|
||||||
|
const startGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
0,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const endGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
-1,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const startHoveredElement =
|
||||||
|
!newElement.startBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(startGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
);
|
||||||
|
const endHoveredElement =
|
||||||
|
!newElement.endBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(endGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
);
|
||||||
|
const startElement = startHoveredElement
|
||||||
|
? startHoveredElement
|
||||||
|
: newElement.startBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.startBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
const endElement = endHoveredElement
|
||||||
|
? endHoveredElement
|
||||||
|
: newElement.endBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.endBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
|
const finalStartPoint = startHoveredElement
|
||||||
|
? bindPointToSnapToElementOutline(
|
||||||
|
startGlobalPoint,
|
||||||
|
endGlobalPoint,
|
||||||
|
startHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: startGlobalPoint;
|
||||||
|
const finalEndPoint = endHoveredElement
|
||||||
|
? bindPointToSnapToElementOutline(
|
||||||
|
endGlobalPoint,
|
||||||
|
startGlobalPoint,
|
||||||
|
endHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: endGlobalPoint;
|
||||||
|
|
||||||
|
startHoveredElement &&
|
||||||
|
bindLinearElement(
|
||||||
|
newElement,
|
||||||
|
startHoveredElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
endHoveredElement &&
|
||||||
|
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
|
||||||
|
|
||||||
|
mutateElement(newElement, {
|
||||||
|
points: [finalStartPoint, finalEndPoint].map(
|
||||||
|
(p): LocalPoint =>
|
||||||
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
|
),
|
||||||
|
...(startElement && newElement.startBinding
|
||||||
|
? {
|
||||||
|
startBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.startBinding!,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
startElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(endElement && newElement.endBinding
|
||||||
|
? {
|
||||||
|
endBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.endBinding,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
endElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isElbowArrow(newElement)) {
|
LinearElementEditor.updateEditorMidPointsCache(
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
newElement,
|
||||||
|
elementsMap,
|
||||||
|
app.state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.dismissLinearEditor();
|
return newElement;
|
||||||
|
});
|
||||||
|
|
||||||
const startGlobalPoint =
|
const newState = {
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
...appState,
|
||||||
newElement,
|
currentItemArrowType: value,
|
||||||
0,
|
};
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const endGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
-1,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const startHoveredElement =
|
|
||||||
!newElement.startBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.startBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
const endElement = endHoveredElement
|
|
||||||
? endHoveredElement
|
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.endBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
// Change the arrow type and update any other state settings for
|
||||||
? bindPointToSnapToElementOutline(
|
// the arrow.
|
||||||
startGlobalPoint,
|
const selectedId = appState.selectedLinearElement?.elementId;
|
||||||
endGlobalPoint,
|
if (selectedId) {
|
||||||
startHoveredElement,
|
const selected = newElements.find((el) => el.id === selectedId);
|
||||||
elementsMap,
|
if (selected) {
|
||||||
)
|
newState.selectedLinearElement = new LinearElementEditor(
|
||||||
: startGlobalPoint;
|
selected as ExcalidrawLinearElement,
|
||||||
const finalEndPoint = endHoveredElement
|
);
|
||||||
? bindPointToSnapToElementOutline(
|
}
|
||||||
endGlobalPoint,
|
}
|
||||||
startGlobalPoint,
|
|
||||||
endHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
return {
|
||||||
bindLinearElement(
|
elements: newElements,
|
||||||
newElement,
|
appState: newState,
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
endHoveredElement,
|
|
||||||
"end",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
mutateElbowArrow(
|
|
||||||
newElement,
|
|
||||||
elementsMap,
|
|
||||||
[finalStartPoint, finalEndPoint].map(
|
|
||||||
(p): LocalPoint =>
|
|
||||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
|
||||||
),
|
|
||||||
vector(0, 0),
|
|
||||||
{
|
|
||||||
...(startElement && newElement.startBinding
|
|
||||||
? {
|
|
||||||
startBinding: {
|
|
||||||
// @ts-ignore TS cannot discern check above
|
|
||||||
...newElement.startBinding!,
|
|
||||||
...calculateFixedPointForElbowArrowBinding(
|
|
||||||
newElement,
|
|
||||||
startElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(endElement && newElement.endBinding
|
|
||||||
? {
|
|
||||||
endBinding: {
|
|
||||||
// @ts-ignore TS cannot discern check above
|
|
||||||
...newElement.endBinding,
|
|
||||||
...calculateFixedPointForElbowArrowBinding(
|
|
||||||
newElement,
|
|
||||||
endElement,
|
|
||||||
"end",
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElement;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
currentItemArrowType: value,
|
|
||||||
},
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type ShortcutName =
|
|||||||
| "saveFileToDisk"
|
| "saveFileToDisk"
|
||||||
| "saveToActiveFile"
|
| "saveToActiveFile"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
|
| "wrapSelectionInFrame"
|
||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||||
|
wrapSelectionInFrame: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -135,7 +135,10 @@ export type ActionName =
|
|||||||
| "autoResize"
|
| "autoResize"
|
||||||
| "elementStats"
|
| "elementStats"
|
||||||
| "searchMenu"
|
| "searchMenu"
|
||||||
| "cropEditor";
|
| "copyElementLink"
|
||||||
|
| "linkToElement"
|
||||||
|
| "cropEditor"
|
||||||
|
| "wrapSelectionInFrame";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ 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,
|
||||||
@@ -210,6 +211,7 @@ 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,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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;
|
||||||
@@ -39,7 +41,7 @@ export interface ClipboardData {
|
|||||||
|
|
||||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||||
|
|
||||||
type ParsedClipboardEvent =
|
type ParsedClipboardEventTextData =
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "mixedContent"; value: PastedMixedContent };
|
| { type: "mixedContent"; value: PastedMixedContent };
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ export const createPasteEvent = ({
|
|||||||
types,
|
types,
|
||||||
files,
|
files,
|
||||||
}: {
|
}: {
|
||||||
types?: { [key in AllowedPasteMimeTypes]?: string };
|
types?: { [key in AllowedPasteMimeTypes]?: string | File };
|
||||||
files?: File[];
|
files?: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!types && !files) {
|
if (!types && !files) {
|
||||||
@@ -88,6 +90,11 @@ 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) {
|
||||||
@@ -217,14 +224,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("text/html");
|
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
|
|
||||||
const content = parseHTMLTree(doc.body);
|
const content = parseHTMLTree(doc.body);
|
||||||
|
|
||||||
@@ -238,34 +245,44 @@ 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 } = {};
|
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
|
||||||
|
|
||||||
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) {
|
||||||
if (error.name === "DataError") {
|
try {
|
||||||
console.warn(
|
if (navigator.clipboard?.readText) {
|
||||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
console.warn(
|
||||||
);
|
`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;
|
||||||
}
|
}
|
||||||
@@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
types[type] = await (await item.getType(type)).text();
|
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
|
||||||
|
types[type] = await (await item.getType(type)).text();
|
||||||
|
} else if (isSupportedImageFileType(type)) {
|
||||||
|
const imageBlob = await item.getType(type);
|
||||||
|
const file = createFile(imageBlob, type, undefined);
|
||||||
|
types[type] = file;
|
||||||
|
} else {
|
||||||
|
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
error instanceof ExcalidrawError
|
||||||
|
? error.message
|
||||||
|
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
|
|||||||
/**
|
/**
|
||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEvent = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEvent> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||||
|
|
||||||
@@ -308,7 +335,7 @@ const parseClipboardEvent = async (
|
|||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData("text/plain") ||
|
event.clipboardData?.getData(MIME_TYPES.text) ||
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -319,7 +346,7 @@ const parseClipboardEvent = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
||||||
|
|
||||||
return { type: "text", value: (text || "").trim() };
|
return { type: "text", value: (text || "").trim() };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -328,13 +355,16 @@ const parseClipboardEvent = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard. Prefers system clipboard.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
|
event,
|
||||||
|
isPlainPaste,
|
||||||
|
);
|
||||||
|
|
||||||
if (parsedEventData.type === "mixedContent") {
|
if (parsedEventData.type === "mixedContent") {
|
||||||
return {
|
return {
|
||||||
@@ -423,8 +453,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("text/plain", text || "");
|
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
|
||||||
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
|
||||||
throw new Error("Failed to setData on clipboardEvent");
|
throw new Error("Failed to setData on clipboardEvent");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { actionClearCanvas } from "../actions";
|
import { actionClearCanvas } from "../actions";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { jotaiScope } from "../jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { useExcalidrawActionManager } from "./App";
|
import { useExcalidrawActionManager } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
|||||||
export const ActiveConfirmDialog = () => {
|
export const ActiveConfirmDialog = () => {
|
||||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||||
activeConfirmDialogAtom,
|
activeConfirmDialogAtom,
|
||||||
jotaiScope,
|
|
||||||
);
|
);
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getColor } from "./ColorPicker";
|
import { getColor } from "./ColorPicker";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { eyeDropperIcon } from "../icons";
|
import { eyeDropperIcon } from "../icons";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -57,10 +56,7 @@ export const ColorInput = ({
|
|||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
activeEyeDropperAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { useExcalidrawContainer } from "../App";
|
import { useExcalidrawContainer } from "../App";
|
||||||
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
|
|||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
|
|||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
activeEyeDropperAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
|
|||||||
import { ShadeList } from "./ShadeList";
|
import { ShadeList } from "./ShadeList";
|
||||||
|
|
||||||
import PickerColorList from "./PickerColorList";
|
import PickerColorList from "./PickerColorList";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { CustomColorList } from "./CustomColorList";
|
import { CustomColorList } from "./CustomColorList";
|
||||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { atom } from "jotai";
|
|
||||||
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
||||||
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
||||||
|
import { atom } from "../../editor-jotai";
|
||||||
|
|
||||||
export const getColorNameAndShadeFromColor = ({
|
export const getColorNameAndShadeFromColor = ({
|
||||||
palette,
|
palette,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "../../utils";
|
} from "../../utils";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
|
||||||
import { deburr } from "../../deburr";
|
import { deburr } from "../../deburr";
|
||||||
import type { MarkRequired } from "../../utility-types";
|
import type { MarkRequired } from "../../utility-types";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
actionLink,
|
actionLink,
|
||||||
actionToggleSearchMenu,
|
actionToggleSearchMenu,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import { jotaiStore } from "../../jotai";
|
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
@@ -56,6 +55,10 @@ 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);
|
||||||
|
|
||||||
@@ -259,6 +262,7 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.cut,
|
actionManager.actions.cut,
|
||||||
actionManager.actions.copy,
|
actionManager.actions.copy,
|
||||||
actionManager.actions.deleteSelectedElements,
|
actionManager.actions.deleteSelectedElements,
|
||||||
|
actionManager.actions.wrapSelectionInFrame,
|
||||||
actionManager.actions.copyStyles,
|
actionManager.actions.copyStyles,
|
||||||
actionManager.actions.pasteStyles,
|
actionManager.actions.pasteStyles,
|
||||||
actionManager.actions.bringToFront,
|
actionManager.actions.bringToFront,
|
||||||
@@ -281,6 +285,8 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.toggleLinearEditor,
|
actionManager.actions.toggleLinearEditor,
|
||||||
actionManager.actions.cropEditor,
|
actionManager.actions.cropEditor,
|
||||||
actionLink,
|
actionLink,
|
||||||
|
actionCopyElementLink,
|
||||||
|
actionLinkToElement,
|
||||||
].map((action: Action) =>
|
].map((action: Action) =>
|
||||||
actionToCommand(
|
actionToCommand(
|
||||||
action,
|
action,
|
||||||
@@ -342,7 +348,7 @@ function CommandPaletteInner({
|
|||||||
keywords: ["delete", "destroy"],
|
keywords: ["delete", "destroy"],
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: () => {
|
perform: () => {
|
||||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
import "./ConfirmDialog.scss";
|
import "./ConfirmDialog.scss";
|
||||||
import DialogActionButton from "./DialogActionButton";
|
import DialogActionButton from "./DialogActionButton";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useSetAtom } from "../editor-jotai";
|
||||||
|
|
||||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -26,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +43,14 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
setIsLibraryMenuOpen(false);
|
setIsLibraryMenuOpen(false);
|
||||||
onCancel();
|
// flush any pending updates synchronously,
|
||||||
|
// otherwise it could lead to crash in some chromium versions (131.0.6778.86),
|
||||||
|
// when `.focus` is invoked with container in some intermediate state
|
||||||
|
// (container seems mounted in DOM, but focus still causes a crash)
|
||||||
|
flushSync(() => {
|
||||||
|
onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
container?.focus();
|
container?.focus();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -52,7 +59,14 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
setIsLibraryMenuOpen(false);
|
setIsLibraryMenuOpen(false);
|
||||||
onConfirm();
|
// flush any pending updates synchronously,
|
||||||
|
// otherwise it leads to crash in some chromium versions (131.0.6778.86),
|
||||||
|
// when `.focus` is invoked with container in some intermediate state
|
||||||
|
// (container seems mounted in DOM, but focus still causes a crash)
|
||||||
|
flushSync(() => {
|
||||||
|
onConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
container?.focus();
|
container?.focus();
|
||||||
}}
|
}}
|
||||||
actionType="danger"
|
actionType="danger"
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import "./Dialog.scss";
|
|||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { queryFocusableElements } from "../utils";
|
import { queryFocusableElements } from "../utils";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useSetAtom } from "../editor-jotai";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CloseIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
|
|
||||||
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
}, [islandNode, props.autofocus]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.ElementLinkDialog {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--editor-container-padding);
|
||||||
|
left: var(--editor-container-padding);
|
||||||
|
|
||||||
|
z-index: var(--zIndex-modal);
|
||||||
|
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
background-color: var(--island-bg-color);
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
left: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__header {
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__input {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.ElementLinkDialog__input-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__remove {
|
||||||
|
color: $oc-red-9;
|
||||||
|
margin-left: 1rem;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon svg {
|
||||||
|
color: $oc-red-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { TextField } from "./TextField";
|
||||||
|
import type { AppProps, AppState, UIAppState } from "../types";
|
||||||
|
import DialogActionButton from "./DialogActionButton";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import {
|
||||||
|
defaultGetElementLinkFromSelection,
|
||||||
|
getLinkIdAndTypeFromSelection,
|
||||||
|
} from "../element/elementLink";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "../element/types";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { TrashIcon } from "./icons";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
|
import "./ElementLinkDialog.scss";
|
||||||
|
import { normalizeLink } from "../data/url";
|
||||||
|
|
||||||
|
const ElementLinkDialog = ({
|
||||||
|
sourceElementId,
|
||||||
|
onClose,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
generateLinkForSelection = defaultGetElementLinkFromSelection,
|
||||||
|
}: {
|
||||||
|
sourceElementId: ExcalidrawElement["id"];
|
||||||
|
elementsMap: ElementsMap;
|
||||||
|
appState: UIAppState;
|
||||||
|
onClose?: () => void;
|
||||||
|
generateLinkForSelection: AppProps["generateLinkForSelection"];
|
||||||
|
}) => {
|
||||||
|
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
|
||||||
|
|
||||||
|
const [nextLink, setNextLink] = useState<string | null>(originalLink);
|
||||||
|
const [linkEdited, setLinkEdited] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedElements = getSelectedElements(elementsMap, appState);
|
||||||
|
let nextLink = originalLink;
|
||||||
|
|
||||||
|
if (selectedElements.length > 0 && generateLinkForSelection) {
|
||||||
|
const idAndType = getLinkIdAndTypeFromSelection(
|
||||||
|
selectedElements,
|
||||||
|
appState as AppState,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idAndType) {
|
||||||
|
nextLink = normalizeLink(
|
||||||
|
generateLinkForSelection(idAndType.id, idAndType.type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNextLink(nextLink);
|
||||||
|
}, [
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
appState.selectedElementIds,
|
||||||
|
originalLink,
|
||||||
|
generateLinkForSelection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
|
||||||
|
const elementToLink = elementsMap.get(sourceElementId);
|
||||||
|
elementToLink &&
|
||||||
|
mutateElement(elementToLink, {
|
||||||
|
link: nextLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextLink && linkEdited && sourceElementId) {
|
||||||
|
const elementToLink = elementsMap.get(sourceElementId);
|
||||||
|
elementToLink &&
|
||||||
|
mutateElement(elementToLink, {
|
||||||
|
link: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose?.();
|
||||||
|
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
appState.openDialog?.name === "elementLinkSelector" &&
|
||||||
|
event.key === KEYS.ENTER
|
||||||
|
) {
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appState.openDialog?.name === "elementLinkSelector" &&
|
||||||
|
event.key === KEYS.ESCAPE
|
||||||
|
) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [appState, onClose, handleConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ElementLinkDialog">
|
||||||
|
<div className="ElementLinkDialog__header">
|
||||||
|
<h2>{t("elementLink.title")}</h2>
|
||||||
|
<p>{t("elementLink.desc")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ElementLinkDialog__input">
|
||||||
|
<TextField
|
||||||
|
value={nextLink ?? ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!linkEdited) {
|
||||||
|
setLinkEdited(true);
|
||||||
|
}
|
||||||
|
setNextLink(value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === KEYS.ENTER) {
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ElementLinkDialog__input-field"
|
||||||
|
selectOnRender
|
||||||
|
/>
|
||||||
|
|
||||||
|
{originalLink && nextLink && (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.remove")}
|
||||||
|
aria-label={t("buttons.remove")}
|
||||||
|
label={t("buttons.remove")}
|
||||||
|
onClick={() => {
|
||||||
|
// removes the link from the input
|
||||||
|
// but doesn't update the element
|
||||||
|
|
||||||
|
// when confirmed, will remove the link from the element
|
||||||
|
setNextLink(null);
|
||||||
|
setLinkEdited(true);
|
||||||
|
}}
|
||||||
|
className="ElementLinkDialog__remove"
|
||||||
|
icon={TrashIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ElementLinkDialog__actions">
|
||||||
|
<DialogActionButton
|
||||||
|
label={t("buttons.cancel")}
|
||||||
|
onClick={() => {
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginRight: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogActionButton
|
||||||
|
label={t("buttons.confirm")}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
actionType="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElementLinkDialog;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { rgbToHex } from "../colors";
|
import { rgbToHex } from "../colors";
|
||||||
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
|
|||||||
import "./EyeDropper.scss";
|
import "./EyeDropper.scss";
|
||||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
|
import { atom } from "../editor-jotai";
|
||||||
|
|
||||||
export type EyeDropperProperties = {
|
export type EyeDropperProperties = {
|
||||||
keepOpenOnAlt: boolean;
|
keepOpenOnAlt: boolean;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Header = () => (
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="HelpDialog__btn"
|
className="HelpDialog__btn"
|
||||||
href="https://blog.excalidraw.com"
|
href="https://plus.excalidraw.com/blog"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
@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);
|
||||||
// ˇˇ yeah, i dunno, open to suggestions here :D
|
box-shadow: var(--shadow-island);
|
||||||
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,
|
||||||
@@ -55,47 +52,16 @@
|
|||||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-triangle {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0 9px 10px;
|
|
||||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0 9px 10px;
|
|
||||||
border-color: transparent transparent var(--popup-bg-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, auto);
|
grid-template-columns: repeat(4, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
}
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
.picker-collapsible {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Popover } from "./Popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
|
import { getLanguage, t } from "../i18n";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Collapsible from "./Stats/Collapsible";
|
||||||
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
|
import { useDevice } from "..";
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
|
||||||
import { getLanguage } from "../i18n";
|
const moreOptionsAtom = atom(false);
|
||||||
import clsx from "clsx";
|
|
||||||
|
type Option<T> = {
|
||||||
|
value: T;
|
||||||
|
text: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
keyBinding: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function Picker<T>({
|
function Picker<T>({
|
||||||
options,
|
options,
|
||||||
@@ -12,30 +24,16 @@ function Picker<T>({
|
|||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
|
numberOfOptionsToAlwaysShow = options.length,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
options: {
|
options: readonly Option<T>[];
|
||||||
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 rFirstItem = React.useRef<HTMLButtonElement>();
|
const device = useDevice();
|
||||||
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(
|
||||||
@@ -44,28 +42,19 @@ function Picker<T>({
|
|||||||
|
|
||||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||||
// Keybinding navigation
|
// Keybinding navigation
|
||||||
const index = options.indexOf(pressedOption);
|
onChange(pressedOption.value);
|
||||||
(rGallery!.current!.children![index] as any).focus();
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.TAB) {
|
} else if (event.key === KEYS.TAB) {
|
||||||
// Tab navigation cycle through options. If the user tabs
|
const index = options.findIndex((option) => option.value === value);
|
||||||
// away from the picker, close the picker. We need to use
|
const nextIndex = event.shiftKey
|
||||||
// a timeout here to let the stack clear before checking.
|
? (options.length + index - 1) % options.length
|
||||||
setTimeout(() => {
|
: (index + 1) % options.length;
|
||||||
const active = rActiveItem.current;
|
onChange(options[nextIndex].value);
|
||||||
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 = Array.prototype.indexOf.call(
|
const index = options.findIndex((option) => option.value === value);
|
||||||
rGallery!.current!.children,
|
|
||||||
activeElement,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const length = options.length;
|
const length = options.length;
|
||||||
let nextIndex = index;
|
let nextIndex = index;
|
||||||
@@ -73,19 +62,26 @@ 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:
|
||||||
case KEYS.ARROW_UP: {
|
|
||||||
nextIndex = (length + index - 1) % length;
|
nextIndex = (length + index - 1) % length;
|
||||||
break;
|
break;
|
||||||
|
// Go the next row
|
||||||
|
case KEYS.ARROW_DOWN: {
|
||||||
|
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Go the previous row
|
||||||
|
case KEYS.ARROW_UP: {
|
||||||
|
nextIndex =
|
||||||
|
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(rGallery.current!.children![nextIndex] as any).focus();
|
onChange(options[nextIndex].value);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
@@ -97,15 +93,26 @@ function Picker<T>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||||
<div
|
|
||||||
className={`picker`}
|
const alwaysVisibleOptions = React.useMemo(
|
||||||
role="dialog"
|
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||||
aria-modal="true"
|
[options, numberOfOptionsToAlwaysShow],
|
||||||
aria-label={label}
|
);
|
||||||
onKeyDown={handleKeyDown}
|
const moreOptions = React.useMemo(
|
||||||
>
|
() => options.slice(numberOfOptionsToAlwaysShow),
|
||||||
<div className="picker-content" ref={rGallery}>
|
[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"
|
||||||
@@ -113,7 +120,6 @@ 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} ${
|
||||||
@@ -122,16 +128,13 @@ 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={(el) => {
|
ref={(ref) => {
|
||||||
if (el && i === 0) {
|
if (value === option.value) {
|
||||||
rFirstItem.current = el;
|
// Use a timeout here to render focus properly
|
||||||
|
setTimeout(() => {
|
||||||
|
ref?.focus();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
if (el && option.value === value) {
|
|
||||||
rActiveItem.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
onChange(option.value);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
@@ -141,7 +144,43 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +190,7 @@ export function IconPicker<T>({
|
|||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
group = "",
|
group = "",
|
||||||
|
numberOfOptionsToAlwaysShow,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -159,51 +199,40 @@ 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>
|
||||||
<button
|
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||||
name={group}
|
<Popover.Trigger
|
||||||
type="button"
|
name={group}
|
||||||
className={isActive ? "active" : ""}
|
type="button"
|
||||||
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}
|
>
|
||||||
</button>
|
{options.find((option) => option.value === value)?.icon}
|
||||||
<React.Suspense fallback="">
|
</Popover.Trigger>
|
||||||
{isActive ? (
|
{isActive && (
|
||||||
<>
|
<Picker
|
||||||
<Popover
|
options={options}
|
||||||
onCloseRequest={(event) =>
|
value={value}
|
||||||
event.target !== rPickerButton.current && setActive(false)
|
label={label}
|
||||||
}
|
onChange={onChange}
|
||||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
onClose={() => {
|
||||||
>
|
setActive(false);
|
||||||
<Picker
|
}}
|
||||||
options={options.filter((opt) => opt.showInPicker !== false)}
|
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||||
value={value}
|
/>
|
||||||
label={label}
|
)}
|
||||||
onChange={onChange}
|
</Popover.Root>
|
||||||
onClose={() => {
|
|
||||||
setActive(false);
|
|
||||||
rPickerButton.current?.focus();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<div className="picker-triangle" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
|
|||||||
import { useDevice } from "./App";
|
import { useDevice } from "./App";
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useAtom, useAtomValue } from "../editor-jotai";
|
||||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
|
||||||
import MainMenu from "./main-menu/MainMenu";
|
import MainMenu from "./main-menu/MainMenu";
|
||||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||||
@@ -60,6 +59,7 @@ 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";
|
||||||
@@ -84,6 +84,7 @@ 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<{
|
||||||
@@ -141,14 +142,14 @@ const LayerUI = ({
|
|||||||
children,
|
children,
|
||||||
app,
|
app,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
|
generateLinkForSelection,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||||
activeEyeDropperAtom,
|
|
||||||
jotaiScope,
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
);
|
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -232,7 +233,8 @@ 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">
|
||||||
@@ -241,90 +243,91 @@ const LayerUI = ({
|
|||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!appState.viewModeEnabled && (
|
{!appState.viewModeEnabled &&
|
||||||
<Section heading="shapes" className="shapes-section">
|
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||||
{(heading: React.ReactNode) => (
|
<Section heading="shapes" className="shapes-section">
|
||||||
<div style={{ position: "relative" }}>
|
{(heading: React.ReactNode) => (
|
||||||
{renderWelcomeScreen && (
|
<div style={{ position: "relative" }}>
|
||||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
{renderWelcomeScreen && (
|
||||||
)}
|
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||||
<Stack.Col gap={4} align="start">
|
)}
|
||||||
<Stack.Row
|
<Stack.Col gap={4} align="start">
|
||||||
gap={1}
|
<Stack.Row
|
||||||
className={clsx("App-toolbar-container", {
|
gap={1}
|
||||||
"zen-mode": appState.zenModeEnabled,
|
className={clsx("App-toolbar-container", {
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Island
|
|
||||||
padding={1}
|
|
||||||
className={clsx("App-toolbar", {
|
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<Island
|
||||||
appState={appState}
|
padding={1}
|
||||||
isMobile={device.editor.isMobile}
|
className={clsx("App-toolbar", {
|
||||||
device={device}
|
"zen-mode": appState.zenModeEnabled,
|
||||||
app={app}
|
})}
|
||||||
/>
|
>
|
||||||
{heading}
|
<HintViewer
|
||||||
<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}
|
||||||
activeTool={appState.activeTool}
|
isMobile={device.editor.isMobile}
|
||||||
UIOptions={UIOptions}
|
device={device}
|
||||||
app={app}
|
app={app}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
{heading}
|
||||||
</Island>
|
<Stack.Row gap={1}>
|
||||||
{isCollaborating && (
|
<PenModeButton
|
||||||
<Island
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
style={{
|
checked={appState.penMode}
|
||||||
marginLeft: 8,
|
onChange={() => onPenModeToggle(null)}
|
||||||
alignSelf: "center",
|
title={t("toolBar.penMode")}
|
||||||
height: "fit-content",
|
penDetected={appState.penDetected}
|
||||||
}}
|
/>
|
||||||
>
|
<LockButton
|
||||||
<LaserPointerButton
|
checked={appState.activeTool.locked}
|
||||||
title={t("toolBar.laser")}
|
onChange={onLockToggle}
|
||||||
checked={
|
title={t("toolBar.lock")}
|
||||||
appState.activeTool.type === TOOL_TYPE.laser
|
/>
|
||||||
}
|
|
||||||
onChange={() =>
|
<div className="App-toolbar__divider" />
|
||||||
app.setActiveTool({ type: TOOL_TYPE.laser })
|
|
||||||
}
|
<HandButton
|
||||||
isMobile
|
checked={isHandToolActive(appState)}
|
||||||
/>
|
onChange={() => onHandToolToggle()}
|
||||||
|
title={t("toolBar.hand")}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShapesSwitcher
|
||||||
|
appState={appState}
|
||||||
|
activeTool={appState.activeTool}
|
||||||
|
UIOptions={UIOptions}
|
||||||
|
app={app}
|
||||||
|
/>
|
||||||
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
)}
|
{isCollaborating && (
|
||||||
</Stack.Row>
|
<Island
|
||||||
</Stack.Col>
|
style={{
|
||||||
</div>
|
marginLeft: 8,
|
||||||
)}
|
alignSelf: "center",
|
||||||
</Section>
|
height: "fit-content",
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
|
<LaserPointerButton
|
||||||
|
title={t("toolBar.laser")}
|
||||||
|
checked={
|
||||||
|
appState.activeTool.type === TOOL_TYPE.laser
|
||||||
|
}
|
||||||
|
onChange={() =>
|
||||||
|
app.setActiveTool({ type: TOOL_TYPE.laser })
|
||||||
|
}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
)}
|
||||||
|
</Stack.Row>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
@@ -341,6 +344,7 @@ 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) && (
|
||||||
@@ -376,7 +380,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
||||||
|
|
||||||
const layerUIJSX = (
|
const layerUIJSX = (
|
||||||
<>
|
<>
|
||||||
@@ -471,6 +475,19 @@ 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()}
|
||||||
@@ -547,11 +564,11 @@ const LayerUI = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UIAppStateContext.Provider value={appState}>
|
<UIAppStateContext.Provider value={appState}>
|
||||||
<Provider scope={tunnels.jotaiScope}>
|
<TunnelsJotaiProvider>
|
||||||
<TunnelsContext.Provider value={tunnels}>
|
<TunnelsContext.Provider value={tunnels}>
|
||||||
{layerUIJSX}
|
{layerUIJSX}
|
||||||
</TunnelsContext.Provider>
|
</TunnelsContext.Provider>
|
||||||
</Provider>
|
</TunnelsJotaiProvider>
|
||||||
</UIAppStateContext.Provider>
|
</UIAppStateContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import {
|
import {
|
||||||
useApp,
|
useApp,
|
||||||
@@ -61,7 +60,7 @@ export const LibraryMenuContent = ({
|
|||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
|
|
||||||
const _onAddToLibrary = useCallback(
|
const _onAddToLibrary = useCallback(
|
||||||
(elements: LibraryItem["elements"]) => {
|
(elements: LibraryItem["elements"]) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useAtom } from "../editor-jotai";
|
||||||
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||||
import { saveLibraryAsJSON } from "../data/json";
|
import { saveLibraryAsJSON } from "../data/json";
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import PublishLibrary from "./PublishLibrary";
|
import PublishLibrary from "./PublishLibrary";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
appState,
|
appState,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||||
isLibraryMenuOpenAtom,
|
isLibraryMenuOpenAtom,
|
||||||
jotaiScope,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRemoveLibAlert = () => {
|
const renderRemoveLibAlert = () => {
|
||||||
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
|
|||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
|
|
||||||
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
||||||
const nextItems = libraryItems.filter(
|
const nextItems = libraryItems.filter(
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ export const MobileMenu = ({
|
|||||||
</Island>
|
</Island>
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||||
<div className="mobile-misc-tools-container">
|
<div className="mobile-misc-tools-container">
|
||||||
{!appState.viewModeEnabled && (
|
{!appState.viewModeEnabled &&
|
||||||
<DefaultSidebarTriggerTunnel.Out />
|
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||||
)}
|
<DefaultSidebarTriggerTunnel.Out />
|
||||||
|
)}
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
onChange={() => onPenModeToggle(null)}
|
onChange={() => onPenModeToggle(null)}
|
||||||
@@ -129,7 +130,10 @@ export const MobileMenu = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderAppToolbar = () => {
|
const renderAppToolbar = () => {
|
||||||
if (appState.viewModeEnabled) {
|
if (
|
||||||
|
appState.viewModeEnabled ||
|
||||||
|
appState.openDialog?.name === "elementLinkSelector"
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div className="App-toolbar-content">
|
||||||
<MainMenuTunnel.Out />
|
<MainMenuTunnel.Out />
|
||||||
@@ -154,7 +158,9 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled &&
|
||||||
|
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||||
|
renderToolbar()}
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -166,6 +172,7 @@ 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
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { Dialog } from "../Dialog";
|
import { Dialog } from "../Dialog";
|
||||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||||
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
||||||
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
|
|||||||
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
||||||
const [overwriteConfirmState, setState] = useAtom(
|
const [overwriteConfirmState, setState] = useAtom(
|
||||||
overwriteConfirmStateAtom,
|
overwriteConfirmStateAtom,
|
||||||
jotaiScope,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!overwriteConfirmState.active) {
|
if (!overwriteConfirmState.active) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { atom } from "jotai";
|
import { atom, editorJotaiStore } from "../../editor-jotai";
|
||||||
import { jotaiStore } from "../../jotai";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
export type OverwriteConfirmState =
|
export type OverwriteConfirmState =
|
||||||
@@ -32,7 +31,7 @@ export async function openConfirmModal({
|
|||||||
color: "danger" | "warning";
|
color: "danger" | "warning";
|
||||||
}) {
|
}) {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
jotaiStore.set(overwriteConfirmStateAtom, {
|
editorJotaiStore.set(overwriteConfirmStateAtom, {
|
||||||
active: true,
|
active: true,
|
||||||
onConfirm: () => resolve(true),
|
onConfirm: () => resolve(true),
|
||||||
onClose: () => resolve(false),
|
onClose: () => resolve(false),
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import { measureText } from "../element/textElement";
|
|||||||
import { addEventListener, getFontString } from "../utils";
|
import { addEventListener, getFontString } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
|
|||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
|
||||||
const searchQuery = inputValue.trim() as SearchQuery;
|
const searchQuery = inputValue.trim() as SearchQuery;
|
||||||
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
|
|||||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const [focusIndex, setFocusIndex] = useAtom(
|
const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
|
||||||
searchItemInFocusAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -294,6 +290,7 @@ 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]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
import { atom, useSetAtom } from "jotai";
|
import { atom, useSetAtom } from "../../editor-jotai";
|
||||||
import { jotaiScope } from "../../jotai";
|
|
||||||
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
||||||
import { SidebarPropsContext } from "./common";
|
import { SidebarPropsContext } from "./common";
|
||||||
import { SidebarHeader } from "./SidebarHeader";
|
import { SidebarHeader } from "./SidebarHeader";
|
||||||
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
|
|||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setIsSidebarDockedAtom(!!docked);
|
setIsSidebarDockedAtom(!!docked);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface CollapsibleProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
openTrigger: () => void;
|
openTrigger: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Collapsible = ({
|
const Collapsible = ({
|
||||||
@@ -16,6 +17,7 @@ const Collapsible = ({
|
|||||||
open,
|
open,
|
||||||
openTrigger,
|
openTrigger,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: CollapsibleProps) => {
|
}: CollapsibleProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -26,6 +28,7 @@ const Collapsible = ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
onClick={openTrigger}
|
onClick={openTrigger}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
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, resizeElement } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } 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";
|
||||||
@@ -23,20 +31,124 @@ 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];
|
||||||
if (origElement) {
|
const latestElement = elementsMap.get(origElement.id);
|
||||||
|
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"
|
||||||
@@ -55,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
MIN_WIDTH_OR_HEIGHT,
|
MIN_WIDTH_OR_HEIGHT,
|
||||||
);
|
);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
keepAspectRatio,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldMaintainAspectRatio: keepAspectRatio,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -99,14 +214,17 @@ 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);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
keepAspectRatio,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldMaintainAspectRatio: keepAspectRatio,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -117,9 +235,25 @@ const DimensionDragInput = ({
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
}: DimensionDragInputProps) => {
|
}: DimensionDragInputProps) => {
|
||||||
const value =
|
let value = round(property === "width" ? element.width : element.height, 2);
|
||||||
Math.round((property === "width" ? element.width : element.height) * 100) /
|
|
||||||
100;
|
if (
|
||||||
|
appState.croppingElementId &&
|
||||||
|
appState.croppingElementId === element.id &&
|
||||||
|
isImageElement(element) &&
|
||||||
|
element.crop
|
||||||
|
) {
|
||||||
|
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||||
|
getUncroppedWidthAndHeight(element);
|
||||||
|
if (property === "width") {
|
||||||
|
const ratio = uncroppedWidth / element.crop.naturalWidth;
|
||||||
|
value = round(element.crop.width * ratio, 2);
|
||||||
|
}
|
||||||
|
if (property === "height") {
|
||||||
|
const ratio = uncroppedHeight / element.crop.naturalHeight;
|
||||||
|
value = round(element.crop.height * ratio, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragInput
|
<DragInput
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ 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 { rescalePointsInElement } from "../../element/resizeElements";
|
import {
|
||||||
|
rescalePointsInElement,
|
||||||
|
resizeSingleElement,
|
||||||
|
} from "../../element/resizeElements";
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
@@ -17,7 +20,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, resizeElement } from "./utils";
|
import { getElementsInAtomicUnit } 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";
|
||||||
@@ -69,7 +72,6 @@ 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(
|
||||||
@@ -79,7 +81,7 @@ const resizeElementInGroup = (
|
|||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const newFontSize = boundTextElement.fontSize * scale;
|
const newFontSize = boundTextElement.fontSize * scale;
|
||||||
updateBoundElements(latestElement, elementsMap, {
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
newSize: { width: updates.width, height: updates.height },
|
||||||
});
|
});
|
||||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||||
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||||
@@ -151,7 +153,6 @@ 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) {
|
||||||
@@ -224,15 +225,17 @@ 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);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
false,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
false,
|
{
|
||||||
|
shouldInformMutation: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,14 +328,17 @@ 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);
|
||||||
|
|
||||||
resizeElement(
|
resizeSingleElement(
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
false,
|
latestElement,
|
||||||
origElement,
|
origElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
originalElementsMap,
|
||||||
scene,
|
property === "width" ? "e" : "s",
|
||||||
|
{
|
||||||
|
shouldInformMutation: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ const MultiPosition = ({
|
|||||||
const [x1, y1] = getCommonBounds(elementsInUnit);
|
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||||
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import type { DragInputCallbackType } from "./DragInput";
|
|||||||
import { getStepSizedValue, moveElement } from "./utils";
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { pointFrom, pointRotateRads } from "../../../math";
|
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
|
||||||
|
import { isImageElement } from "../../element/typeChecks";
|
||||||
|
import {
|
||||||
|
getFlipAdjustedCropPosition,
|
||||||
|
getUncroppedWidthAndHeight,
|
||||||
|
} from "../../element/cropElement";
|
||||||
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
|
|
||||||
interface PositionProps {
|
interface PositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -18,12 +24,14 @@ 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();
|
||||||
@@ -38,6 +46,82 @@ 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;
|
||||||
@@ -97,8 +181,22 @@ 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,
|
||||||
);
|
);
|
||||||
const value =
|
let value = round(property === "x" ? topLeftX : topLeftY, 2);
|
||||||
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
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ 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 } from "../../element/typeChecks";
|
import { isElbowArrow, isImageElement } 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;
|
||||||
@@ -128,6 +130,13 @@ 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;
|
||||||
@@ -244,8 +253,34 @@ 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">
|
||||||
{t(`element.${singleElement.type}`)}
|
{appState.croppingElementId
|
||||||
|
? t("labels.imageCropping")
|
||||||
|
: t(`element.${singleElement.type}`)}
|
||||||
</StatsRow>
|
</StatsRow>
|
||||||
|
|
||||||
<StatsRow>
|
<StatsRow>
|
||||||
@@ -387,7 +422,8 @@ 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
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,17 +5,7 @@ import {
|
|||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "../../element/binding";
|
} from "../../element/binding";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import {
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
measureFontSizeFromWidth,
|
|
||||||
rescalePointsInElement,
|
|
||||||
} from "../../element/resizeElements";
|
|
||||||
import {
|
|
||||||
getApproxMinLineHeight,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getBoundTextElement,
|
|
||||||
getBoundTextMaxWidth,
|
|
||||||
handleBindTextResize,
|
|
||||||
} from "../../element/textElement";
|
|
||||||
import {
|
import {
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@@ -34,7 +24,6 @@ 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"
|
||||||
@@ -121,97 +110,6 @@ export const newOrigin = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resizeElement = (
|
|
||||||
nextWidth: number,
|
|
||||||
nextHeight: number,
|
|
||||||
keepAspectRatio: boolean,
|
|
||||||
origElement: ExcalidrawElement,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
scene: Scene,
|
|
||||||
shouldInformMutation = true,
|
|
||||||
) => {
|
|
||||||
const latestElement = elementsMap.get(origElement.id);
|
|
||||||
if (!latestElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
const minWidth = getApproxMinLineWidth(
|
|
||||||
getFontString(boundTextElement),
|
|
||||||
boundTextElement.lineHeight,
|
|
||||||
);
|
|
||||||
const minHeight = getApproxMinLineHeight(
|
|
||||||
boundTextElement.fontSize,
|
|
||||||
boundTextElement.lineHeight,
|
|
||||||
);
|
|
||||||
nextWidth = Math.max(nextWidth, minWidth);
|
|
||||||
nextHeight = Math.max(nextHeight, minHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width: oldWidth, height: oldHeight } = latestElement;
|
|
||||||
|
|
||||||
mutateElement(
|
|
||||||
latestElement,
|
|
||||||
{
|
|
||||||
...newOrigin(
|
|
||||||
latestElement.x,
|
|
||||||
latestElement.y,
|
|
||||||
latestElement.width,
|
|
||||||
latestElement.height,
|
|
||||||
nextWidth,
|
|
||||||
nextHeight,
|
|
||||||
latestElement.angle,
|
|
||||||
),
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
|
|
||||||
},
|
|
||||||
shouldInformMutation,
|
|
||||||
);
|
|
||||||
updateBindings(latestElement, elementsMap, elements, scene, {
|
|
||||||
newSize: {
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
boundTextFont = {
|
|
||||||
fontSize: boundTextElement.fontSize,
|
|
||||||
};
|
|
||||||
if (keepAspectRatio) {
|
|
||||||
const updatedElement = {
|
|
||||||
...latestElement,
|
|
||||||
width: nextWidth,
|
|
||||||
height: nextHeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextFont = measureFontSizeFromWidth(
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
getBoundTextMaxWidth(updatedElement, boundTextElement),
|
|
||||||
);
|
|
||||||
boundTextFont = {
|
|
||||||
fontSize: nextFont?.size ?? boundTextElement.fontSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBoundElements(latestElement, elementsMap, {
|
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont) {
|
|
||||||
mutateElement(boundTextElement, {
|
|
||||||
fontSize: boundTextFont.fontSize,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const moveElement = (
|
export const moveElement = (
|
||||||
newTopLeftX: number,
|
newTopLeftX: number,
|
||||||
newTopLeftY: number,
|
newTopLeftY: number,
|
||||||
@@ -302,6 +200,7 @@ 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)) {
|
||||||
@@ -312,6 +211,7 @@ export const updateBindings = (
|
|||||||
scene,
|
scene,
|
||||||
true,
|
true,
|
||||||
[],
|
[],
|
||||||
|
options?.zoom,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updateBoundElements(latestElement, elementsMap, options);
|
updateBoundElements(latestElement, elementsMap, options);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
|
|||||||
import { ArrowRightIcon } from "../icons";
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
import "./TTDDialog.scss";
|
import "./TTDDialog.scss";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../../editor-jotai";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
|
|||||||
|
|
||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
import type { TranslationKeys } from "../i18n";
|
import type { TranslationKeys } from "../i18n";
|
||||||
|
import { EditorJotaiProvider } from "../editor-jotai";
|
||||||
|
|
||||||
describe("Test <Trans/>", () => {
|
describe("Test <Trans/>", () => {
|
||||||
it("should translate the the strings correctly", () => {
|
it("should translate the the strings correctly", () => {
|
||||||
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<>
|
<EditorJotaiProvider>
|
||||||
<div data-testid="test1">
|
<div data-testid="test1">
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
||||||
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
|
|||||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</EditorJotaiProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ 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,
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ 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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import React, { useLayoutEffect, useRef } from "react";
|
import React, { useLayoutEffect, useRef } from "react";
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
|
import { atom } from "../../editor-jotai";
|
||||||
|
|
||||||
export const withInternalFallback = <P,>(
|
export const withInternalFallback = <P,>(
|
||||||
componentName: string,
|
componentName: string,
|
||||||
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
|
|||||||
__fallback?: boolean;
|
__fallback?: boolean;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { jotaiScope } = useTunnels();
|
const {
|
||||||
|
tunnelsJotai: { useAtom },
|
||||||
|
} = useTunnels();
|
||||||
// for rerenders
|
// for rerenders
|
||||||
const [, setCounter] = useAtom(renderAtom, jotaiScope);
|
const [, setCounter] = useAtom(renderAtom);
|
||||||
// for initial & subsequent renders. Tracked as component state
|
// for initial & subsequent renders. Tracked as component state
|
||||||
// due to excalidraw multi-instance scanerios.
|
// due to excalidraw multi-instance scanerios.
|
||||||
const metaRef = useRef({
|
const metaRef = useRef({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
|
|
||||||
import { ToolButton } from "../ToolButton";
|
import { ToolButton } from "../ToolButton";
|
||||||
import { FreedrawIcon, TrashIcon } from "../icons";
|
import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -30,18 +30,19 @@ 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, useExcalidrawAppState } from "../App";
|
import { useAppProps, useDevice, 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";
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 320;
|
import "./Hyperlink.scss";
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -73,6 +74,7 @@ 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 || "";
|
||||||
|
|
||||||
@@ -170,6 +172,15 @@ 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;
|
||||||
@@ -196,16 +207,21 @@ 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, isEditing]);
|
}, [setAppState, element]);
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
trackEvent("hyperlink", "edit", "popup-ui");
|
trackEvent("hyperlink", "edit", "popup-ui");
|
||||||
@@ -229,19 +245,14 @@ export const Hyperlink = ({
|
|||||||
style={{
|
style={{
|
||||||
top: `${y}px`,
|
top: `${y}px`,
|
||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
width: CONTAINER_WIDTH,
|
width: POPUP_WIDTH,
|
||||||
padding: CONTAINER_PADDING,
|
padding: POPUP_PADDING,
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!element.link && !isEditing) {
|
|
||||||
setAppState({ showHyperlinkPopup: "editor" });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
className={clsx("excalidraw-hyperlinkContainer-input")}
|
className={clsx("excalidraw-hyperlinkContainer-input")}
|
||||||
placeholder="Type or paste your link here"
|
placeholder={t("labels.link.hint")}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={inputVal}
|
value={inputVal}
|
||||||
onChange={(event) => setInputVal(event.target.value)}
|
onChange={(event) => setInputVal(event.target.value)}
|
||||||
@@ -302,6 +313,21 @@ 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"
|
||||||
@@ -328,7 +354,7 @@ const getCoordsForPopover = (
|
|||||||
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
|
const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2;
|
||||||
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
|
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
@@ -338,12 +364,10 @@ export const getContextMenuLabel = (
|
|||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
const label = selectedElements[0]?.link
|
const label = isEmbeddableElement(selectedElements[0])
|
||||||
? isEmbeddableElement(selectedElements[0])
|
? "labels.link.editEmbed"
|
||||||
? "labels.link.editEmbed"
|
: selectedElements[0]?.link
|
||||||
: "labels.link.edit"
|
? "labels.link.edit"
|
||||||
: isEmbeddableElement(selectedElements[0])
|
|
||||||
? "labels.link.createEmbed"
|
|
||||||
: "labels.link.create";
|
: "labels.link.create";
|
||||||
return label;
|
return label;
|
||||||
};
|
};
|
||||||
@@ -376,7 +400,9 @@ const renderTooltip = (
|
|||||||
|
|
||||||
tooltipDiv.classList.add("excalidraw-tooltip--visible");
|
tooltipDiv.classList.add("excalidraw-tooltip--visible");
|
||||||
tooltipDiv.style.maxWidth = "20rem";
|
tooltipDiv.style.maxWidth = "20rem";
|
||||||
tooltipDiv.textContent = element.link;
|
tooltipDiv.textContent = isElementLink(element.link)
|
||||||
|
? t("labels.link.goToElement")
|
||||||
|
: element.link;
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
@@ -450,9 +476,9 @@ const shouldHideLinkPopup = (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
clientX >= popoverX - threshold &&
|
clientX >= popoverX - threshold &&
|
||||||
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
|
clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold &&
|
||||||
clientY >= popoverY - threshold &&
|
clientY >= popoverY - threshold &&
|
||||||
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
|
clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
|||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
|
`<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,
|
||||||
|
|||||||
@@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootOneIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const FontSizeSmallIcon = createIcon(
|
export const FontSizeSmallIcon = createIcon(
|
||||||
<>
|
<>
|
||||||
<g clipPath="url(#a)">
|
<g clipPath="url(#a)">
|
||||||
@@ -2156,3 +2204,18 @@ export const cropIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ import {
|
|||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useSetAtom } from "../../editor-jotai";
|
||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
@@ -189,10 +188,7 @@ Help.displayName = "Help";
|
|||||||
export const ClearCanvas = () => {
|
export const ClearCanvas = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const setActiveConfirmDialog = useSetAtom(
|
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
||||||
activeConfirmDialogAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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);
|
||||||
@@ -213,9 +214,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",
|
||||||
@@ -229,6 +230,12 @@ 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",
|
||||||
@@ -448,3 +455,6 @@ 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";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import tunnel from "tunnel-rat";
|
import tunnel from "tunnel-rat";
|
||||||
|
import { createIsolation } from "jotai-scope";
|
||||||
|
|
||||||
export type Tunnel = ReturnType<typeof tunnel>;
|
export type Tunnel = ReturnType<typeof tunnel>;
|
||||||
|
|
||||||
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
|
|||||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||||
OverwriteConfirmDialogTunnel: Tunnel;
|
OverwriteConfirmDialogTunnel: Tunnel;
|
||||||
TTDDialogTriggerTunnel: Tunnel;
|
TTDDialogTriggerTunnel: Tunnel;
|
||||||
jotaiScope: symbol;
|
// this can be removed once we create jotai stores per each editor
|
||||||
|
// instance
|
||||||
|
tunnelsJotai: ReturnType<typeof createIsolation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||||
|
|
||||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||||
|
|
||||||
|
const tunnelsJotai = createIsolation();
|
||||||
|
|
||||||
export const useInitializeTunnels = () => {
|
export const useInitializeTunnels = () => {
|
||||||
return React.useMemo((): TunnelsContextValue => {
|
return React.useMemo((): TunnelsContextValue => {
|
||||||
return {
|
return {
|
||||||
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
|
|||||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||||
OverwriteConfirmDialogTunnel: tunnel(),
|
OverwriteConfirmDialogTunnel: tunnel(),
|
||||||
TTDDialogTriggerTunnel: tunnel(),
|
TTDDialogTriggerTunnel: tunnel(),
|
||||||
jotaiScope: Symbol(),
|
tunnelsJotai,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"elementId": "text-2",
|
"elementId": "text-2",
|
||||||
"fixedPoint": null,
|
"fixedPoint": null,
|
||||||
"focus": 0,
|
"focus": 0,
|
||||||
"gap": 205,
|
"gap": 14,
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ 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) => {
|
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
|
|
||||||
if (blob.type === MIME_TYPES.png) {
|
if (blob.type === MIME_TYPES.png) {
|
||||||
@@ -46,9 +48,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return await (
|
return decodeSvgBase64Payload({
|
||||||
await import("./image")
|
|
||||||
).decodeSvgMetadata({
|
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -107,11 +107,15 @@ 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 !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
return isSupportedImageFileType(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSceneOrLibraryFromBlob = async (
|
export const loadSceneOrLibraryFromBlob = async (
|
||||||
@@ -249,6 +253,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** async. For sync variant, use getDataURL_sync */
|
||||||
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
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();
|
||||||
@@ -261,6 +266,16 @@ export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDataURL_sync = (
|
||||||
|
data: string | Uint8Array | ArrayBuffer,
|
||||||
|
mimeType: ValueOf<typeof MIME_TYPES>,
|
||||||
|
): DataURL => {
|
||||||
|
return `data:${mimeType};base64,${stringToBase64(
|
||||||
|
toByteString(data),
|
||||||
|
true,
|
||||||
|
)}` as DataURL;
|
||||||
|
};
|
||||||
|
|
||||||
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
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));
|
||||||
@@ -274,6 +289,10 @@ 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: {
|
||||||
@@ -315,7 +334,7 @@ export const resizeImageFile = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new File(
|
return new File(
|
||||||
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
|
||||||
file.name,
|
file.name,
|
||||||
{
|
{
|
||||||
type: opts.outputType || file.type,
|
type: opts.outputType || file.type,
|
||||||
|
|||||||
@@ -5,24 +5,23 @@ import { encryptData, decryptData } from "./encryption";
|
|||||||
// byte (binary) strings
|
// byte (binary) strings
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// fast, Buffer-compatible implem
|
// Buffer-compatible implem.
|
||||||
export const toByteString = (
|
//
|
||||||
data: string | Uint8Array | ArrayBuffer,
|
// Note that in V8, spreading the uint8array (by chunks) into
|
||||||
): Promise<string> => {
|
// `String.fromCharCode(...uint8array)` tends to be faster for large
|
||||||
return new Promise((resolve, reject) => {
|
// strings/buffers, in case perf is needed in the future.
|
||||||
const blob =
|
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
|
||||||
typeof data === "string"
|
const bytes =
|
||||||
? new Blob([new TextEncoder().encode(data)])
|
typeof data === "string"
|
||||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
? new TextEncoder().encode(data)
|
||||||
const reader = new FileReader();
|
: data instanceof Uint8Array
|
||||||
reader.onload = (event) => {
|
? data
|
||||||
if (!event.target || typeof event.target.result !== "string") {
|
: new Uint8Array(data);
|
||||||
return reject(new Error("couldn't convert to byte string"));
|
let bstring = "";
|
||||||
}
|
for (const byte of bytes) {
|
||||||
resolve(event.target.result);
|
bstring += String.fromCharCode(byte);
|
||||||
};
|
}
|
||||||
reader.readAsBinaryString(blob);
|
return bstring;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const byteStringToArrayBuffer = (byteString: string) => {
|
const byteStringToArrayBuffer = (byteString: string) => {
|
||||||
@@ -46,12 +45,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 = async (str: string, isByteString = false) => {
|
export const stringToBase64 = (str: string, isByteString = false) => {
|
||||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
|
||||||
};
|
};
|
||||||
|
|
||||||
// async to align with stringToBase64
|
// async to align with stringToBase64
|
||||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
export const base64ToString = (base64: string, isByteString = false) => {
|
||||||
return isByteString
|
return isByteString
|
||||||
? window.atob(base64)
|
? window.atob(base64)
|
||||||
: byteStringToString(window.atob(base64));
|
: byteStringToString(window.atob(base64));
|
||||||
@@ -66,6 +65,20 @@ 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
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -82,18 +95,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 = async ({
|
export const encode = ({
|
||||||
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;
|
||||||
}): Promise<EncodedData> => {
|
}): EncodedData => {
|
||||||
let deflated!: string;
|
let deflated!: string;
|
||||||
if (compress !== false) {
|
if (compress !== false) {
|
||||||
try {
|
try {
|
||||||
deflated = await toByteString(deflate(text));
|
deflated = toByteString(deflate(text));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("encode: cannot deflate", error);
|
console.error("encode: cannot deflate", error);
|
||||||
}
|
}
|
||||||
@@ -102,11 +115,11 @@ export const encode = async ({
|
|||||||
version: "1",
|
version: "1",
|
||||||
encoding: "bstring",
|
encoding: "bstring",
|
||||||
compressed: !!deflated,
|
compressed: !!deflated,
|
||||||
encoded: deflated || (await toByteString(text)),
|
encoded: deflated || toByteString(text),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decode = async (data: EncodedData): Promise<string> => {
|
export const decode = (data: EncodedData): string => {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
|
|
||||||
switch (data.encoding) {
|
switch (data.encoding) {
|
||||||
@@ -114,7 +127,7 @@ export const decode = async (data: EncodedData): Promise<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
|
||||||
: await byteStringToString(data.encoded);
|
: byteStringToString(data.encoded);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
|
|
||||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
mimeTypes.push(MIME_TYPES[type]);
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|
||||||
return mimeTypes;
|
return mimeTypes;
|
||||||
}, [] as string[]);
|
}, [] as string[]);
|
||||||
|
|
||||||
@@ -39,34 +38,62 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
mimeTypes,
|
mimeTypes,
|
||||||
multiple: opts.multiple ?? false,
|
multiple: opts.multiple ?? false,
|
||||||
legacySetup: (resolve, reject, input) => {
|
legacySetup: (resolve, reject, input) => {
|
||||||
|
let isResolved = false;
|
||||||
|
let checkInterval: number | null = null;
|
||||||
|
|
||||||
|
// Increased delay for iOS to ensure file selection is complete
|
||||||
|
const CHECK_INTERVAL = 100; // 100ms
|
||||||
|
const MAX_CHECKS = 50; // 5 seconds total
|
||||||
|
let checkCount = 0;
|
||||||
|
|
||||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||||
const focusHandler = () => {
|
|
||||||
checkForFile();
|
|
||||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
|
||||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
|
||||||
scheduleRejection();
|
|
||||||
};
|
|
||||||
const checkForFile = () => {
|
const checkForFile = () => {
|
||||||
// this hack might not work when expecting multiple files
|
if (isResolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
|
isResolved = true;
|
||||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||||
resolve(ret as RetType);
|
resolve(ret as RetType);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkCount++;
|
||||||
|
if (checkCount >= MAX_CHECKS) {
|
||||||
|
scheduleRejection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusHandler = () => {
|
||||||
|
// Start checking for files only after focus event
|
||||||
|
checkInterval = window.setInterval(() => {
|
||||||
|
if (checkForFile()) {
|
||||||
|
clearInterval(checkInterval!);
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL);
|
||||||
|
|
||||||
|
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||||
|
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
|
};
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||||
});
|
});
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
checkForFile();
|
|
||||||
}, INPUT_CHANGE_INTERVAL_MS);
|
|
||||||
return (rejectPromise) => {
|
return (rejectPromise) => {
|
||||||
clearInterval(interval);
|
if (checkInterval) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
scheduleRejection.cancel();
|
scheduleRejection.cancel();
|
||||||
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
||||||
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
||||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
if (rejectPromise) {
|
|
||||||
// so that something is shown in console if we need to debug this
|
if (rejectPromise && !isResolved) {
|
||||||
console.warn("Opening the file was canceled (legacy-fs).");
|
console.warn("Opening the file was canceled (legacy-fs).");
|
||||||
rejectPromise(new AbortError());
|
rejectPromise(new AbortError());
|
||||||
}
|
}
|
||||||
@@ -82,6 +109,7 @@ 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;
|
||||||
@@ -93,10 +121,11 @@ 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 type { FileSystemHandle };
|
|
||||||
export { nativeFileSystemSupported };
|
export { nativeFileSystemSupported };
|
||||||
|
export type { FileSystemHandle };
|
||||||
|
|||||||
@@ -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 { stringToBase64, encode, decode, base64ToString } from "./encode";
|
import { encode, decode } 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(
|
||||||
await encode({
|
encode({
|
||||||
text: metadata,
|
text: metadata,
|
||||||
compress: true,
|
compress: true,
|
||||||
}),
|
}),
|
||||||
@@ -59,60 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => {
|
|||||||
}
|
}
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return 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 +5,7 @@ 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";
|
||||||
@@ -15,8 +16,9 @@ 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 { isSomeElementSelected, getSelectedElements } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } 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";
|
||||||
@@ -25,7 +27,6 @@ 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";
|
||||||
@@ -130,6 +131,7 @@ 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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -168,9 +170,8 @@ export const exportCanvas = async (
|
|||||||
return fileSave(blob, {
|
return fileSave(blob, {
|
||||||
description: "Export to PNG",
|
description: "Export to PNG",
|
||||||
name,
|
name,
|
||||||
// FIXME reintroduce `excalidraw.png` when most people upgrade away
|
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||||
// from 111.0.5563.64 (arm64), see #6349
|
mimeTypes: [IMAGE_MIME_TYPES.png],
|
||||||
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
|
|
||||||
fileHandle,
|
fileHandle,
|
||||||
});
|
});
|
||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { restoreLibraryItems } from "./restore";
|
import { restoreLibraryItems } from "./restore";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
import { atom } from "jotai";
|
import { atom, editorJotaiStore } from "../editor-jotai";
|
||||||
import { jotaiStore } from "../jotai";
|
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
@@ -35,6 +34,9 @@ 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 */
|
||||||
@@ -188,13 +190,13 @@ class Library {
|
|||||||
|
|
||||||
private notifyListeners = () => {
|
private notifyListeners = () => {
|
||||||
if (this.updateQueue.length > 0) {
|
if (this.updateQueue.length > 0) {
|
||||||
jotaiStore.set(libraryItemsAtom, (s) => ({
|
editorJotaiStore.set(libraryItemsAtom, (s) => ({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
libraryItems: this.currLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: s.isInitialized,
|
isInitialized: s.isInitialized,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
editorJotaiStore.set(libraryItemsAtom, {
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
libraryItems: this.currLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
@@ -222,7 +224,7 @@ class Library {
|
|||||||
destroy = () => {
|
destroy = () => {
|
||||||
this.updateQueue = [];
|
this.updateQueue = [];
|
||||||
this.currLibraryItems = [];
|
this.currLibraryItems = [];
|
||||||
jotaiStore.set(libraryItemSvgsCache, new Map());
|
editorJotaiStore.set(libraryItemSvgsCache, new Map());
|
||||||
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
||||||
// jotaiStore.set(libraryItemsAtom, {
|
// jotaiStore.set(libraryItemsAtom, {
|
||||||
// status: "loading",
|
// status: "loading",
|
||||||
@@ -467,6 +469,28 @@ 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
|
||||||
@@ -608,6 +632,11 @@ 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 */
|
||||||
@@ -650,7 +679,13 @@ export const useHandleLibrary = (
|
|||||||
}) => {
|
}) => {
|
||||||
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
libraryUrl = decodeURIComponent(libraryUrl);
|
||||||
|
|
||||||
|
libraryUrl = toValidURL(libraryUrl);
|
||||||
|
|
||||||
|
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
|
||||||
|
|
||||||
|
const request = await fetch(libraryUrl);
|
||||||
const blob = await request.blob();
|
const blob = await request.blob();
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -678,7 +713,12 @@ export const useHandleLibrary = (
|
|||||||
defaultStatus: "published",
|
defaultStatus: "published",
|
||||||
openLibraryMenu: true,
|
openLibraryMenu: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
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)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -101,23 +102,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
return DEFAULT_FONT_FAMILY;
|
return DEFAULT_FONT_FAMILY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const repairBinding = (
|
const repairBinding = <T extends ExcalidrawLinearElement>(
|
||||||
element: ExcalidrawLinearElement,
|
element: T,
|
||||||
binding: PointBinding | FixedPointBinding | null,
|
binding: PointBinding | FixedPointBinding | null,
|
||||||
): PointBinding | FixedPointBinding | null => {
|
): T extends ExcalidrawElbowArrowElement
|
||||||
|
? FixedPointBinding | null
|
||||||
|
: PointBinding | FixedPointBinding | null => {
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const focus = binding.focus || 0;
|
||||||
...binding,
|
|
||||||
focus: binding.focus || 0,
|
if (isElbowArrow(element)) {
|
||||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
const fixedPointBinding:
|
||||||
|
| ExcalidrawElbowArrowElement["startBinding"]
|
||||||
|
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
|
||||||
? {
|
? {
|
||||||
|
...binding,
|
||||||
|
focus,
|
||||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||||
}
|
}
|
||||||
: {}),
|
: null;
|
||||||
};
|
|
||||||
|
return fixedPointBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...binding,
|
||||||
|
focus,
|
||||||
|
} as T extends ExcalidrawElbowArrowElement
|
||||||
|
? FixedPointBinding | null
|
||||||
|
: PointBinding | FixedPointBinding | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
@@ -308,8 +324,7 @@ const restoreElement = (
|
|||||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Separate arrow from linear element
|
const base = {
|
||||||
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
|
|
||||||
type: element.type,
|
type: element.type,
|
||||||
startBinding: repairBinding(element, element.startBinding),
|
startBinding: repairBinding(element, element.startBinding),
|
||||||
endBinding: repairBinding(element, element.endBinding),
|
endBinding: repairBinding(element, element.endBinding),
|
||||||
@@ -321,7 +336,20 @@ const restoreElement = (
|
|||||||
y,
|
y,
|
||||||
elbowed: (element as ExcalidrawArrowElement).elbowed,
|
elbowed: (element as ExcalidrawArrowElement).elbowed,
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
|
// TODO: Separate arrow from linear element
|
||||||
|
return isElbowArrow(element)
|
||||||
|
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
|
||||||
|
...base,
|
||||||
|
elbowed: true,
|
||||||
|
startBinding: repairBinding(element, element.startBinding),
|
||||||
|
endBinding: repairBinding(element, element.endBinding),
|
||||||
|
fixedSegments: element.fixedSegments,
|
||||||
|
startIsSpecial: element.startIsSpecial,
|
||||||
|
endIsSpecial: element.endIsSpecial,
|
||||||
|
})
|
||||||
|
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic elements
|
// generic elements
|
||||||
@@ -639,6 +667,7 @@ 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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
|
|||||||
elementId: "rect-1",
|
elementId: "rect-1",
|
||||||
fixedPoint: null,
|
fixedPoint: null,
|
||||||
focus: 0,
|
focus: 0,
|
||||||
gap: 205,
|
gap: 14,
|
||||||
});
|
});
|
||||||
expect(rect.boundElements).toStrictEqual([
|
expect(rect.boundElements).toStrictEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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("<test>");
|
expect(normalizeLink("<test>")).toBe("<test>");
|
||||||
|
expect(normalizeLink("test&")).toBe("test&");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
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, """);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeLink = (link: string) => {
|
export const normalizeLink = (link: string) => {
|
||||||
link = link.trim();
|
link = link.trim();
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { atom, createStore, type PrimitiveAtom } from "jotai";
|
||||||
|
import { createIsolation } from "jotai-scope";
|
||||||
|
|
||||||
|
const jotai = createIsolation();
|
||||||
|
|
||||||
|
export { atom, PrimitiveAtom };
|
||||||
|
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
|
||||||
|
export const EditorJotaiProvider: ReturnType<
|
||||||
|
typeof createIsolation
|
||||||
|
>["Provider"] = jotai.Provider;
|
||||||
|
|
||||||
|
export const editorJotaiStore: ReturnType<typeof createStore> = createStore();
|
||||||
@@ -97,6 +97,8 @@ 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,
|
||||||
@@ -213,6 +215,7 @@ 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 =
|
||||||
@@ -223,7 +226,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||||||
const element = elementsMap.get(elementId);
|
const element = elementsMap.get(elementId);
|
||||||
if (
|
if (
|
||||||
isBindableElement(element) &&
|
isBindableElement(element) &&
|
||||||
bindingBorderTest(element, coors, elementsMap)
|
bindingBorderTest(element, coors, elementsMap, zoom)
|
||||||
) {
|
) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -235,12 +238,14 @@ 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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -250,6 +255,7 @@ 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;
|
||||||
@@ -262,6 +268,7 @@ 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
|
||||||
@@ -270,6 +277,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
@@ -278,6 +286,7 @@ 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
|
||||||
@@ -286,6 +295,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [start, end];
|
return [start, end];
|
||||||
@@ -296,10 +306,12 @@ 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
|
||||||
@@ -308,6 +320,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
@@ -318,6 +331,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
|
zoom,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
@@ -332,6 +346,7 @@ 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
|
||||||
@@ -342,6 +357,7 @@ 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(
|
||||||
@@ -349,6 +365,7 @@ export const bindOrUnbindLinearElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
elements,
|
elements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
|
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
|
||||||
@@ -358,6 +375,7 @@ 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) {
|
||||||
@@ -368,7 +386,7 @@ export const getSuggestedBindingsForArrows = (
|
|||||||
selectedElements
|
selectedElements
|
||||||
.filter(isLinearElement)
|
.filter(isLinearElement)
|
||||||
.flatMap((element) =>
|
.flatMap((element) =>
|
||||||
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
|
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||||
@@ -406,6 +424,7 @@ export const maybeBindLinearElement = (
|
|||||||
pointerCoords,
|
pointerCoords,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -422,6 +441,26 @@ export const maybeBindLinearElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePointBinding = (
|
||||||
|
binding: { focus: number; gap: number },
|
||||||
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
|
) => {
|
||||||
|
let gap = binding.gap;
|
||||||
|
const maxGap = maxBindingGap(
|
||||||
|
hoveredElement,
|
||||||
|
hoveredElement.width,
|
||||||
|
hoveredElement.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gap > maxGap) {
|
||||||
|
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...binding,
|
||||||
|
gap,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const bindLinearElement = (
|
export const bindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
@@ -433,11 +472,14 @@ export const bindLinearElement = (
|
|||||||
}
|
}
|
||||||
const binding: PointBinding = {
|
const binding: PointBinding = {
|
||||||
elementId: hoveredElement.id,
|
elementId: hoveredElement.id,
|
||||||
...calculateFocusAndGap(
|
...normalizePointBinding(
|
||||||
linearElement,
|
calculateFocusAndGap(
|
||||||
|
linearElement,
|
||||||
|
hoveredElement,
|
||||||
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
|
||||||
elementsMap,
|
|
||||||
),
|
),
|
||||||
...(isElbowArrow(linearElement)
|
...(isElbowArrow(linearElement)
|
||||||
? calculateFixedPointForElbowArrowBinding(
|
? calculateFixedPointForElbowArrowBinding(
|
||||||
@@ -514,6 +556,7 @@ 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(
|
||||||
@@ -524,11 +567,13 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -576,11 +621,11 @@ export const updateBoundElements = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
options?: {
|
options?: {
|
||||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
oldSize?: { width: number; height: number };
|
newSize?: { width: number; height: number };
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
|
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
@@ -603,18 +648,18 @@ export const updateBoundElements = (
|
|||||||
startBinding: maybeCalculateNewGapWhenScaling(
|
startBinding: maybeCalculateNewGapWhenScaling(
|
||||||
changedElement,
|
changedElement,
|
||||||
element.startBinding,
|
element.startBinding,
|
||||||
oldSize,
|
newSize,
|
||||||
),
|
),
|
||||||
endBinding: maybeCalculateNewGapWhenScaling(
|
endBinding: maybeCalculateNewGapWhenScaling(
|
||||||
changedElement,
|
changedElement,
|
||||||
element.endBinding,
|
element.endBinding,
|
||||||
oldSize,
|
newSize,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// `linearElement` is being moved/scaled already, just update the binding
|
// `linearElement` is being moved/scaled already, just update the binding
|
||||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||||
mutateElement(element, bindings);
|
mutateElement(element, bindings, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,22 +701,14 @@ export const updateBoundElements = (
|
|||||||
}> => update !== null,
|
}> => update !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, updates, {
|
||||||
element,
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
updates,
|
? { startBinding: bindings.startBinding }
|
||||||
elementsMap,
|
: {}),
|
||||||
{
|
...(changedElement.id === element.endBinding?.elementId
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
? { endBinding: bindings.endBinding }
|
||||||
? { startBinding: bindings.startBinding }
|
: {}),
|
||||||
: {}),
|
});
|
||||||
...(changedElement.id === element.endBinding?.elementId
|
|
||||||
? { endBinding: bindings.endBinding }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
changedElements,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !boundText.isDeleted) {
|
if (boundText && !boundText.isDeleted) {
|
||||||
@@ -703,6 +740,7 @@ 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));
|
||||||
|
|
||||||
@@ -714,6 +752,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
origPoint,
|
origPoint,
|
||||||
bindableElement,
|
bindableElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!distance) {
|
if (!distance) {
|
||||||
@@ -728,15 +767,14 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
|
return headingForPointFromElement(bindableElement, aabb, p);
|
||||||
|
|
||||||
return pointHeading;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDistanceForBinding = (
|
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,
|
||||||
@@ -747,6 +785,7 @@ const getDistanceForBinding = (
|
|||||||
bindableElement,
|
bindableElement,
|
||||||
bindableElement.width,
|
bindableElement.width,
|
||||||
bindableElement.height,
|
bindableElement.height,
|
||||||
|
zoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
return distance > bindDistance ? null : distance;
|
return distance > bindDistance ? null : distance;
|
||||||
@@ -1174,11 +1213,13 @@ 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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1341,9 +1382,11 @@ 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);
|
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||||
|
|
||||||
const shape = getElementShape(element, elementsMap);
|
const shape = getElementShape(element, elementsMap);
|
||||||
return (
|
return (
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||||
@@ -1356,12 +1399,21 @@ 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(16, Math.min(0.25 * smallerDimension, 32));
|
return Math.max(
|
||||||
|
16,
|
||||||
|
// bigger bindable boundary for bigger elements
|
||||||
|
Math.min(0.25 * smallerDimension, 32),
|
||||||
|
// keep in sync with the zoomed highlight
|
||||||
|
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToBindableElement = (
|
||||||
@@ -2218,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGlobalFixedPoints = (
|
export const getGlobalFixedPoints = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): [GlobalPoint, GlobalPoint] => {
|
): [GlobalPoint, GlobalPoint] => {
|
||||||
|
|||||||
@@ -556,6 +556,10 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -669,6 +673,21 @@ 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),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
|
|
||||||
const MINIMAL_CROP_SIZE = 10;
|
export const MINIMAL_CROP_SIZE = 10;
|
||||||
|
|
||||||
export const cropElement = (
|
export const cropElement = (
|
||||||
element: ExcalidrawImageElement,
|
element: ExcalidrawImageElement,
|
||||||
@@ -585,3 +585,41 @@ const adjustCropPosition = (
|
|||||||
cropY,
|
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),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -42,9 +42,20 @@ export const dragSelectedElements = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = _selectedElements.filter(
|
const selectedElements = _selectedElements.filter((element) => {
|
||||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
|
||||||
);
|
const startElement = _selectedElements.find(
|
||||||
|
(el) => el.id === element.startBinding?.elementId,
|
||||||
|
);
|
||||||
|
const endElement = _selectedElements.find(
|
||||||
|
(el) => el.id === element.endBinding?.elementId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return startElement && endElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// we do not want a frame and its elements to be selected at the same time
|
// we do not want a frame and its elements to be selected at the same time
|
||||||
// but when it happens (due to some bug), we want to avoid updating element
|
// but when it happens (due to some bug), we want to avoid updating element
|
||||||
@@ -78,10 +89,8 @@ export const dragSelectedElements = (
|
|||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||||
if (
|
if (!isArrowElement(element)) {
|
||||||
// skip arrow labels since we calculate its position during render
|
// skip arrow labels since we calculate its position during render
|
||||||
!isArrowElement(element)
|
|
||||||
) {
|
|
||||||
const textElement = getBoundTextElement(
|
const textElement = getBoundTextElement(
|
||||||
element,
|
element,
|
||||||
scene.getNonDeletedElementsMap(),
|
scene.getNonDeletedElementsMap(),
|
||||||
@@ -89,10 +98,10 @@ export const dragSelectedElements = (
|
|||||||
if (textElement) {
|
if (textElement) {
|
||||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||||
}
|
}
|
||||||
|
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||||
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
|
||||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+112
-9
@@ -9,20 +9,121 @@ import {
|
|||||||
render,
|
render,
|
||||||
} from "../tests/test-utils";
|
} from "../tests/test-utils";
|
||||||
import { bindLinearElement } from "./binding";
|
import { bindLinearElement } from "./binding";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw, mutateElement } from "../index";
|
||||||
import { mutateElbowArrow } from "./routing";
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ARROW_TYPE } from "../constants";
|
import { ARROW_TYPE } from "../constants";
|
||||||
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
describe("elbow arrow segment move", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can move the second segment of a fully connected elbow arrow", () => {
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: -100,
|
||||||
|
y: -50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: 200,
|
||||||
|
y: 150,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(0, 0);
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(200, 200);
|
||||||
|
mouse.click();
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(100, 100);
|
||||||
|
mouse.down();
|
||||||
|
mouse.moveTo(115, 100);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
const arrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
|
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
|
||||||
|
expect(arrow.fixedSegments?.length).toBe(1);
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[110, 0],
|
||||||
|
[110, 200],
|
||||||
|
[190, 200],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(105, 74.275);
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[110, 0],
|
||||||
|
[110, 200],
|
||||||
|
[190, 200],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can move the second segment of an unconnected elbow arrow", () => {
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(0, 0);
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(250, 200);
|
||||||
|
mouse.click();
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(125, 100);
|
||||||
|
mouse.down();
|
||||||
|
mouse.moveTo(130, 100);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
const arrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[130, 0],
|
||||||
|
[130, 200],
|
||||||
|
[250, 200],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(130, 100);
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[125, 0],
|
||||||
|
[125, 200],
|
||||||
|
[250, 200],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("elbow arrow routing", () => {
|
describe("elbow arrow routing", () => {
|
||||||
it("can properly generate orthogonal arrow points", () => {
|
it("can properly generate orthogonal arrow points", () => {
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
mutateElement(arrow, {
|
||||||
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
points: [
|
||||||
pointFrom(45 - arrow.x, 99.9 - arrow.y),
|
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||||
]);
|
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||||
|
],
|
||||||
|
});
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[0, 100],
|
[0, 100],
|
||||||
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
|
|||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
|
mutateElement(arrow, {
|
||||||
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||||
|
});
|
||||||
|
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
|
|||||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[35, 0],
|
[35, 0],
|
||||||
[35, 90],
|
|
||||||
[35, 90], // Note that coordinates are rounded above!
|
|
||||||
[35, 165],
|
[35, 165],
|
||||||
[103, 165],
|
[103, 165],
|
||||||
]);
|
]);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user