Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c4eb6db4 | |||
| 1ac2626e47 | |||
| 43d6a7e286 | |||
| 15b7d141c1 | |||
| 550e23a2ab | |||
| d4c6462ab1 | |||
| 530617be90 | |||
| 5211b003b8 | |||
| bbcca06b94 | |||
| f92f04c13c | |||
| 890ed9f31f | |||
| da2e507298 | |||
| f59b4f6af4 | |||
| afcde542f9 | |||
| 4689a6b300 | |||
| 0ae9b383d6 | |||
| f597bd3e01 | |||
| 4987cc53d0 | |||
| d917db438e | |||
| a33a400f01 | |||
| 8a162a4cb4 | |||
| c6a045d092 | |||
| cd50aa719f | |||
| 92bc08207c | |||
| 32df5502ae | |||
| bbdcd30a73 | |||
| 3e334a67ed | |||
| 1d71f84515 | |||
| 550a388b2b | |||
| 6b523563d8 | |||
| 65bc500598 | |||
| 7949aa1f1c | |||
| 15bfa626b4 | |||
| 068895db0e | |||
| b7babe554b | |||
| 6a385d6663 | |||
| 2382fad4f6 | |||
| 480572f893 | |||
| 68b1fdb20e | |||
| a38e82f999 | |||
| a07f6e9e3a | |||
| 7e471b55eb | |||
| 160440b860 | |||
| f207bd0a1c | |||
| 99601baffc | |||
| af1a3d5b76 | |||
| 36e56267c9 | |||
| b09b5cb5f4 | |||
| dd8529743a | |||
| f639d44a95 | |||
| f5ab3e4e12 | |||
| 361a9449bb | |||
| 2e719ff671 | |||
| 79d9dc2f8f | |||
| 9013c84524 | |||
| 47f87f4ecb | |||
| 73bf50e8a8 | |||
| 48c3465b19 | |||
| adc4c9f484 | |||
| def1df2c68 | |||
| 0513b647ec | |||
| a289c42830 | |||
| d67eaa8710 | |||
| 0c3dffb082 | |||
| 0e0f34edd8 | |||
| 4888d9d355 | |||
| 1c39bd5781 | |||
| 90ad885446 | |||
| 1741c234a6 | |||
| 63b50b3586 | |||
| e0fefa8025 | |||
| d426cc968d | |||
| 2409c091ff | |||
| 626fe252ab | |||
| 10bd08ef19 | |||
| 2789d08154 | |||
| 678bb2b819 | |||
| 966f9aead9 | |||
| 4f0a2a9593 | |||
| f3f8217125 | |||
| 89bd6181f2 | |||
| c6fdac131b | |||
| 0415c616b1 | |||
| 740a165452 | |||
| 4997624a3a | |||
| b66daae1f3 | |||
| 1e7df58b5b | |||
| 46da032626 | |||
| 3b0593baa7 |
@@ -7,9 +7,6 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
|
|||||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||||
VITE_APP_WS_SERVER_URL=http://localhost:3002
|
VITE_APP_WS_SERVER_URL=http://localhost:3002
|
||||||
|
|
||||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
|
||||||
VITE_APP_PORTAL_URL=
|
|
||||||
|
|
||||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|||||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
VITE_APP_PORTAL_URL=https://portal.excalidraw.com
|
|
||||||
|
|
||||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
||||||
|
|
||||||
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
|
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
|
||||||
|
|
||||||
# Fill to set socket server URL used for collaboration.
|
# socket server URL used for collaboration
|
||||||
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
|
VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
||||||
VITE_APP_WS_SERVER_URL=
|
|
||||||
|
|
||||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core
|
yarn add @actions/core -W
|
||||||
yarn autorelease
|
yarn autorelease
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ packages/excalidraw/types
|
|||||||
coverage
|
coverage
|
||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
|
examples/**/bundle.*
|
||||||
|
meta*.json
|
||||||
@@ -8,15 +8,15 @@
|
|||||||
import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
||||||
```
|
```
|
||||||
|
|
||||||
`FONT_FAMILY` contains all the font families used in `Excalidraw` as explained below
|
`FONT_FAMILY` contains all the font families used in `Excalidraw`. The default families are the following:
|
||||||
|
|
||||||
| Font Family | Description |
|
| Font Family | Description |
|
||||||
| ----------- | ---------------------- |
|
| ----------- | ---------------------- |
|
||||||
| `Virgil` | The `handwritten` font |
|
| `Excalifont` | The `Hand-drawn` font |
|
||||||
| `Helvetica` | The `Normal` Font |
|
| `Nunito` | The `Normal` Font |
|
||||||
| `Cascadia` | The `Code` Font |
|
| `Comic Shanns` | The `Code` Font |
|
||||||
|
|
||||||
Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`.
|
Pre-selected family is `FONT_FAMILY.Excalifont`, unless it's overriden with `initialData.appState.currentItemFontFamily`.
|
||||||
|
|
||||||
### THEME
|
### THEME
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
|
|||||||
| API | Signature | Usage |
|
| API | Signature | Usage |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
|
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
|
||||||
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
|
| [updateLibrary](#updatelibrary) | `function` | updates the library |
|
||||||
| [addFiles](#addfiles) | `function` | add files data to the appState |
|
| [addFiles](#addfiles) | `function` | add files data to the appState |
|
||||||
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
|
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
|
||||||
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
|
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
|
||||||
@@ -37,7 +37,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
|
|||||||
| [setActiveTool](#setactivetool) | `function` | This API can be used to set the active tool |
|
| [setActiveTool](#setactivetool) | `function` | This API can be used to set the active tool |
|
||||||
| [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas |
|
| [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas |
|
||||||
| [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas |
|
| [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas |
|
||||||
| [toggleMenu](#togglemenu) | `function` | Toggles specific menus on/off |
|
| [toggleSidebar](#toggleSidebar) | `function` | Toggles specific sidebar on/off |
|
||||||
| [onChange](#onChange) | `function` | Subscribes to change events |
|
| [onChange](#onChange) | `function` | Subscribes to change events |
|
||||||
| [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events |
|
| [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events |
|
||||||
| [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events |
|
| [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events |
|
||||||
@@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
|
|||||||
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
||||||
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
||||||
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
||||||
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
|
| `storeAction` | [`StoreAction`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/store.ts#L40) | Parameter to control which updates should be captured by the `Store`. Captured updates are emmitted as increments and listened to by other components, such as `History` for undo / redo purposes. |
|
||||||
|
|
||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
@@ -105,6 +105,7 @@ function App() {
|
|||||||
appState: {
|
appState: {
|
||||||
viewBackgroundColor: "#edf2ff",
|
viewBackgroundColor: "#edf2ff",
|
||||||
},
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
excalidrawAPI.updateScene(sceneData);
|
excalidrawAPI.updateScene(sceneData);
|
||||||
};
|
};
|
||||||
@@ -121,6 +122,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### storeAction
|
||||||
|
|
||||||
|
You can use the `storeAction` to influence undo / redo behaviour.
|
||||||
|
|
||||||
|
> **NOTE**: Some updates are not observed by the store / history - i.e. updates to `collaborators` object or parts of `AppState` which are not observed (not `ObservedAppState`). Such updates will never make it to the undo / redo stacks, regardless of the passed `storeAction` value.
|
||||||
|
|
||||||
|
| | `storeAction` value | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| _Immediately undoable_ | `StoreAction.CAPTURE` | Use for updates which should be captured. Should be used for most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||||
|
| _Eventually undoable_ | `StoreAction.NONE` | Use for updates which should not be captured immediately - likely exceptions which are part of some async multi-step process. Otherwise, all such updates would end up being captured with the next `StoreAction.CAPTURE` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||||
|
| _Never undoable_ | `StoreAction.UPDATE` | Use for updates which should never be recorded, such as remote updates or scene initialization. These updates will _never_ make it to the local undo / redo stacks. |
|
||||||
|
|
||||||
|
|
||||||
### updateLibrary
|
### updateLibrary
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ You can pass `null` / `undefined` if not applicable.
|
|||||||
restoreElements(
|
restoreElements(
|
||||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
||||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
||||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
opts: { refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
|
||||||
)
|
)
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
@@ -51,8 +51,9 @@ The extra optional parameter to configure restored elements. It has the followin
|
|||||||
|
|
||||||
| Prop | Type | Description|
|
| Prop | Type | Description|
|
||||||
| --- | --- | ------|
|
| --- | --- | ------|
|
||||||
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
|
| `refreshDimensions` | `boolean` | Indicates whether we should also _recalculate_ text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
|
||||||
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
|
| `repairBindings` |`boolean` | Indicates whether the _bindings_ for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
|
||||||
|
| `normalizeIndices` |`boolean` | Indicates whether _fractional indices_ for the elements should be normalized. This is to prevent possible issues caused by using stale (too old, too long) indices. |
|
||||||
|
|
||||||
**_How to use_**
|
**_How to use_**
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ restore(
|
|||||||
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>
|
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>
|
||||||
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>
|
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>
|
||||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L4">DataState</a><br/>
|
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L4">DataState</a><br/>
|
||||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
opts: { refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
|
||||||
|
|
||||||
)
|
)
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ yarn add react react-dom @excalidraw/excalidraw
|
|||||||
|
|
||||||
Excalidraw depends on assets such as localization files (if you opt to use them), fonts, and others.
|
Excalidraw depends on assets such as localization files (if you opt to use them), fonts, and others.
|
||||||
|
|
||||||
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist), so you don't need to do anything on your end.
|
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/), so you don't need to do anything on your end.
|
||||||
|
|
||||||
However, if you want to host these files yourself, you can find them in your `node_modules/@excalidraw/excalidraw/dist` directory, in folders `excalidraw-assets` (for production) and `excalidraw-assets-dev` (for development).
|
However, if you want to host these files yourself, you can find them in your `node_modules/@excalidraw/excalidraw/dist` directory, in folders `excalidraw-assets` (for production) and `excalidraw-assets-dev` (for development).
|
||||||
|
|
||||||
@@ -34,6 +34,26 @@ Copy these folders to your static assets directory, and add a `window.EXCALIDRAW
|
|||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
or, if you serve your assets from the root of your CDN, you would do:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Vanilla
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = "https://my.cdn.com/assets/";
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
or, if you prefer the path to be dynamicly set based on the `location.origin`, you could do the following:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Next.js
|
||||||
|
<Script id="load-env-variables" strategy="beforeInteractive" >
|
||||||
|
{ `window["EXCALIDRAW_ASSET_PATH"] = location.origin;` } // or use just "/"!
|
||||||
|
</Script>
|
||||||
|
```
|
||||||
|
|
||||||
### Dimensions of Excalidraw
|
### Dimensions of Excalidraw
|
||||||
|
|
||||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||||
|
|||||||
@@ -32,15 +32,9 @@ function App() {
|
|||||||
|
|
||||||
### Next.js
|
### Next.js
|
||||||
|
|
||||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
Since Excalidraw doesn't support `server side rendering` so it should be rendered only on `client`. The way to achieve this in next.js is using `next.js dynamic import`.
|
||||||
|
|
||||||
Here are two ways on how you can render **Excalidraw** on **Next.js**.
|
If you want to only import `Excalidraw` component you can do :point_down:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1. Using **Next.js Dynamic** import [Recommended].
|
|
||||||
|
|
||||||
Since Excalidraw doesn't support server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`.
|
|
||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
@@ -55,25 +49,88 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2).
|
However the above component only works for named component exports. If you want to import some util / constant or something else apart from Excalidraw, then this approach will not work. Instead you can write a wrapper over Excalidraw and import the wrapper dynamically.
|
||||||
|
|
||||||
|
If you are using `pages router` then importing the wrapper dynamically would work, where as if you are using `app router` then you will have to also add `useClient` directive on top of the file in addition to dynamically importing the wrapper as shown :point_down:
|
||||||
|
|
||||||
2. Importing Excalidraw once **client** is rendered.
|
<Tabs>
|
||||||
|
<TabItem value="Excalidraw Wrapper" label="Excalidraw Wrapper" >
|
||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import { useState, useEffect } from "react";
|
"use client";
|
||||||
export default function App() {
|
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||||
const [Excalidraw, setExcalidraw] = useState(null);
|
|
||||||
useEffect(() => {
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import("@excalidraw/excalidraw").then((comp) =>
|
|
||||||
setExcalidraw(comp.Excalidraw),
|
const ExcalidrawWrapper: React.FC = () => {
|
||||||
|
console.info(convertToExcalidrawElements([{
|
||||||
|
type: "rectangle",
|
||||||
|
id: "rect-1",
|
||||||
|
width: 186.47265625,
|
||||||
|
height: 141.9765625,
|
||||||
|
},]));
|
||||||
|
return (
|
||||||
|
<div style={{height:"500px", width:"500px"}}>
|
||||||
|
<Excalidraw />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
return <>{Excalidraw && <Excalidraw />}</>;
|
export default ExcalidrawWrapper;
|
||||||
}
|
```
|
||||||
```
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="pages" label="Pages router">
|
||||||
|
|
||||||
|
```jsx showLineNumbers
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
// Since client components get prerenderd on server as well hence importing
|
||||||
|
// the excalidraw stuff dynamically with ssr false
|
||||||
|
|
||||||
|
const ExcalidrawWrapper = dynamic(
|
||||||
|
async () => (await import("../excalidrawWrapper")).default,
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<ExcalidrawWrapper />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="app" label="App router">
|
||||||
|
|
||||||
|
```jsx showLineNumbers
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
// Since client components get prerenderd on server as well hence importing
|
||||||
|
// the excalidraw stuff dynamically with ssr false
|
||||||
|
|
||||||
|
const ExcalidrawWrapper = dynamic(
|
||||||
|
async () => (await import("../excalidrawWrapper")).default,
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<ExcalidrawWrapper />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
|
||||||
|
Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/excalidraw/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs-gh6smrdnq-excalidraw.vercel.app/).
|
||||||
|
|
||||||
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d)
|
|
||||||
|
|
||||||
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
|
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,23 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.app-title {
|
||||||
|
margin-block-start: 0.83em;
|
||||||
|
margin-block-end: 0.83em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-wrapper button {
|
.button-wrapper {
|
||||||
z-index: 1;
|
input[type="checkbox"] {
|
||||||
height: 40px;
|
margin: 5px;
|
||||||
max-width: 200px;
|
}
|
||||||
margin: 10px;
|
button {
|
||||||
padding: 5px;
|
z-index: 1;
|
||||||
|
height: 40px;
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw .App-menu_top .buttonList {
|
.excalidraw .App-menu_top .buttonList {
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
|
} from "react";
|
||||||
import ExampleSidebar from "./sidebar/ExampleSidebar";
|
import ExampleSidebar from "./sidebar/ExampleSidebar";
|
||||||
|
|
||||||
import type * as TExcalidraw from "../index";
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import "./App.scss";
|
|
||||||
import initialData from "./initialData";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { resolvablePromise, ResolvablePromise } from "../utils";
|
|
||||||
import { EVENT, ROUNDNESS } from "../constants";
|
import {
|
||||||
import { distance2d } from "../math";
|
resolvablePromise,
|
||||||
import { fileOpen } from "../data/filesystem";
|
ResolvablePromise,
|
||||||
import { loadSceneOrLibraryFromBlob } from "../../utils";
|
distance2d,
|
||||||
|
fileOpen,
|
||||||
|
withBatchedUpdates,
|
||||||
|
withBatchedUpdatesThrottled,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
|
import CustomFooter from "./CustomFooter";
|
||||||
|
import MobileFooter from "./MobileFooter";
|
||||||
|
import initialData from "../initialData";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
@@ -18,19 +33,14 @@ import type {
|
|||||||
Gesture,
|
Gesture,
|
||||||
LibraryItems,
|
LibraryItems,
|
||||||
PointerDownState as ExcalidrawPointerDownState,
|
PointerDownState as ExcalidrawPointerDownState,
|
||||||
} from "../types";
|
} from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||||
import type { NonDeletedExcalidrawElement, Theme } from "../element/types";
|
import type {
|
||||||
import { ImportedLibraryData } from "../data/types";
|
NonDeletedExcalidrawElement,
|
||||||
import CustomFooter from "./CustomFooter";
|
Theme,
|
||||||
import MobileFooter from "./MobileFooter";
|
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||||
import { KEYS } from "../keys";
|
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
|
||||||
|
|
||||||
declare global {
|
import "./App.scss";
|
||||||
interface Window {
|
|
||||||
ExcalidrawLib: typeof TExcalidraw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Comment = {
|
type Comment = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -51,31 +61,6 @@ type PointerDownState = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { useEffect, useState, useRef, useCallback } = window.React;
|
|
||||||
|
|
||||||
// This is so that we use the bundled excalidraw.development.js file instead
|
|
||||||
// of the actual source code
|
|
||||||
const {
|
|
||||||
exportToCanvas,
|
|
||||||
exportToSvg,
|
|
||||||
exportToBlob,
|
|
||||||
exportToClipboard,
|
|
||||||
Excalidraw,
|
|
||||||
useHandleLibrary,
|
|
||||||
MIME_TYPES,
|
|
||||||
sceneCoordsToViewportCoords,
|
|
||||||
viewportCoordsToSceneCoords,
|
|
||||||
restoreElements,
|
|
||||||
Sidebar,
|
|
||||||
Footer,
|
|
||||||
WelcomeScreen,
|
|
||||||
MainMenu,
|
|
||||||
LiveCollaborationTrigger,
|
|
||||||
convertToExcalidrawElements,
|
|
||||||
TTDDialog,
|
|
||||||
TTDDialogTrigger,
|
|
||||||
} = window.ExcalidrawLib;
|
|
||||||
|
|
||||||
const COMMENT_ICON_DIMENSION = 32;
|
const COMMENT_ICON_DIMENSION = 32;
|
||||||
const COMMENT_INPUT_HEIGHT = 50;
|
const COMMENT_INPUT_HEIGHT = 50;
|
||||||
const COMMENT_INPUT_WIDTH = 150;
|
const COMMENT_INPUT_WIDTH = 150;
|
||||||
@@ -84,8 +69,38 @@ export interface AppProps {
|
|||||||
appTitle: string;
|
appTitle: string;
|
||||||
useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void;
|
useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void;
|
||||||
customArgs?: any[];
|
customArgs?: any[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}
|
}
|
||||||
export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|
||||||
|
export default function App({
|
||||||
|
appTitle,
|
||||||
|
useCustom,
|
||||||
|
customArgs,
|
||||||
|
children,
|
||||||
|
excalidrawLib,
|
||||||
|
}: AppProps) {
|
||||||
|
const {
|
||||||
|
exportToCanvas,
|
||||||
|
exportToSvg,
|
||||||
|
exportToBlob,
|
||||||
|
exportToClipboard,
|
||||||
|
useHandleLibrary,
|
||||||
|
MIME_TYPES,
|
||||||
|
sceneCoordsToViewportCoords,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
|
restoreElements,
|
||||||
|
Sidebar,
|
||||||
|
Footer,
|
||||||
|
WelcomeScreen,
|
||||||
|
MainMenu,
|
||||||
|
LiveCollaborationTrigger,
|
||||||
|
convertToExcalidrawElements,
|
||||||
|
TTDDialog,
|
||||||
|
TTDDialogTrigger,
|
||||||
|
ROUNDNESS,
|
||||||
|
loadSceneOrLibraryFromBlob,
|
||||||
|
} = excalidrawLib;
|
||||||
const appRef = useRef<any>(null);
|
const appRef = useRef<any>(null);
|
||||||
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
||||||
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
||||||
@@ -147,8 +162,105 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI, convertToExcalidrawElements, MIME_TYPES]);
|
||||||
|
|
||||||
|
const renderExcalidraw = (children: React.ReactNode) => {
|
||||||
|
const Excalidraw: any = Children.toArray(children).find(
|
||||||
|
(child) =>
|
||||||
|
React.isValidElement(child) &&
|
||||||
|
typeof child.type !== "string" &&
|
||||||
|
//@ts-ignore
|
||||||
|
child.type.displayName === "Excalidraw",
|
||||||
|
);
|
||||||
|
if (!Excalidraw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newElement = cloneElement(
|
||||||
|
Excalidraw,
|
||||||
|
{
|
||||||
|
excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api),
|
||||||
|
initialData: initialStatePromiseRef.current.promise,
|
||||||
|
onChange: (
|
||||||
|
elements: NonDeletedExcalidrawElement[],
|
||||||
|
state: AppState,
|
||||||
|
) => {
|
||||||
|
console.info("Elements :", elements, "State : ", state);
|
||||||
|
},
|
||||||
|
onPointerUpdate: (payload: {
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
pointersMap: Gesture["pointers"];
|
||||||
|
}) => setPointerData(payload),
|
||||||
|
viewModeEnabled,
|
||||||
|
zenModeEnabled,
|
||||||
|
gridModeEnabled,
|
||||||
|
theme,
|
||||||
|
name: "Custom name of drawing",
|
||||||
|
UIOptions: {
|
||||||
|
canvasActions: {
|
||||||
|
loadScene: false,
|
||||||
|
},
|
||||||
|
tools: { image: !disableImageTool },
|
||||||
|
},
|
||||||
|
renderTopRightUI,
|
||||||
|
onLinkOpen,
|
||||||
|
onPointerDown,
|
||||||
|
onScrollChange: rerenderCommentIcons,
|
||||||
|
validateEmbeddable: true,
|
||||||
|
},
|
||||||
|
<>
|
||||||
|
{excalidrawAPI && (
|
||||||
|
<Footer>
|
||||||
|
<CustomFooter
|
||||||
|
excalidrawAPI={excalidrawAPI}
|
||||||
|
excalidrawLib={excalidrawLib}
|
||||||
|
/>
|
||||||
|
</Footer>
|
||||||
|
)}
|
||||||
|
<WelcomeScreen />
|
||||||
|
<Sidebar name="custom">
|
||||||
|
<Sidebar.Tabs>
|
||||||
|
<Sidebar.Header />
|
||||||
|
<Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
|
||||||
|
<Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
|
||||||
|
<Sidebar.TabTriggers>
|
||||||
|
<Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
|
||||||
|
<Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
|
||||||
|
</Sidebar.TabTriggers>
|
||||||
|
</Sidebar.Tabs>
|
||||||
|
</Sidebar>
|
||||||
|
<Sidebar.Trigger
|
||||||
|
name="custom"
|
||||||
|
tab="one"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
bottom: "20px",
|
||||||
|
zIndex: 9999999999999999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toggle Custom Sidebar
|
||||||
|
</Sidebar.Trigger>
|
||||||
|
{renderMenu()}
|
||||||
|
{excalidrawAPI && (
|
||||||
|
<TTDDialogTrigger icon={<span>😀</span>}>
|
||||||
|
Text to diagram
|
||||||
|
</TTDDialogTrigger>
|
||||||
|
)}
|
||||||
|
<TTDDialog
|
||||||
|
onTextSubmit={async (_) => {
|
||||||
|
console.info("submit");
|
||||||
|
// sleep for 2s
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
throw new Error("error, go away now");
|
||||||
|
// return "dummy";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
return newElement;
|
||||||
|
};
|
||||||
const renderTopRightUI = (isMobile: boolean) => {
|
const renderTopRightUI = (isMobile: boolean) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -332,8 +444,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
) => {
|
) => {
|
||||||
return withBatchedUpdates((event) => {
|
return withBatchedUpdates((event) => {
|
||||||
window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove);
|
window.removeEventListener("pointermove", pointerDownState.onMove);
|
||||||
window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp);
|
window.removeEventListener("pointerup", pointerDownState.onUp);
|
||||||
excalidrawAPI?.setActiveTool({ type: "selection" });
|
excalidrawAPI?.setActiveTool({ type: "selection" });
|
||||||
const distance = distance2d(
|
const distance = distance2d(
|
||||||
pointerDownState.x,
|
pointerDownState.x,
|
||||||
@@ -397,8 +509,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
onPointerMoveFromPointerDownHandler(pointerDownState);
|
onPointerMoveFromPointerDownHandler(pointerDownState);
|
||||||
const onPointerUp =
|
const onPointerUp =
|
||||||
onPointerUpFromPointerDownHandler(pointerDownState);
|
onPointerUpFromPointerDownHandler(pointerDownState);
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
|
||||||
pointerDownState.onMove = onPointerMove;
|
pointerDownState.onMove = onPointerMove;
|
||||||
pointerDownState.onUp = onPointerUp;
|
pointerDownState.onUp = onPointerUp;
|
||||||
@@ -490,7 +602,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
}}
|
}}
|
||||||
onBlur={saveComment}
|
onBlur={saveComment}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!event.shiftKey && event.key === KEYS.ENTER) {
|
if (!event.shiftKey && event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
saveComment();
|
saveComment();
|
||||||
}
|
}
|
||||||
@@ -523,7 +635,12 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
</MainMenu.ItemCustom>
|
</MainMenu.ItemCustom>
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
|
|
||||||
{excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && (
|
||||||
|
<MobileFooter
|
||||||
|
excalidrawLib={excalidrawLib}
|
||||||
|
excalidrawAPI={excalidrawAPI}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainMenu>
|
</MainMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -672,83 +789,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="excalidraw-wrapper">
|
<div className="excalidraw-wrapper">
|
||||||
<Excalidraw
|
{renderExcalidraw(children)}
|
||||||
excalidrawAPI={(api: ExcalidrawImperativeAPI) =>
|
|
||||||
setExcalidrawAPI(api)
|
|
||||||
}
|
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
|
||||||
onChange={(elements, state) => {
|
|
||||||
// console.info("Elements :", elements, "State : ", state);
|
|
||||||
}}
|
|
||||||
onPointerUpdate={(payload: {
|
|
||||||
pointer: { x: number; y: number };
|
|
||||||
button: "down" | "up";
|
|
||||||
pointersMap: Gesture["pointers"];
|
|
||||||
}) => setPointerData(payload)}
|
|
||||||
viewModeEnabled={viewModeEnabled}
|
|
||||||
zenModeEnabled={zenModeEnabled}
|
|
||||||
gridModeEnabled={gridModeEnabled}
|
|
||||||
theme={theme}
|
|
||||||
name="Custom name of drawing"
|
|
||||||
UIOptions={{
|
|
||||||
canvasActions: {
|
|
||||||
loadScene: false,
|
|
||||||
},
|
|
||||||
tools: { image: !disableImageTool },
|
|
||||||
}}
|
|
||||||
renderTopRightUI={renderTopRightUI}
|
|
||||||
onLinkOpen={onLinkOpen}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onScrollChange={rerenderCommentIcons}
|
|
||||||
// allow all urls
|
|
||||||
validateEmbeddable={true}
|
|
||||||
>
|
|
||||||
{excalidrawAPI && (
|
|
||||||
<Footer>
|
|
||||||
<CustomFooter excalidrawAPI={excalidrawAPI} />
|
|
||||||
</Footer>
|
|
||||||
)}
|
|
||||||
<WelcomeScreen />
|
|
||||||
<Sidebar name="custom">
|
|
||||||
<Sidebar.Tabs>
|
|
||||||
<Sidebar.Header />
|
|
||||||
<Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
|
|
||||||
<Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
|
|
||||||
<Sidebar.TabTriggers>
|
|
||||||
<Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
|
|
||||||
<Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
|
|
||||||
</Sidebar.TabTriggers>
|
|
||||||
</Sidebar.Tabs>
|
|
||||||
</Sidebar>
|
|
||||||
<Sidebar.Trigger
|
|
||||||
name="custom"
|
|
||||||
tab="one"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
bottom: "20px",
|
|
||||||
zIndex: 9999999999999999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Toggle Custom Sidebar
|
|
||||||
</Sidebar.Trigger>
|
|
||||||
{renderMenu()}
|
|
||||||
{excalidrawAPI && (
|
|
||||||
<TTDDialogTrigger icon={<span>😀</span>}>
|
|
||||||
Text to diagram
|
|
||||||
</TTDDialogTrigger>
|
|
||||||
)}
|
|
||||||
<TTDDialog
|
|
||||||
onTextSubmit={async (_) => {
|
|
||||||
console.info("submit");
|
|
||||||
// sleep for 2s
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
throw new Error("error, go away now");
|
|
||||||
// return "dummy";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Excalidraw>
|
|
||||||
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
|
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
|
||||||
{comment && renderComment()}
|
{comment && renderComment()}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ExcalidrawImperativeAPI } from "../types";
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||||
|
|
||||||
const { Button, MIME_TYPES } = window.ExcalidrawLib;
|
|
||||||
const COMMENT_SVG = (
|
const COMMENT_SVG = (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -17,24 +17,28 @@ const COMMENT_SVG = (
|
|||||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CustomFooter = ({
|
const CustomFooter = ({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
|
excalidrawLib,
|
||||||
}: {
|
}: {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { Button, MIME_TYPES } = excalidrawLib;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onSelect={() => alert("General Kenobi!")}
|
onSelect={() => alert("General Kenobi!")}
|
||||||
className="you are a bold one"
|
style={{ marginLeft: "1rem", width: "auto" }}
|
||||||
style={{ marginLeft: "1rem" }}
|
|
||||||
title="Hello there!"
|
title="Hello there!"
|
||||||
>
|
>
|
||||||
{COMMENT_SVG}
|
Hit me
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="custom-element"
|
className="custom-element"
|
||||||
onClick={() => {
|
onSelect={() => {
|
||||||
excalidrawAPI?.setActiveTool({
|
excalidrawAPI?.setActiveTool({
|
||||||
type: "custom",
|
type: "custom",
|
||||||
customType: "comment",
|
customType: "comment",
|
||||||
@@ -57,15 +61,10 @@ const CustomFooter = ({
|
|||||||
)}`;
|
)}`;
|
||||||
excalidrawAPI?.setCursor(`url(${url}), auto`);
|
excalidrawAPI?.setCursor(`url(${url}), auto`);
|
||||||
}}
|
}}
|
||||||
|
title="Comments!"
|
||||||
>
|
>
|
||||||
{COMMENT_SVG}
|
{COMMENT_SVG}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className="custom-footer"
|
|
||||||
onClick={() => alert("This is dummy footer")}
|
|
||||||
>
|
|
||||||
custom footer
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||||
|
import CustomFooter from "./CustomFooter";
|
||||||
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
|
const MobileFooter = ({
|
||||||
|
excalidrawAPI,
|
||||||
|
excalidrawLib,
|
||||||
|
}: {
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
|
excalidrawLib: typeof TExcalidraw;
|
||||||
|
}) => {
|
||||||
|
const { useDevice, Footer } = excalidrawLib;
|
||||||
|
|
||||||
|
const device = useDevice();
|
||||||
|
if (device.editor.isMobile) {
|
||||||
|
return (
|
||||||
|
<Footer>
|
||||||
|
<CustomFooter
|
||||||
|
excalidrawAPI={excalidrawAPI}
|
||||||
|
excalidrawLib={excalidrawLib}
|
||||||
|
/>
|
||||||
|
</Footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
export default MobileFooter;
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import "./ExampleSidebar.scss";
|
import "./ExampleSidebar.scss";
|
||||||
|
|
||||||
const React = window.React;
|
|
||||||
|
|
||||||
export default function Sidebar({ children }: { children: React.ReactNode }) {
|
export default function Sidebar({ children }: { children: React.ReactNode }) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ExcalidrawElementSkeleton } from "../data/transform";
|
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
|
||||||
import type { FileId } from "../element/types";
|
import type { FileId } from "@excalidraw/excalidraw/element/types";
|
||||||
|
|
||||||
const elements: ExcalidrawElementSkeleton[] = [
|
const elements: ExcalidrawElementSkeleton[] = [
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "examples",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"@excalidraw/excalidraw": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig"
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
|
import { fileOpen as _fileOpen } from "browser-fs-access";
|
||||||
|
import type { MIME_TYPES } from "@excalidraw/excalidraw";
|
||||||
|
import { AbortError } from "../../packages/excalidraw/errors";
|
||||||
|
|
||||||
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
|
|
||||||
|
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
|
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
};
|
||||||
|
export const resolvablePromise = <T>() => {
|
||||||
|
let resolve!: any;
|
||||||
|
let reject!: any;
|
||||||
|
const promise = new Promise((_resolve, _reject) => {
|
||||||
|
resolve = _resolve;
|
||||||
|
reject = _reject;
|
||||||
|
});
|
||||||
|
(promise as any).resolve = resolve;
|
||||||
|
(promise as any).reject = reject;
|
||||||
|
return promise as ResolvablePromise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
|
||||||
|
const xd = x2 - x1;
|
||||||
|
const yd = y2 - y1;
|
||||||
|
return Math.hypot(xd, yd);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||||
|
extensions?: FILE_EXTENSION[];
|
||||||
|
description: string;
|
||||||
|
multiple?: M;
|
||||||
|
}): Promise<M extends false | undefined ? File : File[]> => {
|
||||||
|
// an unsafe TS hack, alas not much we can do AFAIK
|
||||||
|
type RetType = M extends false | undefined ? File : File[];
|
||||||
|
|
||||||
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|
||||||
|
return mimeTypes;
|
||||||
|
}, [] as string[]);
|
||||||
|
|
||||||
|
const extensions = opts.extensions?.reduce((acc, ext) => {
|
||||||
|
if (ext === "jpg") {
|
||||||
|
return acc.concat(".jpg", ".jpeg");
|
||||||
|
}
|
||||||
|
return acc.concat(`.${ext}`);
|
||||||
|
}, [] as string[]);
|
||||||
|
|
||||||
|
return _fileOpen({
|
||||||
|
description: opts.description,
|
||||||
|
extensions,
|
||||||
|
mimeTypes,
|
||||||
|
multiple: opts.multiple ?? false,
|
||||||
|
legacySetup: (resolve, reject, input) => {
|
||||||
|
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||||
|
const focusHandler = () => {
|
||||||
|
checkForFile();
|
||||||
|
document.addEventListener("keyup", scheduleRejection);
|
||||||
|
document.addEventListener("pointerup", scheduleRejection);
|
||||||
|
scheduleRejection();
|
||||||
|
};
|
||||||
|
const checkForFile = () => {
|
||||||
|
// this hack might not work when expecting multiple files
|
||||||
|
if (input.files?.length) {
|
||||||
|
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||||
|
resolve(ret as RetType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.addEventListener("focus", focusHandler);
|
||||||
|
});
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
checkForFile();
|
||||||
|
}, INPUT_CHANGE_INTERVAL_MS);
|
||||||
|
return (rejectPromise) => {
|
||||||
|
clearInterval(interval);
|
||||||
|
scheduleRejection.cancel();
|
||||||
|
window.removeEventListener("focus", focusHandler);
|
||||||
|
document.removeEventListener("keyup", scheduleRejection);
|
||||||
|
document.removeEventListener("pointerup", scheduleRejection);
|
||||||
|
if (rejectPromise) {
|
||||||
|
// so that something is shown in console if we need to debug this
|
||||||
|
console.warn("Opening the file was canceled (legacy-fs).");
|
||||||
|
rejectPromise(new AbortError());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}) as Promise<RetType>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const debounce = <T extends any[]>(
|
||||||
|
fn: (...args: T) => void,
|
||||||
|
timeout: number,
|
||||||
|
) => {
|
||||||
|
let handle = 0;
|
||||||
|
let lastArgs: T | null = null;
|
||||||
|
const ret = (...args: T) => {
|
||||||
|
lastArgs = args;
|
||||||
|
clearTimeout(handle);
|
||||||
|
handle = window.setTimeout(() => {
|
||||||
|
lastArgs = null;
|
||||||
|
fn(...args);
|
||||||
|
}, timeout);
|
||||||
|
};
|
||||||
|
ret.flush = () => {
|
||||||
|
clearTimeout(handle);
|
||||||
|
if (lastArgs) {
|
||||||
|
const _lastArgs = lastArgs;
|
||||||
|
lastArgs = null;
|
||||||
|
fn(..._lastArgs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ret.cancel = () => {
|
||||||
|
lastArgs = null;
|
||||||
|
clearTimeout(handle);
|
||||||
|
};
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withBatchedUpdates = <
|
||||||
|
TFunction extends ((event: any) => void) | (() => void),
|
||||||
|
>(
|
||||||
|
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||||
|
) =>
|
||||||
|
((event) => {
|
||||||
|
unstable_batchedUpdates(func as TFunction, event);
|
||||||
|
}) as TFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* barches React state updates and throttles the calls to a single call per
|
||||||
|
* animation frame
|
||||||
|
*/
|
||||||
|
export const withBatchedUpdatesThrottled = <
|
||||||
|
TFunction extends ((event: any) => void) | (() => void),
|
||||||
|
>(
|
||||||
|
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||||
|
) => {
|
||||||
|
// @ts-ignore
|
||||||
|
return throttleRAF<Parameters<TFunction>>(((event) => {
|
||||||
|
unstable_batchedUpdates(func, event);
|
||||||
|
}) as TFunction);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3005) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
distDir: "build",
|
||||||
|
typescript: {
|
||||||
|
// The ts config doesn't work with `jsx: preserve" and if updated to `react-jsx` it gets ovewritten by next js throwing ts errors hence I am ignoring build errors until this is fixed.
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
// This is needed as in pages router the code for importing types throws error as its outside next js app
|
||||||
|
transpilePackages: ["../"],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "with-nextjs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
|
||||||
|
"dev": "yarn build:workspace && next dev -p 3005",
|
||||||
|
"build": "yarn build:workspace && next build",
|
||||||
|
"start": "next start -p 3006",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@excalidraw/excalidraw": "*",
|
||||||
|
"next": "14.1",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"path2d-polyfill": "2.0.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import "../common.scss";
|
||||||
|
|
||||||
|
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||||
|
// with ssr false
|
||||||
|
const ExcalidrawWithClientOnly = dynamic(
|
||||||
|
async () => (await import("../excalidrawWrapper")).default,
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a href="/excalidraw-in-pages">Switch to Pages router</a>
|
||||||
|
<h1 className="page-title">App Router</h1>
|
||||||
|
|
||||||
|
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
|
||||||
|
<ExcalidrawWithClientOnly />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1c7ed6;
|
||||||
|
font-size: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import App from "../../components/App";
|
||||||
|
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
|
const ExcalidrawWrapper: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<App
|
||||||
|
appTitle={"Excalidraw with Nextjs Example"}
|
||||||
|
useCustom={(api: any, args?: any[]) => {}}
|
||||||
|
excalidrawLib={excalidrawLib}
|
||||||
|
>
|
||||||
|
<Excalidraw />
|
||||||
|
</App>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExcalidrawWrapper;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import "../common.scss";
|
||||||
|
|
||||||
|
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||||
|
// with ssr false
|
||||||
|
const Excalidraw = dynamic(
|
||||||
|
async () => (await import("../excalidrawWrapper")).default,
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a href="/">Switch to App router</a>
|
||||||
|
<h1 className="page-title">Pages Router</h1>
|
||||||
|
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
|
||||||
|
<Excalidraw />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "build/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"outputDirectory": "build"
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@excalidraw/excalidraw@workspace:^":
|
||||||
|
version "0.17.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.2.tgz#9a636a1e6bb3c88c5883347d3a7e75e9cce8ab96"
|
||||||
|
integrity sha512-7pqUWD8+mPjDhF4XxG3gw4rvE2JGaLW3Vss5UZfTbITPxAtFaGEc1K081bncitnaYhUwN9ENJE0i87QB3poDwQ==
|
||||||
|
|
||||||
|
"@next/env@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.4.tgz#d5cda0c4a862d70ae760e58c0cd96a8899a2e49a"
|
||||||
|
integrity sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==
|
||||||
|
|
||||||
|
"@next/swc-darwin-arm64@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz#27b1854c2cd04eb1d5e75081a1a792ad91526618"
|
||||||
|
integrity sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==
|
||||||
|
|
||||||
|
"@next/swc-darwin-x64@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz#9940c449e757d0ee50bb9e792d2600cc08a3eb3b"
|
||||||
|
integrity sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==
|
||||||
|
|
||||||
|
"@next/swc-linux-arm64-gnu@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz#0eafd27c8587f68ace7b4fa80695711a8434de21"
|
||||||
|
integrity sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==
|
||||||
|
|
||||||
|
"@next/swc-linux-arm64-musl@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz#2b0072adb213f36dada5394ea67d6e82069ae7dd"
|
||||||
|
integrity sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-gnu@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz#68c67d20ebc8e3f6ced6ff23a4ba2a679dbcec32"
|
||||||
|
integrity sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-musl@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz#67cd81b42fb2caf313f7992fcf6d978af55a1247"
|
||||||
|
integrity sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==
|
||||||
|
|
||||||
|
"@next/swc-win32-arm64-msvc@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz#be06585906b195d755ceda28f33c633e1443f1a3"
|
||||||
|
integrity sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==
|
||||||
|
|
||||||
|
"@next/swc-win32-ia32-msvc@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz#e76cabefa9f2d891599c3d85928475bd8d3f6600"
|
||||||
|
integrity sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==
|
||||||
|
|
||||||
|
"@next/swc-win32-x64-msvc@14.0.4":
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz#e74892f1a9ccf41d3bf5979ad6d3d77c07b9cba1"
|
||||||
|
integrity sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==
|
||||||
|
|
||||||
|
"@swc/helpers@0.5.2":
|
||||||
|
version "0.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d"
|
||||||
|
integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@types/node@^20":
|
||||||
|
version "20.11.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f"
|
||||||
|
integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==
|
||||||
|
dependencies:
|
||||||
|
undici-types "~5.26.4"
|
||||||
|
|
||||||
|
"@types/prop-types@*":
|
||||||
|
version "15.7.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
|
||||||
|
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
|
||||||
|
|
||||||
|
"@types/react-dom@^18":
|
||||||
|
version "18.2.18"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
|
||||||
|
integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react@*", "@types/react@^18":
|
||||||
|
version "18.2.47"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.47.tgz#85074b27ab563df01fbc3f68dc64bf7050b0af40"
|
||||||
|
integrity sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/prop-types" "*"
|
||||||
|
"@types/scheduler" "*"
|
||||||
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/scheduler@*":
|
||||||
|
version "0.16.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff"
|
||||||
|
integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==
|
||||||
|
|
||||||
|
busboy@1.6.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
|
||||||
|
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||||
|
dependencies:
|
||||||
|
streamsearch "^1.1.0"
|
||||||
|
|
||||||
|
caniuse-lite@^1.0.30001406:
|
||||||
|
version "1.0.30001576"
|
||||||
|
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz#893be772cf8ee6056d6c1e2d07df365b9ec0a5c4"
|
||||||
|
integrity sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg==
|
||||||
|
|
||||||
|
client-only@0.0.1:
|
||||||
|
version "0.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
|
||||||
|
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
||||||
|
|
||||||
|
csstype@^3.0.2:
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||||
|
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||||
|
|
||||||
|
glob-to-regexp@^0.4.1:
|
||||||
|
version "0.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
|
||||||
|
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
|
||||||
|
|
||||||
|
graceful-fs@^4.1.2, graceful-fs@^4.2.11:
|
||||||
|
version "4.2.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||||
|
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||||
|
|
||||||
|
"js-tokens@^3.0.0 || ^4.0.0":
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
|
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||||
|
|
||||||
|
loose-envify@^1.1.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
|
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||||
|
dependencies:
|
||||||
|
js-tokens "^3.0.0 || ^4.0.0"
|
||||||
|
|
||||||
|
nanoid@^3.3.6:
|
||||||
|
version "3.3.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||||
|
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||||
|
|
||||||
|
next@14.0.4:
|
||||||
|
version "14.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/next/-/next-14.0.4.tgz#bf00b6f835b20d10a5057838fa2dfced1d0d84dc"
|
||||||
|
integrity sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==
|
||||||
|
dependencies:
|
||||||
|
"@next/env" "14.0.4"
|
||||||
|
"@swc/helpers" "0.5.2"
|
||||||
|
busboy "1.6.0"
|
||||||
|
caniuse-lite "^1.0.30001406"
|
||||||
|
graceful-fs "^4.2.11"
|
||||||
|
postcss "8.4.31"
|
||||||
|
styled-jsx "5.1.1"
|
||||||
|
watchpack "2.4.0"
|
||||||
|
optionalDependencies:
|
||||||
|
"@next/swc-darwin-arm64" "14.0.4"
|
||||||
|
"@next/swc-darwin-x64" "14.0.4"
|
||||||
|
"@next/swc-linux-arm64-gnu" "14.0.4"
|
||||||
|
"@next/swc-linux-arm64-musl" "14.0.4"
|
||||||
|
"@next/swc-linux-x64-gnu" "14.0.4"
|
||||||
|
"@next/swc-linux-x64-musl" "14.0.4"
|
||||||
|
"@next/swc-win32-arm64-msvc" "14.0.4"
|
||||||
|
"@next/swc-win32-ia32-msvc" "14.0.4"
|
||||||
|
"@next/swc-win32-x64-msvc" "14.0.4"
|
||||||
|
|
||||||
|
path2d-polyfill@2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz#24c554a738f42700d6961992bf5f1049672f2391"
|
||||||
|
integrity sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==
|
||||||
|
|
||||||
|
picocolors@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||||
|
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||||
|
|
||||||
|
postcss@8.4.31:
|
||||||
|
version "8.4.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
|
||||||
|
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||||
|
dependencies:
|
||||||
|
nanoid "^3.3.6"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
source-map-js "^1.0.2"
|
||||||
|
|
||||||
|
react-dom@^18:
|
||||||
|
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"
|
||||||
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
|
react@^18:
|
||||||
|
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"
|
||||||
|
|
||||||
|
scheduler@^0.23.0:
|
||||||
|
version "0.23.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||||
|
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.1.0"
|
||||||
|
|
||||||
|
source-map-js@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
|
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||||
|
|
||||||
|
streamsearch@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||||
|
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||||
|
|
||||||
|
styled-jsx@5.1.1:
|
||||||
|
version "5.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f"
|
||||||
|
integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==
|
||||||
|
dependencies:
|
||||||
|
client-only "0.0.1"
|
||||||
|
|
||||||
|
tslib@^2.4.0:
|
||||||
|
version "2.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||||
|
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||||
|
|
||||||
|
typescript@^5:
|
||||||
|
version "5.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
|
||||||
|
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
||||||
|
|
||||||
|
undici-types@~5.26.4:
|
||||||
|
version "5.26.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
|
||||||
|
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
||||||
|
|
||||||
|
watchpack@2.4.0:
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
|
||||||
|
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
|
||||||
|
dependencies:
|
||||||
|
glob-to-regexp "^0.4.1"
|
||||||
|
graceful-fs "^4.1.2"
|
||||||
@@ -13,20 +13,20 @@
|
|||||||
window.name = "codesandbox";
|
window.name = "codesandbox";
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
|
||||||
<link rel="stylesheet" href="bundle.css" />
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="https://unpkg.com/react@18.2.0/umd/react.development.js"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.development.js"></script>
|
|
||||||
<!-- This is so that we use the bundled excalidraw.development.js file instead
|
<!-- This is so that we use the bundled excalidraw.development.js file instead
|
||||||
of the actual source code -->
|
of the actual source code -->
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as ExcalidrawLib from "/dist/browser/dev/index.js";
|
import * as ExcalidrawLib from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
|
console.log(ExcalidrawLib);
|
||||||
window.ExcalidrawLib = ExcalidrawLib;
|
window.ExcalidrawLib = ExcalidrawLib;
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="bundle.js"></script>
|
<script type="module" src="index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import App from "../components/App";
|
||||||
|
import React, { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ExcalidrawLib: typeof TExcalidraw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root")!;
|
||||||
|
const root = createRoot(rootElement);
|
||||||
|
const { Excalidraw } = window.ExcalidrawLib;
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<App
|
||||||
|
appTitle={"Excalidraw Example"}
|
||||||
|
useCustom={(api: any, args?: any[]) => {}}
|
||||||
|
excalidrawLib={window.ExcalidrawLib}
|
||||||
|
>
|
||||||
|
<Excalidraw />
|
||||||
|
</App>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "with-script-in-browser",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"@excalidraw/excalidraw": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "5.0.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
|
||||||
|
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
|
||||||
|
"build:preview": "yarn build && vite preview --port 5002"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"outputDirectory": "example/public",
|
"outputDirectory": "dist",
|
||||||
"installCommand": "yarn install"
|
"installCommand": "yarn install"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
// open the browser
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
publicDir: "public",
|
||||||
|
});
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz#2acd20be6d4f0458bc8c784103495ff24f13b1d3"
|
||||||
|
integrity sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==
|
||||||
|
|
||||||
|
"@esbuild/android-arm64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz#b45d000017385c9051a4f03e17078abb935be220"
|
||||||
|
integrity sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==
|
||||||
|
|
||||||
|
"@esbuild/android-arm@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.11.tgz#f46f55414e1c3614ac682b29977792131238164c"
|
||||||
|
integrity sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==
|
||||||
|
|
||||||
|
"@esbuild/android-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.11.tgz#bfc01e91740b82011ef503c48f548950824922b2"
|
||||||
|
integrity sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz#533fb7f5a08c37121d82c66198263dcc1bed29bf"
|
||||||
|
integrity sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz#62f3819eff7e4ddc656b7c6815a31cf9a1e7d98e"
|
||||||
|
integrity sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz#d478b4195aa3ca44160272dab85ef8baf4175b4a"
|
||||||
|
integrity sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz#7bdcc1917409178257ca6a1a27fe06e797ec18a2"
|
||||||
|
integrity sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz#58ad4ff11685fcc735d7ff4ca759ab18fcfe4545"
|
||||||
|
integrity sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==
|
||||||
|
|
||||||
|
"@esbuild/linux-arm@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz#ce82246d873b5534d34de1e5c1b33026f35e60e3"
|
||||||
|
integrity sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz#cbae1f313209affc74b80f4390c4c35c6ab83fa4"
|
||||||
|
integrity sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz#5f32aead1c3ec8f4cccdb7ed08b166224d4e9121"
|
||||||
|
integrity sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz#38eecf1cbb8c36a616261de858b3c10d03419af9"
|
||||||
|
integrity sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz#9c5725a94e6ec15b93195e5a6afb821628afd912"
|
||||||
|
integrity sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz#2dc4486d474a2a62bbe5870522a9a600e2acb916"
|
||||||
|
integrity sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz#4ad8567df48f7dd4c71ec5b1753b6f37561a65a8"
|
||||||
|
integrity sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==
|
||||||
|
|
||||||
|
"@esbuild/linux-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz#b7390c4d5184f203ebe7ddaedf073df82a658766"
|
||||||
|
integrity sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz#d633c09492a1721377f3bccedb2d821b911e813d"
|
||||||
|
integrity sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz#17388c76e2f01125bf831a68c03a7ffccb65d1a2"
|
||||||
|
integrity sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz#e320636f00bb9f4fdf3a80e548cb743370d41767"
|
||||||
|
integrity sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz#c778b45a496e90b6fc373e2a2bb072f1441fe0ee"
|
||||||
|
integrity sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz#481a65fee2e5cce74ec44823e6b09ecedcc5194c"
|
||||||
|
integrity sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==
|
||||||
|
|
||||||
|
"@esbuild/win32-x64@0.19.11":
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz#a5d300008960bb39677c46bf16f53ec70d8dee04"
|
||||||
|
integrity sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.5.tgz#b752b6c88a14ccfcbdf3f48c577ccc3a7f0e66b9"
|
||||||
|
integrity sha512-idWaG8xeSRCfRq9KpRysDHJ/rEHBEXcHuJ82XY0yYFIWnLMjZv9vF/7DOq8djQ2n3Lk6+3qfSH8AqlmHlmi1MA==
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.5.tgz#33757c3a448b9ef77b6f6292d8b0ec45c87e9c1a"
|
||||||
|
integrity sha512-f14d7uhAMtsCGjAYwZGv6TwuS3IFaM4ZnGMUn3aCBgkcHAYErhV1Ad97WzBvS2o0aaDv4mVz+syiN0ElMyfBPg==
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.5.tgz#5234ba62665a3f443143bc8bcea9df2cc58f55fb"
|
||||||
|
integrity sha512-ndoXeLx455FffL68OIUrVr89Xu1WLzAG4n65R8roDlCoYiQcGGg6MALvs2Ap9zs7AHg8mpHtMpwC8jBBjZrT/w==
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.5.tgz#981256c054d3247b83313724938d606798a919d1"
|
||||||
|
integrity sha512-UmElV1OY2m/1KEEqTlIjieKfVwRg0Zwg4PLgNf0s3glAHXBN99KLpw5A5lrSYCa1Kp63czTpVll2MAqbZYIHoA==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.5.tgz#120678a5a2b3a283a548dbb4d337f9187a793560"
|
||||||
|
integrity sha512-Q0LcU61v92tQB6ae+udZvOyZ0wfpGojtAKrrpAaIqmJ7+psq4cMIhT/9lfV6UQIpeItnq/2QDROhNLo00lOD1g==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz#c99d857e2372ece544b6f60b85058ad259f64114"
|
||||||
|
integrity sha512-dkRscpM+RrR2Ee3eOQmRWFjmV/payHEOrjyq1VZegRUa5OrZJ2MAxBNs05bZuY0YCtpqETDy1Ix4i/hRqX98cA==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.5.tgz#3064060f568a5718c2a06858cd6e6d24f2ff8632"
|
||||||
|
integrity sha512-QaKFVOzzST2xzY4MAmiDmURagWLFh+zZtttuEnuNn19AiZ0T3fhPyjPPGwLNdiDT82ZE91hnfJsUiDwF9DClIQ==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.5.tgz#987d30b5d2b992fff07d055015991a57ff55fbad"
|
||||||
|
integrity sha512-HeGqmRJuyVg6/X6MpE2ur7GbymBPS8Np0S/vQFHDmocfORT+Zt76qu+69NUoxXzGqVP1pzaY6QIi0FJWLC3OPA==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz#85946ee4d068bd12197aeeec2c6f679c94978a49"
|
||||||
|
integrity sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.5.tgz#fe0b20f9749a60eb1df43d20effa96c756ddcbd4"
|
||||||
|
integrity sha512-ezyFUOwldYpj7AbkwyW9AJ203peub81CaAIVvckdkyH8EvhEIoKzaMFJj0G4qYJ5sw3BpqhFrsCc30t54HV8vg==
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.5.tgz#422661ef0e16699a234465d15b2c1089ef963b2a"
|
||||||
|
integrity sha512-aHSsMnUw+0UETB0Hlv7B/ZHOGY5bQdwMKJSzGfDfvyhnpmVxLMGnQPGNE9wgqkLUs3+gbG1Qx02S2LLfJ5GaRQ==
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.5.tgz#7b73a145891c202fbcc08759248983667a035d85"
|
||||||
|
integrity sha512-AiqiLkb9KSf7Lj/o1U3SEP9Zn+5NuVKgFdRIZkvd4N0+bYrTOovVd0+LmYCPQGbocT4kvFyK+LXCDiXPBF3fyA==
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc@4.9.5":
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.5.tgz#10491ccf4f63c814d4149e0316541476ea603602"
|
||||||
|
integrity sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ==
|
||||||
|
|
||||||
|
"@types/estree@1.0.5":
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
||||||
|
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
|
||||||
|
|
||||||
|
esbuild@^0.19.3:
|
||||||
|
version "0.19.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.11.tgz#4a02dca031e768b5556606e1b468fe72e3325d96"
|
||||||
|
integrity sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==
|
||||||
|
optionalDependencies:
|
||||||
|
"@esbuild/aix-ppc64" "0.19.11"
|
||||||
|
"@esbuild/android-arm" "0.19.11"
|
||||||
|
"@esbuild/android-arm64" "0.19.11"
|
||||||
|
"@esbuild/android-x64" "0.19.11"
|
||||||
|
"@esbuild/darwin-arm64" "0.19.11"
|
||||||
|
"@esbuild/darwin-x64" "0.19.11"
|
||||||
|
"@esbuild/freebsd-arm64" "0.19.11"
|
||||||
|
"@esbuild/freebsd-x64" "0.19.11"
|
||||||
|
"@esbuild/linux-arm" "0.19.11"
|
||||||
|
"@esbuild/linux-arm64" "0.19.11"
|
||||||
|
"@esbuild/linux-ia32" "0.19.11"
|
||||||
|
"@esbuild/linux-loong64" "0.19.11"
|
||||||
|
"@esbuild/linux-mips64el" "0.19.11"
|
||||||
|
"@esbuild/linux-ppc64" "0.19.11"
|
||||||
|
"@esbuild/linux-riscv64" "0.19.11"
|
||||||
|
"@esbuild/linux-s390x" "0.19.11"
|
||||||
|
"@esbuild/linux-x64" "0.19.11"
|
||||||
|
"@esbuild/netbsd-x64" "0.19.11"
|
||||||
|
"@esbuild/openbsd-x64" "0.19.11"
|
||||||
|
"@esbuild/sunos-x64" "0.19.11"
|
||||||
|
"@esbuild/win32-arm64" "0.19.11"
|
||||||
|
"@esbuild/win32-ia32" "0.19.11"
|
||||||
|
"@esbuild/win32-x64" "0.19.11"
|
||||||
|
|
||||||
|
fsevents@~2.3.2, fsevents@~2.3.3:
|
||||||
|
version "2.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||||
|
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||||
|
|
||||||
|
"js-tokens@^3.0.0 || ^4.0.0":
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
|
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||||
|
|
||||||
|
loose-envify@^1.1.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
|
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||||
|
dependencies:
|
||||||
|
js-tokens "^3.0.0 || ^4.0.0"
|
||||||
|
|
||||||
|
nanoid@^3.3.7:
|
||||||
|
version "3.3.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||||
|
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||||
|
|
||||||
|
picocolors@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||||
|
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||||
|
|
||||||
|
postcss@^8.4.32:
|
||||||
|
version "8.4.33"
|
||||||
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742"
|
||||||
|
integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==
|
||||||
|
dependencies:
|
||||||
|
nanoid "^3.3.7"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
source-map-js "^1.0.2"
|
||||||
|
|
||||||
|
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"
|
||||||
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
rollup@^4.2.0:
|
||||||
|
version "4.9.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.5.tgz#62999462c90f4c8b5d7c38fc7161e63b29101b05"
|
||||||
|
integrity sha512-E4vQW0H/mbNMw2yLSqJyjtkHY9dslf/p0zuT1xehNRqUTBOFMqEjguDvqhXr7N7r/4ttb2jr4T41d3dncmIgbQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/estree" "1.0.5"
|
||||||
|
optionalDependencies:
|
||||||
|
"@rollup/rollup-android-arm-eabi" "4.9.5"
|
||||||
|
"@rollup/rollup-android-arm64" "4.9.5"
|
||||||
|
"@rollup/rollup-darwin-arm64" "4.9.5"
|
||||||
|
"@rollup/rollup-darwin-x64" "4.9.5"
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf" "4.9.5"
|
||||||
|
"@rollup/rollup-linux-arm64-gnu" "4.9.5"
|
||||||
|
"@rollup/rollup-linux-arm64-musl" "4.9.5"
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu" "4.9.5"
|
||||||
|
"@rollup/rollup-linux-x64-gnu" "4.9.5"
|
||||||
|
"@rollup/rollup-linux-x64-musl" "4.9.5"
|
||||||
|
"@rollup/rollup-win32-arm64-msvc" "4.9.5"
|
||||||
|
"@rollup/rollup-win32-ia32-msvc" "4.9.5"
|
||||||
|
"@rollup/rollup-win32-x64-msvc" "4.9.5"
|
||||||
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
scheduler@^0.23.0:
|
||||||
|
version "0.23.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||||
|
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.1.0"
|
||||||
|
|
||||||
|
source-map-js@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
|
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||||
|
|
||||||
|
vite@5.0.6:
|
||||||
|
version "5.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.6.tgz#f9e13503a4c5ccd67312c67803dec921f3bdea7c"
|
||||||
|
integrity sha512-MD3joyAEBtV7QZPl2JVVUai6zHms3YOmLR+BpMzLlX2Yzjfcc4gTgNi09d/Rua3F4EtC8zdwPU8eQYyib4vVMQ==
|
||||||
|
dependencies:
|
||||||
|
esbuild "^0.19.3"
|
||||||
|
postcss "^8.4.32"
|
||||||
|
rollup "^4.2.0"
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.3"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import polyfill from "../packages/excalidraw/polyfill";
|
import polyfill from "../packages/excalidraw/polyfill";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||||
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
|
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
|
||||||
@@ -14,10 +14,9 @@ import {
|
|||||||
} from "../packages/excalidraw/constants";
|
} from "../packages/excalidraw/constants";
|
||||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
|
||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
Theme,
|
OrderedExcalidrawElement,
|
||||||
} from "../packages/excalidraw/element/types";
|
} from "../packages/excalidraw/element/types";
|
||||||
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
||||||
import { t } from "../packages/excalidraw/i18n";
|
import { t } from "../packages/excalidraw/i18n";
|
||||||
@@ -30,7 +29,6 @@ import {
|
|||||||
} from "../packages/excalidraw/index";
|
} from "../packages/excalidraw/index";
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
LibraryItems,
|
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
ExcalidrawInitialDataState,
|
ExcalidrawInitialDataState,
|
||||||
@@ -48,13 +46,13 @@ import {
|
|||||||
} from "../packages/excalidraw/utils";
|
} from "../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
FIREBASE_STORAGE_PREFIXES,
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
|
isExcalidrawPlusSignedUser,
|
||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
import Collab, {
|
import Collab, {
|
||||||
CollabAPI,
|
CollabAPI,
|
||||||
collabAPIAtom,
|
collabAPIAtom,
|
||||||
collabDialogShownAtom,
|
|
||||||
isCollaboratingAtom,
|
isCollaboratingAtom,
|
||||||
isOfflineAtom,
|
isOfflineAtom,
|
||||||
} from "./collab/Collab";
|
} from "./collab/Collab";
|
||||||
@@ -65,7 +63,6 @@ import {
|
|||||||
loadScene,
|
loadScene,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
import {
|
import {
|
||||||
getLibraryItemsFromStorage,
|
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
@@ -83,10 +80,13 @@ import { updateStaleImageStatuses } from "./data/FileManager";
|
|||||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||||
import { loadFilesFromFirebase } from "./data/firebase";
|
import { loadFilesFromFirebase } from "./data/firebase";
|
||||||
import { LocalData } from "./data/LocalData";
|
import {
|
||||||
|
LibraryIndexedDBAdapter,
|
||||||
|
LibraryLocalStorageMigrationAdapter,
|
||||||
|
LocalData,
|
||||||
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { reconcileElements } from "./collab/reconciliation";
|
|
||||||
import {
|
import {
|
||||||
parseLibraryTokensFromUrl,
|
parseLibraryTokensFromUrl,
|
||||||
useHandleLibrary,
|
useHandleLibrary,
|
||||||
@@ -104,6 +104,27 @@ import { ShareableLinkDialog } from "../packages/excalidraw/components/Shareable
|
|||||||
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
||||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||||
import Trans from "../packages/excalidraw/components/Trans";
|
import Trans from "../packages/excalidraw/components/Trans";
|
||||||
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../packages/excalidraw/data/reconcile";
|
||||||
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
|
||||||
|
import {
|
||||||
|
GithubIcon,
|
||||||
|
XBrandIcon,
|
||||||
|
DiscordIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
usersIcon,
|
||||||
|
exportToPlus,
|
||||||
|
share,
|
||||||
|
youtubeIcon,
|
||||||
|
} from "../packages/excalidraw/components/icons";
|
||||||
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -252,7 +273,7 @@ const initializeScene = async (opts: {
|
|||||||
},
|
},
|
||||||
elements: reconcileElements(
|
elements: reconcileElements(
|
||||||
scene?.elements || [],
|
scene?.elements || [],
|
||||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -283,6 +304,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -305,15 +329,18 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [excalidrawAPI, excalidrawRefCallback] =
|
const [excalidrawAPI, excalidrawRefCallback] =
|
||||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||||
|
|
||||||
|
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
const [collabAPI] = useAtom(collabAPIAtom);
|
const [collabAPI] = useAtom(collabAPIAtom);
|
||||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
|
||||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
return isCollaborationLink(window.location.href);
|
return isCollaborationLink(window.location.href);
|
||||||
});
|
});
|
||||||
|
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||||
|
|
||||||
useHandleLibrary({
|
useHandleLibrary({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
adapter: LibraryIndexedDBAdapter,
|
||||||
|
// TODO maybe remove this in several months (shipped: 24-03-11)
|
||||||
|
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -411,7 +438,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...data.scene,
|
||||||
...restore(data.scene, null, null, { repairBindings: true }),
|
...restore(data.scene, null, null, { repairBindings: true }),
|
||||||
commitToHistory: true,
|
commitToStore: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -443,8 +470,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
});
|
});
|
||||||
excalidrawAPI.updateLibrary({
|
LibraryIndexedDBAdapter.load().then((data) => {
|
||||||
libraryItems: getLibraryItemsFromStorage(),
|
if (data) {
|
||||||
|
excalidrawAPI.updateLibrary({
|
||||||
|
libraryItems: data.libraryItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
collabAPI?.setUsername(username || "");
|
collabAPI?.setUsername(username || "");
|
||||||
}
|
}
|
||||||
@@ -539,25 +570,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
languageDetector.cacheUserLanguage(langCode);
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
}, [langCode]);
|
}, [langCode]);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>(
|
|
||||||
() =>
|
|
||||||
(localStorage.getItem(
|
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
|
||||||
) as Theme | null) ||
|
|
||||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
|
||||||
importFromLocalStorage().appState?.theme ||
|
|
||||||
THEME.LIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
|
||||||
// currently only used for body styling during init (see public/index.html),
|
|
||||||
// but may change in the future
|
|
||||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
@@ -565,8 +579,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
collabAPI.syncElements(elements);
|
collabAPI.syncElements(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(appState.theme);
|
|
||||||
|
|
||||||
// this check is redundant, but since this is a hot path, it's best
|
// this check is redundant, but since this is a hot path, it's best
|
||||||
// not to evaludate the nested expression every time
|
// not to evaludate the nested expression every time
|
||||||
if (!LocalData.isSavePaused()) {
|
if (!LocalData.isSavePaused()) {
|
||||||
@@ -607,37 +619,38 @@ const ExcalidrawWrapper = () => {
|
|||||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
) => {
|
) => {
|
||||||
if (exportedElements.length === 0) {
|
if (exportedElements.length === 0) {
|
||||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||||
}
|
}
|
||||||
if (canvas) {
|
try {
|
||||||
try {
|
const { url, errorMessage } = await exportToBackend(
|
||||||
const { url, errorMessage } = await exportToBackend(
|
exportedElements,
|
||||||
exportedElements,
|
{
|
||||||
{
|
...appState,
|
||||||
...appState,
|
viewBackgroundColor: appState.exportBackground
|
||||||
viewBackgroundColor: appState.exportBackground
|
? appState.viewBackgroundColor
|
||||||
? appState.viewBackgroundColor
|
: getDefaultAppState().viewBackgroundColor,
|
||||||
: getDefaultAppState().viewBackgroundColor,
|
},
|
||||||
},
|
files,
|
||||||
files,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
setLatestShareableLink(url);
|
setLatestShareableLink(url);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name !== "AbortError") {
|
if (error.name !== "AbortError") {
|
||||||
const { width, height } = canvas;
|
const { width, height } = appState;
|
||||||
console.error(error, { width, height });
|
console.error(error, {
|
||||||
throw new Error(error.message);
|
width,
|
||||||
}
|
height,
|
||||||
|
devicePixelRatio: window.devicePixelRatio,
|
||||||
|
});
|
||||||
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -655,17 +668,13 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryChange = async (items: LibraryItems) => {
|
|
||||||
if (!items.length) {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const serializedItems = JSON.stringify(items);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
|
const onCollabDialogOpen = useCallback(
|
||||||
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||||
|
[setShareDialogState],
|
||||||
|
);
|
||||||
|
|
||||||
// browsers generally prevent infinite self-embedding, there are
|
// browsers generally prevent infinite self-embedding, there are
|
||||||
// cases where it still happens, and while we disallow self-embedding
|
// cases where it still happens, and while we disallow self-embedding
|
||||||
// by not whitelisting our own origin, this serves as an additional guard
|
// by not whitelisting our own origin, this serves as an additional guard
|
||||||
@@ -685,6 +694,45 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExcalidrawPlusCommand = {
|
||||||
|
label: "Excalidraw+",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: ["plus", "cloud", "server"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ExcalidrawPlusAppCommand = {
|
||||||
|
label: "Sign up",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: [
|
||||||
|
"excalidraw",
|
||||||
|
"plus",
|
||||||
|
"cloud",
|
||||||
|
"server",
|
||||||
|
"signin",
|
||||||
|
"login",
|
||||||
|
"signup",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
@@ -703,27 +751,30 @@ const ExcalidrawWrapper = () => {
|
|||||||
toggleTheme: true,
|
toggleTheme: true,
|
||||||
export: {
|
export: {
|
||||||
onExportToBackend,
|
onExportToBackend,
|
||||||
renderCustomUI: (elements, appState, files) => {
|
renderCustomUI: excalidrawAPI
|
||||||
return (
|
? (elements, appState, files) => {
|
||||||
<ExportToExcalidrawPlus
|
return (
|
||||||
elements={elements}
|
<ExportToExcalidrawPlus
|
||||||
appState={appState}
|
elements={elements}
|
||||||
files={files}
|
appState={appState}
|
||||||
onError={(error) => {
|
files={files}
|
||||||
excalidrawAPI?.updateScene({
|
name={excalidrawAPI.getName()}
|
||||||
appState: {
|
onError={(error) => {
|
||||||
errorMessage: error.message,
|
excalidrawAPI?.updateScene({
|
||||||
},
|
appState: {
|
||||||
});
|
errorMessage: error.message,
|
||||||
}}
|
},
|
||||||
onSuccess={() => {
|
});
|
||||||
excalidrawAPI?.updateScene({
|
}}
|
||||||
appState: { openDialog: null },
|
onSuccess={() => {
|
||||||
});
|
excalidrawAPI.updateScene({
|
||||||
}}
|
appState: { openDialog: null },
|
||||||
/>
|
});
|
||||||
);
|
}}
|
||||||
},
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -731,28 +782,34 @@ const ExcalidrawWrapper = () => {
|
|||||||
renderCustomStats={renderCustomStats}
|
renderCustomStats={renderCustomStats}
|
||||||
detectScroll={false}
|
detectScroll={false}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
onLibraryChange={onLibraryChange}
|
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
theme={theme}
|
theme={editorTheme}
|
||||||
renderTopRightUI={(isMobile) => {
|
renderTopRightUI={(isMobile) => {
|
||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<LiveCollaborationTrigger
|
<div className="top-right-ui">
|
||||||
isCollaborating={isCollaborating}
|
{collabError.message && <CollabError collabError={collabError} />}
|
||||||
onSelect={() => setCollabDialogShown(true)}
|
<LiveCollaborationTrigger
|
||||||
/>
|
isCollaborating={isCollaborating}
|
||||||
|
onSelect={() =>
|
||||||
|
setShareDialogState({ isOpen: true, type: "share" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppMainMenu
|
<AppMainMenu
|
||||||
setCollabDialogShown={setCollabDialogShown}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
|
theme={appTheme}
|
||||||
|
setTheme={(theme) => setAppTheme(theme)}
|
||||||
/>
|
/>
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
setCollabDialogShown={setCollabDialogShown}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
/>
|
/>
|
||||||
<OverwriteConfirmDialog>
|
<OverwriteConfirmDialog>
|
||||||
@@ -767,6 +824,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.getSceneElements(),
|
excalidrawAPI.getSceneElements(),
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
excalidrawAPI.getFiles(),
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -848,11 +906,204 @@ const ExcalidrawWrapper = () => {
|
|||||||
{excalidrawAPI && !isCollabDisabled && (
|
{excalidrawAPI && !isCollabDisabled && (
|
||||||
<Collab excalidrawAPI={excalidrawAPI} />
|
<Collab excalidrawAPI={excalidrawAPI} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ShareDialog
|
||||||
|
collabAPI={collabAPI}
|
||||||
|
onExportToBackend={async () => {
|
||||||
|
if (excalidrawAPI) {
|
||||||
|
try {
|
||||||
|
await onExportToBackend(
|
||||||
|
excalidrawAPI.getSceneElements(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
excalidrawAPI.getFiles(),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrorMessage(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
customCommandPaletteItems={[
|
||||||
|
{
|
||||||
|
label: t("labels.liveCollaboration"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
keywords: [
|
||||||
|
"team",
|
||||||
|
"multiplayer",
|
||||||
|
"share",
|
||||||
|
"public",
|
||||||
|
"session",
|
||||||
|
"invite",
|
||||||
|
],
|
||||||
|
icon: usersIcon,
|
||||||
|
perform: () => {
|
||||||
|
setShareDialogState({
|
||||||
|
isOpen: true,
|
||||||
|
type: "collaborationOnly",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("roomDialog.button_stopSession"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: () => !!collabAPI?.isCollaborating(),
|
||||||
|
keywords: [
|
||||||
|
"stop",
|
||||||
|
"session",
|
||||||
|
"end",
|
||||||
|
"leave",
|
||||||
|
"close",
|
||||||
|
"exit",
|
||||||
|
"collaboration",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
if (collabAPI) {
|
||||||
|
collabAPI.stopCollaboration();
|
||||||
|
if (!collabAPI.isCollaborating()) {
|
||||||
|
setShareDialogState({ isOpen: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.share"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: true,
|
||||||
|
icon: share,
|
||||||
|
keywords: [
|
||||||
|
"link",
|
||||||
|
"shareable",
|
||||||
|
"readonly",
|
||||||
|
"export",
|
||||||
|
"publish",
|
||||||
|
"snapshot",
|
||||||
|
"url",
|
||||||
|
"collaborate",
|
||||||
|
"invite",
|
||||||
|
],
|
||||||
|
perform: async () => {
|
||||||
|
setShareDialogState({ isOpen: true, type: "share" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GitHub",
|
||||||
|
icon: GithubIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: [
|
||||||
|
"issues",
|
||||||
|
"bugs",
|
||||||
|
"requests",
|
||||||
|
"report",
|
||||||
|
"features",
|
||||||
|
"social",
|
||||||
|
"community",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://github.com/excalidraw/excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.followUs"),
|
||||||
|
icon: XBrandIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["twitter", "contact", "social", "community"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://x.com/excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.discordChat"),
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: DiscordIcon,
|
||||||
|
keywords: [
|
||||||
|
"chat",
|
||||||
|
"talk",
|
||||||
|
"contact",
|
||||||
|
"bugs",
|
||||||
|
"requests",
|
||||||
|
"report",
|
||||||
|
"feedback",
|
||||||
|
"suggestions",
|
||||||
|
"social",
|
||||||
|
"community",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://discord.gg/UexuTaE",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "YouTube",
|
||||||
|
icon: youtubeIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["features", "tutorials", "howto", "help", "community"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://youtube.com/@excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(isExcalidrawPlusSignedUser
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...ExcalidrawPlusAppCommand,
|
||||||
|
label: "Sign in / Go to Excalidraw+",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
|
||||||
|
|
||||||
|
{
|
||||||
|
label: t("overwriteConfirm.action.excalidrawPlus.button"),
|
||||||
|
category: DEFAULT_CATEGORIES.export,
|
||||||
|
icon: exportToPlus,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["plus", "export", "save", "backup"],
|
||||||
|
perform: () => {
|
||||||
|
if (excalidrawAPI) {
|
||||||
|
exportToExcalidrawPlus(
|
||||||
|
excalidrawAPI.getSceneElements(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...CommandPalette.defaultItems.toggleTheme,
|
||||||
|
perform: () => {
|
||||||
|
setAppTheme(
|
||||||
|
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
|
|||||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
|
||||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||||
VERSION_DATA_STATE: "version-dataState",
|
VERSION_DATA_STATE: "version-dataState",
|
||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
|
IDB_LIBRARY: "excalidraw-library",
|
||||||
|
|
||||||
|
// do not use apart from migrations
|
||||||
|
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const COOKIES = {
|
export const COOKIES = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import {
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
@@ -36,7 +37,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
generateCollaborationLinkData,
|
generateCollaborationLinkData,
|
||||||
getCollaborationLink,
|
getCollaborationLink,
|
||||||
getCollabServer,
|
|
||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
SyncableExcalidrawElement,
|
SyncableExcalidrawElement,
|
||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
saveUsernameToLocalStorage,
|
saveUsernameToLocalStorage,
|
||||||
} from "../data/localStorage";
|
} from "../data/localStorage";
|
||||||
import Portal from "./Portal";
|
import Portal from "./Portal";
|
||||||
import RoomDialog from "./RoomDialog";
|
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
import { UserIdleState } from "../../packages/excalidraw/types";
|
import { UserIdleState } from "../../packages/excalidraw/types";
|
||||||
import {
|
import {
|
||||||
@@ -71,30 +70,35 @@ import {
|
|||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
} from "../../packages/excalidraw/element/typeChecks";
|
} from "../../packages/excalidraw/element/typeChecks";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import {
|
|
||||||
ReconciledElements,
|
|
||||||
reconcileElements as _reconcileElements,
|
|
||||||
} from "./reconciliation";
|
|
||||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { appJotaiStore } from "../app-jotai";
|
import { appJotaiStore } from "../app-jotai";
|
||||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
|
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||||
|
import {
|
||||||
|
ReconciledExcalidrawElement,
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const collabDialogShownAtom = atom(false);
|
|
||||||
export const isCollaboratingAtom = atom(false);
|
export const isCollaboratingAtom = atom(false);
|
||||||
export const isOfflineAtom = atom(false);
|
export const isOfflineAtom = atom(false);
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
errorMessage: string;
|
errorMessage: string | null;
|
||||||
|
/** errors related to saving */
|
||||||
|
dialogNotifiedErrors: Record<string, boolean>;
|
||||||
username: string;
|
username: string;
|
||||||
activeRoomLink: string;
|
activeRoomLink: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||||
|
|
||||||
type CollabInstance = InstanceType<typeof Collab>;
|
type CollabInstance = InstanceType<typeof Collab>;
|
||||||
|
|
||||||
export interface CollabAPI {
|
export interface CollabAPI {
|
||||||
@@ -105,19 +109,20 @@ export interface CollabAPI {
|
|||||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
setUsername: (username: string) => void;
|
setUsername: CollabInstance["setUsername"];
|
||||||
|
getUsername: CollabInstance["getUsername"];
|
||||||
|
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||||
|
setCollabError: CollabInstance["setErrorDialog"];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicProps {
|
interface CollabProps {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = PublicProps & { modalIsShown: boolean };
|
class Collab extends PureComponent<CollabProps, CollabState> {
|
||||||
|
|
||||||
class Collab extends PureComponent<Props, CollabState> {
|
|
||||||
portal: Portal;
|
portal: Portal;
|
||||||
fileManager: FileManager;
|
fileManager: FileManager;
|
||||||
excalidrawAPI: Props["excalidrawAPI"];
|
excalidrawAPI: CollabProps["excalidrawAPI"];
|
||||||
activeIntervalId: number | null;
|
activeIntervalId: number | null;
|
||||||
idleTimeoutId: number | null;
|
idleTimeoutId: number | null;
|
||||||
|
|
||||||
@@ -125,12 +130,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
private collaborators = new Map<SocketId, Collaborator>();
|
private collaborators = new Map<SocketId, Collaborator>();
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: CollabProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
errorMessage: "",
|
errorMessage: null,
|
||||||
|
dialogNotifiedErrors: {},
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
activeRoomLink: "",
|
activeRoomLink: null,
|
||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
this.portal = new Portal(this);
|
||||||
this.fileManager = new FileManager({
|
this.fileManager = new FileManager({
|
||||||
@@ -195,6 +201,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||||
stopCollaboration: this.stopCollaboration,
|
stopCollaboration: this.stopCollaboration,
|
||||||
setUsername: this.setUsername,
|
setUsername: this.setUsername,
|
||||||
|
getUsername: this.getUsername,
|
||||||
|
getActiveRoomLink: this.getActiveRoomLink,
|
||||||
|
setCollabError: this.setErrorDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||||
@@ -267,24 +276,39 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
syncableElements: readonly SyncableExcalidrawElement[],
|
syncableElements: readonly SyncableExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const savedData = await saveToFirebase(
|
const storedElements = await saveToFirebase(
|
||||||
this.portal,
|
this.portal,
|
||||||
syncableElements,
|
syncableElements,
|
||||||
this.excalidrawAPI.getAppState(),
|
this.excalidrawAPI.getAppState(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
this.resetErrorIndicator();
|
||||||
this.handleRemoteSceneUpdate(
|
|
||||||
this.reconcileElements(savedData.reconciledElements),
|
if (this.isCollaborating() && storedElements) {
|
||||||
);
|
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.setState({
|
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||||
// firestore doesn't return a specific error code when size exceeded
|
? t("errors.collabSaveFailed_sizeExceeded")
|
||||||
errorMessage: /is longer than.*?bytes/.test(error.message)
|
: t("errors.collabSaveFailed");
|
||||||
? t("errors.collabSaveFailed_sizeExceeded")
|
|
||||||
: t("errors.collabSaveFailed"),
|
if (
|
||||||
});
|
!this.state.dialogNotifiedErrors[errorMessage] ||
|
||||||
|
!this.isCollaborating()
|
||||||
|
) {
|
||||||
|
this.setErrorDialog(errorMessage);
|
||||||
|
this.setState({
|
||||||
|
dialogNotifiedErrors: {
|
||||||
|
...this.state.dialogNotifiedErrors,
|
||||||
|
[errorMessage]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCollaborating()) {
|
||||||
|
this.setErrorIndicator(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -293,6 +317,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
|
this.resetErrorIndicator(true);
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
@@ -331,7 +356,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -342,9 +366,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
this.fileManager.reset();
|
this.fileManager.reset();
|
||||||
if (!opts?.isUnload) {
|
if (!opts?.isUnload) {
|
||||||
this.setIsCollaborating(false);
|
this.setIsCollaborating(false);
|
||||||
this.setState({
|
this.setActiveRoomLink(null);
|
||||||
activeRoomLink: "",
|
|
||||||
});
|
|
||||||
this.collaborators = new Map();
|
this.collaborators = new Map();
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
collaborators: this.collaborators,
|
collaborators: this.collaborators,
|
||||||
@@ -406,11 +428,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
startCollaboration = async (
|
startCollaboration = async (
|
||||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
): Promise<ImportedDataState | null> => {
|
) => {
|
||||||
if (!this.state.username) {
|
if (!this.state.username) {
|
||||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||||
const username = getRandomUsername();
|
const username = getRandomUsername();
|
||||||
this.onUsernameChange(username);
|
this.setUsername(username);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +454,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
// TODO: `ImportedDataState` type here seems abused
|
||||||
|
const scenePromise = resolvablePromise<
|
||||||
|
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
|
||||||
|
| null
|
||||||
|
>();
|
||||||
|
|
||||||
this.setIsCollaborating(true);
|
this.setIsCollaborating(true);
|
||||||
LocalData.pauseSave("collaboration");
|
LocalData.pauseSave("collaboration");
|
||||||
@@ -452,13 +478,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
this.fallbackInitializationHandler = fallbackInitializationHandler;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const socketServerData = await getCollabServer();
|
|
||||||
|
|
||||||
this.portal.socket = this.portal.open(
|
this.portal.socket = this.portal.open(
|
||||||
socketIOClient(socketServerData.url, {
|
socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
|
||||||
transports: socketServerData.polling
|
transports: ["websocket", "polling"],
|
||||||
? ["websocket", "polling"]
|
|
||||||
: ["websocket"],
|
|
||||||
}),
|
}),
|
||||||
roomId,
|
roomId,
|
||||||
roomKey,
|
roomKey,
|
||||||
@@ -467,7 +489,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setErrorDialog(error.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,14 +500,12 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
// remove deleted elements from elements array & history to ensure we don't
|
// remove deleted elements from elements array to ensure we don't
|
||||||
// expose potentially sensitive user data in case user manually deletes
|
// expose potentially sensitive user data in case user manually deletes
|
||||||
// existing elements (or clears scene), which would otherwise be persisted
|
// existing elements (or clears scene), which would otherwise be persisted
|
||||||
// to database even if deleted before creating the room.
|
// to database even if deleted before creating the room.
|
||||||
this.excalidrawAPI.history.clear();
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
@@ -519,10 +539,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
this.initializeRoom({ fetchScene: false });
|
this.initializeRoom({ fetchScene: false });
|
||||||
const remoteElements = decryptedData.payload.elements;
|
const remoteElements = decryptedData.payload.elements;
|
||||||
const reconciledElements = this.reconcileElements(remoteElements);
|
const reconciledElements =
|
||||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
this._reconcileElements(remoteElements);
|
||||||
init: true,
|
this.handleRemoteSceneUpdate(reconciledElements);
|
||||||
});
|
|
||||||
// noop if already resolved via init from firebase
|
// noop if already resolved via init from firebase
|
||||||
scenePromise.resolve({
|
scenePromise.resolve({
|
||||||
elements: reconciledElements,
|
elements: reconciledElements,
|
||||||
@@ -533,7 +552,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
case WS_SUBTYPES.UPDATE:
|
case WS_SUBTYPES.UPDATE:
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(
|
||||||
this.reconcileElements(decryptedData.payload.elements),
|
this._reconcileElements(decryptedData.payload.elements),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||||
@@ -629,9 +648,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
this.initializeIdleDetector();
|
this.initializeIdleDetector();
|
||||||
|
|
||||||
this.setState({
|
this.setActiveRoomLink(window.location.href);
|
||||||
activeRoomLink: window.location.href,
|
|
||||||
});
|
|
||||||
|
|
||||||
return scenePromise;
|
return scenePromise;
|
||||||
};
|
};
|
||||||
@@ -683,17 +700,15 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
private reconcileElements = (
|
private _reconcileElements = (
|
||||||
remoteElements: readonly ExcalidrawElement[],
|
remoteElements: readonly ExcalidrawElement[],
|
||||||
): ReconciledElements => {
|
): ReconciledExcalidrawElement[] => {
|
||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||||
remoteElements = restoreElements(remoteElements, null);
|
const reconciledElements = reconcileElements(
|
||||||
|
|
||||||
const reconciledElements = _reconcileElements(
|
|
||||||
localElements,
|
localElements,
|
||||||
remoteElements,
|
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -724,20 +739,12 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}, LOAD_IMAGES_TIMEOUT);
|
}, LOAD_IMAGES_TIMEOUT);
|
||||||
|
|
||||||
private handleRemoteSceneUpdate = (
|
private handleRemoteSceneUpdate = (
|
||||||
elements: ReconciledElements,
|
elements: ReconciledExcalidrawElement[],
|
||||||
{ init = false }: { init?: boolean } = {},
|
|
||||||
) => {
|
) => {
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: !!init,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
|
||||||
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
|
||||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
|
||||||
// right now we think this is the right tradeoff.
|
|
||||||
this.excalidrawAPI.history.clear();
|
|
||||||
|
|
||||||
this.loadImageFiles();
|
this.loadImageFiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -870,7 +877,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
if (
|
if (
|
||||||
getSceneVersion(elements) >
|
getSceneVersion(elements) >
|
||||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||||
@@ -881,7 +888,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
this.broadcastElements(elements);
|
this.broadcastElements(elements);
|
||||||
this.queueSaveToFirebase();
|
this.queueSaveToFirebase();
|
||||||
};
|
};
|
||||||
@@ -914,41 +921,49 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
{ leading: false },
|
{ leading: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
handleClose = () => {
|
|
||||||
appJotaiStore.set(collabDialogShownAtom, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
setUsername = (username: string) => {
|
setUsername = (username: string) => {
|
||||||
this.setState({ username });
|
this.setState({ username });
|
||||||
};
|
|
||||||
|
|
||||||
onUsernameChange = (username: string) => {
|
|
||||||
this.setUsername(username);
|
|
||||||
saveUsernameToLocalStorage(username);
|
saveUsernameToLocalStorage(username);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
getUsername = () => this.state.username;
|
||||||
const { username, errorMessage, activeRoomLink } = this.state;
|
|
||||||
|
|
||||||
const { modalIsShown } = this.props;
|
setActiveRoomLink = (activeRoomLink: string | null) => {
|
||||||
|
this.setState({ activeRoomLink });
|
||||||
|
appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||||
|
|
||||||
|
setErrorIndicator = (errorMessage: string | null) => {
|
||||||
|
appJotaiStore.set(collabErrorIndicatorAtom, {
|
||||||
|
message: errorMessage,
|
||||||
|
nonce: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
|
||||||
|
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
|
||||||
|
if (resetDialogNotifiedErrors) {
|
||||||
|
this.setState({
|
||||||
|
dialogNotifiedErrors: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setErrorDialog = (errorMessage: string | null) => {
|
||||||
|
this.setState({
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { errorMessage } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{modalIsShown && (
|
{errorMessage != null && (
|
||||||
<RoomDialog
|
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||||
handleClose={this.handleClose}
|
|
||||||
activeRoomLink={activeRoomLink}
|
|
||||||
username={username}
|
|
||||||
onUsernameChange={this.onUsernameChange}
|
|
||||||
onRoomCreate={() => this.startCollaboration(null)}
|
|
||||||
onRoomDestroy={this.stopCollaboration}
|
|
||||||
setErrorMessage={(errorMessage) => {
|
|
||||||
this.setState({ errorMessage });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{errorMessage && (
|
|
||||||
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
|
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
@@ -967,11 +982,6 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
|||||||
window.collab = window.collab || ({} as Window["collab"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _Collab: React.FC<PublicProps> = (props) => {
|
export default Collab;
|
||||||
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
|
||||||
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default _Collab;
|
|
||||||
|
|
||||||
export type TCollabClass = Collab;
|
export type TCollabClass = Collab;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.collab-errors-button {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
margin-inline-end: 1rem;
|
||||||
|
|
||||||
|
color: var(--color-danger);
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-errors-button-shake {
|
||||||
|
animation: strong-shake 0.15s 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes strong-shake {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||||
|
import { warning } from "../../packages/excalidraw/components/icons";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import "./CollabError.scss";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
type ErrorIndicator = {
|
||||||
|
message: string | null;
|
||||||
|
/** used to rerun the useEffect responsible for animation */
|
||||||
|
nonce: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
|
||||||
|
message: null,
|
||||||
|
nonce: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
clearAnimationRef.current = setTimeout(() => {
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(clearAnimationRef.current);
|
||||||
|
};
|
||||||
|
}, [collabError.message, collabError.nonce]);
|
||||||
|
|
||||||
|
if (!collabError.message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={collabError.message} long={true}>
|
||||||
|
<div
|
||||||
|
className={clsx("collab-errors-button", {
|
||||||
|
"collab-errors-button-shake": isAnimating,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{warning}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CollabError.displayName = "CollabError";
|
||||||
|
|
||||||
|
export default CollabError;
|
||||||
@@ -2,11 +2,12 @@ import {
|
|||||||
isSyncableElement,
|
isSyncableElement,
|
||||||
SocketUpdateData,
|
SocketUpdateData,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
|
||||||
import { TCollabClass } from "./Collab";
|
import { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||||
import {
|
import {
|
||||||
OnUserFollowedPayload,
|
OnUserFollowedPayload,
|
||||||
@@ -16,9 +17,7 @@ import {
|
|||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|
||||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
@@ -133,7 +132,7 @@ class Portal {
|
|||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||||
allElements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
syncAll: boolean,
|
syncAll: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||||
@@ -143,25 +142,17 @@ class Portal {
|
|||||||
// sync out only the elements we think we need to to save bandwidth.
|
// sync out only the elements we think we need to to save bandwidth.
|
||||||
// periodically we'll resync the whole thing to make sure no one diverges
|
// periodically we'll resync the whole thing to make sure no one diverges
|
||||||
// due to a dropped message (server goes down etc).
|
// due to a dropped message (server goes down etc).
|
||||||
const syncableElements = allElements.reduce(
|
const syncableElements = elements.reduce((acc, element) => {
|
||||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
if (
|
||||||
if (
|
(syncAll ||
|
||||||
(syncAll ||
|
!this.broadcastedElementVersions.has(element.id) ||
|
||||||
!this.broadcastedElementVersions.has(element.id) ||
|
element.version > this.broadcastedElementVersions.get(element.id)!) &&
|
||||||
element.version >
|
isSyncableElement(element)
|
||||||
this.broadcastedElementVersions.get(element.id)!) &&
|
) {
|
||||||
isSyncableElement(element)
|
acc.push(element);
|
||||||
) {
|
}
|
||||||
acc.push({
|
return acc;
|
||||||
...element,
|
}, [] as SyncableExcalidrawElement[]);
|
||||||
// z-index info for the reconciler
|
|
||||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[] as BroadcastedExcalidrawElement[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||||
type: updateType,
|
type: updateType,
|
||||||
|
|||||||
@@ -65,19 +65,18 @@ export const RoomModal = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
} catch (e) {
|
||||||
setJustCopied(true);
|
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(error.message);
|
|
||||||
}
|
}
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
@@ -120,7 +119,7 @@ export const RoomModal = ({
|
|||||||
size="large"
|
size="large"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
label="Share"
|
label="Share"
|
||||||
startIcon={getShareIcon()}
|
icon={getShareIcon()}
|
||||||
className="RoomDialog__active__share"
|
className="RoomDialog__active__share"
|
||||||
onClick={shareRoomLink}
|
onClick={shareRoomLink}
|
||||||
/>
|
/>
|
||||||
@@ -130,7 +129,7 @@ export const RoomModal = ({
|
|||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label="Copy link"
|
label="Copy link"
|
||||||
startIcon={copyIcon}
|
icon={copyIcon}
|
||||||
onClick={copyRoomLink}
|
onClick={copyRoomLink}
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
@@ -166,7 +165,7 @@ export const RoomModal = ({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="danger"
|
color="danger"
|
||||||
label={t("roomDialog.button_stopSession")}
|
label={t("roomDialog.button_stopSession")}
|
||||||
startIcon={playerStopFilledIcon}
|
icon={playerStopFilledIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent("share", "room closed");
|
trackEvent("share", "room closed");
|
||||||
onRoomDestroy();
|
onRoomDestroy();
|
||||||
@@ -195,7 +194,7 @@ export const RoomModal = ({
|
|||||||
<FilledButton
|
<FilledButton
|
||||||
size="large"
|
size="large"
|
||||||
label={t("roomDialog.button_startSession")}
|
label={t("roomDialog.button_startSession")}
|
||||||
startIcon={playerPlayIcon}
|
icon={playerPlayIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
onRoomCreate();
|
onRoomCreate();
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
|
||||||
_brand: "reconciledElements";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldDiscardRemoteElement = (
|
|
||||||
localAppState: AppState,
|
|
||||||
local: ExcalidrawElement | undefined,
|
|
||||||
remote: BroadcastedExcalidrawElement,
|
|
||||||
): boolean => {
|
|
||||||
if (
|
|
||||||
local &&
|
|
||||||
// local element is being edited
|
|
||||||
(local.id === localAppState.editingElement?.id ||
|
|
||||||
local.id === localAppState.resizingElement?.id ||
|
|
||||||
local.id === localAppState.draggingElement?.id ||
|
|
||||||
// local element is newer
|
|
||||||
local.version > remote.version ||
|
|
||||||
// resolve conflicting edits deterministically by taking the one with
|
|
||||||
// the lowest versionNonce
|
|
||||||
(local.version === remote.version &&
|
|
||||||
local.versionNonce < remote.versionNonce))
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reconcileElements = (
|
|
||||||
localElements: readonly ExcalidrawElement[],
|
|
||||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
|
||||||
localAppState: AppState,
|
|
||||||
): ReconciledElements => {
|
|
||||||
const localElementsData =
|
|
||||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
|
||||||
|
|
||||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
|
||||||
|
|
||||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
let remoteElementIdx = -1;
|
|
||||||
for (const remoteElement of remoteElements) {
|
|
||||||
remoteElementIdx++;
|
|
||||||
|
|
||||||
const local = localElementsData.get(remoteElement.id);
|
|
||||||
|
|
||||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
|
||||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
|
||||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
|
||||||
if (local) {
|
|
||||||
// Unless the remote and local elements are the same element in which case
|
|
||||||
// we need to keep it as we'd otherwise discard it from the resulting
|
|
||||||
// array.
|
|
||||||
if (local[0] === remoteElement) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
duplicates.set(local[0], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// parent may not be defined in case the remote client is running an older
|
|
||||||
// excalidraw version
|
|
||||||
const parent =
|
|
||||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
|
||||||
remoteElements[remoteElementIdx - 1]?.id ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (parent != null) {
|
|
||||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
|
||||||
|
|
||||||
// ^ indicates the element is the first in elements array
|
|
||||||
if (parent === "^") {
|
|
||||||
offset++;
|
|
||||||
if (cursor === 0) {
|
|
||||||
reconciledElements.unshift(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor - offset,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let idx = localElementsData.has(parent)
|
|
||||||
? localElementsData.get(parent)![1]
|
|
||||||
: null;
|
|
||||||
if (idx != null) {
|
|
||||||
idx += offset;
|
|
||||||
}
|
|
||||||
if (idx != null && idx >= cursor) {
|
|
||||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
|
||||||
offset++;
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
idx + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor = idx + 1;
|
|
||||||
} else if (idx != null) {
|
|
||||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
|
||||||
offset++;
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor++;
|
|
||||||
} else {
|
|
||||||
reconciledElements.push(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
reconciledElements.length - 1 - offset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// no parent z-index information, local element exists → replace in place
|
|
||||||
} else if (local) {
|
|
||||||
reconciledElements[local[1]] = remoteElement;
|
|
||||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
|
||||||
// otherwise push to the end
|
|
||||||
} else {
|
|
||||||
reconciledElements.push(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
reconciledElements.length - 1 - offset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
|
||||||
(element) => !duplicates.has(element),
|
|
||||||
);
|
|
||||||
|
|
||||||
return ret as ReconciledElements;
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
import {
|
||||||
|
arrowBarToLeftIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
|
import { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { LanguageList } from "./LanguageList";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
setCollabDialogShown: (toggle: boolean) => any;
|
onCollabDialogOpen: () => any;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
|
theme: Theme | "system";
|
||||||
|
setTheme: (theme: Theme | "system") => void;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
@@ -17,25 +24,38 @@ export const AppMainMenu: React.FC<{
|
|||||||
{props.isCollabEnabled && (
|
{props.isCollabEnabled && (
|
||||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||||
isCollaborating={props.isCollaborating}
|
isCollaborating={props.isCollaborating}
|
||||||
onSelect={() => props.setCollabDialogShown(true)}
|
onSelect={() => props.onCollabDialogOpen()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={PlusPromoIcon}
|
icon={ExcalLogo}
|
||||||
href={`${
|
href={`${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
className="ExcalidrawPlus"
|
className=""
|
||||||
>
|
>
|
||||||
Excalidraw+
|
Excalidraw+
|
||||||
</MainMenu.ItemLink>
|
</MainMenu.ItemLink>
|
||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
|
<MainMenu.ItemLink
|
||||||
|
icon={arrowBarToLeftIcon}
|
||||||
|
href={`${import.meta.env.VITE_APP_PLUS_APP}${
|
||||||
|
isExcalidrawPlusSignedUser ? "" : "/sign-up"
|
||||||
|
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||||
|
className="highlighted"
|
||||||
|
>
|
||||||
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
|
</MainMenu.ItemLink>
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme />
|
<MainMenu.DefaultItems.ToggleTheme
|
||||||
|
allowSystemTheme
|
||||||
|
theme={props.theme}
|
||||||
|
onSelect={props.setTheme}
|
||||||
|
/>
|
||||||
<MainMenu.ItemCustom>
|
<MainMenu.ItemCustom>
|
||||||
<LanguageList style={{ width: "100%" }} />
|
<LanguageList style={{ width: "100%" }} />
|
||||||
</MainMenu.ItemCustom>
|
</MainMenu.ItemCustom>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
import { arrowBarToLeftIcon } from "../../packages/excalidraw/components/icons";
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||||
|
|
||||||
export const AppWelcomeScreen: React.FC<{
|
export const AppWelcomeScreen: React.FC<{
|
||||||
setCollabDialogShown: (toggle: boolean) => any;
|
onCollabDialogOpen: () => any;
|
||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -52,7 +52,7 @@ export const AppWelcomeScreen: React.FC<{
|
|||||||
<WelcomeScreen.Center.MenuItemHelp />
|
<WelcomeScreen.Center.MenuItemHelp />
|
||||||
{props.isCollabEnabled && (
|
{props.isCollabEnabled && (
|
||||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||||
onSelect={() => props.setCollabDialogShown(true)}
|
onSelect={() => props.onCollabDialogOpen()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isExcalidrawPlusSignedUser && (
|
{!isExcalidrawPlusSignedUser && (
|
||||||
@@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
|
|||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
||||||
shortcut={null}
|
shortcut={null}
|
||||||
icon={PlusPromoIcon}
|
icon={arrowBarToLeftIcon}
|
||||||
>
|
>
|
||||||
Try Excalidraw Plus!
|
Sign up
|
||||||
</WelcomeScreen.Center.MenuItemLink>
|
</WelcomeScreen.Center.MenuItemLink>
|
||||||
)}
|
)}
|
||||||
</WelcomeScreen.Center.Menu>
|
</WelcomeScreen.Center.Menu>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
.ref(`/migrations/scenes/${id}`)
|
.ref(`/migrations/scenes/${id}`)
|
||||||
.put(blob, {
|
.put(blob, {
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
data: JSON.stringify({ version: 2, name: appState.name }),
|
data: JSON.stringify({ version: 2, name }),
|
||||||
created: Date.now().toString(),
|
created: Date.now().toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: Partial<AppState>;
|
appState: Partial<AppState>;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
|
name: string;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}> = ({ elements, appState, files, onError, onSuccess }) => {
|
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card color="primary">
|
<Card color="primary">
|
||||||
@@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||||
await exportToExcalidrawPlus(elements, appState, files);
|
await exportToExcalidrawPlus(elements, appState, files, name);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
|
|
||||||
window.open(
|
window.open(
|
||||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,18 @@
|
|||||||
* (localStorage, indexedDB).
|
* (localStorage, indexedDB).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
|
import {
|
||||||
|
createStore,
|
||||||
|
entries,
|
||||||
|
del,
|
||||||
|
getMany,
|
||||||
|
set,
|
||||||
|
setMany,
|
||||||
|
get,
|
||||||
|
} from "idb-keyval";
|
||||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||||
|
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||||
|
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -22,6 +32,7 @@ import {
|
|||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||||
import { debounce } from "../../packages/excalidraw/utils";
|
import { debounce } from "../../packages/excalidraw/utils";
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
import { FileManager } from "./FileManager";
|
import { FileManager } from "./FileManager";
|
||||||
@@ -183,3 +194,52 @@ export class LocalData {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export class LibraryIndexedDBAdapter {
|
||||||
|
/** IndexedDB database and store name */
|
||||||
|
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
|
||||||
|
/** library data store key */
|
||||||
|
private static key = "libraryData";
|
||||||
|
|
||||||
|
private static store = createStore(
|
||||||
|
`${LibraryIndexedDBAdapter.idb_name}-db`,
|
||||||
|
`${LibraryIndexedDBAdapter.idb_name}-store`,
|
||||||
|
);
|
||||||
|
|
||||||
|
static async load() {
|
||||||
|
const IDBData = await get<LibraryPersistedData>(
|
||||||
|
LibraryIndexedDBAdapter.key,
|
||||||
|
LibraryIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
|
||||||
|
return IDBData || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static save(data: LibraryPersistedData): MaybePromise<void> {
|
||||||
|
return set(
|
||||||
|
LibraryIndexedDBAdapter.key,
|
||||||
|
data,
|
||||||
|
LibraryIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LS Adapter used only for migrating LS library data
|
||||||
|
* to indexedDB */
|
||||||
|
export class LibraryLocalStorageMigrationAdapter {
|
||||||
|
static load() {
|
||||||
|
const LSData = localStorage.getItem(
|
||||||
|
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
|
||||||
|
);
|
||||||
|
if (LSData != null) {
|
||||||
|
const libraryItems: ImportedDataState["libraryItems"] =
|
||||||
|
JSON.parse(LSData);
|
||||||
|
if (libraryItems) {
|
||||||
|
return { libraryItems };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
static clear() {
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||||
import Portal from "../collab/Portal";
|
import Portal from "../collab/Portal";
|
||||||
@@ -18,10 +19,13 @@ import {
|
|||||||
decryptData,
|
decryptData,
|
||||||
} from "../../packages/excalidraw/data/encryption";
|
} from "../../packages/excalidraw/data/encryption";
|
||||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
import { reconcileElements } from "../collab/reconciliation";
|
|
||||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -230,7 +234,7 @@ export const saveToFirebase = async (
|
|||||||
!socket ||
|
!socket ||
|
||||||
isSavedToFirebase(portal, elements)
|
isSavedToFirebase(portal, elements)
|
||||||
) {
|
) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebase = await loadFirestore();
|
const firebase = await loadFirestore();
|
||||||
@@ -238,56 +242,59 @@ export const saveToFirebase = async (
|
|||||||
|
|
||||||
const docRef = firestore.collection("scenes").doc(roomId);
|
const docRef = firestore.collection("scenes").doc(roomId);
|
||||||
|
|
||||||
const savedData = await firestore.runTransaction(async (transaction) => {
|
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||||
const snapshot = await transaction.get(docRef);
|
const snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
if (!snapshot.exists) {
|
if (!snapshot.exists) {
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
firebase,
|
||||||
elements,
|
elements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
transaction.set(docRef, sceneDocument);
|
transaction.set(docRef, storedScene);
|
||||||
|
|
||||||
return {
|
return storedScene;
|
||||||
elements,
|
|
||||||
reconciledElements: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
||||||
const prevElements = getSyncableElements(
|
const prevStoredElements = getSyncableElements(
|
||||||
await decryptElements(prevDocData, roomKey),
|
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reconciledElements = getSyncableElements(
|
const reconciledElements = getSyncableElements(
|
||||||
reconcileElements(elements, prevElements, appState),
|
reconcileElements(
|
||||||
|
elements,
|
||||||
|
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
||||||
|
appState,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
firebase,
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
transaction.update(docRef, sceneDocument);
|
transaction.update(docRef, storedScene);
|
||||||
return {
|
|
||||||
elements,
|
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
|
||||||
reconciledElements,
|
return storedScene;
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
FirebaseSceneVersionCache.set(socket, savedData.elements);
|
const storedElements = getSyncableElements(
|
||||||
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
|
);
|
||||||
|
|
||||||
return { reconciledElements: savedData.reconciledElements };
|
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||||
|
|
||||||
|
return storedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||||
const firebase = await loadFirestore();
|
const firebase = await loadFirestore();
|
||||||
const db = firebase.firestore();
|
const db = firebase.firestore();
|
||||||
|
|
||||||
@@ -298,14 +305,14 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
await decryptElements(storedScene, roomKey),
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
FirebaseSceneVersionCache.set(socket, elements);
|
FirebaseSceneVersionCache.set(socket, elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElements(elements, null);
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFilesFromFirebase = async (
|
export const loadFilesFromFirebase = async (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { isInitializedImageElement } from "../../packages/excalidraw/element/typ
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
DELETED_ELEMENT_TIMEOUT,
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
@@ -35,12 +37,11 @@ import {
|
|||||||
import { encodeFilesForUpload } from "./FileManager";
|
import { encodeFilesForUpload } from "./FileManager";
|
||||||
import { saveFilesToFirebase } from "./firebase";
|
import { saveFilesToFirebase } from "./firebase";
|
||||||
|
|
||||||
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||||
_brand: "SyncableExcalidrawElement";
|
MakeBrand<"SyncableExcalidrawElement">;
|
||||||
};
|
|
||||||
|
|
||||||
export const isSyncableElement = (
|
export const isSyncableElement = (
|
||||||
element: ExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
): element is SyncableExcalidrawElement => {
|
): element is SyncableExcalidrawElement => {
|
||||||
if (element.isDeleted) {
|
if (element.isDeleted) {
|
||||||
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
||||||
@@ -51,7 +52,9 @@ export const isSyncableElement = (
|
|||||||
return !isInvisiblySmallElement(element);
|
return !isInvisiblySmallElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
export const getSyncableElements = (
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
|
) =>
|
||||||
elements.filter((element) =>
|
elements.filter((element) =>
|
||||||
isSyncableElement(element),
|
isSyncableElement(element),
|
||||||
) as SyncableExcalidrawElement[];
|
) as SyncableExcalidrawElement[];
|
||||||
@@ -65,35 +68,6 @@ const generateRoomId = async () => {
|
|||||||
return bytesToHexString(buffer);
|
return bytesToHexString(buffer);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Right now the reason why we resolve connection params (url, polling...)
|
|
||||||
* from upstream is to allow changing the params immediately when needed without
|
|
||||||
* having to wait for clients to update the SW.
|
|
||||||
*
|
|
||||||
* If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks)
|
|
||||||
*/
|
|
||||||
export const getCollabServer = async (): Promise<{
|
|
||||||
url: string;
|
|
||||||
polling: boolean;
|
|
||||||
}> => {
|
|
||||||
if (import.meta.env.VITE_APP_WS_SERVER_URL) {
|
|
||||||
return {
|
|
||||||
url: import.meta.env.VITE_APP_WS_SERVER_URL,
|
|
||||||
polling: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
`${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
|
|
||||||
);
|
|
||||||
return await resp.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
throw new Error(t("errors.cannotResolveCollabServer"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EncryptedData = {
|
export type EncryptedData = {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
iv: Uint8Array;
|
iv: Uint8Array;
|
||||||
@@ -295,7 +269,7 @@ export const loadScene = async (
|
|||||||
// in the scene database/localStorage, and instead fetch them async
|
// in the scene database/localStorage, and instead fetch them async
|
||||||
// from a different database
|
// from a different database
|
||||||
files: data.files,
|
files: data.files,
|
||||||
commitToHistory: false,
|
commitToStore: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "../../packages/excalidraw/appState";
|
} from "../../packages/excalidraw/appState";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
|
||||||
|
|
||||||
export const saveUsernameToLocalStorage = (username: string) => {
|
export const saveUsernameToLocalStorage = (username: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
|
|||||||
try {
|
try {
|
||||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||||
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
||||||
|
|
||||||
const appStateSize = appState?.length || 0;
|
const appStateSize = appState?.length || 0;
|
||||||
const collabSize = collab?.length || 0;
|
const collabSize = collab?.length || 0;
|
||||||
const librarySize = library?.length || 0;
|
|
||||||
|
|
||||||
return appStateSize + collabSize + librarySize + getElementsStorageSize();
|
return appStateSize + collabSize + getElementsStorageSize();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryItemsFromStorage = () => {
|
|
||||||
try {
|
|
||||||
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
return libraryItems || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -64,12 +64,30 @@
|
|||||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||||
<script>
|
<script>
|
||||||
try {
|
try {
|
||||||
//
|
function setTheme(theme) {
|
||||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
if (theme === "dark") {
|
||||||
if (theme === "dark") {
|
document.documentElement.classList.add("dark");
|
||||||
document.documentElement.classList.add("dark");
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
function getTheme() {
|
||||||
|
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||||
|
|
||||||
|
if (theme && theme === "system") {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
} else {
|
||||||
|
return theme || "light";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(getTheme());
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error setting dark mode", e);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
html.dark {
|
html.dark {
|
||||||
@@ -78,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!------------------------------------------------------------------------->
|
<!------------------------------------------------------------------------->
|
||||||
<% if ("%PROD%" === "true") { %>
|
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||||
//
|
//
|
||||||
@@ -122,7 +140,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||||
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
{
|
{
|
||||||
const _WebSocket = window.WebSocket;
|
const _WebSocket = window.WebSocket;
|
||||||
@@ -196,7 +215,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="index.tsx"></script>
|
<script type="module" src="index.tsx"></script>
|
||||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
|
||||||
<!-- 100% privacy friendly analytics -->
|
<!-- 100% privacy friendly analytics -->
|
||||||
<script>
|
<script>
|
||||||
// need to load this script dynamically bcs. of iframe embed tracking
|
// need to load this script dynamically bcs. of iframe embed tracking
|
||||||
@@ -229,6 +247,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
<% } %>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-right-ui {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -31,7 +38,7 @@
|
|||||||
background-color: #ecfdf5;
|
background-color: #ecfdf5;
|
||||||
color: #064e3c;
|
color: #064e3c;
|
||||||
}
|
}
|
||||||
&.ExcalidrawPlus {
|
&.highlighted {
|
||||||
color: var(--color-promo);
|
color: var(--color-promo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"vite-plugin-html": "3.2.2"
|
||||||
|
},
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.RoomDialog {
|
.ShareDialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -10,8 +10,25 @@
|
|||||||
height: calc(100vh - 5rem);
|
height: calc(100vh - 5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__separator {
|
||||||
|
border-top: 1px solid var(--dialog-border-color);
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
|
span {
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
padding: 0px 0.75rem;
|
||||||
|
transform: translateY(-1ch);
|
||||||
|
display: inline-flex;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__popover {
|
&__popover {
|
||||||
@keyframes RoomDialog__popover__scaleIn {
|
@keyframes ShareDialog__popover__scaleIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -50,10 +67,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
transform-origin: var(--radix-popover-content-transform-origin);
|
transform-origin: var(--radix-popover-content-transform-origin);
|
||||||
animation: RoomDialog__popover__scaleIn 150ms ease-out;
|
animation: ShareDialog__popover__scaleIn 150ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__inactive {
|
&__picker {
|
||||||
font-family: "Assistant";
|
font-family: "Assistant";
|
||||||
|
|
||||||
&__illustration {
|
&__illustration {
|
||||||
@@ -95,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__start_session {
|
&__button {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
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";
|
||||||
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
|
import { KEYS } from "../../packages/excalidraw/keys";
|
||||||
|
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||||
|
import {
|
||||||
|
copyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
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 { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
||||||
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
|
|
||||||
|
import "./ShareDialog.scss";
|
||||||
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
|
||||||
|
type OnExportToBackend = () => void;
|
||||||
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
|
|
||||||
|
export const shareDialogStateAtom = atom<
|
||||||
|
{ isOpen: false } | { isOpen: true; type: ShareDialogType }
|
||||||
|
>({ isOpen: false });
|
||||||
|
|
||||||
|
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 ShareDialogProps = {
|
||||||
|
collabAPI: CollabAPI | null;
|
||||||
|
handleClose: () => void;
|
||||||
|
onExportToBackend: OnExportToBackend;
|
||||||
|
type: ShareDialogType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActiveRoomDialog = ({
|
||||||
|
collabAPI,
|
||||||
|
activeRoomLink,
|
||||||
|
handleClose,
|
||||||
|
}: {
|
||||||
|
collabAPI: CollabAPI;
|
||||||
|
activeRoomLink: string;
|
||||||
|
handleClose: () => void;
|
||||||
|
}) => {
|
||||||
|
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) {
|
||||||
|
collabAPI.setCollabError(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.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="ShareDialog__active__header">
|
||||||
|
{t("labels.liveCollaboration").replace(/\./g, "")}
|
||||||
|
</h3>
|
||||||
|
<TextField
|
||||||
|
defaultValue={collabAPI.getUsername()}
|
||||||
|
placeholder="Your name"
|
||||||
|
label="Your name"
|
||||||
|
onChange={collabAPI.setUsername}
|
||||||
|
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||||
|
/>
|
||||||
|
<div className="ShareDialog__active__linkRow">
|
||||||
|
<TextField
|
||||||
|
ref={ref}
|
||||||
|
label="Link"
|
||||||
|
readonly
|
||||||
|
fullWidth
|
||||||
|
value={activeRoomLink}
|
||||||
|
/>
|
||||||
|
{isShareSupported && (
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
variant="icon"
|
||||||
|
label="Share"
|
||||||
|
icon={getShareIcon()}
|
||||||
|
className="ShareDialog__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="ShareDialog__popover"
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={5.5}
|
||||||
|
>
|
||||||
|
{tablerCheckIcon} copied
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
<div className="ShareDialog__active__description">
|
||||||
|
<p>
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ShareDialog__active__description__emoji"
|
||||||
|
>
|
||||||
|
🔒{" "}
|
||||||
|
</span>
|
||||||
|
{t("roomDialog.desc_privacy")}
|
||||||
|
</p>
|
||||||
|
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ShareDialog__active__actions">
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
|
color="danger"
|
||||||
|
label={t("roomDialog.button_stopSession")}
|
||||||
|
icon={playerStopFilledIcon}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent("share", "room closed");
|
||||||
|
collabAPI.stopCollaboration();
|
||||||
|
if (!collabAPI.isCollaborating()) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { collabAPI } = props;
|
||||||
|
|
||||||
|
const startCollabJSX = collabAPI ? (
|
||||||
|
<>
|
||||||
|
<div className="ShareDialog__picker__header">
|
||||||
|
{t("labels.liveCollaboration").replace(/\./g, "")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ShareDialog__picker__description">
|
||||||
|
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
|
||||||
|
{t("roomDialog.desc_privacy")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ShareDialog__picker__button">
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
label={t("roomDialog.button_startSession")}
|
||||||
|
icon={playerPlayIcon}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||||
|
collabAPI.startCollaboration(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.type === "share" && (
|
||||||
|
<div className="ShareDialog__separator">
|
||||||
|
<span>{t("shareDialog.or")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{startCollabJSX}
|
||||||
|
|
||||||
|
{props.type === "share" && (
|
||||||
|
<>
|
||||||
|
<div className="ShareDialog__picker__header">
|
||||||
|
{t("exportDialog.link_title")}
|
||||||
|
</div>
|
||||||
|
<div className="ShareDialog__picker__description">
|
||||||
|
{t("exportDialog.link_details")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ShareDialog__picker__button">
|
||||||
|
<FilledButton
|
||||||
|
size="large"
|
||||||
|
label={t("exportDialog.link_button")}
|
||||||
|
icon={LinkIcon}
|
||||||
|
onClick={async () => {
|
||||||
|
await props.onExportToBackend();
|
||||||
|
props.handleClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShareDialogInner = (props: ShareDialogProps) => {
|
||||||
|
const activeRoomLink = useAtomValue(activeRoomLinkAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||||
|
<div className="ShareDialog">
|
||||||
|
{props.collabAPI && activeRoomLink ? (
|
||||||
|
<ActiveRoomDialog
|
||||||
|
collabAPI={props.collabAPI}
|
||||||
|
activeRoomLink={activeRoomLink}
|
||||||
|
handleClose={props.handleClose}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ShareDialogPicker {...props} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShareDialog = (props: {
|
||||||
|
collabAPI: CollabAPI | null;
|
||||||
|
onExportToBackend: OnExportToBackend;
|
||||||
|
}) => {
|
||||||
|
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
|
|
||||||
|
const { openDialog } = useUIAppState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openDialog) {
|
||||||
|
setShareDialogState({ isOpen: false });
|
||||||
|
}
|
||||||
|
}, [openDialog, setShareDialogState]);
|
||||||
|
|
||||||
|
if (!shareDialogState.isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShareDialogInner
|
||||||
|
handleClose={() => setShareDialogState({ isOpen: false })}
|
||||||
|
collabAPI={props.collabAPI}
|
||||||
|
onExportToBackend={props.onExportToBackend}
|
||||||
|
type={shareDialogState.type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -216,32 +216,23 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g>
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
<rect
|
<path
|
||||||
height="4"
|
d="M10 12l10 0"
|
||||||
rx="1"
|
|
||||||
width="18"
|
|
||||||
x="3"
|
|
||||||
y="8"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
x2="12"
|
|
||||||
y1="8"
|
|
||||||
y2="21"
|
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
|
d="M10 12l4 4"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
|
d="M10 12l4 -4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 4l0 16"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -249,7 +240,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
<div
|
<div
|
||||||
class="welcome-screen-menu-item__text"
|
class="welcome-screen-menu-item__text"
|
||||||
>
|
>
|
||||||
Try Excalidraw Plus!
|
Sign up
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
render,
|
render,
|
||||||
updateSceneData,
|
updateSceneData,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "../../packages/excalidraw/tests/test-utils";
|
} from "../../packages/excalidraw/tests/test-utils";
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
||||||
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
|
||||||
|
import {
|
||||||
|
createRedoAction,
|
||||||
|
createUndoAction,
|
||||||
|
} from "../../packages/excalidraw/actions/actionHistory";
|
||||||
|
import { newElementWith } from "../../packages/excalidraw";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
Object.defineProperty(window, "crypto", {
|
Object.defineProperty(window, "crypto", {
|
||||||
@@ -20,17 +27,6 @@ Object.defineProperty(window, "crypto", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => {
|
|
||||||
const module = (await importActual()) as any;
|
|
||||||
return {
|
|
||||||
__esmodule: true,
|
|
||||||
...module,
|
|
||||||
getCollabServer: vi.fn(() => ({
|
|
||||||
url: /* doesn't really matter */ "http://localhost:3002",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
||||||
const loadFromFirebase = async () => null;
|
const loadFromFirebase = async () => null;
|
||||||
const saveToFirebase = () => {};
|
const saveToFirebase = () => {};
|
||||||
@@ -67,39 +63,188 @@ vi.mock("socket.io-client", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
|
||||||
|
* while having access to both scenes, appstates stores, histories and etc.
|
||||||
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
|
*/
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
it("creating room should reset deleted elements", async () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
// To update the scene with deleted elements before starting collab
|
const rect1Props = {
|
||||||
|
type: "rectangle",
|
||||||
|
id: "A",
|
||||||
|
height: 200,
|
||||||
|
width: 100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rect2Props = {
|
||||||
|
type: "rectangle",
|
||||||
|
id: "B",
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rect1 = API.createElement({ ...rect1Props });
|
||||||
|
const rect2 = API.createElement({ ...rect2Props });
|
||||||
|
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
elements: [
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
API.createElement({ type: "rectangle", id: "A" }),
|
commitToStore: true,
|
||||||
API.createElement({
|
});
|
||||||
type: "rectangle",
|
|
||||||
id: "B",
|
updateSceneData({
|
||||||
isDeleted: true,
|
elements: syncInvalidIndices([
|
||||||
}),
|
rect1,
|
||||||
],
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
});
|
]),
|
||||||
await waitFor(() => {
|
commitToStore: true,
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({ id: "A" }),
|
|
||||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
|
||||||
]);
|
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
|
||||||
});
|
|
||||||
window.collab.startCollaboration(null);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history);
|
|
||||||
// noop
|
|
||||||
h.app.actionManager.executeAction(undoAction);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
|
||||||
|
window.collab.startCollaboration(null);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
// we never delete from the local snapshot as it is used for correct diff calculation
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoAction = createUndoAction(h.history, h.store);
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate force deleting the element remotely
|
||||||
|
updateSceneData({
|
||||||
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const redoAction = createRedoAction(h.history, h.store);
|
||||||
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// simulate local update
|
||||||
|
updateSceneData({
|
||||||
|
elements: syncInvalidIndices([
|
||||||
|
h.elements[0],
|
||||||
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
|
]),
|
||||||
|
commitToStore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// we expect to iterate the stack to the first visible change
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate force deleting the element remotely
|
||||||
|
updateSceneData({
|
||||||
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// snapshot was correctly updated and marked the element as deleted
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
|
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||||
|
]);
|
||||||
|
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
import { expect } from "chai";
|
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
|
||||||
import {
|
|
||||||
BroadcastedExcalidrawElement,
|
|
||||||
ReconciledElements,
|
|
||||||
reconcileElements,
|
|
||||||
} from "../../excalidraw-app/collab/reconciliation";
|
|
||||||
import { randomInteger } from "../../packages/excalidraw/random";
|
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
type Id = string;
|
|
||||||
type ElementLike = {
|
|
||||||
id: string;
|
|
||||||
version: number;
|
|
||||||
versionNonce: number;
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
|
||||||
|
|
||||||
const createElement = (opts: { uid: string } | ElementLike) => {
|
|
||||||
let uid: string;
|
|
||||||
let id: string;
|
|
||||||
let version: number | null;
|
|
||||||
let parent: string | null = null;
|
|
||||||
let versionNonce: number | null = null;
|
|
||||||
if ("uid" in opts) {
|
|
||||||
const match = opts.uid.match(
|
|
||||||
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
|
|
||||||
)!;
|
|
||||||
parent = match[1];
|
|
||||||
id = match[2];
|
|
||||||
version = match[3] ? parseInt(match[3]) : null;
|
|
||||||
uid = version ? `${id}:${version}` : id;
|
|
||||||
} else {
|
|
||||||
({ id, version, versionNonce } = opts);
|
|
||||||
parent = parent || null;
|
|
||||||
uid = id;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
uid,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
versionNonce: versionNonce || randomInteger(),
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent || null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const idsToElements = (
|
|
||||||
ids: (Id | ElementLike)[],
|
|
||||||
cache: Cache = {},
|
|
||||||
): readonly ExcalidrawElement[] => {
|
|
||||||
return ids.reduce((acc, _uid, idx) => {
|
|
||||||
const {
|
|
||||||
uid,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent,
|
|
||||||
versionNonce,
|
|
||||||
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
|
|
||||||
const cached = cache[uid];
|
|
||||||
const elem = {
|
|
||||||
id,
|
|
||||||
version: version ?? 0,
|
|
||||||
versionNonce,
|
|
||||||
...cached,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent,
|
|
||||||
} as BroadcastedExcalidrawElement;
|
|
||||||
// @ts-ignore
|
|
||||||
cache[uid] = elem;
|
|
||||||
acc.push(elem);
|
|
||||||
return acc;
|
|
||||||
}, [] as ExcalidrawElement[]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
|
||||||
return elements.map((el, idx, els) => {
|
|
||||||
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanElements = (elements: ReconciledElements) => {
|
|
||||||
return elements.map((el) => {
|
|
||||||
// @ts-ignore
|
|
||||||
delete el[PRECEDING_ELEMENT_KEY];
|
|
||||||
// @ts-ignore
|
|
||||||
delete el.next;
|
|
||||||
// @ts-ignore
|
|
||||||
delete el.prev;
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
|
||||||
local: (Id | ElementLike)[],
|
|
||||||
remote: (Id | ElementLike)[],
|
|
||||||
target: U[],
|
|
||||||
bidirectional = true,
|
|
||||||
) => {
|
|
||||||
const cache: Cache = {};
|
|
||||||
const _local = idsToElements(local, cache);
|
|
||||||
const _remote = idsToElements(remote, cache);
|
|
||||||
const _target = target.map((uid) => {
|
|
||||||
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
|
||||||
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
|
|
||||||
}) as any as ReconciledElements;
|
|
||||||
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
|
|
||||||
expect(target.length).equal(remoteReconciled.length);
|
|
||||||
expect(cleanElements(remoteReconciled)).deep.equal(
|
|
||||||
cleanElements(_target),
|
|
||||||
"remote reconciliation",
|
|
||||||
);
|
|
||||||
|
|
||||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
|
||||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
|
||||||
if (bidirectional) {
|
|
||||||
try {
|
|
||||||
expect(
|
|
||||||
cleanElements(
|
|
||||||
reconcileElements(
|
|
||||||
cloneJSON(__local),
|
|
||||||
cloneJSON(__remote),
|
|
||||||
{} as AppState,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("local original", __local);
|
|
||||||
console.error("remote reconciled", __remote);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findIndex = <T>(
|
|
||||||
array: readonly T[],
|
|
||||||
cb: (element: T, index: number, array: readonly T[]) => boolean,
|
|
||||||
fromIndex: number = 0,
|
|
||||||
) => {
|
|
||||||
if (fromIndex < 0) {
|
|
||||||
fromIndex = array.length + fromIndex;
|
|
||||||
}
|
|
||||||
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
|
|
||||||
let index = fromIndex - 1;
|
|
||||||
while (++index < array.length) {
|
|
||||||
if (cb(array[index], index, array)) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("elements reconciliation", () => {
|
|
||||||
it("reconcileElements()", () => {
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// in following tests, we pass:
|
|
||||||
// (1) an array of local elements and their version (:1, :2...)
|
|
||||||
// (2) an array of remote elements and their version (:1, :2...)
|
|
||||||
// (3) expected reconciled elements
|
|
||||||
//
|
|
||||||
// in the reconciled array:
|
|
||||||
// :L means local element was resolved
|
|
||||||
// :R means remote element was resolved
|
|
||||||
//
|
|
||||||
// if a remote element is prefixed with parentheses, the enclosed string:
|
|
||||||
// (^) means the element is the first element in the array
|
|
||||||
// (<id>) means the element is preceded by <id> element
|
|
||||||
//
|
|
||||||
// if versions are missing, it defaults to version 0
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// non-annotated elements
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// usually when we sync elements they should always be annotated with
|
|
||||||
// their (preceding elements) parents, but let's test a couple of cases when
|
|
||||||
// they're not for whatever reason (remote clients are on older version...),
|
|
||||||
// in which case the first synced element either replaces existing element
|
|
||||||
// or is pushed at the end of the array
|
|
||||||
|
|
||||||
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
|
||||||
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
|
|
||||||
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
|
||||||
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
|
||||||
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
|
||||||
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
|
||||||
test(["A"], ["A:1"], ["A:R"]);
|
|
||||||
|
|
||||||
// C isn't added to the end because it follows B (even if B was resolved
|
|
||||||
// to local version)
|
|
||||||
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
|
|
||||||
|
|
||||||
// some of the following tests are kinda arbitrary and they're less
|
|
||||||
// likely to happen in real-world cases
|
|
||||||
|
|
||||||
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(
|
|
||||||
["A:2", "B:2", "C"],
|
|
||||||
["D", "B:1", "A:3"],
|
|
||||||
["B:L", "A:R", "C:L", "D:R"],
|
|
||||||
);
|
|
||||||
test(
|
|
||||||
["A:2", "B:2", "C"],
|
|
||||||
["D", "B:2", "A:3", "C"],
|
|
||||||
["D:R", "B:L", "A:R", "C:L"],
|
|
||||||
);
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E", "F"],
|
|
||||||
["A", "B:2", "X", "E:2", "F", "Y"],
|
|
||||||
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// annotated elements
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C"],
|
|
||||||
["(B)X", "(A)Y", "(Y)Z"],
|
|
||||||
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
|
|
||||||
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B"],
|
|
||||||
["(A)C", "(^)D", "F"],
|
|
||||||
["A:L", "C:R", "D:R", "F:R", "B:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(B)C:1", "B", "D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C"],
|
|
||||||
["(^)X", "(A)Y", "(B)Z"],
|
|
||||||
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["B", "A", "C"],
|
|
||||||
["(^)X", "(A)Y", "(B)Z"],
|
|
||||||
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E"],
|
|
||||||
["(A)X", "(C)Y", "(D)Z"],
|
|
||||||
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["X", "Y", "Z"],
|
|
||||||
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
|
|
||||||
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E"],
|
|
||||||
["(C)X", "(A)Y", "(D)E:1"],
|
|
||||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["C:1", "B", "D:1"],
|
|
||||||
["A", "B", "C:1", "D:1"],
|
|
||||||
["A:R", "B:L", "C:L", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "(C)B", "(B)D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "(C)B", "(B)D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["C:1", "B", "D:1"],
|
|
||||||
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
|
|
||||||
["A:R", "B:L", "C:R", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(C)X", "(B)Y", "(A)Z"],
|
|
||||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
|
|
||||||
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "B", "D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
|
||||||
|
|
||||||
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
|
|
||||||
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
|
|
||||||
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
|
|
||||||
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
|
||||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
||||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("test identical elements reconciliation", () => {
|
|
||||||
const testIdentical = (
|
|
||||||
local: ElementLike[],
|
|
||||||
remote: ElementLike[],
|
|
||||||
expected: Id[],
|
|
||||||
) => {
|
|
||||||
const ret = reconcileElements(
|
|
||||||
local as any as ExcalidrawElement[],
|
|
||||||
remote as any as ExcalidrawElement[],
|
|
||||||
{} as AppState,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
|
||||||
throw new Error("reconcileElements: duplicate elements found");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ret.map((x) => x.id)).to.deep.equal(expected);
|
|
||||||
};
|
|
||||||
|
|
||||||
// identical id/version/versionNonce
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
testIdentical(
|
|
||||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
|
||||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
|
||||||
["A"],
|
|
||||||
);
|
|
||||||
testIdentical(
|
|
||||||
[
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
["B", "A"],
|
|
||||||
);
|
|
||||||
testIdentical(
|
|
||||||
[
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
["B", "A"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// actually identical (arrays and element objects)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const elements1 = [
|
|
||||||
{
|
|
||||||
id: "A",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "B",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testIdentical(elements1, elements1, ["A", "B"]);
|
|
||||||
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
|
||||||
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
|
||||||
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
|
||||||
|
|
||||||
const el1 = {
|
|
||||||
id: "A",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
};
|
|
||||||
const el2 = {
|
|
||||||
id: "B",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
};
|
|
||||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
|
import { THEME } from "../packages/excalidraw";
|
||||||
|
import { EVENT } from "../packages/excalidraw/constants";
|
||||||
|
import { Theme } from "../packages/excalidraw/element/types";
|
||||||
|
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
||||||
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
|
||||||
|
export const appThemeAtom = atom<Theme | "system">(
|
||||||
|
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
||||||
|
| Theme
|
||||||
|
| "system"
|
||||||
|
| null) || THEME.LIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
||||||
|
window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
export const useHandleAppTheme = () => {
|
||||||
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = getDarkThemeMediaQuery();
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (appTheme === "system") {
|
||||||
|
mediaQuery?.addEventListener("change", handleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.altKey &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.code === CODES.D
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery?.removeEventListener("change", handleChange);
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [appTheme, editorTheme, setAppTheme]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
|
||||||
|
|
||||||
|
if (appTheme === "system") {
|
||||||
|
setEditorTheme(
|
||||||
|
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setEditorTheme(appTheme);
|
||||||
|
}
|
||||||
|
}, [appTheme]);
|
||||||
|
|
||||||
|
return { editorTheme };
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr";
|
|||||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import checker from "vite-plugin-checker";
|
import checker from "vite-plugin-checker";
|
||||||
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
|
||||||
// To load .env.local variables
|
// To load .env.local variables
|
||||||
const envVars = loadEnv("", `../`);
|
const envVars = loadEnv("", `../`);
|
||||||
@@ -189,6 +190,9 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
createHtmlPlugin({
|
||||||
|
minify: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
publicDir: "../public",
|
publicDir: "../public",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
"packages/utils"
|
"packages/utils",
|
||||||
|
"examples/excalidraw",
|
||||||
|
"examples/excalidraw/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.6",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.6.1",
|
"vite-plugin-checker": "0.6.1",
|
||||||
"vite-plugin-ejs": "1.7.0",
|
"vite-plugin-ejs": "1.7.0",
|
||||||
"vite-plugin-pwa": "0.17.4",
|
"vite-plugin-pwa": "0.17.4",
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
types
|
types
|
||||||
bundle.js
|
|
||||||
bundle.css
|
|
||||||
|
|||||||
@@ -13,13 +13,38 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||||
|
|
||||||
|
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
|
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
|
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
|
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
||||||
|
|
||||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||||
- Remove `ExcalidrawEmbeddableElement.validated` attribute. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
|
||||||
|
- Extended `window.EXCALIDRAW_ASSET_PATH` to accept array of paths `string[]` as a value, allowing to specify multiple base `URL` fallbacks. [#8286](https://github.com/excalidraw/excalidraw/pull/8286)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
|
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
|
||||||
|
|
||||||
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||||
|
|
||||||
#### Bundler
|
#### Bundler
|
||||||
@@ -54,10 +79,12 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||||
|
|
||||||
## 0.17.1 (2023-11-28)
|
## 0.17.3 (2024-02-09)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||||
|
|
||||||
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
|
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -66,14 +93,16 @@ define: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||||
|
|
||||||
|
- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339)
|
||||||
|
|
||||||
## Excalidraw Library
|
## Excalidraw Library
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0.17.0 (2023-11-14)
|
## 0.17.0 (2023-11-14)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ After installation you will see a folder `excalidraw-assets` and `excalidraw-ass
|
|||||||
|
|
||||||
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
||||||
|
|
||||||
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist)
|
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/)
|
||||||
|
|
||||||
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
|
|||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
|
|||||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||||
if (selectedElements.some((element) => element.type === type)) {
|
if (selectedElements.some((element) => element.type === type)) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||||
@@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.addedToLibrary") },
|
toast: { message: t("toast.addedToLibrary") },
|
||||||
@@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
@@ -58,5 +59,5 @@ export const actionAddToLibrary = register({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
label: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { StoreAction } from "../store";
|
||||||
|
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const alignActionsPredicate = (
|
const alignActionsPredicate = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: UIAppState,
|
||||||
_: unknown,
|
_: unknown,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
@@ -40,8 +41,13 @@ const alignSelectedElements = (
|
|||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
const updatedElements = alignElements(
|
||||||
|
selectedElements,
|
||||||
|
elementsMap,
|
||||||
|
alignment,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
@@ -54,6 +60,8 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
|
label: "labels.alignTop",
|
||||||
|
icon: AlignTopIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -63,7 +71,7 @@ export const actionAlignTop = register({
|
|||||||
position: "start",
|
position: "start",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -85,6 +93,8 @@ export const actionAlignTop = register({
|
|||||||
|
|
||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
|
label: "labels.alignBottom",
|
||||||
|
icon: AlignBottomIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -94,7 +104,7 @@ export const actionAlignBottom = register({
|
|||||||
position: "end",
|
position: "end",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -116,6 +126,8 @@ export const actionAlignBottom = register({
|
|||||||
|
|
||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
|
label: "labels.alignLeft",
|
||||||
|
icon: AlignLeftIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -125,7 +137,7 @@ export const actionAlignLeft = register({
|
|||||||
position: "start",
|
position: "start",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -147,6 +159,8 @@ export const actionAlignLeft = register({
|
|||||||
|
|
||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
|
label: "labels.alignRight",
|
||||||
|
icon: AlignRightIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -156,7 +170,7 @@ export const actionAlignRight = register({
|
|||||||
position: "end",
|
position: "end",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -178,6 +192,8 @@ export const actionAlignRight = register({
|
|||||||
|
|
||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
|
label: "labels.centerVertically",
|
||||||
|
icon: CenterVerticallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -187,7 +203,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
position: "center",
|
position: "center",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
@@ -205,6 +221,8 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
|
|
||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
|
label: "labels.centerHorizontally",
|
||||||
|
icon: CenterHorizontallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -214,7 +232,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
position: "center",
|
position: "center",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getOriginalContainerHeightFromCache,
|
getOriginalContainerHeightFromCache,
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "../element/textWysiwyg";
|
} from "../element/containerCache";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
@@ -31,12 +31,14 @@ import {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
import { getFontString } from "../utils";
|
import { arrayToMap, getFontString } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
contextItemLabel: "labels.unbindText",
|
label: "labels.unbindText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -45,10 +47,11 @@ export const actionUnbindText = register({
|
|||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
selectedElements.forEach((element) => {
|
selectedElements.forEach((element) => {
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const { width, height, baseline } = measureText(
|
const { width, height } = measureText(
|
||||||
boundTextElement.originalText,
|
boundTextElement.originalText,
|
||||||
getFontString(boundTextElement),
|
getFontString(boundTextElement),
|
||||||
boundTextElement.lineHeight,
|
boundTextElement.lineHeight,
|
||||||
@@ -57,12 +60,15 @@ export const actionUnbindText = register({
|
|||||||
element.id,
|
element.id,
|
||||||
);
|
);
|
||||||
resetOriginalContainerCache(element.id);
|
resetOriginalContainerCache(element.id);
|
||||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
containerId: null,
|
containerId: null,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
baseline,
|
|
||||||
text: boundTextElement.originalText,
|
text: boundTextElement.originalText,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -80,14 +86,14 @@ export const actionUnbindText = register({
|
|||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionBindText = register({
|
export const actionBindText = register({
|
||||||
name: "bindText",
|
name: "bindText",
|
||||||
contextItemLabel: "labels.bindText",
|
label: "labels.bindText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -106,7 +112,10 @@ export const actionBindText = register({
|
|||||||
if (
|
if (
|
||||||
textElement &&
|
textElement &&
|
||||||
bindingContainer &&
|
bindingContainer &&
|
||||||
getBoundTextElement(bindingContainer) === null
|
getBoundTextElement(
|
||||||
|
bindingContainer,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
) === null
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -141,7 +150,11 @@ export const actionBindText = register({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const originalContainerHeight = container.height;
|
const originalContainerHeight = container.height;
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
// overwritting the cache with original container height so
|
// overwritting the cache with original container height so
|
||||||
// it can be restored when unbind
|
// it can be restored when unbind
|
||||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||||
@@ -149,7 +162,7 @@ export const actionBindText = register({
|
|||||||
return {
|
return {
|
||||||
elements: pushTextAboveContainer(elements, container, textElement),
|
elements: pushTextAboveContainer(elements, container, textElement),
|
||||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -169,6 +182,8 @@ const pushTextAboveContainer = (
|
|||||||
(ele) => ele.id === container.id,
|
(ele) => ele.id === container.id,
|
||||||
);
|
);
|
||||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||||
|
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||||
|
|
||||||
return updatedElements;
|
return updatedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,12 +202,14 @@ const pushContainerBelowText = (
|
|||||||
(ele) => ele.id === textElement.id,
|
(ele) => ele.id === textElement.id,
|
||||||
);
|
);
|
||||||
updatedElements.splice(textElementIndex, 0, container);
|
updatedElements.splice(textElementIndex, 0, container);
|
||||||
|
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||||
|
|
||||||
return updatedElements;
|
return updatedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionWrapTextInContainer = register({
|
export const actionWrapTextInContainer = register({
|
||||||
name: "wrapTextInContainer",
|
name: "wrapTextInContainer",
|
||||||
contextItemLabel: "labels.createContainerFromText",
|
label: "labels.createContainerFromText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -282,13 +299,18 @@ export const actionWrapTextInContainer = register({
|
|||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
updatedElements = pushContainerBelowText(
|
updatedElements = pushContainerBelowText(
|
||||||
[...updatedElements, container],
|
[...updatedElements, container],
|
||||||
container,
|
container,
|
||||||
textElement,
|
textElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
containerIds[container.id] = true;
|
containerIds[container.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +321,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: containerIds,
|
selectedElementIds: containerIds,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
import {
|
||||||
|
handIcon,
|
||||||
|
MoonIcon,
|
||||||
|
SunIcon,
|
||||||
|
TrashIcon,
|
||||||
|
zoomAreaIcon,
|
||||||
|
ZoomInIcon,
|
||||||
|
ZoomOutIcon,
|
||||||
|
ZoomResetIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
import {
|
||||||
|
CURSOR_TYPE,
|
||||||
|
MAX_ZOOM,
|
||||||
|
MIN_ZOOM,
|
||||||
|
THEME,
|
||||||
|
ZOOM_STEP,
|
||||||
|
} from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -22,9 +37,12 @@ import {
|
|||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
import { SceneBounds } from "../element/bounds";
|
import { SceneBounds } from "../element/bounds";
|
||||||
import { setCursor } from "../cursor";
|
import { setCursor } from "../cursor";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
|
label: "labels.canvasBackground",
|
||||||
|
paletteName: "Change canvas background color",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, ...value },
|
||||||
commitToHistory: !!value.viewBackgroundColor,
|
storeAction: !!value.viewBackgroundColor
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||||
@@ -59,6 +79,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
|
label: "labels.clearCanvas",
|
||||||
|
paletteName: "Clear canvas",
|
||||||
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -88,14 +111,16 @@ export const actionClearCanvas = register({
|
|||||||
? { ...appState.activeTool, type: "selection" }
|
? { ...appState.activeTool, type: "selection" }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
|
label: "buttons.zoomIn",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
|
icon: ZoomInIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -111,16 +136,17 @@ export const actionZoomIn = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="zoom-in-button zoom-button"
|
className="zoom-in-button zoom-button"
|
||||||
icon={ZoomInIcon}
|
icon={ZoomInIcon}
|
||||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||||
aria-label={t("buttons.zoomIn")}
|
aria-label={t("buttons.zoomIn")}
|
||||||
|
disabled={appState.zoom.value >= MAX_ZOOM}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
@@ -133,6 +159,8 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
|
label: "buttons.zoomOut",
|
||||||
|
icon: ZoomOutIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
@@ -149,16 +177,17 @@ export const actionZoomOut = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="zoom-out-button zoom-button"
|
className="zoom-out-button zoom-button"
|
||||||
icon={ZoomOutIcon}
|
icon={ZoomOutIcon}
|
||||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||||
aria-label={t("buttons.zoomOut")}
|
aria-label={t("buttons.zoomOut")}
|
||||||
|
disabled={appState.zoom.value <= MIN_ZOOM}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
@@ -171,6 +200,8 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
|
label: "buttons.resetZoom",
|
||||||
|
icon: ZoomResetIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
@@ -187,7 +218,7 @@ export const actionResetZoom = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
@@ -262,8 +293,8 @@ export const zoomToFitBounds = ({
|
|||||||
|
|
||||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||||
newZoomValue = Math.min(
|
newZoomValue = Math.min(
|
||||||
Math.max(newZoomValue, 0.1),
|
Math.max(newZoomValue, MIN_ZOOM),
|
||||||
30.0,
|
MAX_ZOOM,
|
||||||
) as NormalizedZoomValue;
|
) as NormalizedZoomValue;
|
||||||
|
|
||||||
let appStateWidth = appState.width;
|
let appStateWidth = appState.width;
|
||||||
@@ -308,7 +339,7 @@ export const zoomToFitBounds = ({
|
|||||||
scrollY,
|
scrollY,
|
||||||
zoom: { value: newZoomValue },
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,6 +371,8 @@ export const zoomToFit = ({
|
|||||||
// size, it won't be zoomed in.
|
// size, it won't be zoomed in.
|
||||||
export const actionZoomToFitSelectionInViewport = register({
|
export const actionZoomToFitSelectionInViewport = register({
|
||||||
name: "zoomToFitSelectionInViewport",
|
name: "zoomToFitSelectionInViewport",
|
||||||
|
label: "labels.zoomToFitViewport",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -363,6 +396,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
|
|
||||||
export const actionZoomToFitSelection = register({
|
export const actionZoomToFitSelection = register({
|
||||||
name: "zoomToFitSelection",
|
name: "zoomToFitSelection",
|
||||||
|
label: "helpDialog.zoomToSelection",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -385,6 +420,8 @@ export const actionZoomToFitSelection = register({
|
|||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
|
label: "helpDialog.zoomToFit",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
@@ -405,6 +442,13 @@ export const actionZoomToFit = register({
|
|||||||
|
|
||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
|
label: (_, appState) => {
|
||||||
|
return appState.theme === THEME.DARK
|
||||||
|
? "buttons.lightMode"
|
||||||
|
: "buttons.darkMode";
|
||||||
|
},
|
||||||
|
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||||
|
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
@@ -414,7 +458,7 @@ export const actionToggleTheme = register({
|
|||||||
theme:
|
theme:
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||||
@@ -425,6 +469,7 @@ export const actionToggleTheme = register({
|
|||||||
|
|
||||||
export const actionToggleEraserTool = register({
|
export const actionToggleEraserTool = register({
|
||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
@@ -451,7 +496,7 @@ export const actionToggleEraserTool = register({
|
|||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.E,
|
keyTest: (event) => event.key === KEYS.E,
|
||||||
@@ -459,7 +504,11 @@ export const actionToggleEraserTool = register({
|
|||||||
|
|
||||||
export const actionToggleHandTool = register({
|
export const actionToggleHandTool = register({
|
||||||
name: "toggleHandTool",
|
name: "toggleHandTool",
|
||||||
|
label: "toolBar.hand",
|
||||||
|
paletteName: "Toggle hand tool",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
icon: handIcon,
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
@@ -486,7 +535,7 @@ export const actionToggleHandTool = register({
|
|||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
label: "labels.copy",
|
||||||
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||||
const elementsToCopy = app.scene.getSelectedElements({
|
const elementsToCopy = app.scene.getSelectedElements({
|
||||||
@@ -28,7 +32,7 @@ export const actionCopy = register({
|
|||||||
await copyToClipboard(elementsToCopy, app.files, event);
|
await copyToClipboard(elementsToCopy, app.files, event);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
@@ -37,16 +41,16 @@ export const actionCopy = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copy",
|
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPaste = register({
|
export const actionPaste = register({
|
||||||
name: "paste",
|
name: "paste",
|
||||||
|
label: "labels.paste",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, data, app) => {
|
perform: async (elements, appState, data, app) => {
|
||||||
let types;
|
let types;
|
||||||
@@ -63,7 +67,7 @@ export const actionPaste = register({
|
|||||||
|
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("hints.firefox_clipboard_write"),
|
errorMessage: t("hints.firefox_clipboard_write"),
|
||||||
@@ -72,7 +76,7 @@ export const actionPaste = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||||
@@ -85,7 +89,7 @@ export const actionPaste = register({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||||
@@ -94,32 +98,34 @@ export const actionPaste = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.paste",
|
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCut = register({
|
export const actionCut = register({
|
||||||
name: "cut",
|
name: "cut",
|
||||||
|
label: "labels.cut",
|
||||||
|
icon: cutIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||||
actionCopy.perform(elements, appState, event, app);
|
actionCopy.perform(elements, appState, event, app);
|
||||||
return actionDeleteSelected.perform(elements, appState);
|
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsSvg = register({
|
export const actionCopyAsSvg = register({
|
||||||
name: "copyAsSvg",
|
name: "copyAsSvg",
|
||||||
|
label: "labels.copyAsSvg",
|
||||||
|
icon: svgIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +144,11 @@ export const actionCopyAsSvg = register({
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
|
name: app.getName(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -150,23 +157,25 @@ export const actionCopyAsSvg = register({
|
|||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
predicate: (elements) => {
|
predicate: (elements) => {
|
||||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyAsSvg",
|
keywords: ["svg", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsPng = register({
|
export const actionCopyAsPng = register({
|
||||||
name: "copyAsPng",
|
name: "copyAsPng",
|
||||||
|
label: "labels.copyAsPng",
|
||||||
|
icon: pngIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@@ -184,6 +193,7 @@ export const actionCopyAsPng = register({
|
|||||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
|
name: app.getName(),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -199,7 +209,7 @@ export const actionCopyAsPng = register({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -208,19 +218,20 @@ export const actionCopyAsPng = register({
|
|||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
predicate: (elements) => {
|
predicate: (elements) => {
|
||||||
return probablySupportsClipboardBlob && elements.length > 0;
|
return probablySupportsClipboardBlob && elements.length > 0;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyAsPng",
|
|
||||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||||
|
keywords: ["png", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const copyText = register({
|
export const copyText = register({
|
||||||
name: "copyText",
|
name: "copyText",
|
||||||
|
label: "labels.copyText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@@ -236,9 +247,13 @@ export const copyText = register({
|
|||||||
return acc;
|
return acc;
|
||||||
}, [])
|
}, [])
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
copyTextToSystemClipboard(text);
|
try {
|
||||||
|
copyTextToSystemClipboard(text);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
@@ -252,5 +267,5 @@ export const copyText = register({
|
|||||||
.some(isTextElement)
|
.some(isTextElement)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyText",
|
keywords: ["text", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
|
|||||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -72,8 +73,10 @@ const handleGroupEditingState = (
|
|||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
|
label: "labels.delete",
|
||||||
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
@@ -81,7 +84,8 @@ export const actionDeleteSelected = register({
|
|||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -109,7 +113,7 @@ export const actionDeleteSelected = register({
|
|||||||
...nextAppState,
|
...nextAppState,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +145,7 @@ export const actionDeleteSelected = register({
|
|||||||
: [0],
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
@@ -161,13 +165,14 @@ export const actionDeleteSelected = register({
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
},
|
},
|
||||||
commitToHistory: isSomeElementSelected(
|
storeAction: isSomeElementSelected(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
),
|
)
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
|
||||||
keyTest: (event, appState, elements) =>
|
keyTest: (event, appState, elements) =>
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -32,7 +33,11 @@ const distributeSelectedElements = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
const updatedElements = distributeElements(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
distribution,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
@@ -45,6 +50,7 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
|
label: "labels.distributeHorizontally",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -53,7 +59,7 @@ export const distributeHorizontally = register({
|
|||||||
space: "between",
|
space: "between",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -75,6 +81,7 @@ export const distributeHorizontally = register({
|
|||||||
|
|
||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
|
label: "labels.distributeVertically",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -83,7 +90,7 @@ export const distributeVertically = register({
|
|||||||
space: "between",
|
space: "between",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -31,14 +31,22 @@ import {
|
|||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
label: "labels.duplicateSelection",
|
||||||
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
@@ -47,16 +55,15 @@ export const actionDuplicateSelection = register({
|
|||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: ret.appState,
|
appState: ret.appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...duplicateElements(elements, appState),
|
...duplicateElements(elements, appState),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.duplicateSelection",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@@ -85,6 +92,7 @@ const duplicateElements = (
|
|||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
const newElement = duplicateElement(
|
const newElement = duplicateElement(
|
||||||
@@ -96,6 +104,7 @@ const duplicateElements = (
|
|||||||
y: element.y + GRID_SIZE / 2,
|
y: element.y + GRID_SIZE / 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
oldElements.push(element);
|
oldElements.push(element);
|
||||||
newElements.push(newElement);
|
newElements.push(newElement);
|
||||||
@@ -139,7 +148,7 @@ const duplicateElements = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||||
const isElementAFrameLike = isFrameLikeElement(element);
|
const isElementAFrameLike = isFrameLikeElement(element);
|
||||||
|
|
||||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||||
@@ -233,8 +242,10 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// step (3)
|
// step (3)
|
||||||
|
const finalElements = syncMovedIndices(
|
||||||
const finalElements = finalElementsReversed.reverse();
|
finalElementsReversed.reverse(),
|
||||||
|
arrayToMap(newElements),
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -10,11 +13,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
|
|
||||||
export const actionToggleElementLock = register({
|
export const actionToggleElementLock = register({
|
||||||
name: "toggleElementLock",
|
name: "toggleElementLock",
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selected = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: false,
|
||||||
|
});
|
||||||
|
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||||
|
return selected[0].locked
|
||||||
|
? "labels.elementLock.unlock"
|
||||||
|
: "labels.elementLock.lock";
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldLock(selected)
|
||||||
|
? "labels.elementLock.lockAll"
|
||||||
|
: "labels.elementLock.unlockAll";
|
||||||
|
},
|
||||||
|
icon: (appState, elements) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
|
||||||
|
},
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return !selectedElements.some(
|
return (
|
||||||
(element) => element.locked && element.frameId,
|
selectedElements.length > 0 &&
|
||||||
|
!selectedElements.some((element) => element.locked && element.frameId)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -44,24 +67,9 @@ export const actionToggleElementLock = register({
|
|||||||
? null
|
? null
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: (elements, appState, app) => {
|
|
||||||
const selected = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
includeBoundTextElement: false,
|
|
||||||
});
|
|
||||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
|
||||||
return selected[0].locked
|
|
||||||
? "labels.elementLock.unlock"
|
|
||||||
: "labels.elementLock.lock";
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldLock(selected)
|
|
||||||
? "labels.elementLock.lockAll"
|
|
||||||
: "labels.elementLock.unlockAll";
|
|
||||||
},
|
|
||||||
keyTest: (event, appState, elements, app) => {
|
keyTest: (event, appState, elements, app) => {
|
||||||
return (
|
return (
|
||||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||||
@@ -77,10 +85,16 @@ export const actionToggleElementLock = register({
|
|||||||
|
|
||||||
export const actionUnlockAllElements = register({
|
export const actionUnlockAllElements = register({
|
||||||
name: "unlockAllElements",
|
name: "unlockAllElements",
|
||||||
|
paletteName: "Unlock all elements",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
predicate: (elements) => {
|
icon: UnlockedIcon,
|
||||||
return elements.some((element) => element.locked);
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length === 0 &&
|
||||||
|
elements.some((element) => element.locked)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const lockedElements = elements.filter((el) => el.locked);
|
const lockedElements = elements.filter((el) => el.locked);
|
||||||
@@ -98,8 +112,8 @@ export const actionUnlockAllElements = register({
|
|||||||
lockedElements.map((el) => [el.id, true]),
|
lockedElements.map((el) => [el.id, true]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.elementLock.unlockAll",
|
label: "labels.elementLock.unlockAll",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { questionCircle, saveAs } from "../components/icons";
|
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@@ -19,21 +19,23 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
|||||||
import { Theme } from "../element/types";
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
|
label: "labels.fileTitle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return {
|
||||||
|
appState: { ...appState, name: value },
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || "Unnamed"}
|
value={app.getName()}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
isNameEditable={
|
|
||||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
|
||||||
}
|
|
||||||
ignoreFocus={data?.ignoreFocus ?? false}
|
ignoreFocus={data?.ignoreFocus ?? false}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -41,11 +43,12 @@ export const actionChangeProjectName = register({
|
|||||||
|
|
||||||
export const actionChangeExportScale = register({
|
export const actionChangeExportScale = register({
|
||||||
name: "changeExportScale",
|
name: "changeExportScale",
|
||||||
|
label: "imageExportDialog.scale",
|
||||||
trackEvent: { category: "export", action: "scale" },
|
trackEvent: { category: "export", action: "scale" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportScale: value },
|
appState: { ...appState, exportScale: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||||
@@ -90,11 +93,12 @@ export const actionChangeExportScale = register({
|
|||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
|
label: "imageExportDialog.label.withBackground",
|
||||||
trackEvent: { category: "export", action: "toggleBackground" },
|
trackEvent: { category: "export", action: "toggleBackground" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportBackground: value },
|
appState: { ...appState, exportBackground: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
@@ -109,11 +113,12 @@ export const actionChangeExportBackground = register({
|
|||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
export const actionChangeExportEmbedScene = register({
|
||||||
name: "changeExportEmbedScene",
|
name: "changeExportEmbedScene",
|
||||||
|
label: "imageExportDialog.tooltip.embedScene",
|
||||||
trackEvent: { category: "export", action: "embedScene" },
|
trackEvent: { category: "export", action: "embedScene" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
appState: { ...appState, exportEmbedScene: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
@@ -131,6 +136,8 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
|
label: "buttons.save",
|
||||||
|
icon: ExportIcon,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -144,11 +151,16 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
? await resaveAsImageWithScene(
|
||||||
: await saveAsJSON(elements, appState, app.files);
|
elements,
|
||||||
|
appState,
|
||||||
|
app.files,
|
||||||
|
app.getName(),
|
||||||
|
)
|
||||||
|
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
@@ -170,7 +182,7 @@ export const actionSaveToActiveFile = register({
|
|||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -179,6 +191,8 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
|
label: "exportDialog.disk_title",
|
||||||
|
icon: ExportIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
@@ -190,9 +204,10 @@ export const actionSaveFileToDisk = register({
|
|||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
},
|
},
|
||||||
app.files,
|
app.files,
|
||||||
|
app.getName(),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openDialog: null,
|
openDialog: null,
|
||||||
@@ -206,7 +221,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -227,6 +242,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
|
label: "buttons.load",
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -244,7 +260,7 @@ export const actionLoadScene = register({
|
|||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
files,
|
files,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
@@ -255,7 +271,7 @@ export const actionLoadScene = register({
|
|||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
files: app.files,
|
files: app.files,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -264,11 +280,12 @@ export const actionLoadScene = register({
|
|||||||
|
|
||||||
export const actionExportWithDarkMode = register({
|
export const actionExportWithDarkMode = register({
|
||||||
name: "exportWithDarkMode",
|
name: "exportWithDarkMode",
|
||||||
|
label: "imageExportDialog.label.darkMode",
|
||||||
trackEvent: { category: "export", action: "toggleTheme" },
|
trackEvent: { category: "export", action: "toggleTheme" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportWithDarkMode: value },
|
appState: { ...appState, exportWithDarkMode: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { updateActiveTool } from "../utils";
|
import { arrayToMap, updateActiveTool } from "../utils";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -8,7 +8,6 @@ import { register } from "./register";
|
|||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
import { isPathALoop } from "../math";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import Scene from "../scene/Scene";
|
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
@@ -16,20 +15,21 @@ import {
|
|||||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (elements, appState, _, app) => {
|
||||||
elements,
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
appState,
|
|
||||||
_,
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
{ interactiveCanvas, focusContainer, scene },
|
|
||||||
) => {
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
if (isBindingElement(element)) {
|
if (isBindingElement(element)) {
|
||||||
@@ -37,6 +37,7 @@ export const actionFinalize = register({
|
|||||||
element,
|
element,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -48,8 +49,9 @@ export const actionFinalize = register({
|
|||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +92,9 @@ export const actionFinalize = register({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInvisiblySmallElement(multiPointElement)) {
|
if (isInvisiblySmallElement(multiPointElement)) {
|
||||||
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.filter(
|
newElements = newElements.filter(
|
||||||
(el) => el.id !== multiPointElement.id,
|
(el) => el.id !== multiPointElement.id,
|
||||||
);
|
);
|
||||||
@@ -125,13 +129,9 @@ export const actionFinalize = register({
|
|||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
multiPointElement,
|
multiPointElement,
|
||||||
-1,
|
-1,
|
||||||
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||||
multiPointElement,
|
|
||||||
appState,
|
|
||||||
Scene.getScene(multiPointElement)!,
|
|
||||||
{ x, y },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +186,12 @@ export const actionFinalize = register({
|
|||||||
// To select the linear element when user has finished mutipoint editing
|
// To select the linear element when user has finished mutipoint editing
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
multiPointElement && isLinearElement(multiPointElement)
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
? new LinearElementEditor(multiPointElement, scene)
|
? new LinearElementEditor(multiPointElement)
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeleted,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
|
} from "../element/types";
|
||||||
import { resizeMultipleElements } from "../element/resizeElements";
|
import { resizeMultipleElements } from "../element/resizeElements";
|
||||||
import { AppState, PointerDownState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
@@ -13,48 +17,66 @@ import {
|
|||||||
unbindLinearElements,
|
unbindLinearElements,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
|
label: "labels.flipHorizontal",
|
||||||
|
icon: flipHorizontal,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
elements: updateFrameMembershipOfSelectedElements(
|
elements: updateFrameMembershipOfSelectedElements(
|
||||||
flipSelectedElements(elements, appState, "horizontal"),
|
flipSelectedElements(
|
||||||
|
elements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
appState,
|
||||||
|
"horizontal",
|
||||||
|
app,
|
||||||
|
),
|
||||||
appState,
|
appState,
|
||||||
app,
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||||
contextItemLabel: "labels.flipHorizontal",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionFlipVertical = register({
|
export const actionFlipVertical = register({
|
||||||
name: "flipVertical",
|
name: "flipVertical",
|
||||||
|
label: "labels.flipVertical",
|
||||||
|
icon: flipVertical,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
elements: updateFrameMembershipOfSelectedElements(
|
elements: updateFrameMembershipOfSelectedElements(
|
||||||
flipSelectedElements(elements, appState, "vertical"),
|
flipSelectedElements(
|
||||||
|
elements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
appState,
|
||||||
|
"vertical",
|
||||||
|
app,
|
||||||
|
),
|
||||||
appState,
|
appState,
|
||||||
app,
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||||
contextItemLabel: "labels.flipVertical",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const flipSelectedElements = (
|
const flipSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@@ -67,8 +89,11 @@ const flipSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = flipElements(
|
const updatedElements = flipElements(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
appState,
|
appState,
|
||||||
flipDirection,
|
flipDirection,
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
@@ -79,24 +104,28 @@ const flipSelectedElements = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
elements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements);
|
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
{ originalElements: arrayToMap(elements) } as PointerDownState,
|
elementsMap,
|
||||||
elements,
|
selectedElements,
|
||||||
|
elementsMap,
|
||||||
"nw",
|
"nw",
|
||||||
true,
|
true,
|
||||||
flipDirection === "horizontal" ? maxX : minX,
|
flipDirection === "horizontal" ? maxX : minX,
|
||||||
flipDirection === "horizontal" ? minY : maxY,
|
flipDirection === "horizontal" ? minY : maxY,
|
||||||
);
|
);
|
||||||
|
|
||||||
(isBindingEnabled(appState)
|
isBindingEnabled(appState)
|
||||||
? bindOrUnbindSelectedElements
|
? bindOrUnbindSelectedElements(selectedElements, app)
|
||||||
: unbindLinearElements)(elements);
|
: unbindLinearElements(selectedElements, elementsMap);
|
||||||
|
|
||||||
return elements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { ExcalidrawElement } from "../element/types";
|
|||||||
import { removeAllElementsFromFrame } from "../frame";
|
import { removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameChildren } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
|
import { frameToolIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
const isSingleFrameSelected = (
|
||||||
|
appState: UIAppState,
|
||||||
|
app: AppClassProperties,
|
||||||
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +24,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
|||||||
|
|
||||||
export const actionSelectAllElementsInFrame = register({
|
export const actionSelectAllElementsInFrame = register({
|
||||||
name: "selectAllElementsInFrame",
|
name: "selectAllElementsInFrame",
|
||||||
|
label: "labels.selectAllElementsInFrame",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElement =
|
const selectedElement =
|
||||||
@@ -39,23 +45,23 @@ export const actionSelectAllElementsInFrame = register({
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
isSingleFrameSelected(appState, app),
|
isSingleFrameSelected(appState, app),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionRemoveAllElementsFromFrame = register({
|
export const actionRemoveAllElementsFromFrame = register({
|
||||||
name: "removeAllElementsFromFrame",
|
name: "removeAllElementsFromFrame",
|
||||||
|
label: "labels.removeAllElementsFromFrame",
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElement =
|
const selectedElement =
|
||||||
@@ -63,34 +69,30 @@ export const actionRemoveAllElementsFromFrame = register({
|
|||||||
|
|
||||||
if (isFrameLikeElement(selectedElement)) {
|
if (isFrameLikeElement(selectedElement)) {
|
||||||
return {
|
return {
|
||||||
elements: removeAllElementsFromFrame(
|
elements: removeAllElementsFromFrame(elements, selectedElement),
|
||||||
elements,
|
|
||||||
selectedElement,
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: {
|
selectedElementIds: {
|
||||||
[selectedElement.id]: true,
|
[selectedElement.id]: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
isSingleFrameSelected(appState, app),
|
isSingleFrameSelected(appState, app),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionupdateFrameRendering = register({
|
export const actionupdateFrameRendering = register({
|
||||||
name: "updateFrameRendering",
|
name: "updateFrameRendering",
|
||||||
|
label: "labels.updateFrameRendering",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
@@ -103,16 +105,18 @@ export const actionupdateFrameRendering = register({
|
|||||||
enabled: !appState.frameRendering.enabled,
|
enabled: !appState.frameRendering.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.updateFrameRendering",
|
|
||||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSetFrameAsActiveTool = register({
|
export const actionSetFrameAsActiveTool = register({
|
||||||
name: "setFrameAsActiveTool",
|
name: "setFrameAsActiveTool",
|
||||||
|
label: "toolBar.frame",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
icon: frameToolIcon,
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const nextActiveTool = updateActiveTool(appState, {
|
const nextActiveTool = updateActiveTool(appState, {
|
||||||
type: "frame",
|
type: "frame",
|
||||||
@@ -131,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
type: "frame",
|
type: "frame",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +31,8 @@ import {
|
|||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
@@ -61,6 +67,8 @@ const enableActionGroup = (
|
|||||||
|
|
||||||
export const actionGroup = register({
|
export const actionGroup = register({
|
||||||
name: "group",
|
name: "group",
|
||||||
|
label: "labels.group",
|
||||||
|
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@@ -69,7 +77,7 @@ export const actionGroup = register({
|
|||||||
});
|
});
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
// if everything is already grouped into 1 group, there is nothing to do
|
// if everything is already grouped into 1 group, there is nothing to do
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
@@ -89,7 +97,7 @@ export const actionGroup = register({
|
|||||||
]);
|
]);
|
||||||
if (combinedSet.size === elementIdsInGroup.size) {
|
if (combinedSet.size === elementIdsInGroup.size) {
|
||||||
// no incremental ids in the selected ids
|
// no incremental ids in the selected ids
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +113,9 @@ export const actionGroup = register({
|
|||||||
const frameElementsMap = groupByFrameLikes(selectedElements);
|
const frameElementsMap = groupByFrameLikes(selectedElements);
|
||||||
|
|
||||||
frameElementsMap.forEach((elementsInFrame, frameId) => {
|
frameElementsMap.forEach((elementsInFrame, frameId) => {
|
||||||
nextElements = removeElementsFromFrame(
|
removeElementsFromFrame(
|
||||||
nextElements,
|
|
||||||
elementsInFrame,
|
elementsInFrame,
|
||||||
appState,
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,18 +139,19 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
const lastGroupElementIndex = nextElements.lastIndexOf(
|
||||||
|
lastElementInGroup as OrderedExcalidrawElement,
|
||||||
|
);
|
||||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = nextElements
|
const elementsBeforeGroup = nextElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
.filter(
|
.filter(
|
||||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||||
);
|
);
|
||||||
nextElements = [
|
const reorderedElements = syncMovedIndices(
|
||||||
...elementsBeforeGroup,
|
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
|
||||||
...elementsInGroup,
|
arrayToMap(elementsInGroup),
|
||||||
...elementsAfterGroup,
|
);
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -154,11 +162,10 @@ export const actionGroup = register({
|
|||||||
getNonDeletedElements(nextElements),
|
getNonDeletedElements(nextElements),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
elements: nextElements,
|
elements: reorderedElements,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.group",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
enableActionGroup(elements, appState, app),
|
enableActionGroup(elements, appState, app),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -178,11 +185,15 @@ export const actionGroup = register({
|
|||||||
|
|
||||||
export const actionUngroup = register({
|
export const actionUngroup = register({
|
||||||
name: "ungroup",
|
name: "ungroup",
|
||||||
|
label: "labels.ungroup",
|
||||||
|
icon: (appState) => <UngroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
|
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextElements = [...elements];
|
let nextElements = [...elements];
|
||||||
@@ -227,9 +238,14 @@ export const actionUngroup = register({
|
|||||||
if (frame) {
|
if (frame) {
|
||||||
nextElements = replaceAllElementsInFrame(
|
nextElements = replaceAllElementsInFrame(
|
||||||
nextElements,
|
nextElements,
|
||||||
getElementsInResizingFrame(nextElements, frame, appState),
|
getElementsInResizingFrame(
|
||||||
|
nextElements,
|
||||||
|
frame,
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
frame,
|
frame,
|
||||||
appState,
|
app,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -250,14 +266,13 @@ export const actionUngroup = register({
|
|||||||
return {
|
return {
|
||||||
appState: { ...appState, ...updateAppState },
|
appState: { ...appState, ...updateAppState },
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key === KEYS.G.toUpperCase(),
|
event.key === KEYS.G.toUpperCase(),
|
||||||
contextItemLabel: "labels.ungroup",
|
|
||||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||||
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
|||||||
@@ -2,104 +2,117 @@ import { Action, ActionResult } from "./types";
|
|||||||
import { UndoIcon, RedoIcon } from "../components/icons";
|
import { UndoIcon, RedoIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import History, { HistoryEntry } from "../history";
|
import { History, HistoryChangedEvent } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
|
import { SceneElementsMap } from "../element/types";
|
||||||
|
import { IStore, StoreAction } from "../store";
|
||||||
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
appState: Readonly<AppState>,
|
||||||
appState: AppState,
|
updater: () => [SceneElementsMap, AppState] | void,
|
||||||
updater: () => HistoryEntry | null,
|
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
const commitToHistory = false;
|
|
||||||
if (
|
if (
|
||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.resizingElement &&
|
!appState.resizingElement &&
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
!appState.draggingElement
|
!appState.draggingElement
|
||||||
) {
|
) {
|
||||||
const data = updater();
|
const result = updater();
|
||||||
if (data === null) {
|
|
||||||
return { commitToHistory };
|
if (!result) {
|
||||||
|
return { storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = arrayToMap(prevElements);
|
const [nextElementsMap, nextAppState] = result;
|
||||||
const nextElements = data.elements;
|
const nextElements = Array.from(nextElementsMap.values());
|
||||||
const nextElementMap = arrayToMap(nextElements);
|
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
|
||||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
|
||||||
);
|
|
||||||
const elements = nextElements
|
|
||||||
.map((nextElement) =>
|
|
||||||
newElementWith(
|
|
||||||
prevElementMap.get(nextElement.id) || nextElement,
|
|
||||||
nextElement,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.concat(
|
|
||||||
deletedElements.map((prevElement) =>
|
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixBindingsAfterDeletion(elements, deletedElements);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
appState: nextAppState,
|
||||||
appState: { ...appState, ...data.appState },
|
elements: nextElements,
|
||||||
commitToHistory,
|
storeAction: StoreAction.UPDATE,
|
||||||
syncHistory: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { commitToHistory };
|
|
||||||
|
return { storeAction: StoreAction.NONE };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History) => Action;
|
type ActionCreator = (history: History, store: IStore) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
|
label: "buttons.undo",
|
||||||
|
icon: UndoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(appState, () =>
|
||||||
|
history.undo(
|
||||||
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
|
appState,
|
||||||
|
store.snapshot,
|
||||||
|
),
|
||||||
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData, data }) => {
|
||||||
<ToolButton
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
type="button"
|
history.onHistoryChangedEmitter,
|
||||||
icon={UndoIcon}
|
new HistoryChangedEvent(),
|
||||||
aria-label={t("buttons.undo")}
|
);
|
||||||
onClick={updateData}
|
|
||||||
size={data?.size || "medium"}
|
return (
|
||||||
/>
|
<ToolButton
|
||||||
),
|
type="button"
|
||||||
commitToHistory: () => false,
|
icon={UndoIcon}
|
||||||
|
aria-label={t("buttons.undo")}
|
||||||
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
|
disabled={isUndoStackEmpty}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history) => ({
|
export const createRedoAction: ActionCreator = (history, store) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
|
label: "buttons.redo",
|
||||||
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(appState, () =>
|
||||||
|
history.redo(
|
||||||
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
|
appState,
|
||||||
|
store.snapshot,
|
||||||
|
),
|
||||||
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] &&
|
(event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData, data }) => {
|
||||||
<ToolButton
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
type="button"
|
history.onHistoryChangedEmitter,
|
||||||
icon={RedoIcon}
|
new HistoryChangedEvent(),
|
||||||
aria-label={t("buttons.redo")}
|
);
|
||||||
onClick={updateData}
|
|
||||||
size={data?.size || "medium"}
|
return (
|
||||||
/>
|
<ToolButton
|
||||||
),
|
type="button"
|
||||||
commitToHistory: () => false,
|
icon={RedoIcon}
|
||||||
|
aria-label={t("buttons.redo")}
|
||||||
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
|
disabled={isRedoStackEmpty}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
export const actionToggleLinearEditor = register({
|
||||||
name: "toggleLinearEditor",
|
name: "toggleLinearEditor",
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selectedElement = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
return appState.editingLinearElement?.elementId === selectedElement?.id
|
||||||
|
? "labels.lineEditor.exit"
|
||||||
|
: "labels.lineEditor.edit";
|
||||||
|
},
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "element",
|
category: "element",
|
||||||
},
|
},
|
||||||
@@ -24,22 +36,13 @@ export const actionToggleLinearEditor = register({
|
|||||||
const editingLinearElement =
|
const editingLinearElement =
|
||||||
appState.editingLinearElement?.elementId === selectedElement.id
|
appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
? null
|
? null
|
||||||
: new LinearElementEditor(selectedElement, app.scene);
|
: new LinearElementEditor(selectedElement);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement,
|
editingLinearElement,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: (elements, appState, app) => {
|
|
||||||
const selectedElement = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
})[0] as ExcalidrawLinearElement;
|
|
||||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
|
||||||
? "labels.lineEditor.exit"
|
|
||||||
: "labels.lineEditor.edit";
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
|
import { LinkIcon } from "../components/icons";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { isEmbeddableElement } from "../element/typeChecks";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionLink = register({
|
||||||
|
name: "hyperlink",
|
||||||
|
label: (elements, appState) => getContextMenuLabel(elements, appState),
|
||||||
|
icon: LinkIcon,
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
if (appState.showHyperlinkPopup === "editor") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
showHyperlinkPopup: "editor",
|
||||||
|
openMenu: null,
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
trackEvent: { category: "hyperlink", action: "click" },
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||||
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return selectedElements.length === 1;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={LinkIcon}
|
||||||
|
aria-label={t(getContextMenuLabel(elements, appState))}
|
||||||
|
title={`${
|
||||||
|
isEmbeddableElement(elements[0])
|
||||||
|
? t("labels.link.labelEmbed")
|
||||||
|
: t("labels.link.label")
|
||||||
|
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
|
label: "buttons.menu",
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform: (_, appState) => ({
|
perform: (_, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
}),
|
}),
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@@ -28,13 +30,14 @@ export const actionToggleCanvasMenu = register({
|
|||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
export const actionToggleEditMenu = register({
|
||||||
name: "toggleEditMenu",
|
name: "toggleEditMenu",
|
||||||
|
label: "buttons.edit",
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform: (_elements, appState) => ({
|
perform: (_elements, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
}),
|
}),
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@@ -53,6 +56,8 @@ export const actionToggleEditMenu = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
|
label: "welcomeScreen.defaults.helpHint",
|
||||||
|
icon: HelpIconThin,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||||
perform: (_elements, appState, _, { focusContainer }) => {
|
perform: (_elements, appState, _, { focusContainer }) => {
|
||||||
@@ -69,7 +74,7 @@ export const actionShortcuts = register({
|
|||||||
name: "help",
|
name: "help",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||||
import { eyeIcon } from "../components/icons";
|
import {
|
||||||
|
eyeIcon,
|
||||||
|
microphoneIcon,
|
||||||
|
microphoneMutedIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
|
label: "Go to a collaborator",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "collab" },
|
trackEvent: { category: "collab" },
|
||||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||||
@@ -21,7 +28,7 @@ export const actionGoToCollaborator = register({
|
|||||||
...appState,
|
...appState,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,18 +42,49 @@ export const actionGoToCollaborator = register({
|
|||||||
// Close mobile menu
|
// Close mobile menu
|
||||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, data, appState }) => {
|
PanelComponent: ({ updateData, data, appState }) => {
|
||||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||||
data as GoToCollaboratorComponentProps;
|
data as GoToCollaboratorComponentProps;
|
||||||
|
|
||||||
const background = getClientColor(clientId);
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
const statusClassNames = clsx({
|
||||||
|
"is-followed": isBeingFollowed,
|
||||||
|
"is-current-user": collaborator.isCurrentUser === true,
|
||||||
|
"is-speaking": collaborator.isSpeaking,
|
||||||
|
"is-in-call": collaborator.isInCall,
|
||||||
|
"is-muted": collaborator.isMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusIconJSX = collaborator.isInCall ? (
|
||||||
|
collaborator.isSpeaking ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-speaking-indicator"
|
||||||
|
title={t("userList.hint.isSpeaking")}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
) : collaborator.isMuted ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-microphone-muted"
|
||||||
|
title={t("userList.hint.micMuted")}
|
||||||
|
>
|
||||||
|
{microphoneMutedIcon}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return withName ? (
|
return withName ? (
|
||||||
<div
|
<div
|
||||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||||
|
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||||
onClick={() => updateData<Collaborator>(collaborator)}
|
onClick={() => updateData<Collaborator>(collaborator)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -54,30 +92,42 @@ export const actionGoToCollaborator = register({
|
|||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.avatarUrl}
|
||||||
isBeingFollowed={isBeingFollowed}
|
className={statusClassNames}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
|
||||||
/>
|
/>
|
||||||
{collaborator.username}
|
<div className="UserList__collaborator-name">
|
||||||
<div
|
{collaborator.username}
|
||||||
className="UserList__collaborator-follow-status-icon"
|
</div>
|
||||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
{isBeingFollowed && (
|
||||||
aria-hidden
|
<div
|
||||||
>
|
className="UserList__collaborator-status-icon-is-followed"
|
||||||
{eyeIcon}
|
title={t("userList.hint.followStatus")}
|
||||||
|
>
|
||||||
|
{eyeIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusIconJSX}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Avatar
|
<div
|
||||||
color={background}
|
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||||
onClick={() => {
|
>
|
||||||
updateData(collaborator);
|
<Avatar
|
||||||
}}
|
color={background}
|
||||||
name={collaborator.username || ""}
|
onClick={() => {
|
||||||
src={collaborator.avatarUrl}
|
updateData(collaborator);
|
||||||
isBeingFollowed={isBeingFollowed}
|
}}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
name={collaborator.username || ""}
|
||||||
/>
|
src={collaborator.avatarUrl}
|
||||||
|
className={statusClassNames}
|
||||||
|
/>
|
||||||
|
{statusIconJSX && (
|
||||||
|
<div className="UserList__collaborator-status-icon">
|
||||||
|
{statusIconJSX}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppState, Primitive } from "../types";
|
import { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
ArrowheadCircleOutlineIcon,
|
ArrowheadCircleOutlineIcon,
|
||||||
ArrowheadDiamondIcon,
|
ArrowheadDiamondIcon,
|
||||||
ArrowheadDiamondOutlineIcon,
|
ArrowheadDiamondOutlineIcon,
|
||||||
|
fontSizeIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
@@ -66,7 +67,6 @@ import {
|
|||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +96,7 @@ import {
|
|||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -189,6 +190,7 @@ const offsetElementAfterFontResize = (
|
|||||||
const changeFontSize = (
|
const changeFontSize = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
app: AppClassProperties,
|
||||||
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
||||||
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
||||||
) => {
|
) => {
|
||||||
@@ -206,7 +208,11 @@ const changeFontSize = (
|
|||||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
fontSize: newFontSize,
|
fontSize: newFontSize,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
@@ -226,7 +232,7 @@ const changeFontSize = (
|
|||||||
? [...newFontSizes][0]
|
? [...newFontSizes][0]
|
||||||
: fallbackValue ?? appState.currentItemFontSize,
|
: fallbackValue ?? appState.currentItemFontSize,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,6 +240,7 @@ const changeFontSize = (
|
|||||||
|
|
||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
|
label: "labels.stroke",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -255,7 +262,9 @@ export const actionChangeStrokeColor = register({
|
|||||||
...appState,
|
...appState,
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
commitToHistory: !!value.currentItemStrokeColor,
|
storeAction: !!value.currentItemStrokeColor
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
@@ -284,6 +293,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
|
|
||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
|
label: "labels.changeBackground",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -298,7 +308,9 @@ export const actionChangeBackgroundColor = register({
|
|||||||
...appState,
|
...appState,
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
commitToHistory: !!value.currentItemBackgroundColor,
|
storeAction: !!value.currentItemBackgroundColor
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
@@ -327,6 +339,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
|
label: "labels.fill",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
trackEvent(
|
trackEvent(
|
||||||
@@ -341,7 +354,7 @@ export const actionChangeFillStyle = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemFillStyle: value },
|
appState: { ...appState, currentItemFillStyle: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
@@ -404,6 +417,7 @@ export const actionChangeFillStyle = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
|
label: "labels.strokeWidth",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -413,7 +427,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemStrokeWidth: value },
|
appState: { ...appState, currentItemStrokeWidth: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -457,6 +471,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
|
|
||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
|
label: "labels.sloppiness",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -467,7 +482,7 @@ export const actionChangeSloppiness = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemRoughness: value },
|
appState: { ...appState, currentItemRoughness: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -508,6 +523,7 @@ export const actionChangeSloppiness = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
|
label: "labels.strokeStyle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -517,7 +533,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemStrokeStyle: value },
|
appState: { ...appState, currentItemStrokeStyle: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -558,6 +574,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
|
label: "labels.opacity",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -571,7 +588,7 @@ export const actionChangeOpacity = register({
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -599,11 +616,12 @@ export const actionChangeOpacity = register({
|
|||||||
|
|
||||||
export const actionChangeFontSize = register({
|
export const actionChangeFontSize = register({
|
||||||
name: "changeFontSize",
|
name: "changeFontSize",
|
||||||
|
label: "labels.fontSize",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<ButtonIconSelect
|
<ButtonIconSelect
|
||||||
@@ -641,14 +659,21 @@ export const actionChangeFontSize = register({
|
|||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
return element.fontSize;
|
return element.fontSize;
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
return boundTextElement.fontSize;
|
return boundTextElement.fontSize;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
(element) =>
|
(element) =>
|
||||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
isTextElement(element) ||
|
||||||
|
getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
) !== null,
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection
|
hasSelection
|
||||||
? null
|
? null
|
||||||
@@ -662,9 +687,11 @@ export const actionChangeFontSize = register({
|
|||||||
|
|
||||||
export const actionDecreaseFontSize = register({
|
export const actionDecreaseFontSize = register({
|
||||||
name: "decreaseFontSize",
|
name: "decreaseFontSize",
|
||||||
|
label: "labels.decreaseFontSize",
|
||||||
|
icon: fontSizeIcon,
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, (element) =>
|
return changeFontSize(elements, appState, app, (element) =>
|
||||||
Math.round(
|
Math.round(
|
||||||
// get previous value before relative increase (doesn't work fully
|
// get previous value before relative increase (doesn't work fully
|
||||||
// due to rounding and float precision issues)
|
// due to rounding and float precision issues)
|
||||||
@@ -684,9 +711,11 @@ export const actionDecreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionIncreaseFontSize = register({
|
export const actionIncreaseFontSize = register({
|
||||||
name: "increaseFontSize",
|
name: "increaseFontSize",
|
||||||
|
label: "labels.increaseFontSize",
|
||||||
|
icon: fontSizeIcon,
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, (element) =>
|
return changeFontSize(elements, appState, app, (element) =>
|
||||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -702,8 +731,9 @@ export const actionIncreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
|
label: "labels.fontFamily",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(
|
||||||
elements,
|
elements,
|
||||||
@@ -717,7 +747,11 @@ export const actionChangeFontFamily = register({
|
|||||||
lineHeight: getDefaultLineHeight(value),
|
lineHeight: getDefaultLineHeight(value),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,10 +763,10 @@ export const actionChangeFontFamily = register({
|
|||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFontFamily: value,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
const options: {
|
const options: {
|
||||||
value: FontFamilyValues;
|
value: FontFamilyValues;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -772,14 +806,21 @@ export const actionChangeFontFamily = register({
|
|||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
return element.fontFamily;
|
return element.fontFamily;
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
return boundTextElement.fontFamily;
|
return boundTextElement.fontFamily;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
(element) =>
|
(element) =>
|
||||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
isTextElement(element) ||
|
||||||
|
getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
) !== null,
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection
|
hasSelection
|
||||||
? null
|
? null
|
||||||
@@ -794,8 +835,9 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
name: "changeTextAlign",
|
name: "changeTextAlign",
|
||||||
|
label: "Change text alignment",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(
|
||||||
elements,
|
elements,
|
||||||
@@ -806,7 +848,11 @@ export const actionChangeTextAlign = register({
|
|||||||
oldElement,
|
oldElement,
|
||||||
{ textAlign: value },
|
{ textAlign: value },
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,10 +864,11 @@ export const actionChangeTextAlign = register({
|
|||||||
...appState,
|
...appState,
|
||||||
currentItemTextAlign: value,
|
currentItemTextAlign: value,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
@@ -854,14 +901,18 @@ export const actionChangeTextAlign = register({
|
|||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
return element.textAlign;
|
return element.textAlign;
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
return boundTextElement.textAlign;
|
return boundTextElement.textAlign;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
(element) =>
|
(element) =>
|
||||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
isTextElement(element) ||
|
||||||
|
getBoundTextElement(element, elementsMap) !== null,
|
||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemTextAlign,
|
hasSelection ? null : appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
@@ -874,8 +925,9 @@ export const actionChangeTextAlign = register({
|
|||||||
|
|
||||||
export const actionChangeVerticalAlign = register({
|
export const actionChangeVerticalAlign = register({
|
||||||
name: "changeVerticalAlign",
|
name: "changeVerticalAlign",
|
||||||
|
label: "Change vertical alignment",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(
|
elements: changeProperty(
|
||||||
elements,
|
elements,
|
||||||
@@ -887,7 +939,11 @@ export const actionChangeVerticalAlign = register({
|
|||||||
{ verticalAlign: value },
|
{ verticalAlign: value },
|
||||||
);
|
);
|
||||||
|
|
||||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,10 +954,10 @@ export const actionChangeVerticalAlign = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<ButtonIconSelect<VerticalAlign | false>
|
<ButtonIconSelect<VerticalAlign | false>
|
||||||
@@ -933,14 +989,21 @@ export const actionChangeVerticalAlign = register({
|
|||||||
if (isTextElement(element) && element.containerId) {
|
if (isTextElement(element) && element.containerId) {
|
||||||
return element.verticalAlign;
|
return element.verticalAlign;
|
||||||
}
|
}
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
return boundTextElement.verticalAlign;
|
return boundTextElement.verticalAlign;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
(element) =>
|
(element) =>
|
||||||
isTextElement(element) || getBoundTextElement(element) !== null,
|
isTextElement(element) ||
|
||||||
|
getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
) !== null,
|
||||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -952,6 +1015,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
|
|
||||||
export const actionChangeRoundness = register({
|
export const actionChangeRoundness = register({
|
||||||
name: "changeRoundness",
|
name: "changeRoundness",
|
||||||
|
label: "Change edge roundness",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -971,7 +1035,7 @@ export const actionChangeRoundness = register({
|
|||||||
...appState,
|
...appState,
|
||||||
currentItemRoundness: value,
|
currentItemRoundness: value,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
@@ -1090,6 +1154,7 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
export const actionChangeArrowhead = register({
|
||||||
name: "changeArrowhead",
|
name: "changeArrowhead",
|
||||||
|
label: "Change arrowheads",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
@@ -1122,7 +1187,7 @@ export const actionChangeArrowhead = register({
|
|||||||
? "currentItemStartArrowhead"
|
? "currentItemStartArrowhead"
|
||||||
: "currentItemEndArrowhead"]: value.type,
|
: "currentItemEndArrowhead"]: value.type,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import { ExcalidrawElement } from "../element/types";
|
|||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
|
label: "labels.selectAll",
|
||||||
|
icon: selectAllIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
@@ -43,12 +48,11 @@ export const actionSelectAll = register({
|
|||||||
// single linear element selected
|
// single linear element selected
|
||||||
Object.keys(selectedElementIds).length === 1 &&
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
isLinearElement(elements[0])
|
isLinearElement(elements[0])
|
||||||
? new LinearElementEditor(elements[0], app.scene)
|
? new LinearElementEditor(elements[0])
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAll",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,19 +25,26 @@ import {
|
|||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { ExcalidrawTextElement } from "../element/types";
|
import { ExcalidrawTextElement } from "../element/types";
|
||||||
|
import { paintIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
|
label: "labels.copyStyles",
|
||||||
|
icon: paintIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsCopied = [];
|
const elementsCopied = [];
|
||||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||||
elementsCopied.push(element);
|
elementsCopied.push(element);
|
||||||
if (element && hasBoundTextElement(element)) {
|
if (element && hasBoundTextElement(element)) {
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
elementsCopied.push(boundTextElement);
|
elementsCopied.push(boundTextElement);
|
||||||
}
|
}
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -48,23 +55,24 @@ export const actionCopyStyles = register({
|
|||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.copyStyles") },
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyStyles",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
|
label: "labels.pasteStyles",
|
||||||
|
icon: paintIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsCopied = JSON.parse(copiedStyles);
|
const elementsCopied = JSON.parse(copiedStyles);
|
||||||
const pastedElement = elementsCopied[0];
|
const pastedElement = elementsCopied[0];
|
||||||
const boundTextElement = elementsCopied[1];
|
const boundTextElement = elementsCopied[1];
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, commitToHistory: false };
|
return { elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, {
|
const selectedElements = getSelectedElements(elements, appState, {
|
||||||
@@ -125,7 +133,11 @@ export const actionPasteStyles = register({
|
|||||||
element.id === newElement.containerId,
|
element.id === newElement.containerId,
|
||||||
) || null;
|
) || null;
|
||||||
}
|
}
|
||||||
redrawTextBoundingBox(newElement, container);
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -149,10 +161,9 @@ export const actionPasteStyles = register({
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.pasteStyles",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { gridIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
|
icon: gridIcon,
|
||||||
|
keywords: ["snap"],
|
||||||
|
label: "labels.toggleGrid",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@@ -17,13 +22,12 @@ export const actionToggleGridMode = register({
|
|||||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridSize !== null,
|
checked: (appState: AppState) => appState.gridSize !== null,
|
||||||
predicate: (element, appState, props) => {
|
predicate: (element, appState, props) => {
|
||||||
return typeof props.gridModeEnabled === "undefined";
|
return typeof props.gridModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.showGrid",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { magnetIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleObjectsSnapMode = register({
|
export const actionToggleObjectsSnapMode = register({
|
||||||
name: "objectsSnapMode",
|
name: "objectsSnapMode",
|
||||||
viewMode: true,
|
label: "buttons.objectsSnapMode",
|
||||||
|
icon: magnetIcon,
|
||||||
|
viewMode: false,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||||
@@ -15,14 +19,13 @@ export const actionToggleObjectsSnapMode = register({
|
|||||||
objectsSnapModeEnabled: !this.checked!(appState),
|
objectsSnapModeEnabled: !this.checked!(appState),
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "buttons.objectsSnapMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||||
});
|
});
|
||||||
|
|||||||