mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
123 Commits
mrazator/g
...
dwelle/dou
Author | SHA1 | Date | |
---|---|---|---|
a22927d4d1 | |||
ca9b7a505e | |||
36b387f973 | |||
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 | |||
7d52176fea | |||
958e03fcc6 | |||
4cedf3d966 | |||
e957c8e9ee | |||
eb09b48ae6 | |||
61623bbeba | |||
15ca182333 | |||
b479f3bd65 | |||
21815fb930 | |||
47ee8a0094 | |||
a977dd1bf5 | |||
3fe1883f3f | |||
a80cb5896a | |||
6dfa18414a | |||
8ca4cf3260 | |||
f3f0ab7c83 | |||
44a1c8d857 | |||
e0a22edfbd | |||
c07f5a0c80 | |||
508f16dc04 | |||
c1b310c56b | |||
d4900e8f19 | |||
caf2db934c | |||
60e3801691 | |||
80f3b75d42 | |||
dc812bee19 | |||
01e83cc9a5 | |||
813f9b702e | |||
fd39712ba6 | |||
b46ca0192b | |||
72b7c937b1 | |||
d107215564 | |||
6959a363f0 | |||
5a11c70714 | |||
6ff56c36e3 | |||
51ea184938 | |||
21fff26d31 | |||
f4dd23fc31 | |||
e3d1dee9d0 | |||
e3f31df747 | |||
60e75406e0 | |||
b396e07b90 | |||
2d1d84a47b | |||
ee30225062 | |||
16cae4fc07 | |||
576bc0dbe5 | |||
00af35c692 | |||
ea7c702cfc | |||
26d2296578 | |||
afb68a6467 | |||
b459e5cfd2 | |||
5facc0d6da | |||
824ad603e1 | |||
5e1ff7cafe | |||
b5d7f5b4ba | |||
fb4bb29aa5 | |||
3cfcc7b489 | |||
4320a3cf41 | |||
8420e1aa13 | |||
5daf1a1b4e | |||
97981804d7 | |||
f7b3befd0a | |||
7b2bee9746 | |||
88014ace4a | |||
87a9430809 | |||
99b91c46f7 | |||
1ea5b26f25 | |||
d5f4ee7b3f | |||
261304c1a4 | |||
84398a7e5c | |||
54491d13d4 |
@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
|
||||
VITE_APP_WS_SERVER_URL=http://localhost:3002
|
||||
|
||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
||||
VITE_APP_PLUS_APP=http://localhost:3000
|
||||
|
||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||
|
||||
@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
|
||||
# put these in your .env.local, or make sure you don't commit!
|
||||
# must be lowercase `true` when turned on
|
||||
#
|
||||
# whether to enable Service Workers in development
|
||||
VITE_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
@ -39,3 +37,14 @@ VITE_APP_COLLAPSE_OVERLAY=true
|
||||
|
||||
# Set this flag to false to disable eslint
|
||||
VITE_APP_ENABLE_ESLINT=true
|
||||
|
||||
# Enable PWA in dev server
|
||||
VITE_APP_ENABLE_PWA=false
|
||||
|
||||
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
|
||||
7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
|
||||
ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
|
||||
UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
|
||||
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
|
||||
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
|
||||
HQIDAQAB'
|
||||
|
@ -15,3 +15,18 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
VITE_APP_ENABLE_TRACKING=false
|
||||
|
||||
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
|
||||
/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
|
||||
kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
|
||||
gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
|
||||
PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
|
||||
iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
|
||||
PQIDAQAB'
|
||||
|
||||
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
|
||||
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
|
||||
VITE_APP_COLLAPSE_OVERLAY=false
|
||||
# Enable eslint in dev server
|
||||
VITE_APP_ENABLE_ESLINT=false
|
||||
|
||||
|
@ -8,3 +8,4 @@ public/workbox
|
||||
packages/excalidraw/types
|
||||
examples/**/public
|
||||
dev-dist
|
||||
coverage
|
||||
|
2
.github/workflows/locales-coverage.yml
vendored
2
.github/workflows/locales-coverage.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
|
2
.github/workflows/semantic-pr-title.yml
vendored
2
.github/workflows/semantic-pr-title.yml
vendored
@ -11,6 +11,6 @@ jobs:
|
||||
semantic:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v3.0.0
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
<h4 align="center">
|
||||
<a href="https://excalidraw.com">Excalidraw Editor</a> |
|
||||
<a href="https://blog.excalidraw.com">Blog</a> |
|
||||
<a href="https://plus.excalidraw.com/blog">Blog</a> |
|
||||
<a href="https://docs.excalidraw.com">Documentation</a> |
|
||||
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
||||
</h4>
|
||||
|
@ -133,7 +133,7 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
|
||||
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
|
||||
|
||||
### MainMenu.Group
|
||||
|
||||
|
@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
|
||||
|
||||
<img
|
||||
src={require("@site/static/img/welcome-screen-overview.png").default}
|
||||
alt="Excalidraw logo: Sketch handrawn like diagrams."
|
||||
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
|
||||
/>
|
||||
|
||||
### Center
|
||||
|
@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
||||
|
||||
| Font Family | Description |
|
||||
| ----------- | ---------------------- |
|
||||
| `Virgil` | The `handwritten` font |
|
||||
| `Virgil` | The `Hand-drawn` font |
|
||||
| `Helvetica` | The `Normal` Font |
|
||||
| `Cascadia` | The `Code` Font |
|
||||
|
||||
|
@ -3,31 +3,32 @@
|
||||
All `props` are _optional_.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` | `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 |
|
||||
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
|
||||
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
|
||||
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
|
||||
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
|
||||
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
|
||||
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
|
||||
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
|
||||
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
|
||||
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered |
|
||||
| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode |
|
||||
| [`onChange`](#onchange) | `function` | \_ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
|
||||
| [`onPointerUpdate`](#onpointerupdate) | `function` | \_ | Callback triggered when mouse pointer is updated. |
|
||||
| [`onPointerDown`](#onpointerdown) | `function` | \_ | This prop if passed gets triggered on pointer down events |
|
||||
| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. |
|
||||
| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene |
|
||||
| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. |
|
||||
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
|
||||
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
|
||||
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
|
||||
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | _ | Render function that renders custom UI in top right corner |
|
||||
| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | _ | Render function that can be used to render custom stats on the stats dialog. |
|
||||
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | _ | This indicates if the app is in `view` mode. |
|
||||
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | _ | This indicates if the `zen` mode is enabled |
|
||||
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | _ | This indicates if the `grid` mode is enabled |
|
||||
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
|
||||
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner |
|
||||
| [`renderCustomStats`](/docs/@excalidraw/excalidraw/api/props/render-props#rendercustomstats) | `function` | \_ | Render function that can be used to render custom stats on the stats dialog. |
|
||||
| [`viewModeEnabled`](#viewmodeenabled) | `boolean` | \_ | This indicates if the app is in `view` mode. |
|
||||
| [`zenModeEnabled`](#zenmodeenabled) | `boolean` | \_ | This indicates if the `zen` mode is enabled |
|
||||
| [`gridModeEnabled`](#gridmodeenabled) | `boolean` | \_ | This indicates if the `grid` mode is enabled |
|
||||
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | \_ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
|
||||
| [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component |
|
||||
| [`name`](#name) | `string` | | Name of the drawing |
|
||||
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
|
||||
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
|
||||
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
|
||||
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
|
||||
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
|
||||
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
|
||||
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
|
||||
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
||||
|
||||
@ -93,9 +94,8 @@ This callback is triggered when mouse pointer is updated.
|
||||
|
||||
This prop if passed will be triggered on pointer down events and has the below signature.
|
||||
|
||||
|
||||
<pre>
|
||||
(activeTool:{" "}
|
||||
(activeTool:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115">
|
||||
{" "}
|
||||
AppState["activeTool"]
|
||||
@ -143,6 +143,14 @@ This callback if supplied will get triggered when the library is updated and has
|
||||
|
||||
It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage.
|
||||
|
||||
### generateLinkForSelection
|
||||
|
||||
This prop if passed will be used to replace the default link generation function. The idea is that the host app can take over the creation of element links, which can be used to navigate to a particular element or a group. If the host app chooses a different key for element link id, then the host app should also take care of the handling and the navigation in `onLinkOpen`.
|
||||
|
||||
```tsx
|
||||
(id: string, type: "element" | "group") => string;
|
||||
```
|
||||
|
||||
### onLinkOpen
|
||||
|
||||
This prop if passed will be triggered when clicked on `link`. To handle the redirect yourself (such as when using your own router for internal links), you must call `event.preventDefault()`.
|
||||
@ -207,8 +215,7 @@ This prop indicates whether the shows the grid. When supplied, the value takes p
|
||||
|
||||
### libraryReturnUrl
|
||||
|
||||
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
|
||||
If supplied, this URL will be used when user tries to install a library from [libraries.excalidraw.com](https://libraries.excalidraw.com). Defaults to _window.location.origin + window.location.pathname_. To install the libraries in the same tab from which it was opened, you need to set `window.name` (to any alphanumeric string) — if it's not set it will open in a new tab.
|
||||
|
||||
### theme
|
||||
|
||||
@ -220,7 +227,6 @@ You can use [`THEME`](/docs/@excalidraw/excalidraw/api/utils#theme) to specify t
|
||||
|
||||
This prop sets the `name` of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over _intialData.appState.name_, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
|
||||
|
||||
|
||||
### detectScroll
|
||||
|
||||
Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method).
|
||||
|
@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
||||
getDimensions,<br/>
|
||||
files,<br/>
|
||||
exportPadding?: number;<br/>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
|
||||
</pre>
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|
@ -12,7 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
Throughout the documentation we use live, editable Excalidraw examples like the one shown below.
|
||||
|
||||
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
|
||||
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
|
||||
For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
|
||||
|
||||
:::
|
||||
@ -70,9 +70,9 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
||||
height: 141.9765625,
|
||||
},]));
|
||||
return (
|
||||
<div style={{height:"500px", width:"500px"}}>
|
||||
<div style={{height:"500px", width:"500px"}}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ExcalidrawWrapper;
|
||||
@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
||||
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// Since client components get prerenderd on server as well hence importing
|
||||
|
||||
// Since client components get prerenderd on server as well hence importing
|
||||
// the excalidraw stuff dynamically with ssr false
|
||||
|
||||
const ExcalidrawWrapper = dynamic(
|
||||
@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ExcalidrawWrapper />
|
||||
<ExcalidrawWrapper />
|
||||
);
|
||||
}
|
||||
```
|
||||
@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// Since client components get prerenderd on server as well hence importing
|
||||
// Since client components get prerenderd on server as well hence importing
|
||||
// the excalidraw stuff dynamically with ssr false
|
||||
|
||||
const ExcalidrawWrapper = dynamic(
|
||||
@ -153,7 +153,7 @@ Since Vite removes env variables by default, you can update the vite config to e
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
},
|
||||
```
|
||||
:::
|
||||
:::
|
||||
|
||||
## Browser
|
||||
|
||||
@ -235,3 +235,5 @@ root.render(React.createElement(App));
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
|
||||
|
@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
|
||||
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
|
||||
try {
|
||||
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
|
||||
const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
|
||||
fontSize: number,
|
||||
});
|
||||
const excalidrawElements = convertToExcalidrawElements(elements);
|
||||
|
@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
||||
|
||||
// editor state (canvas config, preferences, ...)
|
||||
"appState": {
|
||||
"gridSize": null,
|
||||
"gridSize": 20,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
|
||||
|
@ -66,7 +66,7 @@ const config = {
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
to: "https://blog.excalidraw.com",
|
||||
to: "https://plus.excalidraw.com/blog",
|
||||
label: "Blog",
|
||||
position: "left",
|
||||
},
|
||||
@ -111,7 +111,7 @@ const config = {
|
||||
items: [
|
||||
{
|
||||
label: "Blog",
|
||||
to: "https://blog.excalidraw.com",
|
||||
to: "https://plus.excalidraw.com/blog",
|
||||
},
|
||||
{
|
||||
label: "GitHub",
|
||||
|
@ -1547,7 +1547,7 @@
|
||||
"@docusaurus/theme-search-algolia" "2.2.0"
|
||||
"@docusaurus/types" "2.2.0"
|
||||
|
||||
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
"@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
braces@^3.0.2, braces@~3.0.2:
|
||||
braces@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
||||
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
fill-range@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
||||
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
finalhandler@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
|
||||
@ -5207,11 +5221,11 @@ methods@~1.1.2:
|
||||
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
||||
|
||||
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
dependencies:
|
||||
braces "^3.0.2"
|
||||
braces "^3.0.3"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
||||
@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
|
||||
strip-ansi "^6.0.1"
|
||||
text-table "^0.2.0"
|
||||
|
||||
react-dom@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||
react-dom@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-error-overlay@^6.0.11:
|
||||
version "6.0.11"
|
||||
@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.3"
|
||||
|
||||
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-router-config@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||
@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
|
||||
use-composed-ref "^1.3.0"
|
||||
use-latest "^1.2.1"
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
react@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
readable-stream@^2.0.1:
|
||||
version "2.3.7"
|
||||
@ -6664,13 +6684,12 @@ sax@^1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
||||
scheduler@^0.20.2:
|
||||
version "0.20.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
||||
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
||||
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@2.7.0:
|
||||
version "2.7.0"
|
||||
|
@ -40,7 +40,7 @@ import type {
|
||||
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||
|
||||
import "./App.scss";
|
||||
import "./ExampleApp.scss";
|
||||
|
||||
type Comment = {
|
||||
x: number;
|
||||
@ -73,7 +73,7 @@ export interface AppProps {
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}
|
||||
|
||||
export default function App({
|
||||
export default function ExampleApp({
|
||||
appTitle,
|
||||
useCustom,
|
||||
customArgs,
|
2
examples/excalidraw/with-nextjs/.gitignore
vendored
2
examples/excalidraw/with-nextjs/.gitignore
vendored
@ -36,4 +36,4 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# copied assets
|
||||
public/*.woff2
|
||||
public/**/*.woff2
|
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
"start": "next start -p 3006",
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import App from "../../components/App";
|
||||
import App from "../../components/ExampleApp";
|
||||
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
|
@ -1,2 +1,2 @@
|
||||
# copied assets
|
||||
public/*.woff2
|
||||
public/**/*.woff2
|
@ -1,4 +1,4 @@
|
||||
import App from "../components/App";
|
||||
import App from "../components/ExampleApp";
|
||||
import React, { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
|
@ -13,7 +13,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
||||
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
|
||||
"start": "yarn build:workspace && vite",
|
||||
"build": "yarn build:workspace && vite build",
|
||||
"build:preview": "yarn build && vite preview --port 5002"
|
||||
|
@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
StoreAction,
|
||||
reconcileElements,
|
||||
@ -121,6 +120,14 @@ import {
|
||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||
import { useAppLangCode } from "./app-language/language-state";
|
||||
import DebugCanvas, {
|
||||
debugRenderer,
|
||||
isVisualDebuggerEnabled,
|
||||
loadSavedDebugState,
|
||||
} from "./components/DebugCanvas";
|
||||
import { AIComponents } from "./components/AI";
|
||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
import { isElementLink } from "../packages/excalidraw/element/elementLink";
|
||||
|
||||
polyfill();
|
||||
|
||||
@ -337,6 +344,8 @@ const ExcalidrawWrapper = () => {
|
||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||
}
|
||||
|
||||
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent("load", "frame", getFrame());
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
@ -362,6 +371,23 @@ const ExcalidrawWrapper = () => {
|
||||
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||
});
|
||||
|
||||
const [, forceRefresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
const debugState = loadSavedDebugState();
|
||||
|
||||
if (debugState.enabled && !window.visualDebug) {
|
||||
window.visualDebug = {
|
||||
data: [],
|
||||
};
|
||||
} else {
|
||||
delete window.visualDebug;
|
||||
}
|
||||
forceRefresh((prev) => !prev);
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
@ -622,6 +648,16 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render the debug scene if the debug canvas is available
|
||||
if (debugCanvasRef.current && excalidrawAPI) {
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||
@ -813,6 +849,12 @@ const ExcalidrawWrapper = () => {
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
onLinkOpen={(element, event) => {
|
||||
if (element.link && isElementLink(element.link)) {
|
||||
event.preventDefault();
|
||||
excalidrawAPI?.scrollToContent(element.link, { animate: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppMainMenu
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
@ -820,6 +862,7 @@ const ExcalidrawWrapper = () => {
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
theme={appTheme}
|
||||
setTheme={(theme) => setAppTheme(theme)}
|
||||
refresh={() => forceRefresh((prev) => !prev)}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
@ -845,64 +888,9 @@ const ExcalidrawWrapper = () => {
|
||||
</OverwriteConfirmDialog.Action>
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
|
||||
const rateLimitRemaining = response.headers.has(
|
||||
"X-Ratelimit-Remaining",
|
||||
)
|
||||
? parseInt(
|
||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||
10,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
error: new Error(
|
||||
"Too many requests today, please try again tomorrow!",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(json.message || "Generation failed...");
|
||||
}
|
||||
|
||||
const generatedResponse = json.generatedResponse;
|
||||
if (!generatedResponse) {
|
||||
throw new Error("Generation failed...");
|
||||
}
|
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||
} catch (err: any) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
@ -1132,12 +1120,25 @@ const ExcalidrawWrapper = () => {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{isVisualDebuggerEnabled() && excalidrawAPI && (
|
||||
<DebugCanvas
|
||||
appState={excalidrawAPI.getAppState()}
|
||||
scale={window.devicePixelRatio}
|
||||
ref={debugCanvasRef}
|
||||
/>
|
||||
)}
|
||||
</Excalidraw>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExcalidrawApp = () => {
|
||||
const isCloudExportWindow =
|
||||
window.location.pathname === "/excalidraw-plus-export";
|
||||
if (isCloudExportWindow) {
|
||||
return <ExcalidrawPlusIframeExport />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider unstable_createStore={() => appJotaiStore}>
|
||||
|
@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
|
||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import type { UIAppState } from "../packages/excalidraw/types";
|
||||
import { Stats } from "../packages/excalidraw";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.storage")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.scene")}</td>
|
||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.total")}</td>
|
||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.version")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
style={{ textAlign: "center", cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(getVersion());
|
||||
props.setToast(t("toast.copyToClipboard"));
|
||||
} catch {}
|
||||
}}
|
||||
title={t("stats.versionCopy")}
|
||||
>
|
||||
{timestamp}
|
||||
<br />
|
||||
{hash}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
<Stats.StatsRows order={-1}>
|
||||
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
|
||||
<Stats.StatsRow
|
||||
style={{ textAlign: "center", cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(getVersion());
|
||||
props.setToast(t("toast.copyToClipboard"));
|
||||
} catch {}
|
||||
}}
|
||||
title={t("stats.versionCopy")}
|
||||
>
|
||||
{timestamp}
|
||||
<br />
|
||||
{hash}
|
||||
</Stats.StatsRow>
|
||||
|
||||
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
|
||||
<Stats.StatsRow columns={2}>
|
||||
<div>{t("stats.scene")}</div>
|
||||
<div>{nFormatter(storageSizes.scene, 1)}</div>
|
||||
</Stats.StatsRow>
|
||||
<Stats.StatsRow columns={2}>
|
||||
<div>{t("stats.total")}</div>
|
||||
<div>{nFormatter(storageSizes.total, 1)}</div>
|
||||
</Stats.StatsRow>
|
||||
</Stats.StatsRows>
|
||||
);
|
||||
};
|
||||
|
||||
|
222
excalidraw-app/ExcalidrawPlusIframeExport.tsx
Normal file
222
excalidraw-app/ExcalidrawPlusIframeExport.tsx
Normal file
@ -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;
|
||||
};
|
@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import type {
|
||||
BinaryFileData,
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/types";
|
||||
@ -9,6 +10,7 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
@ -157,7 +159,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
throw new AbortError();
|
||||
}
|
||||
|
||||
return saveFilesToFirebase({
|
||||
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
|
||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||
files: await encodeFilesForUpload({
|
||||
files: addedFiles,
|
||||
@ -165,6 +167,29 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
savedFiles: savedFiles.reduce(
|
||||
(acc: Map<FileId, BinaryFileData>, id) => {
|
||||
const fileData = addedFiles.get(id);
|
||||
if (fileData) {
|
||||
acc.set(id, fileData);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
new Map(),
|
||||
),
|
||||
erroredFiles: erroredFiles.reduce(
|
||||
(acc: Map<FileId, BinaryFileData>, id) => {
|
||||
const fileData = addedFiles.get(id);
|
||||
if (fileData) {
|
||||
acc.set(id, fileData);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
new Map(),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
@ -394,7 +419,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
.filter((element) => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
!this.fileManager.isFileHandled(element.fileId) &&
|
||||
!this.fileManager.isFileTracked(element.fileId) &&
|
||||
!element.isDeleted &&
|
||||
(opts.forceFetchFiles
|
||||
? element.status !== "pending" ||
|
||||
|
@ -116,20 +116,26 @@ class Portal {
|
||||
}
|
||||
}
|
||||
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: this.collab.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||
// this will signal collaborators to pull image data from server
|
||||
// (using mutation instead of newElementWith otherwise it'd break
|
||||
// in-progress dragging)
|
||||
return newElementWith(element, { status: "saved" });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
let isChanged = false;
|
||||
const newElements = this.collab.excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
|
||||
isChanged = true;
|
||||
// this will signal collaborators to pull image data from server
|
||||
// (using mutation instead of newElementWith otherwise it'd break
|
||||
// in-progress dragging)
|
||||
return newElementWith(element, { status: "saved" });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
if (isChanged) {
|
||||
this.collab.excalidrawAPI.updateScene({
|
||||
elements: newElements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
}
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
|
@ -1,218 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { KEYS } from "../../packages/excalidraw/keys";
|
||||
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
playerPlayIcon,
|
||||
playerStopFilledIcon,
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
|
||||
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
|
||||
import "./RoomDialog.scss";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||
|
||||
if (isAppleBrowser) {
|
||||
return shareIOS;
|
||||
} else if (isWindowsBrowser) {
|
||||
return shareWindows;
|
||||
}
|
||||
|
||||
return share;
|
||||
};
|
||||
|
||||
export type RoomModalProps = {
|
||||
handleClose: () => void;
|
||||
activeRoomLink: string;
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
};
|
||||
|
||||
export const RoomModal = ({
|
||||
activeRoomLink,
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
setErrorMessage,
|
||||
username,
|
||||
onUsernameChange,
|
||||
handleClose,
|
||||
}: RoomModalProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const shareRoomLink = async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t("roomDialog.shareTitle"),
|
||||
text: t("roomDialog.shareTitle"),
|
||||
url: activeRoomLink,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Just ignore.
|
||||
}
|
||||
};
|
||||
|
||||
if (activeRoomLink) {
|
||||
return (
|
||||
<>
|
||||
<h3 className="RoomDialog__active__header">
|
||||
{t("labels.liveCollaboration")}
|
||||
</h3>
|
||||
<TextField
|
||||
value={username}
|
||||
placeholder="Your name"
|
||||
label="Your name"
|
||||
onChange={onUsernameChange}
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||
/>
|
||||
<div className="RoomDialog__active__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={activeRoomLink}
|
||||
/>
|
||||
{isShareSupported && (
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
icon={getShareIcon()}
|
||||
className="RoomDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="RoomDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="RoomDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className="RoomDialog__active__description__emoji"
|
||||
>
|
||||
🔒{" "}
|
||||
</span>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__active__actions">
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
onRoomDestroy();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="RoomDialog__inactive__illustration">
|
||||
<CollabImage />
|
||||
</div>
|
||||
<div className="RoomDialog__inactive__header">
|
||||
{t("labels.liveCollaboration")}
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__inactive__description">
|
||||
<strong>{t("roomDialog.desc_intro")}</strong>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
|
||||
<div className="RoomDialog__inactive__start_session">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
onRoomCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomDialog = (props: RoomModalProps) => {
|
||||
return (
|
||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||
<div className="RoomDialog">
|
||||
<RoomModal {...props} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomDialog;
|
159
excalidraw-app/components/AI.tsx
Normal file
159
excalidraw-app/components/AI.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
DiagramToCodePlugin,
|
||||
exportToBlob,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
TTDDialog,
|
||||
} from "../../packages/excalidraw";
|
||||
import { getDataURL } from "../../packages/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "../../packages/excalidraw/utils";
|
||||
|
||||
export const AIComponents = ({
|
||||
excalidrawAPI,
|
||||
}: {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<DiagramToCodePlugin
|
||||
generate={async ({ frame, children }) => {
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
|
||||
const blob = await exportToBlob({
|
||||
elements: children,
|
||||
appState: {
|
||||
...appState,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
},
|
||||
exportingFrame: frame,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
mimeType: MIME_TYPES.jpg,
|
||||
});
|
||||
|
||||
const dataURL = await getDataURL(blob);
|
||||
|
||||
const textFromFrameChildren = getTextFromElements(children);
|
||||
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/diagram-to-code/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
texts: textFromFrameChildren,
|
||||
image: dataURL,
|
||||
theme: appState.theme,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const errorJSON = safelyParseJSON(text);
|
||||
|
||||
if (!errorJSON) {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
if (errorJSON.statusCode === 429) {
|
||||
return {
|
||||
html: `<html>
|
||||
<body style="margin: 0; text-align: center">
|
||||
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
|
||||
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
|
||||
</br>
|
||||
</br>
|
||||
<div>You can also try <a href="${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(errorJSON.message || text);
|
||||
}
|
||||
|
||||
try {
|
||||
const { html } = await response.json();
|
||||
|
||||
if (!html) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
return {
|
||||
html,
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
|
||||
const rateLimitRemaining = response.headers.has(
|
||||
"X-Ratelimit-Remaining",
|
||||
)
|
||||
? parseInt(
|
||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||
10,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
error: new Error(
|
||||
"Too many requests today, please try again tomorrow!",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(json.message || "Generation failed...");
|
||||
}
|
||||
|
||||
const generatedResponse = json.generatedResponse;
|
||||
if (!generatedResponse) {
|
||||
throw new Error("Generation failed...");
|
||||
}
|
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||
} catch (err: any) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||
|
||||
export const AppFooter = React.memo(() => {
|
||||
return (
|
||||
<Footer>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: ".5rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
});
|
||||
export const AppFooter = React.memo(
|
||||
({ onChange }: { onChange: () => void }) => {
|
||||
return (
|
||||
<Footer>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: ".5rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -2,11 +2,13 @@ import React from "react";
|
||||
import {
|
||||
loginIcon,
|
||||
ExcalLogo,
|
||||
eyeIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { LanguageList } from "../app-language/LanguageList";
|
||||
import { saveDebugState } from "./DebugCanvas";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
|
||||
isCollabEnabled: boolean;
|
||||
theme: Theme | "system";
|
||||
setTheme: (theme: Theme | "system") => void;
|
||||
refresh: () => void;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<MainMenu>
|
||||
@ -28,6 +31,7 @@ export const AppMainMenu: React.FC<{
|
||||
/>
|
||||
)}
|
||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||
</MainMenu.ItemLink>
|
||||
{import.meta.env.DEV && (
|
||||
<MainMenu.Item
|
||||
icon={eyeIcon}
|
||||
onClick={() => {
|
||||
if (window.visualDebug) {
|
||||
delete window.visualDebug;
|
||||
saveDebugState({ enabled: false });
|
||||
} else {
|
||||
window.visualDebug = { data: [] };
|
||||
saveDebugState({ enabled: true });
|
||||
}
|
||||
props?.refresh();
|
||||
}}
|
||||
>
|
||||
Visual Debug
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
|
311
excalidraw-app/components/DebugCanvas.tsx
Normal file
311
excalidraw-app/components/DebugCanvas.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { type AppState } from "../../packages/excalidraw/types";
|
||||
import { throttleRAF } from "../../packages/excalidraw/utils";
|
||||
import {
|
||||
bootstrapCanvas,
|
||||
getNormalizedCanvasDimensions,
|
||||
} from "../../packages/excalidraw/renderer/helpers";
|
||||
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
|
||||
import {
|
||||
ArrowheadArrowIcon,
|
||||
CloseIcon,
|
||||
TrashIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import {
|
||||
isLineSegment,
|
||||
type GlobalPoint,
|
||||
type LineSegment,
|
||||
} from "../../packages/math";
|
||||
|
||||
const renderLine = (
|
||||
context: CanvasRenderingContext2D,
|
||||
zoom: number,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
color: string,
|
||||
) => {
|
||||
context.save();
|
||||
context.strokeStyle = color;
|
||||
context.beginPath();
|
||||
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
|
||||
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||
context.strokeStyle = "#888";
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.moveTo(-10 * zoom, -10 * zoom);
|
||||
context.lineTo(10 * zoom, 10 * zoom);
|
||||
context.moveTo(10 * zoom, -10 * zoom);
|
||||
context.lineTo(-10 * zoom, 10 * zoom);
|
||||
context.stroke();
|
||||
context.save();
|
||||
};
|
||||
|
||||
const render = (
|
||||
frame: DebugElement[],
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: AppState,
|
||||
) => {
|
||||
frame.forEach((el: DebugElement) => {
|
||||
switch (true) {
|
||||
case isLineSegment(el.data):
|
||||
renderLine(
|
||||
context,
|
||||
appState.zoom.value,
|
||||
el.data as LineSegment<GlobalPoint>,
|
||||
el.color,
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const _debugRenderer = (
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||
canvas,
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
normalizedWidth,
|
||||
normalizedHeight,
|
||||
viewBackgroundColor: "transparent",
|
||||
});
|
||||
|
||||
// Apply zoom
|
||||
context.save();
|
||||
context.translate(
|
||||
appState.scrollX * appState.zoom.value,
|
||||
appState.scrollY * appState.zoom.value,
|
||||
);
|
||||
|
||||
renderOrigin(context, appState.zoom.value);
|
||||
|
||||
if (
|
||||
window.visualDebug?.currentFrame &&
|
||||
window.visualDebug?.data &&
|
||||
window.visualDebug.data.length > 0
|
||||
) {
|
||||
// Render only one frame
|
||||
const [idx] = debugFrameData();
|
||||
|
||||
render(window.visualDebug.data[idx], context, appState);
|
||||
} else {
|
||||
// Render all debug frames
|
||||
window.visualDebug?.data.forEach((frame) => {
|
||||
render(frame, context, appState);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.visualDebug) {
|
||||
window.visualDebug!.data =
|
||||
window.visualDebug?.data.map((frame) =>
|
||||
frame.filter((el) => el.permanent),
|
||||
) ?? [];
|
||||
}
|
||||
};
|
||||
|
||||
const debugFrameData = (): [number, number] => {
|
||||
const currentFrame = window.visualDebug?.currentFrame ?? 0;
|
||||
const frameCount = window.visualDebug?.data.length ?? 0;
|
||||
|
||||
if (frameCount > 0) {
|
||||
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
|
||||
}
|
||||
|
||||
return [0, 0];
|
||||
};
|
||||
|
||||
export const saveDebugState = (debug: { enabled: boolean }) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
||||
JSON.stringify(debug),
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const debugRenderer = throttleRAF(
|
||||
(
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, scale, refresh);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
export const loadSavedDebugState = () => {
|
||||
let debug;
|
||||
try {
|
||||
const savedDebugState = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
||||
);
|
||||
if (savedDebugState) {
|
||||
debug = JSON.parse(savedDebugState) as { enabled: boolean };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return debug ?? { enabled: false };
|
||||
};
|
||||
|
||||
export const isVisualDebuggerEnabled = () =>
|
||||
Array.isArray(window.visualDebug?.data);
|
||||
|
||||
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
const moveForward = useCallback(() => {
|
||||
if (
|
||||
!window.visualDebug?.currentFrame ||
|
||||
isNaN(window.visualDebug?.currentFrame ?? -1)
|
||||
) {
|
||||
window.visualDebug!.currentFrame = 0;
|
||||
}
|
||||
window.visualDebug!.currentFrame += 1;
|
||||
onChange();
|
||||
}, [onChange]);
|
||||
const moveBackward = useCallback(() => {
|
||||
if (
|
||||
!window.visualDebug?.currentFrame ||
|
||||
isNaN(window.visualDebug?.currentFrame ?? -1) ||
|
||||
window.visualDebug?.currentFrame < 1
|
||||
) {
|
||||
window.visualDebug!.currentFrame = 1;
|
||||
}
|
||||
window.visualDebug!.currentFrame -= 1;
|
||||
onChange();
|
||||
}, [onChange]);
|
||||
const reset = useCallback(() => {
|
||||
window.visualDebug!.currentFrame = undefined;
|
||||
onChange();
|
||||
}, [onChange]);
|
||||
const trashFrames = useCallback(() => {
|
||||
if (window.visualDebug) {
|
||||
window.visualDebug.currentFrame = undefined;
|
||||
window.visualDebug.data = [];
|
||||
}
|
||||
onChange();
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="ToolIcon_type_button"
|
||||
data-testid="debug-forward"
|
||||
aria-label="Move forward"
|
||||
type="button"
|
||||
onClick={trashFrames}
|
||||
>
|
||||
<div
|
||||
className="ToolIcon__icon"
|
||||
aria-hidden="true"
|
||||
aria-disabled="false"
|
||||
>
|
||||
{TrashIcon}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="ToolIcon_type_button"
|
||||
data-testid="debug-forward"
|
||||
aria-label="Move forward"
|
||||
type="button"
|
||||
onClick={moveBackward}
|
||||
>
|
||||
<div
|
||||
className="ToolIcon__icon"
|
||||
aria-hidden="true"
|
||||
aria-disabled="false"
|
||||
>
|
||||
<ArrowheadArrowIcon flip />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="ToolIcon_type_button"
|
||||
data-testid="debug-forward"
|
||||
aria-label="Move forward"
|
||||
type="button"
|
||||
onClick={reset}
|
||||
>
|
||||
<div
|
||||
className="ToolIcon__icon"
|
||||
aria-hidden="true"
|
||||
aria-disabled="false"
|
||||
>
|
||||
{CloseIcon}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="ToolIcon_type_button"
|
||||
data-testid="debug-backward"
|
||||
aria-label="Move backward"
|
||||
type="button"
|
||||
onClick={moveForward}
|
||||
>
|
||||
<div
|
||||
className="ToolIcon__icon"
|
||||
aria-hidden="true"
|
||||
aria-disabled="false"
|
||||
>
|
||||
<ArrowheadArrowIcon />
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||
ref,
|
||||
() => canvasRef.current,
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={canvasRef}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default DebugCanvas;
|
@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
|
||||
return (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
|
@ -16,14 +16,26 @@ import type {
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
|
||||
type FileVersion = Required<BinaryFileData>["version"];
|
||||
|
||||
export class FileManager {
|
||||
/** files being fetched */
|
||||
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
private erroredFiles_fetch = new Map<
|
||||
ExcalidrawImageElement["fileId"],
|
||||
true
|
||||
>();
|
||||
/** files being saved */
|
||||
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
private savingFiles = new Map<
|
||||
ExcalidrawImageElement["fileId"],
|
||||
FileVersion
|
||||
>();
|
||||
/* files already saved to persistent storage */
|
||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
|
||||
private erroredFiles_save = new Map<
|
||||
ExcalidrawImageElement["fileId"],
|
||||
FileVersion
|
||||
>();
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
@ -37,8 +49,8 @@ export class FileManager {
|
||||
erroredFiles: Map<FileId, true>;
|
||||
}>;
|
||||
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
|
||||
savedFiles: Map<FileId, true>;
|
||||
erroredFiles: Map<FileId, true>;
|
||||
savedFiles: Map<FileId, BinaryFileData>;
|
||||
erroredFiles: Map<FileId, BinaryFileData>;
|
||||
}>;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
@ -46,19 +58,28 @@ export class FileManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* returns whether file is already saved or being processed
|
||||
* returns whether file is saved/errored, or being processed
|
||||
*/
|
||||
isFileHandled = (id: FileId) => {
|
||||
isFileTracked = (id: FileId) => {
|
||||
return (
|
||||
this.savedFiles.has(id) ||
|
||||
this.fetchingFiles.has(id) ||
|
||||
this.savingFiles.has(id) ||
|
||||
this.erroredFiles.has(id)
|
||||
this.fetchingFiles.has(id) ||
|
||||
this.erroredFiles_fetch.has(id) ||
|
||||
this.erroredFiles_save.has(id)
|
||||
);
|
||||
};
|
||||
|
||||
isFileSaved = (id: FileId) => {
|
||||
return this.savedFiles.has(id);
|
||||
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
|
||||
const fileVersion = this.getFileVersion(file);
|
||||
return (
|
||||
this.savedFiles.get(file.id) === fileVersion ||
|
||||
this.savingFiles.get(file.id) === fileVersion
|
||||
);
|
||||
};
|
||||
|
||||
getFileVersion = (file: BinaryFileData) => {
|
||||
return file.version ?? 1;
|
||||
};
|
||||
|
||||
saveFiles = async ({
|
||||
@ -71,13 +92,16 @@ export class FileManager {
|
||||
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
||||
|
||||
for (const element of elements) {
|
||||
const fileData =
|
||||
isInitializedImageElement(element) && files[element.fileId];
|
||||
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
files[element.fileId] &&
|
||||
!this.isFileHandled(element.fileId)
|
||||
fileData &&
|
||||
// NOTE if errored during save, won't retry due to this check
|
||||
!this.isFileSavedOrBeingSaved(fileData)
|
||||
) {
|
||||
addedFiles.set(element.fileId, files[element.fileId]);
|
||||
this.savingFiles.set(element.fileId, true);
|
||||
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,8 +110,12 @@ export class FileManager {
|
||||
addedFiles,
|
||||
});
|
||||
|
||||
for (const [fileId] of savedFiles) {
|
||||
this.savedFiles.set(fileId, true);
|
||||
for (const [fileId, fileData] of savedFiles) {
|
||||
this.savedFiles.set(fileId, this.getFileVersion(fileData));
|
||||
}
|
||||
|
||||
for (const [fileId, fileData] of erroredFiles) {
|
||||
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
|
||||
}
|
||||
|
||||
return {
|
||||
@ -121,10 +149,10 @@ export class FileManager {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
for (const file of loadedFiles) {
|
||||
this.savedFiles.set(file.id, true);
|
||||
this.savedFiles.set(file.id, this.getFileVersion(file));
|
||||
}
|
||||
for (const [fileId] of erroredFiles) {
|
||||
this.erroredFiles.set(fileId, true);
|
||||
this.erroredFiles_fetch.set(fileId, true);
|
||||
}
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
@ -160,7 +188,7 @@ export class FileManager {
|
||||
): element is InitializedExcalidrawImageElement => {
|
||||
return (
|
||||
isInitializedImageElement(element) &&
|
||||
this.isFileSaved(element.fileId) &&
|
||||
this.savedFiles.has(element.fileId) &&
|
||||
element.status === "pending"
|
||||
);
|
||||
};
|
||||
@ -169,7 +197,8 @@ export class FileManager {
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
this.erroredFiles.clear();
|
||||
this.erroredFiles_fetch.clear();
|
||||
this.erroredFiles_save.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,10 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "../../packages/excalidraw/constants";
|
||||
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
if (
|
||||
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
@ -170,8 +183,8 @@ export class LocalData {
|
||||
);
|
||||
},
|
||||
async saveFiles({ addedFiles }) {
|
||||
const savedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
const savedFiles = new Map<FileId, BinaryFileData>();
|
||||
const erroredFiles = new Map<FileId, BinaryFileData>();
|
||||
|
||||
// before we use `storage` event synchronization, let's update the flag
|
||||
// optimistically. Hopefully nothing fails, and an IDB read executed
|
||||
@ -182,10 +195,10 @@ export class LocalData {
|
||||
[...addedFiles].map(async ([id, fileData]) => {
|
||||
try {
|
||||
await set(id, fileData, filesStore);
|
||||
savedFiles.set(id, true);
|
||||
savedFiles.set(id, fileData);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
erroredFiles.set(id, true);
|
||||
erroredFiles.set(id, fileData);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
|
||||
}) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
const savedFiles = new Map<FileId, true>();
|
||||
const erroredFiles: FileId[] = [];
|
||||
const savedFiles: FileId[] = [];
|
||||
|
||||
await Promise.all(
|
||||
files.map(async ({ id, buffer }) => {
|
||||
@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
},
|
||||
);
|
||||
savedFiles.set(id, true);
|
||||
savedFiles.push(id);
|
||||
} catch (error: any) {
|
||||
erroredFiles.set(id, true);
|
||||
erroredFiles.push(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
@ -54,11 +54,7 @@
|
||||
content="https://excalidraw.com/og-image-3.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<link rel="canonical" href="https://excalidraw.com" />
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
@ -124,51 +120,19 @@
|
||||
<!-- Following placeholder is replaced during the build step -->
|
||||
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="../packages/excalidraw/fonts/fonts.css"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<% } else { %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</script>
|
||||
|
||||
<!-- in DEV we need to preload from the local server and without the hash -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<% } %>
|
||||
|
||||
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="../packages/excalidraw/fonts/assets/fonts.css"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
@ -250,6 +214,7 @@
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="index.tsx"></script>
|
||||
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
@ -282,5 +247,6 @@
|
||||
}
|
||||
</script>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -23,20 +23,20 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"jotai": "1.13.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"vite-plugin-html": "3.2.2",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"socket.io-client": "4.7.2"
|
||||
"socket.io-client": "4.7.2",
|
||||
"vite-plugin-html": "3.2.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
@ -49,5 +49,8 @@
|
||||
"start:production": "yarn build && yarn serve",
|
||||
"serve": "npx http-server build -a localhost -p 5001 -o",
|
||||
"build:preview": "yarn build && vite preview --port 5000"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite-plugin-sitemap": "0.7.1"
|
||||
}
|
||||
}
|
||||
|
@ -58,8 +58,8 @@
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
@ -14,7 +13,6 @@ import {
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||
|
||||
type OnExportToBackend = () => void;
|
||||
type ShareDialogType = "share" | "collaborationOnly";
|
||||
@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
|
||||
handleClose: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
const { onCopy, copyStatus } = useCopyStatus();
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="ShareDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("buttons.copyLink")}
|
||||
icon={copyIcon}
|
||||
status={copyStatus}
|
||||
onClick={() => {
|
||||
copyRoomLink();
|
||||
onCopy();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
|
3
excalidraw-app/vite-env.d.ts
vendored
3
excalidraw-app/vite-env.d.ts
vendored
@ -29,6 +29,9 @@ interface ImportMetaEnv {
|
||||
// Enable eslint in dev server
|
||||
VITE_APP_ENABLE_ESLINT: string;
|
||||
|
||||
// Enable PWA in dev server
|
||||
VITE_APP_ENABLE_PWA: string;
|
||||
|
||||
VITE_APP_PLUS_LP: string;
|
||||
|
||||
VITE_APP_PLUS_APP: string;
|
||||
|
@ -5,204 +5,237 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import Sitemap from "vite-plugin-sitemap";
|
||||
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: Number(envVars.VITE_APP_PORT || 3000),
|
||||
// open the browser
|
||||
open: true,
|
||||
},
|
||||
// We need to specify the envDir since now there are no
|
||||
//more located in parallel with the vite.config.ts file but in parent dir
|
||||
envDir: "../",
|
||||
build: {
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames(chunkInfo) {
|
||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||
// put on root so we are flexible about the CDN path
|
||||
return "[name]-[hash][extname]";
|
||||
}
|
||||
|
||||
return "assets/[name]-[hash][extname]";
|
||||
},
|
||||
// Creating separate chunk for locales except for en and percentages.json so they
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes("packages/excalidraw/locales") &&
|
||||
id.match(/en.json|percentages.json/) === null
|
||||
) {
|
||||
const index = id.indexOf("locales/");
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
// To load .env variables
|
||||
const envVars = loadEnv(mode, `../`);
|
||||
// https://vitejs.dev/config/
|
||||
return {
|
||||
server: {
|
||||
port: Number(envVars.VITE_APP_PORT || 3000),
|
||||
// open the browser
|
||||
open: true,
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [
|
||||
woff2BrowserPlugin(),
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
eslint:
|
||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||
? undefined
|
||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||
overlay: {
|
||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||
},
|
||||
}),
|
||||
svgrPlugin(),
|
||||
ViteEjsPlugin(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
/* set this flag to true to enable in Development mode */
|
||||
enabled: false,
|
||||
},
|
||||
// We need to specify the envDir since now there are no
|
||||
//more located in parallel with the vite.config.ts file but in parent dir
|
||||
envDir: "../",
|
||||
build: {
|
||||
outDir: "build",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames(chunkInfo) {
|
||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||
const family = chunkInfo.name.split("-")[0];
|
||||
return `fonts/${family}/[name][extname]`;
|
||||
}
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||
},
|
||||
},
|
||||
return "assets/[name]-[hash][extname]";
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("fonts.css"),
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "locales",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
name: "Excalidraw",
|
||||
description:
|
||||
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||
icons: [
|
||||
{
|
||||
src: "android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "apple-touch-icon.png",
|
||||
type: "image/png",
|
||||
sizes: "180x180",
|
||||
},
|
||||
{
|
||||
src: "favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
file_handlers: [
|
||||
{
|
||||
action: "/",
|
||||
accept: {
|
||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||
},
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: "/web-share-target",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
files: [
|
||||
{
|
||||
name: "file",
|
||||
accept: [
|
||||
"application/vnd.excalidraw+json",
|
||||
"application/json",
|
||||
".excalidraw",
|
||||
],
|
||||
},
|
||||
],
|
||||
// Creating separate chunk for locales except for en and percentages.json so they
|
||||
// can be cached at runtime and not merged with
|
||||
// app precache. en.json and percentages.json are needed for first load
|
||||
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes("packages/excalidraw/locales") &&
|
||||
id.match(/en.json|percentages.json/) === null
|
||||
) {
|
||||
const index = id.indexOf("locales/");
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/virtual-whiteboard.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/wireframe.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/illustration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/shapes.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/collaboration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/export.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createHtmlPlugin({
|
||||
minify: true,
|
||||
}),
|
||||
],
|
||||
publicDir: "../public",
|
||||
sourcemap: true,
|
||||
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [
|
||||
Sitemap({
|
||||
hostname: "https://excalidraw.com",
|
||||
outDir: "build",
|
||||
changefreq: "monthly",
|
||||
// its static in public folder
|
||||
generateRobotsTxt: false,
|
||||
}),
|
||||
woff2BrowserPlugin(),
|
||||
react(),
|
||||
checker({
|
||||
typescript: true,
|
||||
eslint:
|
||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||
? undefined
|
||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||
overlay: {
|
||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||
},
|
||||
}),
|
||||
svgrPlugin(),
|
||||
ViteEjsPlugin(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
/* set this flag to true to enable in Development mode */
|
||||
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
|
||||
},
|
||||
|
||||
workbox: {
|
||||
// don't precache fonts, locales and separate chunks
|
||||
globIgnores: [
|
||||
"fonts.css",
|
||||
"**/locales/**",
|
||||
"service-worker.js",
|
||||
"**/*.chunk-*.js",
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp(".+.woff2"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 1000,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
|
||||
statuses: [0, 200],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("fonts.css"),
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "fonts",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "locales",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp(".chunk-.+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "chunk",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
name: "Excalidraw",
|
||||
description:
|
||||
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||
icons: [
|
||||
{
|
||||
src: "android-chrome-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "apple-touch-icon.png",
|
||||
type: "image/png",
|
||||
sizes: "180x180",
|
||||
},
|
||||
{
|
||||
src: "favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
id:"excalidraw",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
file_handlers: [
|
||||
{
|
||||
action: "/",
|
||||
accept: {
|
||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||
},
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: "/web-share-target",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
files: [
|
||||
{
|
||||
name: "file",
|
||||
accept: [
|
||||
"application/vnd.excalidraw+json",
|
||||
"application/json",
|
||||
".excalidraw",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/virtual-whiteboard.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/wireframe.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/illustration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/shapes.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/collaboration.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
{
|
||||
src: "/screenshots/export.png",
|
||||
type: "image/png",
|
||||
sizes: "462x945",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
createHtmlPlugin({
|
||||
minify: true,
|
||||
}),
|
||||
],
|
||||
publicDir: "../public",
|
||||
};
|
||||
});
|
||||
|
36
package.json
36
package.json
@ -6,6 +6,7 @@
|
||||
"excalidraw-app",
|
||||
"packages/excalidraw",
|
||||
"packages/utils",
|
||||
"packages/math",
|
||||
"examples/excalidraw",
|
||||
"examples/excalidraw/*"
|
||||
],
|
||||
@ -20,8 +21,8 @@
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vitest/ui": "0.32.2",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"@vitest/ui": "2.0.5",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
@ -36,15 +37,15 @@
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.6.1",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
"vite-plugin-svgr": "4.2.0",
|
||||
"vitest": "2.0.5",
|
||||
"vitest-canvas-mock": "0.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.0.0 - 20.x.x"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"homepage": ".",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
@ -54,15 +55,9 @@
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@ -73,15 +68,22 @@
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage:watch": "vitest --coverage --watch",
|
||||
"test:ui": "yarn test --ui --coverage.enabled=true",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
|
||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.0"
|
||||
"@types/react": "18.2.0",
|
||||
"strip-ansi": "6.0.1"
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
|
||||
|
||||
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
|
||||
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
@ -39,6 +43,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
|
||||
|
||||
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||
|
||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||
|
@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import type { AppState, NormalizedZoomValue } from "../types";
|
||||
import type { AppState, Offsets } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { clamp, roundToStep } from "../../math";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@ -86,7 +87,8 @@ export const actionClearCanvas = register({
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
||||
!appState.viewModeEnabled
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector"
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
@ -104,6 +106,8 @@ export const actionClearCanvas = register({
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
gridStep: appState.gridStep,
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
stats: appState.stats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
@ -244,6 +248,7 @@ export const actionResetZoom = register({
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: SceneBounds,
|
||||
viewportDimensions: { width: number; height: number },
|
||||
viewportZoomFactor: number = 1, // default to 1 if not provided
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
@ -251,92 +256,89 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
const clampedZoomValueToFitElements = Math.min(
|
||||
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
||||
1,
|
||||
);
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
|
||||
const adjustedZoomValue =
|
||||
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
return Math.min(adjustedZoomValue, 1);
|
||||
};
|
||||
|
||||
export const zoomToFitBounds = ({
|
||||
bounds,
|
||||
appState,
|
||||
canvasOffsets,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 0.7,
|
||||
viewportZoomFactor = 1,
|
||||
minZoom = -Infinity,
|
||||
maxZoom = Infinity,
|
||||
}: {
|
||||
bounds: SceneBounds;
|
||||
canvasOffsets?: Offsets;
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
||||
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
||||
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
||||
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
||||
|
||||
const effectiveCanvasWidth =
|
||||
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
||||
const effectiveCanvasHeight =
|
||||
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
||||
|
||||
let adjustedZoomValue;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
adjustedZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, MIN_ZOOM),
|
||||
MAX_ZOOM,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
let appStateWidth = appState.width;
|
||||
|
||||
if (appState.openSidebar) {
|
||||
const sidebarDOMElem = document.querySelector(
|
||||
".sidebar",
|
||||
) as HTMLElement | null;
|
||||
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
appStateWidth = !isRTL
|
||||
? appState.width - sidebarWidth
|
||||
: appState.width + sidebarWidth;
|
||||
}
|
||||
|
||||
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
effectiveCanvasWidth / commonBoundsWidth,
|
||||
effectiveCanvasHeight / commonBoundsHeight,
|
||||
) * viewportZoomFactor;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
||||
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
bounds,
|
||||
{
|
||||
width: effectiveCanvasWidth,
|
||||
height: effectiveCanvasHeight,
|
||||
},
|
||||
viewportZoomFactor,
|
||||
);
|
||||
}
|
||||
|
||||
const newZoomValue = getNormalizedZoom(
|
||||
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
|
||||
);
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
},
|
||||
offsets: canvasOffsets,
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX,
|
||||
scrollY,
|
||||
scrollX: centerScroll.scrollX,
|
||||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
@ -344,25 +346,34 @@ export const zoomToFitBounds = ({
|
||||
};
|
||||
|
||||
export const zoomToFit = ({
|
||||
canvasOffsets,
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: {
|
||||
canvasOffsets?: Offsets;
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
return zoomToFitBounds({
|
||||
canvasOffsets,
|
||||
bounds: commonBounds,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
};
|
||||
|
||||
@ -383,6 +394,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
@ -408,6 +420,7 @@ export const actionZoomToFitSelection = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: true,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
@ -424,7 +437,7 @@ export const actionZoomToFit = register({
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
zoomToFit({
|
||||
targetElements: elements,
|
||||
appState: {
|
||||
@ -432,6 +445,7 @@ export const actionZoomToFit = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
}),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { getTextFromElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
@ -147,14 +147,32 @@ export const actionCopyAsSvg = register({
|
||||
name: app.getName(),
|
||||
},
|
||||
);
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
toast: {
|
||||
message: t("toast.copyToClipboardAsSvg", {
|
||||
exportSelection: selectedElements.length
|
||||
? t("toast.selection")
|
||||
: t("toast.canvas"),
|
||||
exportColorScheme: appState.exportWithDarkMode
|
||||
? t("buttons.darkMode")
|
||||
: t("buttons.lightMode"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
@ -239,16 +257,8 @@ export const copyText = register({
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
try {
|
||||
copyTextToSystemClipboard(text);
|
||||
copyTextToSystemClipboard(getTextFromElements(selectedElements));
|
||||
} catch (e) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
|
55
packages/excalidraw/actions/actionCropEditor.tsx
Normal file
55
packages/excalidraw/actions/actionCropEditor.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { register } from "./register";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { isImageElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawImageElement } from "../element/types";
|
||||
|
||||
export const actionToggleCropEditor = register({
|
||||
name: "cropEditor",
|
||||
label: "helpDialog.cropStart",
|
||||
icon: cropIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
keywords: ["image", "crop"],
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawImageElement;
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
isCropping: false,
|
||||
croppingElementId: selectedElement.id,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.croppingElementId &&
|
||||
selectedElements.length === 1 &&
|
||||
isImageElement(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const label = t("helpDialog.cropStart");
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={cropIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
@ -161,6 +161,7 @@ export const actionDeleteSelected = register({
|
||||
element,
|
||||
selectedPointsIndices,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
import type { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import type { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { DEFAULT_GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
|
||||
perform: (elements, appState, formData, app) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
return {
|
||||
elements,
|
||||
appState: newAppState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: ret.appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@ -99,8 +100,8 @@ const duplicateElements = (
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + GRID_SIZE / 2,
|
||||
y: element.y + GRID_SIZE / 2,
|
||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||
},
|
||||
);
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
|
105
packages/excalidraw/actions/actionElementLink.ts
Normal file
105
packages/excalidraw/actions/actionElementLink.ts
Normal file
@ -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,
|
||||
});
|
@ -6,7 +6,6 @@ import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { pointFrom } from "../../math";
|
||||
import { isPathALoop } from "../shapes";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
@ -50,7 +51,6 @@ export const actionFinalize = register({
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
@ -113,10 +113,10 @@ export const actionFinalize = register({
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((point, index) =>
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? ([firstPoint[0], firstPoint[1]] as const)
|
||||
: point,
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
});
|
||||
}
|
||||
@ -179,7 +179,7 @@ export const actionFinalize = register({
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
editingTextElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
@ -217,6 +217,7 @@ export const actionFinalize = register({
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
style={{ pointerEvents: "all" }}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
211
packages/excalidraw/actions/actionFlip.test.tsx
Normal file
211
packages/excalidraw/actions/actionFlip.test.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import React from "react";
|
||||
import { Excalidraw } from "../index";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { pointFrom } from "../../math";
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("flipping re-centers selection", () => {
|
||||
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
|
||||
const elements = [
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rec1",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
boundElements: [{ id: "arr", type: "arrow" }],
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rec2",
|
||||
x: 220,
|
||||
y: 250,
|
||||
width: 100,
|
||||
height: 100,
|
||||
boundElements: [{ id: "arr", type: "arrow" }],
|
||||
}),
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id: "arr",
|
||||
x: 149.9,
|
||||
y: 95,
|
||||
width: 156,
|
||||
height: 239.9,
|
||||
startBinding: {
|
||||
elementId: "rec1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
fixedPoint: [0.49, -0.05],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rec2",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
fixedPoint: [-0.05, 0.49],
|
||||
},
|
||||
startArrowhead: null,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, -35),
|
||||
pointFrom(-90.9, -35),
|
||||
pointFrom(-90.9, 204.9),
|
||||
pointFrom(65.1, 204.9),
|
||||
],
|
||||
elbowed: true,
|
||||
}),
|
||||
];
|
||||
await render(<Excalidraw initialData={{ elements }} />);
|
||||
|
||||
API.setSelectedElements(elements);
|
||||
|
||||
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
|
||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
||||
expect(rec1?.x).toBeCloseTo(100);
|
||||
expect(rec1?.y).toBeCloseTo(100);
|
||||
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
||||
expect(rec2?.x).toBeCloseTo(220);
|
||||
expect(rec2?.y).toBeCloseTo(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flipping arrowheads", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("flipping bound arrow should flip arrowheads only", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: null,
|
||||
endBinding: {
|
||||
elementId: rect.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
|
||||
API.setElements([rect, arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
|
||||
API.executeAction(actionFlipVertical);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
});
|
||||
|
||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: "circle",
|
||||
startBinding: {
|
||||
elementId: rect.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: rect2.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
|
||||
API.setElements([rect, rect2, arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("circle");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
|
||||
API.executeAction(actionFlipVertical);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
});
|
||||
|
||||
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: "circle",
|
||||
});
|
||||
|
||||
API.setElements([arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
});
|
||||
|
||||
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: null,
|
||||
endBinding: {
|
||||
elementId: rect.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
|
||||
API.setElements([rect, arrow]);
|
||||
API.setSelectedElements([rect, arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
});
|
||||
});
|
@ -2,6 +2,8 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedSceneElementsMap,
|
||||
@ -10,7 +12,6 @@ import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
isBindingEnabled,
|
||||
@ -18,7 +19,14 @@ import {
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
@ -109,18 +117,29 @@ const flipElements = (
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
): ExcalidrawElement[] => {
|
||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||
if (
|
||||
selectedElements.every(
|
||||
(element) =>
|
||||
isArrowElement(element) && (element.startBinding || element.endBinding),
|
||||
)
|
||||
) {
|
||||
return selectedElements.map((element) => {
|
||||
const _element = element as ExcalidrawArrowElement;
|
||||
return newElementWith(_element, {
|
||||
startArrowhead: _element.endArrowhead,
|
||||
endArrowhead: _element.startArrowhead,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resizeMultipleElements(
|
||||
elementsMap,
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
"nw",
|
||||
true,
|
||||
true,
|
||||
flipDirection === "horizontal" ? maxX : minX,
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
);
|
||||
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||
|
||||
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
|
||||
flipByX: flipDirection === "horizontal",
|
||||
flipByY: flipDirection === "vertical",
|
||||
shouldResizeFromCenter: true,
|
||||
shouldMaintainAspectRatio: true,
|
||||
});
|
||||
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
@ -129,7 +148,51 @@ const flipElements = (
|
||||
app.scene,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// flipping arrow elements (and potentially other) makes the selection group
|
||||
// "move" across the canvas because of how arrows can bump against the "wall"
|
||||
// of the selection, so we need to center the group back to the original
|
||||
// position so that repeated flips don't accumulate the offset
|
||||
|
||||
const { elbowArrows, otherElements } = selectedElements.reduce(
|
||||
(
|
||||
acc: {
|
||||
elbowArrows: ExcalidrawElbowArrowElement[];
|
||||
otherElements: ExcalidrawElement[];
|
||||
},
|
||||
element,
|
||||
) =>
|
||||
isElbowArrow(element)
|
||||
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
|
||||
: { ...acc, otherElements: acc.otherElements.concat(element) },
|
||||
{ elbowArrows: [], otherElements: [] },
|
||||
);
|
||||
|
||||
const { midX: newMidX, midY: newMidY } =
|
||||
getCommonBoundingBox(selectedElements);
|
||||
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
||||
otherElements.forEach((element) =>
|
||||
mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
elbowArrows.forEach((element) =>
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
element.points,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
informMutation: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
|
@ -4,8 +4,8 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import type { History } from "../history";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import type { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { KEYS, matchKey } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
import type { SceneElementsMap } from "../element/types";
|
||||
@ -13,17 +13,19 @@ import type { Store } from "../store";
|
||||
import { StoreAction } from "../store";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
|
||||
const writeData = (
|
||||
const executeHistoryAction = (
|
||||
app: AppClassProperties,
|
||||
appState: Readonly<AppState>,
|
||||
updater: () => [SceneElementsMap, AppState] | void,
|
||||
): ActionResult => {
|
||||
if (
|
||||
!appState.multiElement &&
|
||||
!appState.resizingElement &&
|
||||
!appState.editingElement &&
|
||||
!appState.editingTextElement &&
|
||||
!appState.newElement &&
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
!appState.selectionElement
|
||||
!appState.selectionElement &&
|
||||
!app.flowChartCreator.isCreatingChart
|
||||
) {
|
||||
const result = updater();
|
||||
|
||||
@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) =>
|
||||
writeData(appState, () =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
@ -61,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
@ -94,7 +94,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) =>
|
||||
writeData(appState, () =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
@ -102,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
@ -53,6 +53,9 @@ import {
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
ArrowheadCrowfootIcon,
|
||||
ArrowheadCrowfootOneIcon,
|
||||
ArrowheadCrowfootOneOrManyIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
@ -115,6 +118,8 @@ import {
|
||||
} from "../element/binding";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom, vector } from "../../math";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@ -133,7 +138,7 @@ export const changeProperty = (
|
||||
return elements.map((element) => {
|
||||
if (
|
||||
selectedElementIds.get(element.id) ||
|
||||
element.id === appState.editingElement?.id
|
||||
element.id === appState.editingTextElement?.id
|
||||
) {
|
||||
return callback(element);
|
||||
}
|
||||
@ -148,13 +153,13 @@ export const getFormValue = function <T extends Primitive>(
|
||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||
): T {
|
||||
const editingElement = appState.editingElement;
|
||||
const editingTextElement = appState.editingTextElement;
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
|
||||
let ret: T | null = null;
|
||||
|
||||
if (editingElement) {
|
||||
ret = getAttribute(editingElement);
|
||||
if (editingTextElement) {
|
||||
ret = getAttribute(editingTextElement);
|
||||
}
|
||||
|
||||
if (!ret) {
|
||||
@ -1073,19 +1078,20 @@ export const actionChangeFontFamily = register({
|
||||
// open, populate the cache from scratch
|
||||
cachedElementsRef.current.clear();
|
||||
|
||||
const { editingElement } = appState;
|
||||
const { editingTextElement } = appState;
|
||||
|
||||
if (editingElement?.type === "text") {
|
||||
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
||||
const latestEditingElement = app.scene.getElement(
|
||||
editingElement.id,
|
||||
// still check type to be safe
|
||||
if (editingTextElement?.type === "text") {
|
||||
// retrieve the latest version from the scene, as `editingTextElement` isn't mutated
|
||||
const latesteditingTextElement = app.scene.getElement(
|
||||
editingTextElement.id,
|
||||
);
|
||||
|
||||
// inside the wysiwyg editor
|
||||
cachedElementsRef.current.set(
|
||||
editingElement.id,
|
||||
editingTextElement.id,
|
||||
newElementWith(
|
||||
latestEditingElement || editingElement,
|
||||
latesteditingTextElement || editingTextElement,
|
||||
{},
|
||||
true,
|
||||
),
|
||||
@ -1402,59 +1408,65 @@ const getArrowheadOptions = (flip: boolean) => {
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "e",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "dot",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: null,
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
showInPicker: false,
|
||||
},
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "r",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
showInPicker: false,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: null,
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
showInPicker: false,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "t",
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
showInPicker: false,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
showInPicker: false,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
showInPicker: false,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one",
|
||||
text: t("labels.arrowhead_crowfoot_one"),
|
||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_many",
|
||||
text: t("labels.arrowhead_crowfoot_many"),
|
||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one_or_many",
|
||||
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
@ -1518,6 +1530,7 @@ export const actionChangeArrowhead = register({
|
||||
appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
<IconPicker
|
||||
label="arrowhead_end"
|
||||
@ -1534,6 +1547,7 @@ export const actionChangeArrowhead = register({
|
||||
appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -1588,6 +1602,7 @@ export const actionChangeArrowType = register({
|
||||
tupleToCoors(startGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
true,
|
||||
);
|
||||
const endHoveredElement =
|
||||
@ -1596,6 +1611,7 @@ export const actionChangeArrowType = register({
|
||||
tupleToCoors(endGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
true,
|
||||
);
|
||||
const startElement = startHoveredElement
|
||||
@ -1647,10 +1663,10 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
elementsMap,
|
||||
[finalStartPoint, finalEndPoint].map(
|
||||
(point) =>
|
||||
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
||||
(p): LocalPoint =>
|
||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||
),
|
||||
[0, 0],
|
||||
vector(0, 0),
|
||||
{
|
||||
...(startElement && newElement.startBinding
|
||||
? {
|
||||
@ -1682,19 +1698,6 @@ export const actionChangeArrowType = register({
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
startBinding: newElement.startBinding
|
||||
? { ...newElement.startBinding, fixedPoint: null }
|
||||
: null,
|
||||
endBinding: newElement.endBinding
|
||||
? { ...newElement.endBinding, fixedPoint: null }
|
||||
: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import type { AppState } from "../types";
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
predicate: (appState) => appState.gridModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
gridModeEnabled: !this.checked!(appState),
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||
predicate: (element, appState, props) => {
|
||||
return typeof props.gridModeEnabled === "undefined";
|
||||
return props.gridModeEnabled === undefined;
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
||||
|
@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
gridModeEnabled: false,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
|
55
packages/excalidraw/actions/actionToggleSearchMenu.ts
Normal file
55
packages/excalidraw/actions/actionToggleSearchMenu.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import type { AppState } from "../types";
|
||||
import { searchIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
export const actionToggleSearchMenu = register({
|
||||
name: "searchMenu",
|
||||
icon: searchIcon,
|
||||
keywords: ["search", "find"],
|
||||
label: "search.title",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "search_menu",
|
||||
action: "toggle",
|
||||
predicate: (appState) => appState.gridModeEnabled,
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
const searchInput =
|
||||
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
);
|
||||
|
||||
if (searchInput?.matches(":focus")) {
|
||||
return {
|
||||
appState: { ...appState, openSidebar: null },
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
||||
openDialog: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||
predicate: (element, appState, props) => {
|
||||
return props.gridModeEnabled === undefined;
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
|
||||
});
|
@ -86,3 +86,7 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||
|
||||
export { actionToggleCropEditor } from "./actionCropEditor";
|
||||
|
@ -23,7 +23,6 @@ export type ShortcutName =
|
||||
| "sendToBack"
|
||||
| "bringToFront"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
@ -51,7 +50,8 @@ export type ShortcutName =
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "searchMenu";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@ -87,7 +87,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||
],
|
||||
copyAsPng: [getShortcutKey("Shift+Alt+C")],
|
||||
copyAsSvg: [],
|
||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
@ -112,6 +111,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
@ -10,7 +10,6 @@ import type {
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import type { MarkOptional } from "../utility-types";
|
||||
import type { StoreActionType } from "../store";
|
||||
|
||||
export type ActionSource =
|
||||
@ -24,10 +23,7 @@ export type ActionSource =
|
||||
export type ActionResult =
|
||||
| {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: MarkOptional<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
appState?: Partial<AppState> | null;
|
||||
files?: BinaryFiles | null;
|
||||
storeAction: StoreActionType;
|
||||
replaceFiles?: boolean;
|
||||
@ -137,7 +133,11 @@ export type ActionName =
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
| "elementStats"
|
||||
| "searchMenu"
|
||||
| "copyElementLink"
|
||||
| "linkToElement"
|
||||
| "cropEditor";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -191,7 +191,8 @@ export interface Action {
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
| "hyperlink"
|
||||
| "search_menu";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
|
@ -1,6 +1,6 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
|
@ -5,9 +5,11 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_GRID_SIZE,
|
||||
EXPORT_SCALES,
|
||||
STATS_PANELS,
|
||||
THEME,
|
||||
DEFAULT_GRID_STEP,
|
||||
} from "./constants";
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
|
||||
@ -42,7 +44,7 @@ export const getDefaultAppState = (): Omit<
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
newElement: null,
|
||||
editingElement: null,
|
||||
editingTextElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
@ -59,7 +61,9 @@ export const getDefaultAppState = (): Omit<
|
||||
exportEmbedScene: false,
|
||||
exportWithDarkMode: false,
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
gridSize: DEFAULT_GRID_SIZE,
|
||||
gridStep: DEFAULT_GRID_STEP,
|
||||
gridModeEnabled: false,
|
||||
isBindingEnabled: true,
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
@ -80,6 +84,7 @@ export const getDefaultAppState = (): Omit<
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
selectedElementIds: {},
|
||||
hoveredElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
@ -112,6 +117,9 @@ export const getDefaultAppState = (): Omit<
|
||||
objectsSnapModeEnabled: false,
|
||||
userToFollow: null,
|
||||
followedBy: new Set(),
|
||||
isCropping: false,
|
||||
croppingElementId: null,
|
||||
searchMatches: [],
|
||||
};
|
||||
};
|
||||
|
||||
@ -161,7 +169,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
newElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingTextElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
@ -174,6 +182,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||
fileHandle: { browser: false, export: false, server: false },
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
gridStep: { browser: true, export: true, server: true },
|
||||
gridModeEnabled: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
@ -201,6 +211,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
scrollX: { browser: true, export: false, server: false },
|
||||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { browser: true, export: false, server: false },
|
||||
hoveredElementIds: { browser: false, export: false, server: false },
|
||||
selectedGroupIds: { browser: true, export: false, server: false },
|
||||
selectedElementsAreBeingDragged: {
|
||||
browser: false,
|
||||
@ -230,6 +241,9 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
userToFollow: { browser: false, export: false, server: false },
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@ -17,13 +17,16 @@ import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./element/types";
|
||||
@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
|
||||
);
|
||||
|
||||
break;
|
||||
case "croppingElementId": {
|
||||
const croppingElementId = nextAppState[key];
|
||||
const element =
|
||||
croppingElementId && nextElements.get(croppingElementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "editingGroupId":
|
||||
const editingGroupId = nextAppState[key];
|
||||
|
||||
@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
|
||||
selectedElementIds,
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
croppingElementId,
|
||||
...standaloneProps
|
||||
} = delta as ObservedAppState;
|
||||
|
||||
@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
|
||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||
ElementUpdate<Ordered<T>>,
|
||||
"seed"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Elements change is a low level primitive to capture a change between two sets of elements.
|
||||
@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
if (isImageElement(element)) {
|
||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||
// we want to override `crop` only if modified so that we don't reset
|
||||
// when undoing/redoing unrelated change
|
||||
if (_delta.deleted.crop || _delta.inserted.crop) {
|
||||
Object.assign(directlyApplicablePartial, {
|
||||
// apply change verbatim
|
||||
crop: _delta.inserted.crop ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { Radians } from "../math";
|
||||
import { pointFrom } from "../math";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
@ -203,7 +205,7 @@ const chartXLabels = (
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
angle: 5.87,
|
||||
angle: 5.87 as Radians,
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
@ -258,10 +260,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
width: chartWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
@ -272,10 +271,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
height: chartHeight,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -chartHeight],
|
||||
],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
@ -288,10 +284,7 @@ const chartLines = (
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
@ -448,10 +441,7 @@ const chartTypeLine = (
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, cy],
|
||||
],
|
||||
points: [pointFrom(0, 0), pointFrom(0, cy)],
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
||||
import { ExcalidrawError } from "./errors";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@ -39,7 +41,7 @@ export interface ClipboardData {
|
||||
|
||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||
|
||||
type ParsedClipboardEvent =
|
||||
type ParsedClipboardEventTextData =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mixedContent"; value: PastedMixedContent };
|
||||
|
||||
@ -75,7 +77,7 @@ export const createPasteEvent = ({
|
||||
types,
|
||||
files,
|
||||
}: {
|
||||
types?: { [key in AllowedPasteMimeTypes]?: string };
|
||||
types?: { [key in AllowedPasteMimeTypes]?: string | File };
|
||||
files?: File[];
|
||||
}) => {
|
||||
if (!types && !files) {
|
||||
@ -88,6 +90,11 @@ export const createPasteEvent = ({
|
||||
|
||||
if (types) {
|
||||
for (const [type, value] of Object.entries(types)) {
|
||||
if (typeof value !== "string") {
|
||||
files = files || [];
|
||||
files.push(value);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
event.clipboardData?.setData(type, value);
|
||||
if (event.clipboardData?.getData(type) !== value) {
|
||||
@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
|
||||
const maybeParseHTMLPaste = (
|
||||
event: ClipboardEvent,
|
||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
||||
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||
|
||||
const content = parseHTMLTree(doc.body);
|
||||
|
||||
@ -238,34 +245,44 @@ const maybeParseHTMLPaste = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads OS clipboard programmatically. May not work on all browsers.
|
||||
* Will prompt user for permission if not granted.
|
||||
*/
|
||||
export const readSystemClipboard = async () => {
|
||||
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return { "text/plain": await navigator.clipboard?.readText() };
|
||||
}
|
||||
} catch (error: any) {
|
||||
// @ts-ignore
|
||||
if (navigator.clipboard?.read) {
|
||||
console.warn(
|
||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
|
||||
|
||||
let clipboardItems: ClipboardItems;
|
||||
|
||||
try {
|
||||
clipboardItems = await navigator.clipboard?.read();
|
||||
} catch (error: any) {
|
||||
if (error.name === "DataError") {
|
||||
console.warn(
|
||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
||||
);
|
||||
return types;
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
console.warn(
|
||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||
);
|
||||
const readText = await navigator.clipboard?.readText();
|
||||
if (readText) {
|
||||
return { [MIME_TYPES.text]: readText };
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// @ts-ignore
|
||||
if (navigator.clipboard?.read) {
|
||||
console.warn(
|
||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||
);
|
||||
} else {
|
||||
if (error.name === "DataError") {
|
||||
console.warn(
|
||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
||||
);
|
||||
return types;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
types[type] = await (await item.getType(type)).text();
|
||||
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
|
||||
types[type] = await (await item.getType(type)).text();
|
||||
} else if (isSupportedImageFileType(type)) {
|
||||
const imageBlob = await item.getType(type);
|
||||
const file = createFile(imageBlob, type, undefined);
|
||||
types[type] = file;
|
||||
} else {
|
||||
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||
error instanceof ExcalidrawError
|
||||
? error.message
|
||||
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
|
||||
/**
|
||||
* Parses "paste" ClipboardEvent.
|
||||
*/
|
||||
const parseClipboardEvent = async (
|
||||
const parseClipboardEventTextData = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ParsedClipboardEvent> => {
|
||||
): Promise<ParsedClipboardEventTextData> => {
|
||||
try {
|
||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||
|
||||
@ -308,7 +335,7 @@ const parseClipboardEvent = async (
|
||||
return {
|
||||
type: "text",
|
||||
value:
|
||||
event.clipboardData?.getData("text/plain") ||
|
||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
||||
mixedContent.value
|
||||
.map((item) => item.value)
|
||||
.join("\n")
|
||||
@ -319,7 +346,7 @@ const parseClipboardEvent = async (
|
||||
return mixedContent;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
||||
|
||||
return { type: "text", value: (text || "").trim() };
|
||||
} catch {
|
||||
@ -328,13 +355,16 @@ const parseClipboardEvent = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to parse clipboard. Prefers system clipboard.
|
||||
* Attempts to parse clipboard event.
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ClipboardData> => {
|
||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
||||
const parsedEventData = await parseClipboardEventTextData(
|
||||
event,
|
||||
isPlainPaste,
|
||||
);
|
||||
|
||||
if (parsedEventData.type === "mixedContent") {
|
||||
return {
|
||||
@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
|
||||
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
||||
try {
|
||||
if (clipboardEvent) {
|
||||
clipboardEvent.clipboardData?.setData("text/plain", text || "");
|
||||
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
||||
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
|
||||
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
|
||||
throw new Error("Failed to setData on clipboardEvent");
|
||||
}
|
||||
return;
|
||||
|
@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isElbowArrow,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
@ -45,11 +46,11 @@ import {
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
OpenAIIcon,
|
||||
MagicIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { CLASSES } from "../constants";
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
@ -104,7 +105,9 @@ export const SelectedShapeActions = ({
|
||||
) {
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const isEditingTextOrNewElement = Boolean(
|
||||
appState.editingTextElement || appState.newElement,
|
||||
);
|
||||
const device = useDevice();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
@ -125,6 +128,11 @@ export const SelectedShapeActions = ({
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
const showCropEditorAction =
|
||||
!appState.croppingElementId &&
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
@ -234,7 +242,7 @@ export const SelectedShapeActions = ({
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{!isEditing && targetElements.length > 0 && (
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
@ -243,6 +251,7 @@ export const SelectedShapeActions = ({
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showCropEditorAction && renderAction("cropEditor")}
|
||||
{showLineEditorAction && renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -400,7 +409,7 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
{app.props.aiEnabled !== false && (
|
||||
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
@ -410,20 +419,6 @@ export const ShapesSwitcher = ({
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
trackEvent("ai", "open-settings", "d2c");
|
||||
app.setOpenDialog({
|
||||
name: "settings",
|
||||
source: "settings",
|
||||
tab: "diagram-to-code",
|
||||
});
|
||||
}}
|
||||
icon={OpenAIIcon}
|
||||
data-testid="toolbar-magicSettings"
|
||||
>
|
||||
{t("toolBar.magicSettings")}
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
@ -439,7 +434,7 @@ export const ZoomActions = ({
|
||||
renderAction: ActionManager["renderAction"];
|
||||
zoom: Zoom;
|
||||
}) => (
|
||||
<Stack.Col gap={1} className="zoom-actions">
|
||||
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
||||
<Stack.Row align="center">
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
|
||||
return (
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "208px" }}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
|
@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
|
||||
import { SHAPES } from "../../shapes";
|
||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { actionClearCanvas, actionLink } from "../../actions";
|
||||
import {
|
||||
actionClearCanvas,
|
||||
actionLink,
|
||||
actionToggleSearchMenu,
|
||||
} from "../../actions";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
@ -52,6 +56,10 @@ import { trackEvent } from "../../analytics";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
|
||||
import "./CommandPalette.scss";
|
||||
import {
|
||||
actionCopyElementLink,
|
||||
actionLinkToElement,
|
||||
} from "../../actions/actionElementLink";
|
||||
|
||||
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
|
||||
|
||||
@ -275,7 +283,10 @@ function CommandPaletteInner({
|
||||
actionManager.actions.increaseFontSize,
|
||||
actionManager.actions.decreaseFontSize,
|
||||
actionManager.actions.toggleLinearEditor,
|
||||
actionManager.actions.cropEditor,
|
||||
actionLink,
|
||||
actionCopyElementLink,
|
||||
actionLinkToElement,
|
||||
].map((action: Action) =>
|
||||
actionToCommand(
|
||||
action,
|
||||
@ -382,6 +393,15 @@ function CommandPaletteInner({
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("search.title"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
icon: searchIcon,
|
||||
viewMode: true,
|
||||
perform: () => {
|
||||
actionManager.executeAction(actionToggleSearchMenu);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.changeStroke"),
|
||||
keywords: ["color", "outline"],
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import type { DialogProps } from "./Dialog";
|
||||
import { Dialog } from "./Dialog";
|
||||
@ -43,7 +44,14 @@ const ConfirmDialog = (props: Props) => {
|
||||
onClick={() => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onCancel();
|
||||
// flush any pending updates synchronously,
|
||||
// otherwise it could lead to crash in some chromium versions (131.0.6778.86),
|
||||
// when `.focus` is invoked with container in some intermediate state
|
||||
// (container seems mounted in DOM, but focus still causes a crash)
|
||||
flushSync(() => {
|
||||
onCancel();
|
||||
});
|
||||
|
||||
container?.focus();
|
||||
}}
|
||||
/>
|
||||
@ -52,7 +60,14 @@ const ConfirmDialog = (props: Props) => {
|
||||
onClick={() => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onConfirm();
|
||||
// flush any pending updates synchronously,
|
||||
// otherwise it leads to crash in some chromium versions (131.0.6778.86),
|
||||
// when `.focus` is invoked with container in some intermediate state
|
||||
// (container seems mounted in DOM, but focus still causes a crash)
|
||||
flushSync(() => {
|
||||
onConfirm();
|
||||
});
|
||||
|
||||
container?.focus();
|
||||
}}
|
||||
actionType="danger"
|
||||
|
@ -1,8 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
DEFAULT_SIDEBAR,
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { t } from "../i18n";
|
||||
import type { MarkOptional, Merge } from "../utility-types";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import "../components/dropdownMenu/DropdownMenu.scss";
|
||||
import { SearchMenu } from "./SearchMenu";
|
||||
import { LibraryIcon, searchIcon } from "./icons";
|
||||
|
||||
const DefaultSidebarTrigger = withInternalFallback(
|
||||
"DefaultSidebarTrigger",
|
||||
@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
|
||||
);
|
||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||
|
||||
const DefaultTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
return (
|
||||
<DefaultSidebarTabTriggersTunnel.In>
|
||||
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
||||
{children}
|
||||
</DefaultSidebarTabTriggersTunnel.In>
|
||||
);
|
||||
};
|
||||
@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
|
||||
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
|
||||
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...rest}
|
||||
name="default"
|
||||
key="default"
|
||||
className={clsx("default-sidebar", className)}
|
||||
docked={docked ?? appState.defaultSidebarDockedPreference}
|
||||
docked={
|
||||
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
|
||||
}
|
||||
onDock={
|
||||
// `onDock=false` disables docking.
|
||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||
onDock === false || (!onDock && docked != null)
|
||||
isForceDocked || onDock === false || (!onDock && docked != null)
|
||||
? undefined
|
||||
: // compose to allow the host app to listen on default behavior
|
||||
composeEventHandlers(onDock, (docked) => {
|
||||
@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
|
||||
>
|
||||
<Sidebar.Tabs>
|
||||
<Sidebar.Header>
|
||||
{rest.__fallback && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
fontSize: "1.2em",
|
||||
fontWeight: "bold",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
paddingRight: "1em",
|
||||
}}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
</div>
|
||||
)}
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
<Sidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
|
||||
{searchIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
|
||||
{LibraryIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
</Sidebar.TabTriggers>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||
<LibraryMenu />
|
||||
</Sidebar.Tab>
|
||||
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
|
||||
<SearchMenu />
|
||||
</Sidebar.Tab>
|
||||
{children}
|
||||
</Sidebar.Tabs>
|
||||
</Sidebar>
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useApp } from "../App";
|
||||
import type { GenerateDiagramToCode } from "../../types";
|
||||
|
||||
export const DiagramToCodePlugin = (props: {
|
||||
generate: GenerateDiagramToCode;
|
||||
}) => {
|
||||
const app = useApp();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
app.setPlugins({
|
||||
diagramToCode: { generate: props.generate },
|
||||
});
|
||||
}, [app, props.generate]);
|
||||
|
||||
return null;
|
||||
};
|
87
packages/excalidraw/components/ElementLinkDialog.scss
Normal file
87
packages/excalidraw/components/ElementLinkDialog.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
174
packages/excalidraw/components/ElementLinkDialog.tsx
Normal file
174
packages/excalidraw/components/ElementLinkDialog.tsx
Normal file
@ -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,5 +1,19 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
@keyframes successStatusAnimation {
|
||||
0% {
|
||||
transform: scale(0.35);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.ExcButton {
|
||||
--text-color: transparent;
|
||||
@ -16,11 +30,20 @@
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-surface-lowest);
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
.ExcButton__statusIcon {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
|
||||
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
|
||||
}
|
||||
|
||||
&.ExcButton--status-loading,
|
||||
&.ExcButton--status-success {
|
||||
pointer-events: none;
|
||||
|
||||
.ExcButton__contents {
|
||||
@ -28,6 +51,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&,
|
||||
&__contents {
|
||||
display: flex;
|
||||
@ -119,6 +146,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--color-success {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-success-text);
|
||||
--back-color: var(--color-success);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-success);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-success-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-success-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-success-contrast);
|
||||
--border-color: var(--color-success-contrast);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-success-contrast);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-success-contrast-hover);
|
||||
--border-color: var(--color-success-contrast-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-success-contrast-active);
|
||||
--border-color: var(--color-success-contrast-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-muted {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--island-bg-color);
|
||||
|
@ -5,9 +5,15 @@ import "./FilledButton.scss";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { isPromiseLike } from "../utils";
|
||||
import { tablerCheckIcon } from "./icons";
|
||||
|
||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||
export type ButtonColor =
|
||||
| "primary"
|
||||
| "danger"
|
||||
| "warning"
|
||||
| "muted"
|
||||
| "success";
|
||||
export type ButtonSize = "medium" | "large";
|
||||
|
||||
export type FilledButtonProps = {
|
||||
@ -15,6 +21,7 @@ export type FilledButtonProps = {
|
||||
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
status?: null | "loading" | "success";
|
||||
|
||||
variant?: ButtonVariant;
|
||||
color?: ButtonColor;
|
||||
@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
size = "medium",
|
||||
fullWidth,
|
||||
className,
|
||||
status,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
const ret = onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
// delay loading state to prevent flicker in case of quick response
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
}, 50);
|
||||
try {
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _status = isLoading ? "loading" : status;
|
||||
color = _status === "success" ? "success" : color;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
`ExcButton--color-${color}`,
|
||||
`ExcButton--variant-${variant}`,
|
||||
`ExcButton--size-${size}`,
|
||||
`ExcButton--status-${_status}`,
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
type="button"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
disabled={isLoading}
|
||||
disabled={_status === "loading" || _status === "success"}
|
||||
>
|
||||
<div className="ExcButton__contents">
|
||||
{isLoading && <Spinner />}
|
||||
{_status === "loading" ? (
|
||||
<Spinner className="ExcButton__statusIcon" />
|
||||
) : (
|
||||
_status === "success" && (
|
||||
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
|
||||
)
|
||||
)}
|
||||
{icon && (
|
||||
<div className="ExcButton__icon" aria-hidden>
|
||||
{icon}
|
||||
|
@ -15,7 +15,6 @@
|
||||
top: var(--editor-container-padding);
|
||||
right: var(--editor-container-padding);
|
||||
bottom: var(--editor-container-padding);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top.zen-mode {
|
||||
|
@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
|
||||
value: FONT_FAMILY.Excalifont,
|
||||
icon: FreedrawIcon,
|
||||
text: t("labels.handDrawn"),
|
||||
testId: "font-family-handrawn",
|
||||
testId: "font-family-hand-drawn",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Nunito,
|
||||
|
@ -21,6 +21,7 @@ import { t } from "../../i18n";
|
||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||
import { Fonts } from "../../fonts";
|
||||
import type { ValueOf } from "../../utility-types";
|
||||
import { FontFamilyNormalIcon } from "../icons";
|
||||
|
||||
export interface FontDescriptor {
|
||||
value: number;
|
||||
@ -62,12 +63,14 @@ export const FontPickerList = React.memo(
|
||||
const allFonts = useMemo(
|
||||
() =>
|
||||
Array.from(Fonts.registered.entries())
|
||||
.filter(([_, { metadata }]) => !metadata.serverSide)
|
||||
.map(([familyId, { metadata, fonts }]) => {
|
||||
.filter(
|
||||
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
|
||||
)
|
||||
.map(([familyId, { metadata, fontFaces }]) => {
|
||||
const fontDescriptor = {
|
||||
value: familyId,
|
||||
icon: metadata.icon,
|
||||
text: fonts[0].fontFace.family,
|
||||
icon: metadata.icon ?? FontFamilyNormalIcon,
|
||||
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
|
||||
};
|
||||
|
||||
if (metadata.deprecated) {
|
||||
@ -89,7 +92,7 @@ export const FontPickerList = React.memo(
|
||||
);
|
||||
|
||||
const sceneFamilies = useMemo(
|
||||
() => new Set(fonts.getSceneFontFamilies()),
|
||||
() => new Set(fonts.getSceneFamilies()),
|
||||
// cache per selected font family, so hover re-render won't mess it up
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedFontFamily],
|
||||
|
@ -22,7 +22,7 @@ const Header = () => (
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://blog.excalidraw.com"
|
||||
href="https://plus.excalidraw.com/blog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropStart")}
|
||||
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropFinish")}
|
||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
@ -288,6 +298,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("stats.fullTitle")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("search.title")}
|
||||
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
@ -304,6 +318,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
className="HelpDialog__island--editor"
|
||||
caption={t("helpDialog.editor")}
|
||||
>
|
||||
<Shortcut
|
||||
label={t("helpDialog.createFlowchart")}
|
||||
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.navigateFlowchart")}
|
||||
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.moveCanvas")}
|
||||
shortcuts={[
|
||||
|
@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { t } from "../i18n";
|
||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||
import {
|
||||
isFlowchartNodeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextBindableContainer,
|
||||
@ -10,6 +11,9 @@ import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { isNodeInFlowchart } from "../element/flowchart";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
@ -18,10 +22,23 @@ interface HintViewerProps {
|
||||
app: AppClassProperties;
|
||||
}
|
||||
|
||||
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
const getHints = ({
|
||||
appState,
|
||||
isMobile,
|
||||
device,
|
||||
app,
|
||||
}: HintViewerProps): null | string | string[] => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||
appState.searchMatches?.length
|
||||
) {
|
||||
return t("hints.dismissSearch");
|
||||
}
|
||||
|
||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
@ -79,21 +96,29 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
return t("hints.text_selected");
|
||||
}
|
||||
|
||||
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
||||
if (appState.editingTextElement) {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (appState.croppingElementId) {
|
||||
return t("hints.leaveCropEditor");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||
return t("hints.enterCropEditor");
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
if (
|
||||
appState.selectionElement &&
|
||||
!selectedElements.length &&
|
||||
!appState.editingElement &&
|
||||
!appState.editingTextElement &&
|
||||
!appState.editingLinearElement
|
||||
) {
|
||||
return t("hints.deepBoxSelect");
|
||||
}
|
||||
|
||||
if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
|
||||
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||
return t("hints.disableSnapping");
|
||||
}
|
||||
|
||||
@ -115,6 +140,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
isTextBindableContainer(selectedElements[0])
|
||||
) {
|
||||
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||
if (
|
||||
isNodeInFlowchart(
|
||||
selectedElements[0],
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||
}
|
||||
|
||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||
}
|
||||
|
||||
return t("hints.bindTextToElement");
|
||||
}
|
||||
}
|
||||
@ -129,17 +167,24 @@ export const HintViewer = ({
|
||||
device,
|
||||
app,
|
||||
}: HintViewerProps) => {
|
||||
let hint = getHints({
|
||||
const hints = getHints({
|
||||
appState,
|
||||
isMobile,
|
||||
device,
|
||||
app,
|
||||
});
|
||||
if (!hint) {
|
||||
|
||||
if (!hints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hint = getShortcutKey(hint);
|
||||
const hint = Array.isArray(hints)
|
||||
? hints
|
||||
.map((hint) => {
|
||||
return getShortcutKey(hint).replace(/\. ?$/, "");
|
||||
})
|
||||
.join(". ")
|
||||
: getShortcutKey(hints);
|
||||
|
||||
return (
|
||||
<div className="HintViewer">
|
||||
|
@ -1,19 +1,16 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.picker-container {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
padding: 0.5rem;
|
||||
background: var(--popup-bg-color);
|
||||
border: 0 solid transparentize($oc-white, 0.75);
|
||||
// ˇˇ yeah, i dunno, open to suggestions here :D
|
||||
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-container button,
|
||||
@ -55,47 +52,16 @@
|
||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||
}
|
||||
|
||||
.picker-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
:root[dir="ltr"] & {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 12px;
|
||||
}
|
||||
z-index: 10;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-style: solid;
|
||||
border-width: 0 9px 10px;
|
||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-style: solid;
|
||||
border-width: 0 9px 10px;
|
||||
border-color: transparent transparent var(--popup-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.picker-content {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, auto);
|
||||
grid-template-columns: repeat(4, auto);
|
||||
grid-gap: 0.5rem;
|
||||
border-radius: 4px;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-collapsible {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.picker-keybinding {
|
||||
|
@ -1,10 +1,23 @@
|
||||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
import React, { useEffect } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
import { getLanguage } from "../i18n";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import clsx from "clsx";
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useDevice } from "..";
|
||||
|
||||
const moreOptionsAtom = atom(false);
|
||||
|
||||
type Option<T> = {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
};
|
||||
|
||||
function Picker<T>({
|
||||
options,
|
||||
@ -12,30 +25,16 @@ function Picker<T>({
|
||||
label,
|
||||
onChange,
|
||||
onClose,
|
||||
numberOfOptionsToAlwaysShow = options.length,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
}[];
|
||||
options: readonly Option<T>[];
|
||||
onChange: (value: T) => void;
|
||||
onClose: () => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
}) {
|
||||
const rFirstItem = React.useRef<HTMLButtonElement>();
|
||||
const rActiveItem = React.useRef<HTMLButtonElement>();
|
||||
const rGallery = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// After the component is first mounted focus on first input
|
||||
if (rActiveItem.current) {
|
||||
rActiveItem.current.focus();
|
||||
} else if (rGallery.current) {
|
||||
rGallery.current.focus();
|
||||
}
|
||||
}, []);
|
||||
const device = useDevice();
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = options.find(
|
||||
@ -44,28 +43,19 @@ function Picker<T>({
|
||||
|
||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||
// Keybinding navigation
|
||||
const index = options.indexOf(pressedOption);
|
||||
(rGallery!.current!.children![index] as any).focus();
|
||||
onChange(pressedOption.value);
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.TAB) {
|
||||
// Tab navigation cycle through options. If the user tabs
|
||||
// away from the picker, close the picker. We need to use
|
||||
// a timeout here to let the stack clear before checking.
|
||||
setTimeout(() => {
|
||||
const active = rActiveItem.current;
|
||||
const docActive = document.activeElement;
|
||||
if (active !== docActive) {
|
||||
onClose();
|
||||
}
|
||||
}, 0);
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
const nextIndex = event.shiftKey
|
||||
? (options.length + index - 1) % options.length
|
||||
: (index + 1) % options.length;
|
||||
onChange(options[nextIndex].value);
|
||||
} else if (isArrowKey(event.key)) {
|
||||
// Arrow navigation
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = Array.prototype.indexOf.call(
|
||||
rGallery!.current!.children,
|
||||
activeElement,
|
||||
);
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
if (index !== -1) {
|
||||
const length = options.length;
|
||||
let nextIndex = index;
|
||||
@ -73,19 +63,26 @@ function Picker<T>({
|
||||
switch (event.key) {
|
||||
// Select the next option
|
||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||
case KEYS.ARROW_DOWN: {
|
||||
nextIndex = (index + 1) % length;
|
||||
break;
|
||||
}
|
||||
// Select the previous option
|
||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||
case KEYS.ARROW_UP: {
|
||||
nextIndex = (length + index - 1) % length;
|
||||
break;
|
||||
// Go the next row
|
||||
case KEYS.ARROW_DOWN: {
|
||||
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||
break;
|
||||
}
|
||||
// Go the previous row
|
||||
case KEYS.ARROW_UP: {
|
||||
nextIndex =
|
||||
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(rGallery.current!.children![nextIndex] as any).focus();
|
||||
onChange(options[nextIndex].value);
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
@ -97,15 +94,29 @@ function Picker<T>({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`picker`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="picker-content" ref={rGallery}>
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(
|
||||
moreOptionsAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const alwaysVisibleOptions = React.useMemo(
|
||||
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||
[options, numberOfOptionsToAlwaysShow],
|
||||
);
|
||||
const moreOptions = React.useMemo(
|
||||
() => options.slice(numberOfOptionsToAlwaysShow),
|
||||
[options, numberOfOptionsToAlwaysShow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
|
||||
setShowMoreOptions(true);
|
||||
}
|
||||
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
|
||||
|
||||
const renderOptions = (options: Option<T>[]) => {
|
||||
return (
|
||||
<div className="picker-content">
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
type="button"
|
||||
@ -113,7 +124,6 @@ function Picker<T>({
|
||||
active: value === option.value,
|
||||
})}
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(option.value);
|
||||
}}
|
||||
title={`${option.text} ${
|
||||
@ -122,16 +132,13 @@ function Picker<T>({
|
||||
aria-label={option.text || "none"}
|
||||
aria-keyshortcuts={option.keyBinding || undefined}
|
||||
key={option.text}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
rFirstItem.current = el;
|
||||
ref={(ref) => {
|
||||
if (value === option.value) {
|
||||
// Use a timeout here to render focus properly
|
||||
setTimeout(() => {
|
||||
ref?.focus();
|
||||
}, 0);
|
||||
}
|
||||
if (el && option.value === value) {
|
||||
rActiveItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
@ -141,7 +148,43 @@ function Picker<T>({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Content
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
style={{ zIndex: "var(--zIndex-popup)" }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={`picker`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
>
|
||||
{renderOptions(alwaysVisibleOptions)}
|
||||
|
||||
{moreOptions.length > 0 && (
|
||||
<Collapsible
|
||||
label={t("labels.more_options")}
|
||||
open={showMoreOptions}
|
||||
openTrigger={() => {
|
||||
setShowMoreOptions((value) => !value);
|
||||
}}
|
||||
className="picker-collapsible"
|
||||
>
|
||||
{renderOptions(moreOptions)}
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
);
|
||||
}
|
||||
|
||||
@ -151,6 +194,7 @@ export function IconPicker<T>({
|
||||
options,
|
||||
onChange,
|
||||
group = "",
|
||||
numberOfOptionsToAlwaysShow,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
@ -159,51 +203,40 @@ export function IconPicker<T>({
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
showInPicker?: boolean;
|
||||
}[];
|
||||
onChange: (value: T) => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
group?: string;
|
||||
}) {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const rPickerButton = React.useRef<any>(null);
|
||||
const isRTL = getLanguage().rtl;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
name={group}
|
||||
type="button"
|
||||
className={isActive ? "active" : ""}
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={rPickerButton}
|
||||
>
|
||||
{options.find((option) => option.value === value)?.icon}
|
||||
</button>
|
||||
<React.Suspense fallback="">
|
||||
{isActive ? (
|
||||
<>
|
||||
<Popover
|
||||
onCloseRequest={(event) =>
|
||||
event.target !== rPickerButton.current && setActive(false)
|
||||
}
|
||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
||||
>
|
||||
<Picker
|
||||
options={options.filter((opt) => opt.showInPicker !== false)}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
rPickerButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<div className="picker-triangle" />
|
||||
</>
|
||||
) : null}
|
||||
</React.Suspense>
|
||||
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||
<Popover.Trigger
|
||||
name={group}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={rPickerButton}
|
||||
className={isActive ? "active" : ""}
|
||||
>
|
||||
{options.find((option) => option.value === value)?.icon}
|
||||
</Popover.Trigger>
|
||||
{isActive && (
|
||||
<Picker
|
||||
options={options}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
}}
|
||||
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { prepareElementsForExport } from "../data";
|
||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@ -89,6 +90,21 @@ const ImageExportModal = ({
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
|
||||
|
||||
useEffect(() => {
|
||||
// if user changes setting right after export to clipboard, reset the status
|
||||
// so they don't have to wait for the timeout to click the button again
|
||||
resetCopyStatus();
|
||||
}, [
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
resetCopyStatus,
|
||||
]);
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
@ -105,6 +121,7 @@ const ImageExportModal = ({
|
||||
if (!maxWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState: {
|
||||
@ -294,11 +311,17 @@ const ImageExportModal = ({
|
||||
<FilledButton
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
status={copyStatus}
|
||||
onClick={async () => {
|
||||
await onExportImage(
|
||||
EXPORT_IMAGE_TYPES.clipboard,
|
||||
exportedElements,
|
||||
{
|
||||
exportingFrame,
|
||||
},
|
||||
);
|
||||
onCopy();
|
||||
}}
|
||||
icon={copyIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||
|
@ -27,99 +27,6 @@
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
|
||||
& > .Stats {
|
||||
width: 204px;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
font-size: 12px;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.elementType {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.elementsCount {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.statsItem {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
th {
|
||||
border-bottom: 1px solid var(--input-border-color);
|
||||
padding: 4px;
|
||||
}
|
||||
tr {
|
||||
td:nth-child(2) {
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 12px;
|
||||
right: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
|
@ -53,17 +53,17 @@ import { LibraryIcon } from "./icons";
|
||||
import { UIAppStateContext } from "../context/ui-appState";
|
||||
import { DefaultSidebar } from "./DefaultSidebar";
|
||||
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { MagicSettings } from "./MagicSettings";
|
||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
import ElementLinkDialog from "./ElementLinkDialog";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -85,14 +85,7 @@ interface LayerUIProps {
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
isCollaborating: boolean;
|
||||
openAIKey: string | null;
|
||||
isOpenAIKeyPersisted: boolean;
|
||||
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
|
||||
onMagicSettingsConfirm: (
|
||||
apiKey: string,
|
||||
shouldPersist: boolean,
|
||||
source: "tool" | "generation" | "settings",
|
||||
) => void;
|
||||
generateLinkForSelection?: AppProps["generateLinkForSelection"];
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@ -108,6 +101,7 @@ const DefaultMainMenu: React.FC<{
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
@ -149,10 +143,7 @@ const LayerUI = ({
|
||||
children,
|
||||
app,
|
||||
isCollaborating,
|
||||
openAIKey,
|
||||
isOpenAIKeyPersisted,
|
||||
onOpenAIAPIKeyChange,
|
||||
onMagicSettingsConfirm,
|
||||
generateLinkForSelection,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
@ -244,7 +235,8 @@ const LayerUI = ({
|
||||
const shouldShowStats =
|
||||
appState.stats.open &&
|
||||
!appState.zenModeEnabled &&
|
||||
!appState.viewModeEnabled;
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector";
|
||||
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
@ -253,90 +245,91 @@ const LayerUI = ({
|
||||
{renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</Stack.Col>
|
||||
{!appState.viewModeEnabled && (
|
||||
<Section heading="shapes" className="shapes-section">
|
||||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
className={clsx("App-toolbar-container", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx("App-toolbar", {
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
<Section heading="shapes" className="shapes-section">
|
||||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
className={clsx("App-toolbar-container", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
isMobile={device.editor.isMobile}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
|
||||
<ShapesSwitcher
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx("App-toolbar", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
activeTool={appState.activeTool}
|
||||
UIOptions={UIOptions}
|
||||
isMobile={device.editor.isMobile}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={
|
||||
appState.activeTool.type === TOOL_TYPE.laser
|
||||
}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: TOOL_TYPE.laser })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
activeTool={appState.activeTool}
|
||||
UIOptions={UIOptions}
|
||||
app={app}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={
|
||||
appState.activeTool.type === TOOL_TYPE.laser
|
||||
}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: TOOL_TYPE.laser })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
@ -353,6 +346,7 @@ const LayerUI = ({
|
||||
)}
|
||||
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||
@ -360,7 +354,7 @@ const LayerUI = ({
|
||||
)}
|
||||
{shouldShowStats && (
|
||||
<Stats
|
||||
scene={app.scene}
|
||||
app={app}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
@ -482,26 +476,20 @@ const LayerUI = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog?.name === "settings" && (
|
||||
<MagicSettings
|
||||
openAIKey={openAIKey}
|
||||
isPersisted={isOpenAIKeyPersisted}
|
||||
onChange={onOpenAIAPIKeyChange}
|
||||
onConfirm={(apiKey, shouldPersist) => {
|
||||
const source =
|
||||
appState.openDialog?.name === "settings"
|
||||
? appState.openDialog?.source
|
||||
: "settings";
|
||||
setAppState({ openDialog: null }, () => {
|
||||
onMagicSettingsConfirm(apiKey, shouldPersist, source);
|
||||
<ActiveConfirmDialog />
|
||||
{appState.openDialog?.name === "elementLinkSelector" && (
|
||||
<ElementLinkDialog
|
||||
sourceElementId={appState.openDialog.sourceElementId}
|
||||
onClose={() => {
|
||||
setAppState({
|
||||
openDialog: null,
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
}}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
appState={appState}
|
||||
generateLinkForSelection={generateLinkForSelection}
|
||||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
|
@ -1,18 +0,0 @@
|
||||
.excalidraw {
|
||||
.MagicSettings {
|
||||
.Island {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.MagicSettings-confirm {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.MagicSettings__confirm {
|
||||
margin-top: 2rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
@ -1,160 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { MagicIcon, OpenAIIcon } from "./icons";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { KEYS } from "../keys";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { InlineIcon } from "./InlineIcon";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
import "./MagicSettings.scss";
|
||||
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
|
||||
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
|
||||
|
||||
export const MagicSettings = (props: {
|
||||
openAIKey: string | null;
|
||||
isPersisted: boolean;
|
||||
onChange: (key: string, shouldPersist: boolean) => void;
|
||||
onConfirm: (key: string, shouldPersist: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
|
||||
const [shouldPersist, setShouldPersist] = useState<boolean>(
|
||||
props.isPersisted,
|
||||
);
|
||||
|
||||
const appState = useUIAppState();
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
};
|
||||
|
||||
if (appState.openDialog?.name !== "settings") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => {
|
||||
props.onClose();
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
}}
|
||||
title={
|
||||
<div style={{ display: "flex" }}>
|
||||
Wireframe to Code (AI){" "}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.1rem 0.5rem",
|
||||
marginLeft: "1rem",
|
||||
fontSize: 14,
|
||||
borderRadius: "12px",
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
}}
|
||||
>
|
||||
Experimental
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="MagicSettings"
|
||||
autofocus={false}
|
||||
>
|
||||
{/* <h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.25rem",
|
||||
paddingLeft: "2.5rem",
|
||||
}}
|
||||
>
|
||||
AI Settings
|
||||
</h2> */}
|
||||
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
|
||||
{/* <TTDDialogTabTriggers>
|
||||
<TTDDialogTabTrigger tab="text-to-diagram">
|
||||
<InlineIcon icon={brainIcon} /> Text to diagram
|
||||
</TTDDialogTabTrigger>
|
||||
<TTDDialogTabTrigger tab="diagram-to-code">
|
||||
<InlineIcon icon={MagicIcon} /> Wireframe to code
|
||||
</TTDDialogTabTrigger>
|
||||
</TTDDialogTabTriggers> */}
|
||||
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
||||
TODO
|
||||
</TTDDialogTab> */}
|
||||
<TTDDialogTab
|
||||
// className="ttd-dialog-content"
|
||||
tab="diagram-to-code"
|
||||
>
|
||||
<Paragraph>
|
||||
For the diagram-to-code feature we use{" "}
|
||||
<InlineIcon icon={OpenAIIcon} />
|
||||
OpenAI.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
While the OpenAI API is in beta, its use is strictly limited — as
|
||||
such we require you use your own API key. You can create an{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/login?launch"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
OpenAI account
|
||||
</a>
|
||||
, add a small credit (5 USD minimum), and{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
generate your own API key
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Your OpenAI key does not leave the browser, and you can also set
|
||||
your own limit in your OpenAI account dashboard if needed.
|
||||
</Paragraph>
|
||||
<TextField
|
||||
isRedacted
|
||||
value={keyInputValue}
|
||||
placeholder="Paste your API key here"
|
||||
label="OpenAI API key"
|
||||
onChange={(value) => {
|
||||
setKeyInputValue(value);
|
||||
props.onChange(value.trim(), shouldPersist);
|
||||
}}
|
||||
selectOnRender
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
|
||||
/>
|
||||
<Paragraph>
|
||||
By default, your API token is not persisted anywhere so you'll need
|
||||
to insert it again after reload. But, you can persist locally in
|
||||
your browser below.
|
||||
</Paragraph>
|
||||
|
||||
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
|
||||
Persist API key in browser storage
|
||||
</CheckboxItem>
|
||||
|
||||
<Paragraph>
|
||||
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
|
||||
tool to wrap your elements in a frame that will then allow you to
|
||||
turn it into code. This dialog can be accessed using the{" "}
|
||||
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
|
||||
</Paragraph>
|
||||
|
||||
<FilledButton
|
||||
className="MagicSettings__confirm"
|
||||
size="large"
|
||||
label="Confirm"
|
||||
onClick={onConfirm}
|
||||
/>
|
||||
</TTDDialogTab>
|
||||
</TTDDialogTabs>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -91,9 +91,10 @@ export const MobileMenu = ({
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<div className="mobile-misc-tools-container">
|
||||
{!appState.viewModeEnabled && (
|
||||
<DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
<DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
@ -129,7 +130,10 @@ export const MobileMenu = ({
|
||||
};
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
if (appState.viewModeEnabled) {
|
||||
if (
|
||||
appState.viewModeEnabled ||
|
||||
appState.openDialog?.name === "elementLinkSelector"
|
||||
) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<MainMenuTunnel.Out />
|
||||
@ -154,7 +158,9 @@ export const MobileMenu = ({
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
{!appState.viewModeEnabled && renderToolbar()}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
renderToolbar()}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
@ -166,6 +172,7 @@ export const MobileMenu = ({
|
||||
<Island padding={0}>
|
||||
{appState.openMenu === "shape" &&
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
|
@ -48,6 +48,9 @@ const ChartPreviewBtn = (props: {
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
{
|
||||
skipInliningFonts: true,
|
||||
},
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
|
@ -133,6 +133,7 @@ const SingleLibraryItem = ({
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
skipInliningFonts: true,
|
||||
});
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
|
110
packages/excalidraw/components/SearchMenu.scss
Normal file
110
packages/excalidraw/components/SearchMenu.scss
Normal file
@ -0,0 +1,110 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__search {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.layer-ui__search-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
.ExcTextField {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.ExcTextField__input {
|
||||
background-color: #f5f5f9;
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
background-color: #31303b;
|
||||
}
|
||||
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 0;
|
||||
|
||||
input::placeholder {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__search-count {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 8px 0 8px;
|
||||
margin: 0 0.75rem 0.25rem 0.75rem;
|
||||
font-size: 0.8em;
|
||||
|
||||
.result-nav {
|
||||
display: flex;
|
||||
|
||||
.result-nav-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
--button-border: transparent;
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
|
||||
&:first {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__search-result-container {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.layer-ui__result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
|
||||
margin: 0 0.75rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
.text-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
flex: 1;
|
||||
max-height: 48px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
}
|
719
packages/excalidraw/components/SearchMenu.tsx
Normal file
719
packages/excalidraw/components/SearchMenu.tsx
Normal file
@ -0,0 +1,719 @@
|
||||
import { Fragment, memo, useEffect, useRef, useState } from "react";
|
||||
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
|
||||
import { TextField } from "./TextField";
|
||||
import { Button } from "./Button";
|
||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||
import { debounce } from "lodash";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { isTextElement, newTextElement } from "../element";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { addEventListener, getFontString } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import clsx from "clsx";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { t } from "../i18n";
|
||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||
import { randomInteger } from "../random";
|
||||
import { CLASSES, EVENT } from "../constants";
|
||||
import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./SearchMenu.scss";
|
||||
import { round } from "../../math";
|
||||
|
||||
const searchQueryAtom = atom<string>("");
|
||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||
|
||||
const SEARCH_DEBOUNCE = 350;
|
||||
|
||||
type SearchMatchItem = {
|
||||
textElement: ExcalidrawTextElement;
|
||||
searchQuery: SearchQuery;
|
||||
index: number;
|
||||
preview: {
|
||||
indexInSearchQuery: number;
|
||||
previewText: string;
|
||||
moreBefore: boolean;
|
||||
moreAfter: boolean;
|
||||
};
|
||||
matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type SearchMatches = {
|
||||
nonce: number | null;
|
||||
items: SearchMatchItem[];
|
||||
};
|
||||
|
||||
type SearchQuery = string & { _brand: "SearchQuery" };
|
||||
|
||||
export const SearchMenu = () => {
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
||||
const searchQuery = inputValue.trim() as SearchQuery;
|
||||
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
|
||||
nonce: null,
|
||||
items: [],
|
||||
});
|
||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const [focusIndex, setFocusIndex] = useAtom(
|
||||
searchItemInFocusAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
searchQuery !== searchedQueryRef.current ||
|
||||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
|
||||
) {
|
||||
searchedQueryRef.current = null;
|
||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
||||
setSearchMatches({
|
||||
nonce: randomInteger(),
|
||||
items: matchItems,
|
||||
});
|
||||
searchedQueryRef.current = searchQuery;
|
||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
||||
setAppState({
|
||||
searchMatches: matchItems.map((searchMatch) => ({
|
||||
id: searchMatch.textElement.id,
|
||||
focus: false,
|
||||
matchedLines: searchMatch.matchedLines,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isSearching,
|
||||
searchQuery,
|
||||
elementsMap,
|
||||
app,
|
||||
setAppState,
|
||||
setFocusIndex,
|
||||
lastSceneNonceRef,
|
||||
]);
|
||||
|
||||
const goToNextItem = () => {
|
||||
if (searchMatches.items.length > 0) {
|
||||
setFocusIndex((focusIndex) => {
|
||||
if (focusIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (focusIndex + 1) % searchMatches.items.length;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousItem = () => {
|
||||
if (searchMatches.items.length > 0) {
|
||||
setFocusIndex((focusIndex) => {
|
||||
if (focusIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return focusIndex - 1 < 0
|
||||
? searchMatches.items.length - 1
|
||||
: focusIndex - 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setAppState((state) => {
|
||||
return {
|
||||
searchMatches: state.searchMatches.map((match, index) => {
|
||||
if (index === focusIndex) {
|
||||
return { ...match, focus: true };
|
||||
}
|
||||
return { ...match, focus: false };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, [focusIndex, setAppState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMatches.items.length > 0 && focusIndex !== null) {
|
||||
const match = searchMatches.items[focusIndex];
|
||||
|
||||
if (match) {
|
||||
const zoomValue = app.state.zoom.value;
|
||||
|
||||
const matchAsElement = newTextElement({
|
||||
text: match.searchQuery,
|
||||
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
|
||||
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
|
||||
width: match.matchedLines[0]?.width,
|
||||
height: match.matchedLines[0]?.height,
|
||||
fontSize: match.textElement.fontSize,
|
||||
fontFamily: match.textElement.fontFamily,
|
||||
});
|
||||
|
||||
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
|
||||
|
||||
const fontSize = match.textElement.fontSize;
|
||||
const isTextTiny =
|
||||
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
|
||||
|
||||
if (
|
||||
!isElementCompletelyInViewport(
|
||||
[matchAsElement],
|
||||
app.canvas.width / window.devicePixelRatio,
|
||||
app.canvas.height / window.devicePixelRatio,
|
||||
{
|
||||
offsetLeft: app.state.offsetLeft,
|
||||
offsetTop: app.state.offsetTop,
|
||||
scrollX: app.state.scrollX,
|
||||
scrollY: app.state.scrollY,
|
||||
zoom: app.state.zoom,
|
||||
},
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.getEditorUIOffsets(),
|
||||
) ||
|
||||
isTextTiny
|
||||
) {
|
||||
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
|
||||
|
||||
if (isTextTiny) {
|
||||
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
|
||||
zoomOptions = { fitToContent: true };
|
||||
} else {
|
||||
zoomOptions = {
|
||||
fitToViewport: true,
|
||||
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
|
||||
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
zoomOptions = { fitToContent: true };
|
||||
}
|
||||
|
||||
app.scrollToContent(matchAsElement, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
...zoomOptions,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [focusIndex, searchMatches, app]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setFocusIndex(null);
|
||||
searchedQueryRef.current = null;
|
||||
lastSceneNonceRef.current = undefined;
|
||||
setAppState({
|
||||
searchMatches: [],
|
||||
});
|
||||
setIsSearching(false);
|
||||
};
|
||||
}, [setAppState, setFocusIndex]);
|
||||
|
||||
const stableState = useStable({
|
||||
goToNextItem,
|
||||
goToPreviousItem,
|
||||
searchMatches,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const eventHandler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
!app.state.openDialog &&
|
||||
!app.state.openPopup
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!searchInputRef.current?.matches(":focus")) {
|
||||
if (app.state.openDialog) {
|
||||
setAppState({
|
||||
openDialog: null,
|
||||
});
|
||||
}
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
} else {
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest(".layer-ui__search")
|
||||
) {
|
||||
if (stableState.searchMatches.items.length) {
|
||||
if (event.key === KEYS.ENTER) {
|
||||
event.stopPropagation();
|
||||
stableState.goToNextItem();
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
event.stopPropagation();
|
||||
stableState.goToPreviousItem();
|
||||
} else if (event.key === KEYS.ARROW_DOWN) {
|
||||
event.stopPropagation();
|
||||
stableState.goToNextItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// `capture` needed to prevent firing on initial open from App.tsx,
|
||||
// as well as to handle events before App ones
|
||||
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
}, [setAppState, stableState, app]);
|
||||
|
||||
const matchCount = `${searchMatches.items.length} ${
|
||||
searchMatches.items.length === 1
|
||||
? t("search.singleResult")
|
||||
: t("search.multipleResults")
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="layer-ui__search">
|
||||
<div className="layer-ui__search-header">
|
||||
<TextField
|
||||
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
|
||||
value={inputValue}
|
||||
ref={searchInputRef}
|
||||
placeholder={t("search.placeholder")}
|
||||
icon={searchIcon}
|
||||
onChange={(value) => {
|
||||
setInputValue(value);
|
||||
setIsSearching(true);
|
||||
const searchQuery = value.trim() as SearchQuery;
|
||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
||||
setSearchMatches({
|
||||
nonce: randomInteger(),
|
||||
items: matchItems,
|
||||
});
|
||||
setFocusIndex(index);
|
||||
searchedQueryRef.current = searchQuery;
|
||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
||||
setAppState({
|
||||
searchMatches: matchItems.map((searchMatch) => ({
|
||||
id: searchMatch.textElement.id,
|
||||
focus: false,
|
||||
matchedLines: searchMatch.matchedLines,
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSearching(false);
|
||||
});
|
||||
}}
|
||||
selectOnRender
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="layer-ui__search-count">
|
||||
{searchMatches.items.length > 0 && (
|
||||
<>
|
||||
{focusIndex !== null && focusIndex > -1 ? (
|
||||
<div>
|
||||
{focusIndex + 1} / {matchCount}
|
||||
</div>
|
||||
) : (
|
||||
<div>{matchCount}</div>
|
||||
)}
|
||||
<div className="result-nav">
|
||||
<Button
|
||||
onSelect={() => {
|
||||
goToNextItem();
|
||||
}}
|
||||
className="result-nav-btn"
|
||||
>
|
||||
{collapseDownIcon}
|
||||
</Button>
|
||||
<Button
|
||||
onSelect={() => {
|
||||
goToPreviousItem();
|
||||
}}
|
||||
className="result-nav-btn"
|
||||
>
|
||||
{upIcon}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchMatches.items.length === 0 &&
|
||||
searchQuery &&
|
||||
searchedQueryRef.current && (
|
||||
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MatchList
|
||||
matches={searchMatches}
|
||||
onItemClick={setFocusIndex}
|
||||
focusIndex={focusIndex}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItem = (props: {
|
||||
preview: SearchMatchItem["preview"];
|
||||
searchQuery: SearchQuery;
|
||||
highlighted: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const preview = [
|
||||
props.preview.moreBefore ? "..." : "",
|
||||
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
|
||||
props.preview.previewText.slice(
|
||||
props.preview.indexInSearchQuery,
|
||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
||||
),
|
||||
props.preview.previewText.slice(
|
||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
||||
),
|
||||
props.preview.moreAfter ? "..." : "",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={clsx("layer-ui__result-item", {
|
||||
active: props.highlighted,
|
||||
})}
|
||||
onClick={props.onClick}
|
||||
ref={(ref) => {
|
||||
if (props.highlighted) {
|
||||
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="preview-text">
|
||||
{preview.flatMap((text, idx) => (
|
||||
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatchListProps {
|
||||
matches: SearchMatches;
|
||||
onItemClick: (index: number) => void;
|
||||
focusIndex: number | null;
|
||||
searchQuery: SearchQuery;
|
||||
}
|
||||
|
||||
const MatchListBase = (props: MatchListProps) => {
|
||||
return (
|
||||
<div className="layer-ui__search-result-container">
|
||||
{props.matches.items.map((searchMatch, index) => (
|
||||
<ListItem
|
||||
key={searchMatch.textElement.id + searchMatch.index}
|
||||
searchQuery={props.searchQuery}
|
||||
preview={searchMatch.preview}
|
||||
highlighted={index === props.focusIndex}
|
||||
onClick={() => props.onItemClick(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
|
||||
return (
|
||||
prevProps.matches.nonce === nextProps.matches.nonce &&
|
||||
prevProps.focusIndex === nextProps.focusIndex
|
||||
);
|
||||
};
|
||||
|
||||
const MatchList = memo(MatchListBase, areEqual);
|
||||
|
||||
const getMatchPreview = (
|
||||
text: string,
|
||||
index: number,
|
||||
searchQuery: SearchQuery,
|
||||
) => {
|
||||
const WORDS_BEFORE = 2;
|
||||
const WORDS_AFTER = 5;
|
||||
|
||||
const substrBeforeQuery = text.slice(0, index);
|
||||
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
|
||||
// text = "small", query = "mall", not complete before
|
||||
// text = "small", query = "smal", complete before
|
||||
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
|
||||
const startWordIndex =
|
||||
wordsBeforeQuery.length -
|
||||
WORDS_BEFORE -
|
||||
1 -
|
||||
(isQueryCompleteBefore ? 0 : 1);
|
||||
let wordsBeforeAsString =
|
||||
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
|
||||
(isQueryCompleteBefore ? " " : "");
|
||||
|
||||
const MAX_ALLOWED_CHARS = 20;
|
||||
|
||||
wordsBeforeAsString =
|
||||
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
|
||||
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
|
||||
: wordsBeforeAsString;
|
||||
|
||||
const substrAfterQuery = text.slice(index + searchQuery.length);
|
||||
const wordsAfter = substrAfterQuery.split(/\s+/);
|
||||
// text = "small", query = "mall", complete after
|
||||
// text = "small", query = "smal", not complete after
|
||||
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
|
||||
const numberOfWordsToTake = isQueryCompleteAfter
|
||||
? WORDS_AFTER + 1
|
||||
: WORDS_AFTER;
|
||||
const wordsAfterAsString =
|
||||
(isQueryCompleteAfter ? "" : " ") +
|
||||
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
|
||||
|
||||
return {
|
||||
indexInSearchQuery: wordsBeforeAsString.length,
|
||||
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
|
||||
moreBefore: startWordIndex > 0,
|
||||
moreAfter: wordsAfter.length > numberOfWordsToTake,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeWrappedText = (
|
||||
wrappedText: string,
|
||||
originalText: string,
|
||||
): string => {
|
||||
const wrappedLines = wrappedText.split("\n");
|
||||
const normalizedLines: string[] = [];
|
||||
let originalIndex = 0;
|
||||
|
||||
for (let i = 0; i < wrappedLines.length; i++) {
|
||||
let currentLine = wrappedLines[i];
|
||||
const nextLine = wrappedLines[i + 1];
|
||||
|
||||
if (nextLine) {
|
||||
const nextLineIndexInOriginal = originalText.indexOf(
|
||||
nextLine,
|
||||
originalIndex,
|
||||
);
|
||||
|
||||
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
|
||||
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
|
||||
|
||||
while (j > 0) {
|
||||
currentLine += " ";
|
||||
j--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizedLines.push(currentLine);
|
||||
originalIndex = originalIndex + currentLine.length;
|
||||
}
|
||||
|
||||
return normalizedLines.join("\n");
|
||||
};
|
||||
|
||||
const getMatchedLines = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
searchQuery: SearchQuery,
|
||||
index: number,
|
||||
) => {
|
||||
const normalizedText = normalizeWrappedText(
|
||||
textElement.text,
|
||||
textElement.originalText,
|
||||
);
|
||||
|
||||
const lines = normalizedText.split("\n");
|
||||
|
||||
const lineIndexRanges = [];
|
||||
let currentIndex = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const startIndex = currentIndex;
|
||||
const endIndex = startIndex + line.length - 1;
|
||||
|
||||
lineIndexRanges.push({
|
||||
line,
|
||||
startIndex,
|
||||
endIndex,
|
||||
lineNumber,
|
||||
});
|
||||
|
||||
// Move to the next line's start index
|
||||
currentIndex = endIndex + 1;
|
||||
lineNumber++;
|
||||
}
|
||||
|
||||
let startIndex = index;
|
||||
let remainingQuery = textElement.originalText.slice(
|
||||
index,
|
||||
index + searchQuery.length,
|
||||
);
|
||||
const matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[] = [];
|
||||
|
||||
for (const lineIndexRange of lineIndexRanges) {
|
||||
if (remainingQuery === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
startIndex >= lineIndexRange.startIndex &&
|
||||
startIndex <= lineIndexRange.endIndex
|
||||
) {
|
||||
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
|
||||
const textToStart = lineIndexRange.line.slice(
|
||||
0,
|
||||
startIndex - lineIndexRange.startIndex,
|
||||
);
|
||||
|
||||
const matchedWord = remainingQuery.slice(0, matchCapacity);
|
||||
remainingQuery = remainingQuery.slice(matchCapacity);
|
||||
|
||||
const offset = measureText(
|
||||
textToStart,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
// measureText returns a non-zero width for the empty string
|
||||
// which is not what we're after here, hence the check and the correction
|
||||
if (textToStart === "") {
|
||||
offset.width = 0;
|
||||
}
|
||||
|
||||
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
|
||||
const lineLength = measureText(
|
||||
lineIndexRange.line,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
const spaceToStart =
|
||||
textElement.textAlign === "center"
|
||||
? (textElement.width - lineLength.width) / 2
|
||||
: textElement.width - lineLength.width;
|
||||
offset.width += spaceToStart;
|
||||
}
|
||||
|
||||
const { width, height } = measureText(
|
||||
matchedWord,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
const offsetX = offset.width;
|
||||
const offsetY = lineIndexRange.lineNumber * offset.height;
|
||||
|
||||
matchedLines.push({
|
||||
offsetX,
|
||||
offsetY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
startIndex += matchCapacity;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedLines;
|
||||
};
|
||||
|
||||
const escapeSpecialCharacters = (string: string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
||||
};
|
||||
|
||||
const handleSearch = debounce(
|
||||
(
|
||||
searchQuery: SearchQuery,
|
||||
app: AppClassProperties,
|
||||
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
|
||||
) => {
|
||||
if (!searchQuery || searchQuery === "") {
|
||||
cb([], null);
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const texts = elements.filter((el) =>
|
||||
isTextElement(el),
|
||||
) as ExcalidrawTextElement[];
|
||||
|
||||
texts.sort((a, b) => a.y - b.y);
|
||||
|
||||
const matchItems: SearchMatchItem[] = [];
|
||||
|
||||
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
|
||||
|
||||
for (const textEl of texts) {
|
||||
let match = null;
|
||||
const text = textEl.originalText;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const preview = getMatchPreview(text, match.index, searchQuery);
|
||||
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
|
||||
|
||||
if (matchedLines.length > 0) {
|
||||
matchItems.push({
|
||||
textElement: textEl,
|
||||
searchQuery,
|
||||
preview,
|
||||
index: match.index,
|
||||
matchedLines,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visibleIds = new Set(
|
||||
app.visibleElements.map((visibleElement) => visibleElement.id),
|
||||
);
|
||||
|
||||
const focusIndex =
|
||||
matchItems.findIndex((matchItem) =>
|
||||
visibleIds.has(matchItem.textElement.id),
|
||||
) ?? null;
|
||||
|
||||
cb(matchItems, focusIndex);
|
||||
},
|
||||
SEARCH_DEBOUNCE,
|
||||
);
|
@ -52,8 +52,8 @@
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { useI18n } from "../i18n";
|
||||
@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { copyIcon, tablerCheckIcon } from "./icons";
|
||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||
import { copyIcon } from "./icons";
|
||||
|
||||
import "./ShareableLinkDialog.scss";
|
||||
|
||||
@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
|
||||
setErrorMessage,
|
||||
}: ShareableLinkDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const { onCopy, copyStatus } = useCopyStatus();
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
||||
<div className="ShareableLinkDialog">
|
||||
@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
|
||||
value={link}
|
||||
selectOnRender
|
||||
/>
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="ShareableLinkDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("buttons.copyLink")}
|
||||
icon={copyIcon}
|
||||
status={copyStatus}
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
copyRoomLink();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ShareableLinkDialog__description">
|
||||
🔒 {t("alerts.uploadedSecurly")}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user