Compare commits

..

21 Commits

Author SHA1 Message Date
Marcel Mraz
d872adf593 Order based on fractional index in history action 2023-12-11 12:15:38 +01:00
Marcel Mraz
260706c42f First step towards delta based history
Introducing independent change detection for appState and elements

Generalizing object change, cleanup, refactoring, comments, solving typing issues

Shaping increment, change, delta hierarchy

Structural clone of elements

Introducing store and incremental API

Disabling buttons for canvas actions, smaller store and changes improvements

Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands

Solving concurrency issues, solving (partly) linear element issues,  introducing commitToStore breaking change

Fixing existing tests, updating snapshots

Trying to be smarter on the appstate change detection

Extending collab test, refactoring action / updateScene params, bugfixes

Resetting snapshots

Resetting snapshots

UI / API tests for history - WIP

Changing actions related to the observed appstate to at least update the store snapshot - WIP

Adding skipping of snapshot update flag for most no-breaking changes compatible solution

Ignoring uncomitted elements from local async actions, updating store directly in updateScene

Bound element issues - WIP
2023-12-11 11:59:24 +01:00
Ryan Di
5e98047267 test the fixing of fractional indices 2023-12-08 18:06:09 +08:00
Ryan Di
741380bd43 one more case for restoring test 2023-12-08 10:43:34 +08:00
Ryan Di
5ed82cb646 not using jittered keys in tests (for snapshot matching) 2023-12-07 23:40:56 +08:00
Ryan Di
dddb07cf57 restore test 2023-12-07 23:33:31 +08:00
Ryan Di
d6a6c40051 jitter when restoring as well 2023-12-06 23:39:34 +08:00
Ryan Di
bf53d90c68 indices with jitter 2023-12-06 23:25:11 +08:00
Ryan Di
b734f7cba8 fix shift to end z-index error 2023-12-06 16:56:55 +08:00
Ryan Di
4f218856c3 fix fractional indices on duplication 2023-12-06 10:39:13 +08:00
Ryan Di
7dfba985f9 fix fractional indices on adding new elements 2023-12-05 23:04:17 +08:00
Ryan Di
5bc23d6dee merge branch 'fractional-indexing' of github.com:excalidraw/excalidraw into fractional-indexing 2023-12-05 13:09:35 +08:00
Ryan Di
093e684d9e generate real fractional index after z-index actions 2023-12-05 13:07:07 +08:00
Ryan Di
84c1de7a03 generate real fractional index after z-index actions 2023-12-05 13:06:00 +08:00
Ryan Di
d1a9c593cc refactor code 2023-12-01 17:43:01 +08:00
Ryan Di
a7154227cf reconciliate order based on fractional index 2023-12-01 15:59:36 +08:00
Ryan Di
1e132e33ae normalize before replacing 2023-12-01 15:58:49 +08:00
Ryan Di
00ffa08e28 use string as fractional index value 2023-11-30 19:02:14 +08:00
Ryan Di
5c1787bdf4 update data restore to keep fractional index 2023-11-29 18:18:44 +08:00
Ryan Di
de32256466 simplify 2023-11-29 18:13:51 +08:00
Ryan Di
02dc00a47e fractionalIndex as a byproduct or zIndex 2023-11-29 17:41:04 +08:00
639 changed files with 17423 additions and 14795 deletions

View File

@@ -6,6 +6,6 @@
!.prettierrc !.prettierrc
!package.json !package.json
!public/ !public/
!packages/ !src/
!tsconfig.json !tsconfig.json
!yarn.lock !yarn.lock

View File

@@ -13,8 +13,6 @@ 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
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit! # put these in your .env.local, or make sure you don't commit!

View File

@@ -9,8 +9,6 @@ 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
# Fill to set socket server URL used for collaboration. # Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow # Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL= VITE_APP_WS_SERVER_URL=

View File

@@ -5,4 +5,4 @@ package-lock.json
firebase/ firebase/
dist/ dist/
public/workbox public/workbox
packages/excalidraw/types src/packages/excalidraw/types

View File

@@ -44,7 +44,7 @@ jobs:
- name: Auto release preview - name: Auto release preview
id: "autorelease" id: "autorelease"
run: | run: |
yarn add @actions/core -W yarn add @actions/core
yarn autorelease preview ${{ github.event.issue.number }} yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release - name: Post comment post release
if: always() if: always()

View File

@@ -16,7 +16,7 @@ jobs:
- name: Install and lint - name: Install and lint
run: | run: |
yarn install yarn --frozen-lockfile
yarn test:other yarn test:other
yarn test:code yarn test:code
yarn test:typecheck yarn test:typecheck

View File

@@ -22,11 +22,11 @@ jobs:
- name: Create report file - name: Create report file
run: | run: |
yarn locales-coverage yarn locales-coverage
FILE_CHANGED=$(git diff packages/excalidraw/locales/percentages.json) FILE_CHANGED=$(git diff src/locales/percentages.json)
if [ ! -z "${FILE_CHANGED}" ]; then if [ ! -z "${FILE_CHANGED}" ]; then
git config --global user.name 'Excalidraw Bot' git config --global user.name 'Excalidraw Bot'
git config --global user.email 'bot@excalidraw.com' git config --global user.email 'bot@excalidraw.com'
git add packages/excalidraw/locales/percentages.json git add src/locales/percentages.json
git commit -am "Auto commit: Calculate translation coverage" git commit -am "Auto commit: Calculate translation coverage"
git push git push
fi fi

View File

@@ -15,14 +15,16 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.x node-version: 18.x
- name: Install in packages/excalidraw - name: Install
run: yarn run: yarn --frozen-lockfile
working-directory: packages/excalidraw - name: Install in src/packages/excalidraw
run: yarn --frozen-lockfile
working-directory: src/packages/excalidraw
env: env:
CI: true CI: true
- uses: andresz1/size-limit-action@v1 - uses: andresz1/size-limit-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm build_script: build:umd
skip_step: install skip_step: install
directory: packages/excalidraw directory: src/packages/excalidraw

View File

@@ -16,7 +16,7 @@ jobs:
with: with:
node-version: "18.x" node-version: "18.x"
- name: "Install Deps" - name: "Install Deps"
run: yarn install run: yarn --frozen-lockfile
- name: "Test Coverage" - name: "Test Coverage"
run: yarn test:coverage run: yarn test:coverage
- name: "Report Coverage" - name: "Report Coverage"

View File

@@ -13,5 +13,5 @@ jobs:
node-version: 18.x node-version: 18.x
- name: Install and test - name: Install and test
run: | run: |
yarn install yarn --frozen-lockfile
yarn test:app yarn test:app

5
.gitignore vendored
View File

@@ -21,7 +21,10 @@ npm-debug.log*
package-lock.json package-lock.json
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
packages/excalidraw/types src/packages/excalidraw/types
src/packages/excalidraw/example/public/bundle.js
src/packages/excalidraw/example/public/excalidraw-assets-dev
src/packages/excalidraw/example/public/excalidraw.development.js
coverage coverage
dev-dist dev-dist
html html

View File

@@ -85,7 +85,7 @@ We'll be adding these features as drop-in plugins for the npm package in the fut
## Quick start ## Quick start
**Note:** following instructions are for installing the Excalidraw [npm package](https://www.npmjs.com/package/@excalidraw/excalidraw) when integrating Excalidraw into your own app. To run the repository locally for development, please refer to our [Development Guide](https://docs.excalidraw.com/docs/introduction/development). Install the [Excalidraw npm package](https://www.npmjs.com/package/@excalidraw/excalidraw):
``` ```
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -97,7 +97,7 @@ or via yarn
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
Check out our [documentation](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/installation) for more details! Don't forget to check out our [Documentation](https://docs.excalidraw.com)!
## Contributing ## Contributing

View File

@@ -1,3 +1,3 @@
files: files:
- source: /packages/excalidraw/locales/en.json - source: /src/locales/en.json
translation: /packages/excalidraw/locales/%locale%.json translation: /src/locales/%locale%.json

View File

@@ -133,7 +133,7 @@ function App() {
} }
``` ```
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items. Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/src/components/mainMenu/DefaultItems.tsx) of the default items.
### MainMenu.Group ### MainMenu.Group

View File

@@ -37,7 +37,7 @@ Defaults to `THEME.LIGHT` unless passed in `initialData.appState.theme`
### MIME_TYPES ### MIME_TYPES
[`MIME_TYPES`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L101) contains all the mime types supported by `Excalidraw`. [`MIME_TYPES`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L101) contains all the mime types supported by `Excalidraw`.
**How to use ** **How to use **

View File

@@ -2,9 +2,9 @@
We support a simplified API to make it easier to generate Excalidraw elements programmatically. This API is in beta and subject to change before stable. You can check the [PR](https://github.com/excalidraw/excalidraw/pull/6546) for more details. We support a simplified API to make it easier to generate Excalidraw elements programmatically. This API is in beta and subject to change before stable. You can check the [PR](https://github.com/excalidraw/excalidraw/pull/6546) for more details.
For this purpose we introduced a new type [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133). This is the simplified version of [`ExcalidrawElement`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L134) type with the minimum possible attributes so that creating elements programmatically is much easier (especially for cases like binding arrows or creating text containers). For this purpose we introduced a new type [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133). This is the simplified version of [`ExcalidrawElement`](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L134) type with the minimum possible attributes so that creating elements programmatically is much easier (especially for cases like binding arrows or creating text containers).
The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133) can be converted to fully qualified Excalidraw elements by using [`convertToExcalidrawElements`](/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements). The [`ExcalidrawElementSkeleton`](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133) can be converted to fully qualified Excalidraw elements by using [`convertToExcalidrawElements`](/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements).
## convertToExcalidrawElements ## convertToExcalidrawElements
@@ -19,7 +19,7 @@ convertToExcalidrawElements(
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `elements` | [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L137) | | The Excalidraw element Skeleton which needs to be converted to Excalidraw elements. | | `elements` | [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L137) | | The Excalidraw element Skeleton which needs to be converted to Excalidraw elements. |
| `opts` | `{ regenerateIds: boolean }` | ` {regenerateIds: true}` | By default `id` will be regenerated for all the elements irrespective of whether you pass the `id` so if you don't want the ids to regenerated, you can set this attribute to `false`. | | `opts` | `{ regenerateIds: boolean }` | ` {regenerateIds: true}` | By default `id` will be regenerated for all the elements irrespective of whether you pass the `id` so if you don't want the ids to regenerated, you can set this attribute to `false`. |
**_How to use_** **_How to use_**
@@ -71,7 +71,7 @@ function App() {
} }
``` ```
You can pass additional [`properties`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L27) as well to decorate the shapes. You can pass additional [`properties`](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L27) as well to decorate the shapes.
:::info :::info
@@ -192,7 +192,7 @@ convertToExcalidrawElements([
### Text Containers ### Text Containers
In addition to `type`, `x` and `y` properties, [`label`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L124C7-L130C59) property is required for text containers. The `text` property in `label` is required, rest of the attributes are optional. In addition to `type`, `x` and `y` properties, [`label`](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L124C7-L130C59) property is required for text containers. The `text` property in `label` is required, rest of the attributes are optional.
If you don't provide the dimensions of container, we calculate it based of the label dimensions. If you don't provide the dimensions of container, we calculate it based of the label dimensions.
@@ -326,7 +326,7 @@ convertToExcalidrawElements([
### Arrow bindings ### Arrow bindings
To bind arrow to a shape you need to specify its [`start`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L86) and [`end`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L54) properties. You need to pass either `type` or `id` property in `start` and `end` properties, rest of the attributes are optional To bind arrow to a shape you need to specify its [`start`](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L86) and [`end`](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L54) properties. You need to pass either `type` or `id` property in `start` and `end` properties, rest of the attributes are optional
```js ```js
convertToExcalidrawElements([ convertToExcalidrawElements([

View File

@@ -2,7 +2,7 @@
<pre> <pre>
(api:{" "} (api:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L616"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616">
ExcalidrawAPI ExcalidrawAPI
</a> </a>
) => void; ) => void;
@@ -17,12 +17,12 @@ export default function App() {
} }
``` ```
You can use this prop when you want to access some [Excalidraw APIs](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L616). We expose the below APIs :point_down: You can use this prop when you want to access some [Excalidraw APIs](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L616). We expose the below APIs :point_down:
| 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 |
@@ -52,7 +52,7 @@ Additionally `ready` and `readyPromise` from the API have been discontinued. The
<pre> <pre>
(scene:{" "} (scene:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L339"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L339">
sceneData sceneData
</a> </a>
) => void ) => void
@@ -62,10 +62,11 @@ You can use this function to update the scene with the sceneData. It accepts the
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `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/src/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/src/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/src/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`. | | `commitToStore` | `boolean` | Implies if the `store` should update it's snapshot, capture the update and calculates the diff. Captured changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
| `skipSnapshotUpdate` | `boolean` | Implies whether the `store` should skip update of its snapshot, which is necessary for correct diff calculation. Relevant only when `elements` or `appState` are passed in. When `true`, `commitToStore` value will be ignored. Defaults to `false`. |
```jsx live ```jsx live
function App() { function App() {
@@ -125,13 +126,13 @@ function App() {
<pre> <pre>
(opts: &#123; <br /> libraryItems:{" "} (opts: &#123; <br /> libraryItems:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L249"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L249">
LibraryItemsSource LibraryItemsSource
</a> </a>
;<br /> merge?: boolean; <br /> prompt?: boolean; ;<br /> merge?: boolean; <br /> prompt?: boolean;
<br /> openLibraryMenu?: boolean; <br /> openLibraryMenu?: boolean;
<br /> defaultStatus?: "unpublished" | "published"; <br /> &#125;) => Promise&lt; <br /> defaultStatus?: "unpublished" | "published"; <br /> &#125;) => Promise&lt;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L246"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L246">
LibraryItems LibraryItems
</a> </a>
&gt; &gt;
@@ -141,7 +142,7 @@ You can use this function to update the library. It accepts the below attributes
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `libraryItems` | [LibraryItemsSource](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L249) | \_ | The `libraryItems` to be replaced/merged with current library | | `libraryItems` | [LibraryItemsSource](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L249) | \_ | The `libraryItems` to be replaced/merged with current library |
| `merge` | boolean | `false` | Whether to merge with existing library items. | | `merge` | boolean | `false` | Whether to merge with existing library items. |
| `prompt` | boolean | `false` | Whether to prompt user for confirmation. | | `prompt` | boolean | `false` | Whether to prompt user for confirmation. |
| `openLibraryMenu` | boolean | `false` | Keep the library menu open after library is updated. | | `openLibraryMenu` | boolean | `false` | Keep the library menu open after library is updated. |
@@ -189,7 +190,7 @@ function App() {
</button> </button>
<Excalidraw <Excalidraw
ref={(api) => setExcalidrawAPI(api)} ref={(api) => setExcalidrawAPI(api)}
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js // initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/src/initialData.js
initialData={{ initialData={{
libraryItems: initialData.libraryItems, libraryItems: initialData.libraryItems,
appState: { openSidebar: "library" }, appState: { openSidebar: "library" },
@@ -204,7 +205,7 @@ function App() {
<pre> <pre>
(files:{" "} (files:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L59"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L59">
BinaryFileData BinaryFileData
</a> </a>
) => void ) => void
@@ -224,7 +225,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
<pre> <pre>
() =>{" "} () =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement[] ExcalidrawElement[]
</a> </a>
</pre> </pre>
@@ -235,7 +236,7 @@ Returns all the elements including the deleted in the scene.
<pre> <pre>
() => NonDeleted&#60; () => NonDeleted&#60;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement ExcalidrawElement
</a> </a>
[]&#62; []&#62;
@@ -247,7 +248,7 @@ Returns all the elements excluding the deleted in the scene
<pre> <pre>
() =>{" "} () =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">
AppState AppState
</a> </a>
</pre> </pre>
@@ -288,7 +289,7 @@ Scroll the nearest element out of the elements supplied to the center of the vie
| Attribute | type | default | Description | | Attribute | type | default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L115) | All scene elements | The element(s) to scroll to. | | target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. | | opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. | | opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) | | opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
@@ -336,7 +337,7 @@ The unique id of the excalidraw component. This can be used to identify the exca
<pre> <pre>
() =>{" "} () =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L82"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L82">
files files
</a> </a>
</pre> </pre>
@@ -364,7 +365,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` | | `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface | | `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor ## setCursor

View File

@@ -1,18 +1,18 @@
# initialData # initialData
<pre> <pre>
&#123; elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> &#125; &#123; elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> &#125;
</pre> </pre>
This helps to load Excalidraw with `initialData`. It must be an object or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to an object containing the below optional fields. This helps to load Excalidraw with `initialData`. It must be an object or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to an object containing the below optional fields.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) | The `elements` with which `Excalidraw` should be mounted. | | `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114) | The `elements` with which `Excalidraw` should be mounted. |
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) | The `AppState` with which `Excalidraw` should be mounted. | | `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95) | The `AppState` with which `Excalidraw` should be mounted. |
| `scrollToContent` | `boolean` | This attribute indicates whether to `scroll` to the nearest element to center once `Excalidraw` is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained | | `scrollToContent` | `boolean` | This attribute indicates whether to `scroll` to the nearest element to center once `Excalidraw` is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained |
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L247) &#124; Promise&lt;[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L200)&gt; | This library items with which `Excalidraw` should be mounted. | | `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L247) &#124; Promise&lt;[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)&gt; | This library items with which `Excalidraw` should be mounted. |
| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L82) | The `files` added to the scene. | | `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L82) | The `files` added to the scene. |
You might want to use this when you want to load excalidraw with some initial elements and app state. You might want to use this when you want to load excalidraw with some initial elements and app state.

View File

@@ -23,7 +23,7 @@ All `props` are _optional_.
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | | [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component | | [`theme`](#theme) | `"light"` &#124; `"dark"` | `"light"` | The theme of the Excalidraw component |
| [`name`](#name) | `string` | | Name of the drawing | | [`name`](#name) | `string` | | Name of the drawing |
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](#canvasactions) | | [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load | | [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
@@ -33,7 +33,7 @@ All `props` are _optional_.
### Storing custom data on Excalidraw elements ### Storing custom data on Excalidraw elements
Beyond attributes that Excalidraw elements already support, you can store `custom` data on each `element` in a `customData` object. The type of the attribute is [`Record<string, any>`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L66) and is optional. Beyond attributes that Excalidraw elements already support, you can store `custom` data on each `element` in a `customData` object. The type of the attribute is [`Record<string, any>`](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L66) and is optional.
You can use this to add any extra information you need to keep track of. You can use this to add any extra information you need to keep track of.
@@ -59,11 +59,11 @@ Every time component updates, this callback if passed will get triggered and has
(excalidrawElements, appState, files) => void; (excalidrawElements, appState, files) => void;
``` ```
1. `excalidrawElements`: Array of [excalidrawElements](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) in the scene. 1. `excalidrawElements`: Array of [excalidrawElements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114) in the scene.
2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) of the scene. 2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95) of the scene.
3. `files`: The [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L64) which are added to the scene. 3. `files`: The [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) which are added to the scene.
Here you can try saving the data to your backend or local storage for example. Here you can try saving the data to your backend or local storage for example.
@@ -79,14 +79,14 @@ This callback is triggered when mouse pointer is updated.
2.`button`: The position of the button. This will be one of `["down", "up"]` 2.`button`: The position of the button. This will be one of `["down", "up"]`
3.`pointersMap`: [`pointers`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L131) map of the scene 3.`pointersMap`: [`pointers`](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L131) map of the scene
```js ```js
(exportedElements, appState, canvas) => void (exportedElements, appState, canvas) => void
``` ```
1. `exportedElements`: An array of [non deleted elements](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L87) which needs to be exported. 1. `exportedElements`: An array of [non deleted elements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L87) which needs to be exported.
2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) of the scene. 2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95) of the scene.
3. `canvas`: The `HTMLCanvasElement` of the scene. 3. `canvas`: The `HTMLCanvasElement` of the scene.
### onPointerDown ### onPointerDown
@@ -96,11 +96,11 @@ This prop if passed will be triggered on pointer down events and has the below s
<pre> <pre>
(activeTool:{" "} (activeTool:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L115"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L115">
{" "} {" "}
AppState["activeTool"] AppState["activeTool"]
</a> </a>
, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L424"> , pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L424">
PointerDownState PointerDownState
</a>) => void </a>) => void
</pre> </pre>
@@ -119,7 +119,7 @@ This callback is triggered if passed when something is pasted into the scene. Yo
<pre> <pre>
(data:{" "} (data:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/clipboard.ts#L18"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L18">
ClipboardData ClipboardData
</a> </a>
, event: ClipboardEvent &#124; null) => boolean , event: ClipboardEvent &#124; null) => boolean
@@ -135,7 +135,7 @@ This callback if supplied will get triggered when the library is updated and has
<pre> <pre>
(items:{" "} (items:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L200"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">
LibraryItems LibraryItems
</a> </a>
) => void | Promise&lt;any&gt; ) => void | Promise&lt;any&gt;
@@ -149,7 +149,7 @@ This prop if passed will be triggered when clicked on `link`. To handle the redi
<pre> <pre>
(element:{" "} (element:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement ExcalidrawElement
</a> </a>
, event: CustomEvent&lt;&#123; nativeEvent: MouseEvent }&gt;) => void , event: CustomEvent&lt;&#123; nativeEvent: MouseEvent }&gt;) => void
@@ -182,7 +182,7 @@ const onLinkOpen: ExcalidrawProps["onLinkOpen"] = useCallback(
### langCode ### langCode
Determines the `language` of the UI. It should be one of the [available language codes](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/i18n.ts#L14). Defaults to `en` (English). We also export default language and supported languages which you can import as shown below. Determines the `language` of the UI. It should be one of the [available language codes](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L14). Defaults to `en` (English). We also export default language and supported languages which you can import as shown below.
```js ```js
import { defaultLang, languages } from "@excalidraw/excalidraw"; import { defaultLang, languages } from "@excalidraw/excalidraw";
@@ -191,7 +191,7 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
| name | type | | name | type |
| --- | --- | | --- | --- |
| `defaultLang` | `string` | | `defaultLang` | `string` |
| `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/i18n.ts#L15) | | `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
### viewModeEnabled ### viewModeEnabled

View File

@@ -4,7 +4,7 @@
<pre> <pre>
(isMobile: boolean, appState: (isMobile: boolean, appState:
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">
AppState AppState
</a>) => JSX | null </a>) => JSX | null
</pre> </pre>
@@ -66,7 +66,7 @@ function App() {
<pre> <pre>
(element: NonDeleted&lt;ExcalidrawEmbeddableElement&gt;, appState:{" "} (element: NonDeleted&lt;ExcalidrawEmbeddableElement&gt;, appState:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">
AppState AppState
</a> </a>
) => JSX.Element | null ) => JSX.Element | null

View File

@@ -4,7 +4,7 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
<pre> <pre>
&#123; &#123;
<br /> canvasActions?: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L372"> <br /> canvasActions?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L372">
CanvasActions CanvasActions
</a>, <br /> dockedSidebarBreakpoint?: number, <br /> welcomeScreen?: boolean <br /> </a>, <br /> dockedSidebarBreakpoint?: number, <br /> welcomeScreen?: boolean <br />
@@ -55,7 +55,7 @@ If `UIOptions.canvasActions.export` is `false` the export button will not be ren
## dockedSidebarBreakpoint ## dockedSidebarBreakpoint
This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L161). This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L161).
If the _width_ of the _excalidraw_ container exceeds _dockedSidebarBreakpoint_, the sidebar will be `dockable` and the button to `dock` the sidebar will be shown If the _width_ of the _excalidraw_ container exceeds _dockedSidebarBreakpoint_, the sidebar will be `dockable` and the button to `dock` the sidebar will be shown
If user choses to `dock` the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below. If user choses to `dock` the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below.
@@ -73,9 +73,9 @@ function App() {
## tools ## tools
This `prop` controls the visibility of the tools in the editor. This `prop ` controls the visibility of the tools in the editor.
Currently you can control the visibility of `image` tool via this prop. Currently you can control the visibility of `image` tool via this prop.
| Prop | Type | Default | Description | | Prop | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| image | boolean | true | Decides whether `image` tool should be visible. | image | boolean | true | Decides whether `image` tool should be visible.

View File

@@ -20,16 +20,16 @@ exportToCanvas(&#123;<br/>&nbsp;
getDimensions,<br/>&nbsp; getDimensions,<br/>&nbsp;
files,<br/>&nbsp; files,<br/>&nbsp;
exportPadding?: number;<br/> exportPadding?: number;<br/>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a> &#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L21">ExportOpts</a>
</pre> </pre>
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `elements` | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) | | The elements to be exported to canvas. | | `elements` | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114) | | The elements to be exported to canvas. |
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L23) | [Default App State](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/appState.ts#L17) | The app state of the scene. | | `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L23) | [Default App State](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L17) | The app state of the scene. |
| [`getDimensions`](#getdimensions) | `function` | _ | A function which returns the `width`, `height`, and optionally `scale` (defaults to `1`), with which canvas is to be exported. | | [`getDimensions`](#getdimensions) | `function` | _ | A function which returns the `width`, `height`, and optionally `scale` (defaults to `1`), with which canvas is to be exported. |
| `maxWidthOrHeight` | `number` | _ | The maximum `width` or `height` of the exported image. If provided, `getDimensions` is ignored. | | `maxWidthOrHeight` | `number` | _ | The maximum `width` or `height` of the exported image. If provided, `getDimensions` is ignored. |
| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L59) | _ | The files added to the scene. | | `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L59) | _ | The files added to the scene. |
| `exportPadding` | `number` | `10` | The `padding` to be added on canvas. | | `exportPadding` | `number` | `10` | The `padding` to be added on canvas. |
@@ -105,7 +105,7 @@ function App() {
<pre> <pre>
exportToBlob(<br/>&nbsp; exportToBlob(<br/>&nbsp;
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L14">ExportOpts</a> & &#123;<br/>&nbsp; opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L14">ExportOpts</a> & &#123;<br/>&nbsp;
mimeType?: string,<br/>&nbsp; mimeType?: string,<br/>&nbsp;
quality?: number,<br/>&nbsp; quality?: number,<br/>&nbsp;
exportPadding?: number;<br/> exportPadding?: number;<br/>
@@ -134,16 +134,16 @@ Returns a promise which resolves with a [blob](https://developer.mozilla.org/en-
<pre> <pre>
exportToSvg(&#123;<br/>&nbsp; exportToSvg(&#123;<br/>&nbsp;
elements:&nbsp; elements:&nbsp;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
ExcalidrawElement[] ExcalidrawElement[]
</a>,<br/>&nbsp; </a>,<br/>&nbsp;
appState: appState:
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95"> AppState <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95"> AppState
</a>,<br/>&nbsp; </a>,<br/>&nbsp;
exportPadding: number,<br/>&nbsp; exportPadding: number,<br/>&nbsp;
metadata: string,<br/>&nbsp; metadata: string,<br/>&nbsp;
files:&nbsp; files:&nbsp;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L59"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L59">
BinaryFiles BinaryFiles
</a>,<br/> </a>,<br/>
&#125;); &#125;);
@@ -151,10 +151,10 @@ exportToSvg(&#123;<br/>&nbsp;
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) | | The elements to exported as `svg `| | elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114) | | The elements to exported as `svg `|
| appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/appState.ts#L11) | The `appState` of the scene | | appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L11) | The `appState` of the scene |
| exportPadding | number | 10 | The `padding` to be added on canvas | | exportPadding | number | 10 | The `padding` to be added on canvas |
| files | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L64) | undefined | The `files` added to the scene. | | files | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) | undefined | The `files` added to the scene. |
This function returns a promise which resolves to `svg` of the exported drawing. This function returns a promise which resolves to `svg` of the exported drawing.
@@ -164,7 +164,7 @@ This function returns a promise which resolves to `svg` of the exported drawing.
<pre> <pre>
exportToClipboard(<br/>&nbsp; exportToClipboard(<br/>&nbsp;
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a> & &#123;<br/>&nbsp; opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L21">ExportOpts</a> & &#123;<br/>&nbsp;
mimeType?: string,<br/>&nbsp; mimeType?: string,<br/>&nbsp;
quality?: number;<br/>&nbsp; quality?: number;<br/>&nbsp;
type: 'png' | 'svg' |'json'<br/> type: 'png' | 'svg' |'json'<br/>

View File

@@ -8,7 +8,7 @@ id: "restore"
**_Signature_** **_Signature_**
<pre> <pre>
restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState["appState"]</a>,<br/>&nbsp; localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState["appState"]</a>,<br/>&nbsp; localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>
</pre> </pre>
**_How to use_** **_How to use_**
@@ -17,7 +17,7 @@ restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob
import { restoreAppState } from "@excalidraw/excalidraw"; import { restoreAppState } from "@excalidraw/excalidraw";
``` ```
This function will make sure all the `keys` have appropriate `values` in [appState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) and if any key is missing, it will be set to its `default` value. This function will make sure all the `keys` have appropriate `values` in [appState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95) and if any key is missing, it will be set to its `default` value.
When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of the defaults. When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of the defaults.
Use this as a way to not override user's defaults if you persist them. Use this as a way to not override user's defaults if you persist them.
@@ -29,16 +29,16 @@ You can pass `null` / `undefined` if not applicable.
<pre> <pre>
restoreElements( restoreElements(
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp; elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp;
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/>&nbsp; localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp;
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/> opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>
| Prop | Type | Description | | Prop | Type | Description |
| ---- | ---- | ---- | | ---- | ---- | ---- |
| `elements` | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a> | The `elements` to be restored | | `elements` | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a> | The `elements` to be restored |
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. | | [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
| [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements | [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements
#### localElements #### localElements
@@ -70,15 +70,15 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
<pre> <pre>
restore( restore(
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp; data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp;
localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp; localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp;
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/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a><br/>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/> opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>
See [`restoreAppState()`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/excalidraw/README.md#restoreAppState) about `localAppState`, and [`restoreElements()`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/excalidraw/README.md#restoreElements) about `localElements`. See [`restoreAppState()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreAppState) about `localAppState`, and [`restoreElements()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreElements) about `localElements`.
**_How to use_** **_How to use_**
@@ -93,7 +93,7 @@ This function makes sure elements and state is set to appropriate values and set
**_Signature_** **_Signature_**
<pre> <pre>
restoreLibraryItems(libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState["libraryItems"]</a>,<br/>&nbsp; restoreLibraryItems(libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState["libraryItems"]</a>,<br/>&nbsp;
defaultStatus: "published" | "unpublished") defaultStatus: "published" | "unpublished")
</pre> </pre>

View File

@@ -8,7 +8,7 @@ These are pure Javascript functions exported from the @excalidraw/excalidraw [`@
### serializeAsJSON ### serializeAsJSON
Takes the scene elements and state and returns a JSON string. `Deleted` elements as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/json.ts#L42) source for details). Takes the scene elements and state and returns a JSON string. `Deleted` elements as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L42) source for details).
If you want to overwrite the `source` field in the `JSON` string, you can set `window.EXCALIDRAW_EXPORT_SOURCE` to the desired value. If you want to overwrite the `source` field in the `JSON` string, you can set `window.EXCALIDRAW_EXPORT_SOURCE` to the desired value.
@@ -16,8 +16,8 @@ If you want to overwrite the `source` field in the `JSON` string, you can set `w
<pre> <pre>
serializeAsJSON(&#123;<br/>&nbsp; serializeAsJSON(&#123;<br/>&nbsp;
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp; elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp;
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>,<br/> appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>,<br/>
}): string }): string
</pre> </pre>
@@ -37,7 +37,7 @@ If you want to overwrite the source field in the JSON string, you can set `windo
<pre> <pre>
serializeLibraryAsJSON( serializeLibraryAsJSON(
libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L200">LibraryItems[]</a>) libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems[]</a>)
</pre> </pre>
**How to use** **How to use**
@@ -53,7 +53,7 @@ Returns `true` if element is invisibly small (e.g. width & height are zero).
**_Signature_** **_Signature_**
<pre> <pre>
isInvisiblySmallElement(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement</a>): boolean isInvisiblySmallElement(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement</a>): boolean
</pre> </pre>
**How to use** **How to use**
@@ -80,10 +80,10 @@ excalidrawAPI.updateScene(scene);
<pre> <pre>
loadFromBlob(<br/>&nbsp; loadFromBlob(<br/>&nbsp;
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>&nbsp; blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>&nbsp;
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> | null,<br/>&nbsp; localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>&nbsp; localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>&nbsp;
fileHandle?: FileSystemHandle | null <br/> fileHandle?: FileSystemHandle | null <br/>
) => Promise&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/restore.ts#L61">RestoredDataState</a>> ) => Promise&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/restore.ts#L61">RestoredDataState</a>>
</pre> </pre>
### loadLibraryFromBlob ### loadLibraryFromBlob
@@ -130,10 +130,10 @@ if (contents.type === MIME_TYPES.excalidraw) {
<pre> <pre>
loadSceneOrLibraryFromBlob(<br/>&nbsp; loadSceneOrLibraryFromBlob(<br/>&nbsp;
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>&nbsp; blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>&nbsp;
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> | null,<br/>&nbsp; localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>&nbsp; localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>&nbsp;
fileHandle?: FileSystemHandle | null<br/> fileHandle?: FileSystemHandle | null<br/>
) => Promise&lt;&#123; type: string, data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/restore.ts#L53">RestoredDataState</a> | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L33">ImportedLibraryState</a>}> ) => Promise&lt;&#123; type: string, data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/restore.ts#L53">RestoredDataState</a> | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L33">ImportedLibraryState</a>}>
</pre> </pre>
### getFreeDrawSvgPath ### getFreeDrawSvgPath
@@ -149,7 +149,7 @@ import { getFreeDrawSvgPath } from "@excalidraw/excalidraw";
**Signature** **Signature**
<pre> <pre>
getFreeDrawSvgPath(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L182">ExcalidrawFreeDrawElement</a>) getFreeDrawSvgPath(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L182">ExcalidrawFreeDrawElement</a>)
</pre> </pre>
### isLinearElement ### isLinearElement
@@ -165,7 +165,7 @@ import { isLinearElement } from "@excalidraw/excalidraw";
**Signature** **Signature**
<pre> <pre>
isLinearElement(elementType?: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L80">ExcalidrawElement</a>): boolean isLinearElement(elementType?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L80">ExcalidrawElement</a>): boolean
</pre> </pre>
### getNonDeletedElements ### getNonDeletedElements
@@ -181,7 +181,7 @@ import { getNonDeletedElements } from "@excalidraw/excalidraw";
**Signature** **Signature**
<pre> <pre>
getNonDeletedElements(elements:<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114"> readonly ExcalidrawElement[]</a>): as readonly <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L125">NonDeletedExcalidrawElement[]</a> getNonDeletedElements(elements:<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114"> readonly ExcalidrawElement[]</a>): as readonly <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L125">NonDeletedExcalidrawElement[]</a>
</pre> </pre>
### mergeLibraryItems ### mergeLibraryItems
@@ -196,9 +196,9 @@ import { mergeLibraryItems } from "@excalidraw/excalidraw";
<pre> <pre>
mergeLibraryItems(<br/>&nbsp; mergeLibraryItems(<br/>&nbsp;
localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L250">LibraryItems</a>,<br/>&nbsp; localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>,<br/>&nbsp;
otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L200">LibraryItems</a><br/> otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a><br/>
): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L250">LibraryItems</a> ): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">LibraryItems</a>
</pre> </pre>
### parseLibraryTokensFromUrl ### parseLibraryTokensFromUrl
@@ -239,8 +239,8 @@ export const App = () => {
<pre> <pre>
useHandleLibrary(opts: &#123;<br/>&nbsp; useHandleLibrary(opts: &#123;<br/>&nbsp;
excalidrawAPI: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L494">ExcalidrawAPI</a>,<br/>&nbsp; excalidrawAPI: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L494">ExcalidrawAPI</a>,<br/>&nbsp;
getInitialLibraryItems?: () => <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L253">LibraryItemsSource</a><br/> getInitialLibraryItems?: () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L253">LibraryItemsSource</a><br/>
}); });
</pre> </pre>
@@ -253,7 +253,7 @@ This function returns the current `scene` version.
**_Signature_** **_Signature_**
<pre> <pre>
getSceneVersion(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>) getSceneVersion(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>)
</pre> </pre>
**How to use** **How to use**
@@ -274,7 +274,7 @@ import { sceneCoordsToViewportCoords } from "@excalidraw/excalidraw";
<pre> <pre>
sceneCoordsToViewportCoords(&#123; sceneX: number, sceneY: number },<br/>&nbsp; sceneCoordsToViewportCoords(&#123; sceneX: number, sceneY: number },<br/>&nbsp;
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123; x: number, y: number } appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a><br/>): &#123; x: number, y: number }
</pre> </pre>
### viewportCoordsToSceneCoords ### viewportCoordsToSceneCoords
@@ -289,7 +289,7 @@ import { viewportCoordsToSceneCoords } from "@excalidraw/excalidraw";
<pre> <pre>
viewportCoordsToSceneCoords(&#123; clientX: number, clientY: number },<br/>&nbsp; viewportCoordsToSceneCoords(&#123; clientX: number, clientY: number },<br/>&nbsp;
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number} appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number}
</pre> </pre>
### useDevice ### useDevice
@@ -350,8 +350,8 @@ To help with localization, we export the following.
| name | type | | name | type |
| --- | --- | | --- | --- |
| `defaultLang` | `string` | | `defaultLang` | `string` |
| `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/i18n.ts#L15) | | `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
| `useI18n` | [`() => { langCode, t }`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/i18n.ts#L15) | | `useI18n` | [`() => { langCode, t }`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
```js ```js
import { defaultLang, languages, useI18n } from "@excalidraw/excalidraw"; import { defaultLang, languages, useI18n } from "@excalidraw/excalidraw";

View File

@@ -21,7 +21,7 @@ Most notably, you can customize the primary colors, by overriding these variable
- `--color-primary-light` - `--color-primary-light`
- `--color-primary-contrast-offset` — a slightly darker (in light mode), or lighter (in dark mode) `--color-primary` color to fix contrast issues (see [Chubb illusion](https://en.wikipedia.org/wiki/Chubb_illusion)). It will fall back to `--color-primary` if not present. - `--color-primary-contrast-offset` — a slightly darker (in light mode), or lighter (in dark mode) `--color-primary` color to fix contrast issues (see [Chubb illusion](https://en.wikipedia.org/wiki/Chubb_illusion)). It will fall back to `--color-primary` if not present.
For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/css/theme.scss), though most of them will not make sense to override. For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/src/css/theme.scss), though most of them will not make sense to override.
```css showLineNumbers ```css showLineNumbers
.custom-styles .excalidraw { .custom-styles .excalidraw {

View File

@@ -13,7 +13,7 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
1. Install the dependencies 1. Install the dependencies
```bash ```bash
cd packages/excalidraw && yarn cd src/packages/excalidraw && yarn
``` ```
2. Start the example app 2. Start the example app

View File

@@ -39,7 +39,7 @@ Since Vite removes env variables by default, you can update the vite config to e
``` ```
define: { define: {
"process.env.IS_PREACT": JSON.stringify("true"), "process.env.IS_PREACT": process.env.IS_PREACT,
}, },
``` ```

View File

@@ -93,7 +93,7 @@ Since Vite removes env variables by default, you can update the vite config to e
``` ```
define: { define: {
"process.env.IS_PREACT": JSON.stringify("true"), "process.env.IS_PREACT": process.env.IS_PREACT,
}, },
``` ```
::: :::
@@ -148,7 +148,7 @@ import TabItem from "@theme/TabItem";
<h1>Excalidraw Embed Example</h1> <h1>Excalidraw Embed Example</h1>
<div id="app"></div> <div id="app"></div>
</div> </div>
<script type="text/javascript" src="packages/excalidraw/index.js"></script> <script type="text/javascript" src="src/index.js"></script>
</body> </body>
</html> </html>
``` ```

View File

@@ -38,9 +38,9 @@ Add the diagram type in switch case in [`parseMermaid`](https://github.com/excal
## Writing the Excalidraw Skeleton Convertor ## Writing the Excalidraw Skeleton Convertor
With the completion of previous step, we have all the data, now we need to transform it so to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133) format. With the completion of previous step, we have all the data, now we need to transform it so to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133) format.
Similar to [`FlowChartToExcalidrawSkeletonConverter`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/converter/types/flowchart.ts#L24), you have to write the `{{diagramType}}ToExcalidrawSkeletonConverter` which parses the data received in previous step and returns the [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133). Similar to [`FlowChartToExcalidrawSkeletonConverter`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/converter/types/flowchart.ts#L24), you have to write the `{{diagramType}}ToExcalidrawSkeletonConverter` which parses the data received in previous step and returns the [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133).
Thats it, you have added the new diagram type 🥳, now lets test it out! Thats it, you have added the new diagram type 🥳, now lets test it out!

View File

@@ -6,7 +6,7 @@ In this section we will be diving into how the [flowchart parser](https://github
![image](https://github.com/excalidraw/excalidraw/assets/11256141/2a097bbb-64bf-49d6-bf7f-21172bdb538d) ![image](https://github.com/excalidraw/excalidraw/assets/11256141/2a097bbb-64bf-49d6-bf7f-21172bdb538d)
We use `diagram.parser.yy` attribute to parse the data. If you want to know more about how the `diagram.parse.yy` attribute looks like, you can check it [here](https://github.com/mermaid-js/mermaid/blob/00d06c7282a701849793680c1e97da1cfdfcce62/packages/mermaid/src/diagrams/flowchart/flowDb.js#L768), however for scope of flowchart we are using **3** APIs from this parser to compute `vertices`, `edges` and `clusters` as we need these data to transform to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38). We use `diagram.parser.yy` attribute to parse the data. If you want to know more about how the `diagram.parse.yy` attribute looks like, you can check it [here](https://github.com/mermaid-js/mermaid/blob/00d06c7282a701849793680c1e97da1cfdfcce62/packages/mermaid/src/diagrams/flowchart/flowDb.js#L768), however for scope of flowchart we are using **3** APIs from this parser to compute `vertices`, `edges` and `clusters` as we need these data to transform to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133C13-L133C38).
For computing `vertices` and `edge`s lets consider the below svg generated by mermaid For computing `vertices` and `edge`s lets consider the below svg generated by mermaid
@@ -42,7 +42,7 @@ Considering the same example this is the response from the API
} }
} }
``` ```
The dimensions and position is missing in this response and we need that to transform to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38), for this we have our own parser [`parseVertex`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/parseMermaid.ts#L178) which takes the above response and uses the `svg` together to compute position, dimensions and cleans up the response. The dimensions and position is missing in this response and we need that to transform to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133C13-L133C38), for this we have our own parser [`parseVertex`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/parseMermaid.ts#L178) which takes the above response and uses the `svg` together to compute position, dimensions and cleans up the response.
The final output from `parseVertex` looks like :point_down: The final output from `parseVertex` looks like :point_down:

View File

@@ -55,11 +55,11 @@ If you want to understand how flowchart parser works, you can navigate to [Flowc
## Converting to ExcalidrawElementSkeleton ## Converting to ExcalidrawElementSkeleton
Now we have all the data, we just need to transform it to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38) API so it can be rendered in Excalidraw. Now we have all the data, we just need to transform it to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133C13-L133C38) API so it can be rendered in Excalidraw.
For this we have `converters` which takes the parsed mermaid data and gives back the Excalidraw Skeleton. For this we have `converters` which takes the parsed mermaid data and gives back the Excalidraw Skeleton.
For Unsupported types, we have already mentioned above that we convert it to `dataURL` and return the ExcalidrawImageSkeleton. For Unsupported types, we have already mentioned above that we convert it to `dataURL` and return the ExcalidrawImageSkeleton.
For supported types, currently only flowchart, we have [flowchartConverter](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/converter/types/flowchart.ts#L24) which parses the data and converts to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38). For supported types, currently only flowchart, we have [flowchartConverter](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/converter/types/flowchart.ts#L24) which parses the data and converts to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/src/data/transform.ts#L133C13-L133C38).
![image](https://github.com/excalidraw/excalidraw/assets/11256141/00226e9d-043d-4a08-989a-3ad9d2a574f1) ![image](https://github.com/excalidraw/excalidraw/assets/11256141/00226e9d-043d-4a08-989a-3ad9d2a574f1)

View File

@@ -52,6 +52,15 @@ Make sure the title starts with a semantic prefix:
- **chore**: Other changes that don't modify src or test files - **chore**: Other changes that don't modify src or test files
- **revert**: Reverts a previous commit - **revert**: Reverts a previous commit
### Changelog
Add a brief description of your pull request to the changelog located here: [changelog](https://github.com/excalidraw/excalidraw/blob/master/CHANGELOG.md)
Notes:
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
### Testing ### Testing
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise. Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.

View File

@@ -41,7 +41,10 @@ const config = {
showLastUpdateTime: true, showLastUpdateTime: true,
}, },
theme: { theme: {
customCss: [require.resolve("./src/css/custom.scss")], customCss: [
require.resolve("./src/css/custom.scss"),
require.resolve("../src/packages/excalidraw/example/App.scss"),
],
}, },
}), }),
], ],

View File

@@ -1,4 +0,0 @@
{
"outputDirectory": "build",
"installCommand": "yarn install"
}

View File

@@ -1,871 +0,0 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState";
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
Theme,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../packages/excalidraw/index";
import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../packages/excalidraw/types";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../packages/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import Collab, {
CollabAPI,
collabAPIAtom,
collabDialogShownAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "../packages/excalidraw/data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../packages/excalidraw/utility-types";
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../packages/excalidraw/components/Trans";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
let isSelfEmbedding = false;
if (window.self !== window.top) {
try {
const parentUrl = new URL(document.referrer);
const currentUrl = new URL(window.location.href);
if (parentUrl.origin === currentUrl.origin) {
isSelfEmbedding = true;
}
} catch (error) {
// ignore
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
<Trans
i18nKey="overwriteConfirm.modal.shareableLink.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
color: "danger",
} as const;
const initializeScene = async (opts: {
collabAPI: CollabAPI | null;
excalidrawAPI: ExcalidrawImperativeAPI;
}): Promise<
{ scene: ExcalidrawInitialDataState | null } & (
| { isExternalScene: true; id: string; key: string }
| { isExternalScene: false; id?: null; key?: null }
)
> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonBackendMatch = window.location.hash.match(
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
);
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
const localDataState = importFromLocalStorage();
let scene: RestoredDataState & {
scrollToContent?: boolean;
} = await loadScene(null, null, localDataState);
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
if (isExternalScene) {
if (
// don't prompt if scene is empty
!scene.elements.length ||
// don't prompt for collab scenes because we don't override local storage
roomLinkData ||
// otherwise, prompt whether user wants to override current scene
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
jsonBackendMatch[1],
jsonBackendMatch[2],
localDataState,
);
}
scene.scrollToContent = true;
if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else {
// https://github.com/excalidraw/excalidraw/issues/1919
if (document.hidden) {
return new Promise((resolve, reject) => {
window.addEventListener(
"focus",
() => initializeScene(opts).then(resolve).catch(reject),
{
once: true,
},
);
});
}
roomLinkData = null;
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else if (externalUrlMatch) {
window.history.replaceState({}, APP_NAME, window.location.origin);
const url = externalUrlMatch[1];
try {
const request = await fetch(window.decodeURIComponent(url));
const data = await loadFromBlob(await request.blob(), null, null);
if (
!scene.elements.length ||
(await openConfirmModal(shareableLinkConfirmDialog))
) {
return { scene: data, isExternalScene };
}
} catch (error: any) {
return {
scene: {
appState: {
errorMessage: t("alerts.invalidSceneUrl"),
},
},
isExternalScene,
};
}
}
if (roomLinkData && opts.collabAPI) {
const { excalidrawAPI } = opts;
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
return {
// when collaborating, the state may have already been updated at this
// point (we may have received updates from other clients), so reconcile
// elements and appState with existing state
scene: {
...scene,
appState: {
...restoreAppState(
{
...scene?.appState,
theme: localDataState?.appState?.theme || scene?.appState?.theme,
},
excalidrawAPI.getAppState(),
),
// necessary if we're invoking from a hashchange handler which doesn't
// go through App.initializeScene() that resets this flag
isLoading: false,
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
},
isExternalScene: true,
id: roomLinkData.roomId,
key: roomLinkData.roomKey,
};
} else if (scene) {
return isExternalScene && jsonBackendMatch
? {
scene,
isExternalScene,
id: jsonBackendMatch[1],
key: jsonBackendMatch[2],
}
: { scene, isExternalScene: false };
}
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
// initial state
// ---------------------------------------------------------------------------
const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise =
resolvablePromise<ExcalidrawInitialDataState | null>();
}
useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW
setTimeout(() => {
trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
useHandleLibrary({
excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage,
});
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return;
}
if (collabAPI?.isCollaborating()) {
if (data.scene.elements) {
collabAPI
.fetchImageFilesFromFirebase({
elements: data.scene.elements,
forceFetchFiles: true,
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
} else {
const fileIds =
data.scene.elements?.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
return acc.concat(element.fileId);
}
return acc;
}, [] as FileId[]) || [];
if (data.isExternalScene) {
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
).then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
} else if (isInitialLoad) {
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
// on fresh load, clear unused files from IDB (from previous
// session)
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
}
}
};
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
const onHashChange = async (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) {
if (
collabAPI?.isCollaborating() &&
!isCollaborationLink(window.location.href)
) {
collabAPI.stopCollaboration(false);
}
excalidrawAPI.updateScene({ appState: { isLoading: true } });
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
});
}
});
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
}
if (
!document.hidden &&
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
) {
// don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
});
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
});
collabAPI?.setUsername(username || "");
}
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
const currFiles = excalidrawAPI.getFiles();
const fileIds =
elements?.reduce((acc, element) => {
if (
isInitializedImageElement(element) &&
// only load and update images that aren't already loaded
!currFiles[element.fileId]
) {
return acc.concat(element.fileId);
}
return acc;
}, [] as FileId[]) || [];
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
}
}
}, SYNC_BROWSER_TABS_TIMEOUT);
const onUnload = () => {
LocalData.flushSave();
};
const visibilityChange = (event: FocusEvent | Event) => {
if (event.type === EVENT.BLUR || document.hidden) {
LocalData.flushSave();
}
if (
event.type === EVENT.VISIBILITY_CHANGE ||
event.type === EVENT.FOCUS
) {
syncData();
}
};
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onUnload, false);
window.addEventListener(EVENT.BLUR, visibilityChange, false);
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
document.removeEventListener(
EVENT.VISIBILITY_CHANGE,
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
LocalData.flushSave();
if (
excalidrawAPI &&
LocalData.fileStorage.shouldPreventUnload(
excalidrawAPI.getSceneElements(),
)
) {
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(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 = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
if (collabAPI?.isCollaborating()) {
collabAPI.syncElements(elements);
}
setTheme(appState.theme);
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
const newElement = newElementWith(element, { status: "saved" });
if (newElement !== element) {
didChange = true;
}
return newElement;
}
return element;
});
if (didChange) {
excalidrawAPI.updateScene({
elements,
});
}
}
});
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
canvas: HTMLCanvasElement,
) => {
if (exportedElements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (canvas) {
try {
const { url, errorMessage } = await exportToBackend(
exportedElements,
{
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
},
files,
);
if (errorMessage) {
throw new Error(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
throw new Error(error.message);
}
}
}
};
const renderCustomStats = (
elements: readonly NonDeletedExcalidrawElement[],
appState: UIAppState,
) => {
return (
<CustomStats
setToast={(message) => excalidrawAPI!.setToast({ message })}
appState={appState}
elements={elements}
/>
);
};
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);
// browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
if (isSelfEmbedding) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
height: "100%",
}}
>
<h1>I'm not a pretzel!</h1>
</div>
);
}
return (
<div
style={{ height: "100%" }}
className={clsx("excalidraw-app", {
"is-collaborating": isCollaborating,
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{
canvasActions: {
toggleTheme: true,
export: {
onExportToBackend,
renderCustomUI: (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI?.updateScene({
appState: { openDialog: null },
});
}}
/>
);
},
},
},
}}
langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => setCollabDialogShown(true)}
/>
);
}}
>
<AppMainMenu
setCollabDialogShown={setCollabDialogShown}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
/>
<AppWelcomeScreen
setCollabDialogShown={setCollabDialogShown}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
onClick={() => {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
}}
>
{t("overwriteConfirm.action.excalidrawPlus.description")}
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
onCloseRequest={() => setLatestShareableLink(null)}
setErrorMessage={setErrorMessage}
/>
)}
{excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} />
)}
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</Excalidraw>
</div>
);
};
const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
);
};
export default ExcalidrawApp;

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "../packages/excalidraw/utils"; import { debounce, getVersion, nFormatter } from "../src/utils";
import { import {
getElementsStorageSize, getElementsStorageSize,
getTotalStorageSize, getTotalStorageSize,
} from "./data/localStorage"; } from "./data/localStorage";
import { DEFAULT_VERSION } from "../packages/excalidraw/constants"; import { DEFAULT_VERSION } from "../src/constants";
import { t } from "../packages/excalidraw/i18n"; import { t } from "../src/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../src/clipboard";
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types"; import { NonDeletedExcalidrawElement } from "../src/element/types";
import { UIAppState } from "../packages/excalidraw/types"; import { UIAppState } from "../src/types";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };

View File

@@ -15,17 +15,11 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000;
export const WS_EVENTS = { export const WS_EVENTS = {
SERVER_VOLATILE: "server-volatile-broadcast", SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast", SERVER: "server-broadcast",
USER_FOLLOW_CHANGE: "user-follow", };
USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change",
} as const;
export enum WS_SUBTYPES { export enum WS_SCENE_EVENT_TYPES {
INVALID_RESPONSE = "INVALID_RESPONSE",
INIT = "SCENE_INIT", INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE", UPDATE = "SCENE_UPDATE",
MOUSE_LOCATION = "MOUSE_LOCATION",
IDLE_STATUS = "IDLE_STATUS",
USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
} }
export const FIREBASE_STORAGE_PREFIXES = { export const FIREBASE_STORAGE_PREFIXES = {

View File

@@ -1,37 +1,31 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import { import { ExcalidrawImperativeAPI } from "../../src/types";
ExcalidrawImperativeAPI, import { ErrorDialog } from "../../src/components/ErrorDialog";
SocketId, import { APP_NAME, ENV, EVENT } from "../../src/constants";
} from "../../packages/excalidraw/types"; import { ImportedDataState } from "../../src/data/types";
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { import {
ExcalidrawElement, ExcalidrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types"; } from "../../src/element/types";
import { import {
getSceneVersion, getSceneVersion,
restoreElements, restoreElements,
zoomToFitBounds, } from "../../src/packages/excalidraw/index";
} from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../src/types";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
import { import {
assertNever,
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
throttleRAF, withBatchedUpdates,
} from "../../packages/excalidraw/utils"; } from "../../src/utils";
import { import {
CURSOR_SYNC_TIMEOUT, CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT, INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT, LOAD_IMAGES_TIMEOUT,
WS_SUBTYPES, WS_SCENE_EVENT_TYPES,
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants"; } from "../app_constants";
import { import {
generateCollaborationLinkData, generateCollaborationLinkData,
@@ -54,35 +48,29 @@ import {
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { t } from "../../packages/excalidraw/i18n"; import { t } from "../../src/i18n";
import { UserIdleState } from "../../packages/excalidraw/types"; import { UserIdleState } from "../../src/types";
import { import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
} from "../../packages/excalidraw/constants";
import { import {
encodeFilesForUpload, encodeFilesForUpload,
FileManager, FileManager,
updateStaleImageStatuses, updateStaleImageStatuses,
} from "../data/FileManager"; } from "../data/FileManager";
import { AbortError } from "../../packages/excalidraw/errors"; import { AbortError } from "../../src/errors";
import { import {
isImageElement, isImageElement,
isInitializedImageElement, isInitializedImageElement,
} from "../../packages/excalidraw/element/typeChecks"; } from "../../src/element/typeChecks";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { import {
ReconciledElements, ReconciledElements,
reconcileElements as _reconcileElements, reconcileElements as _reconcileElements,
} from "./reconciliation"; } from "./reconciliation";
import { decryptData } from "../../packages/excalidraw/data/encryption"; import { decryptData } from "../../src/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, useAtom } from "jotai";
import { appJotaiStore } from "../app-jotai"; import { appJotaiStore } from "../app-jotai";
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false); export const collabDialogShownAtom = atom(false);
@@ -123,7 +111,7 @@ class Collab extends PureComponent<Props, CollabState> {
private socketInitializationTimer?: number; private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<SocketId, Collaborator>(); private collaborators = new Map<string, Collaborator>();
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@@ -163,28 +151,12 @@ class Collab extends PureComponent<Props, CollabState> {
this.idleTimeoutId = null; this.idleTimeoutId = null;
} }
private onUmmount: (() => void) | null = null;
componentDidMount() { componentDidMount() {
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.addEventListener("online", this.onOfflineStatusToggle); window.addEventListener("online", this.onOfflineStatusToggle);
window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle);
window.addEventListener(EVENT.UNLOAD, this.onUnload); window.addEventListener(EVENT.UNLOAD, this.onUnload);
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
this.portal.socket && this.portal.broadcastUserFollowed(payload);
});
const throttledRelayUserViewportBounds = throttleRAF(
this.relayVisibleSceneBounds,
);
const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
throttledRelayUserViewportBounds(),
);
this.onUmmount = () => {
unsubOnUserFollow();
unsubOnScrollChange();
};
this.onOfflineStatusToggle(); this.onOfflineStatusToggle();
const collabAPI: CollabAPI = { const collabAPI: CollabAPI = {
@@ -232,7 +204,6 @@ class Collab extends PureComponent<Props, CollabState> {
window.clearTimeout(this.idleTimeoutId); window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null; this.idleTimeoutId = null;
} }
this.onUmmount?.();
} }
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
@@ -331,7 +302,6 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: false,
}); });
} }
}; };
@@ -385,7 +355,7 @@ class Collab extends PureComponent<Props, CollabState> {
iv: Uint8Array, iv: Uint8Array,
encryptedData: ArrayBuffer, encryptedData: ArrayBuffer,
decryptionKey: string, decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => { ) => {
try { try {
const decrypted = await decryptData(iv, encryptedData, decryptionKey); const decrypted = await decryptData(iv, encryptedData, decryptionKey);
@@ -397,7 +367,7 @@ class Collab extends PureComponent<Props, CollabState> {
window.alert(t("alerts.decryptFailed")); window.alert(t("alerts.decryptFailed"));
console.error(error); console.error(error);
return { return {
type: WS_SUBTYPES.INVALID_RESPONSE, type: "INVALID_RESPONSE",
}; };
} }
}; };
@@ -442,7 +412,6 @@ class Collab extends PureComponent<Props, CollabState> {
); );
const fallbackInitializationHandler = () => { const fallbackInitializationHandler = () => {
console.log("fallbackInitializationHandler");
this.initializeRoom({ this.initializeRoom({
roomLinkData: existingRoomLinkData, roomLinkData: existingRoomLinkData,
fetchScene: true, fetchScene: true,
@@ -479,14 +448,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));
@@ -514,18 +481,14 @@ class Collab extends PureComponent<Props, CollabState> {
); );
switch (decryptedData.type) { switch (decryptedData.type) {
case WS_SUBTYPES.INVALID_RESPONSE: case "INVALID_RESPONSE":
return; return;
case WS_SUBTYPES.INIT: { case WS_SCENE_EVENT_TYPES.INIT: {
console.log("INIT (1)");
if (!this.portal.socketInitialized) { if (!this.portal.socketInitialized) {
console.log("INIT (2)");
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.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, { this.handleRemoteSceneUpdate(reconciledElements);
init: true,
});
// noop if already resolved via init from firebase // noop if already resolved via init from firebase
scenePromise.resolve({ scenePromise.resolve({
elements: reconciledElements, elements: reconciledElements,
@@ -534,82 +497,47 @@ class Collab extends PureComponent<Props, CollabState> {
} }
break; break;
} }
case WS_SUBTYPES.UPDATE: case WS_SCENE_EVENT_TYPES.UPDATE:
this.handleRemoteSceneUpdate( this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements), this.reconcileElements(decryptedData.payload.elements),
); );
break; break;
case WS_SUBTYPES.MOUSE_LOCATION: { case "MOUSE_LOCATION": {
const { pointer, button, username, selectedElementIds } = const { pointer, button, username, selectedElementIds } =
decryptedData.payload; decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId || decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097) // @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID; decryptedData.payload.socketID;
this.updateCollaborator(socketId, { const collaborators = new Map(this.collaborators);
pointer, const user = collaborators.get(socketId) || {}!;
button, user.pointer = pointer;
selectedElementIds, user.button = button;
username, user.selectedElementIds = selectedElementIds;
}); user.username = username;
collaborators.set(socketId, user);
break;
}
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
const { sceneBounds, socketId } = decryptedData.payload;
const appState = this.excalidrawAPI.getAppState();
// we're not following the user
// (shouldn't happen, but could be late message or bug upstream)
if (appState.userToFollow?.socketId !== socketId) {
console.warn(
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
);
return;
}
// cross-follow case, ignore updates in this case
if (
appState.userToFollow &&
appState.followedBy.has(appState.userToFollow.socketId)
) {
return;
}
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
appState: zoomToFitBounds({ collaborators,
appState,
bounds: sceneBounds,
fitToViewport: true,
viewportZoomFactor: 1,
}).appState,
}); });
break; break;
} }
case "IDLE_STATUS": {
case WS_SUBTYPES.IDLE_STATUS: {
const { userState, socketId, username } = decryptedData.payload; const { userState, socketId, username } = decryptedData.payload;
this.updateCollaborator(socketId, { const collaborators = new Map(this.collaborators);
userState, const user = collaborators.get(socketId) || {}!;
username, user.userState = userState;
user.username = username;
this.excalidrawAPI.updateScene({
collaborators,
}); });
break; break;
} }
default: {
assertNever(decryptedData, null);
}
} }
}, },
); );
this.portal.socket.on("first-in-room", async () => { this.portal.socket.on("first-in-room", async () => {
console.log("first-in-room");
if (this.portal.socket) { if (this.portal.socket) {
this.portal.socket.off("first-in-room"); this.portal.socket.off("first-in-room");
} }
@@ -620,17 +548,6 @@ class Collab extends PureComponent<Props, CollabState> {
scenePromise.resolve(sceneData); scenePromise.resolve(sceneData);
}); });
this.portal.socket.on(
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
(followedBy: SocketId[]) => {
this.excalidrawAPI.updateScene({
appState: { followedBy: new Set(followedBy) },
});
this.relayVisibleSceneBounds({ force: true });
},
);
this.initializeIdleDetector(); this.initializeIdleDetector();
this.setState({ this.setState({
@@ -727,21 +644,11 @@ class Collab extends PureComponent<Props, CollabState> {
}); });
}, LOAD_IMAGES_TIMEOUT); }, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = ( private handleRemoteSceneUpdate = (elements: ReconciledElements) => {
elements: ReconciledElements,
{ 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();
}; };
@@ -799,39 +706,20 @@ class Collab extends PureComponent<Props, CollabState> {
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange); document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
}; };
setCollaborators(sockets: SocketId[]) { setCollaborators(sockets: string[]) {
const collaborators: InstanceType<typeof Collab>["collaborators"] = const collaborators: InstanceType<typeof Collab>["collaborators"] =
new Map(); new Map();
for (const socketId of sockets) { for (const socketId of sockets) {
collaborators.set( if (this.collaborators.has(socketId)) {
socketId, collaborators.set(socketId, this.collaborators.get(socketId)!);
Object.assign({}, this.collaborators.get(socketId), { } else {
isCurrentUser: socketId === this.portal.socket?.id, collaborators.set(socketId, {});
}), }
);
} }
this.collaborators = collaborators; this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators }); this.excalidrawAPI.updateScene({ collaborators });
} }
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
const collaborators = new Map(this.collaborators);
const user: Mutable<Collaborator> = Object.assign(
{},
collaborators.get(socketId),
updates,
{
isCurrentUser: socketId === this.portal.socket?.id,
},
);
collaborators.set(socketId, user);
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({
collaborators,
});
};
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
this.lastBroadcastedOrReceivedSceneVersion = version; this.lastBroadcastedOrReceivedSceneVersion = version;
}; };
@@ -857,19 +745,6 @@ class Collab extends PureComponent<Props, CollabState> {
CURSOR_SYNC_TIMEOUT, CURSOR_SYNC_TIMEOUT,
); );
relayVisibleSceneBounds = (props?: { force: boolean }) => {
const appState = this.excalidrawAPI.getAppState();
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
this.portal.broadcastVisibleSceneBounds(
{
sceneBounds: getVisibleSceneBounds(appState),
},
`follow@${this.portal.socket.id}`,
);
}
};
onIdleStateChange = (userState: UserIdleState) => { onIdleStateChange = (userState: UserIdleState) => {
this.portal.broadcastIdleChange(userState); this.portal.broadcastIdleChange(userState);
}; };
@@ -879,7 +754,7 @@ class Collab extends PureComponent<Props, CollabState> {
getSceneVersion(elements) > getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion() this.getLastBroadcastedOrReceivedSceneVersion()
) { ) {
this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements(); this.queueBroadcastAllElements();
} }
@@ -892,7 +767,7 @@ class Collab extends PureComponent<Props, CollabState> {
queueBroadcastAllElements = throttle(() => { queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene( this.portal.broadcastScene(
WS_SUBTYPES.UPDATE, WS_SCENE_EVENT_TYPES.UPDATE,
this.excalidrawAPI.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
true, true,
); );

View File

@@ -6,24 +6,22 @@ import {
import { TCollabClass } from "./Collab"; import { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { import {
OnUserFollowedPayload, WS_EVENTS,
SocketId, FILE_UPLOAD_TIMEOUT,
UserIdleState, WS_SCENE_EVENT_TYPES,
} from "../../packages/excalidraw/types"; } from "../app_constants";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { UserIdleState } from "../../src/types";
import { trackEvent } from "../../src/analytics";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation"; import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../packages/excalidraw/data/encryption"; import { encryptData } from "../../src/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import type { Socket } from "socket.io-client";
class Portal { class Portal {
collab: TCollabClass; collab: TCollabClass;
socket: Socket | null = null; socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null; roomId: string | null = null;
roomKey: string | null = null; roomKey: string | null = null;
@@ -33,35 +31,26 @@ class Portal {
this.collab = collab; this.collab = collab;
} }
open(socket: Socket, id: string, key: string) { open(socket: SocketIOClient.Socket, id: string, key: string) {
this.socket = socket; this.socket = socket;
this.roomId = id; this.roomId = id;
this.roomKey = key; this.roomKey = key;
this.socket.on("connect", () => {
console.log("connect");
});
console.log("subbing to init-room");
// Initialize socket listeners // Initialize socket listeners
this.socket.on("init-room", () => { this.socket.on("init-room", () => {
console.log("init-room (1)");
if (this.socket) { if (this.socket) {
console.log("init-room (2)");
this.socket.emit("join-room", this.roomId); this.socket.emit("join-room", this.roomId);
trackEvent("share", "room joined"); trackEvent("share", "room joined");
} }
}); });
this.socket.on("new-user", async (_socketId: string) => { this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene( this.broadcastScene(
WS_SUBTYPES.INIT, WS_SCENE_EVENT_TYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(), this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true, /* syncAll */ true,
); );
}); });
this.socket.on("room-user-change", (clients: SocketId[]) => { this.socket.on("room-user-change", (clients: string[]) => {
console.log("room-user-change", clients);
this.collab.setCollaborators(clients); this.collab.setCollaborators(clients);
}); });
@@ -93,7 +82,6 @@ class Portal {
async _broadcastSocketData( async _broadcastSocketData(
data: SocketUpdateData, data: SocketUpdateData,
volatile: boolean = false, volatile: boolean = false,
roomId?: string,
) { ) {
if (this.isOpen()) { if (this.isOpen()) {
const json = JSON.stringify(data); const json = JSON.stringify(data);
@@ -102,7 +90,7 @@ class Portal {
this.socket?.emit( this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
roomId ?? this.roomId, this.roomId,
encryptedBuffer, encryptedBuffer,
iv, iv,
); );
@@ -141,11 +129,11 @@ class Portal {
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);
broadcastScene = async ( broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
allElements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
syncAll: boolean, syncAll: boolean,
) => { ) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) { if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT"); throw new Error("syncAll must be true when sending SCENE.INIT");
} }
@@ -161,11 +149,7 @@ class Portal {
this.broadcastedElementVersions.get(element.id)!) && this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element) isSyncableElement(element)
) { ) {
acc.push({ acc.push(element);
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
} }
return acc; return acc;
}, },
@@ -194,9 +178,9 @@ class Portal {
broadcastIdleChange = (userState: UserIdleState) => { broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) { if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = { const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: WS_SUBTYPES.IDLE_STATUS, type: "IDLE_STATUS",
payload: { payload: {
socketId: this.socket.id as SocketId, socketId: this.socket.id,
userState, userState,
username: this.collab.state.username, username: this.collab.state.username,
}, },
@@ -214,9 +198,9 @@ class Portal {
}) => { }) => {
if (this.socket?.id) { if (this.socket?.id) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: WS_SUBTYPES.MOUSE_LOCATION, type: "MOUSE_LOCATION",
payload: { payload: {
socketId: this.socket.id as SocketId, socketId: this.socket.id,
pointer: payload.pointer, pointer: payload.pointer,
button: payload.button || "up", button: payload.button || "up",
selectedElementIds: selectedElementIds:
@@ -224,43 +208,12 @@ class Portal {
username: this.collab.state.username, username: this.collab.state.username,
}, },
}; };
return this._broadcastSocketData( return this._broadcastSocketData(
data as SocketUpdateData, data as SocketUpdateData,
true, // volatile true, // volatile
); );
} }
}; };
broadcastVisibleSceneBounds = (
payload: {
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
},
roomId: string,
) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
payload: {
socketId: this.socket.id as SocketId,
username: this.collab.state.username,
sceneBounds: payload.sceneBounds,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
roomId,
);
}
};
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
if (this.socket?.id) {
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
}
};
} }
export default Portal; export default Portal;

View File

@@ -1,4 +1,4 @@
@import "../../packages/excalidraw/css/variables.module.scss"; @import "../../src/css/variables.module";
.excalidraw { .excalidraw {
.RoomDialog { .RoomDialog {

View File

@@ -1,13 +1,13 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../../src/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../src/analytics";
import { getFrame } from "../../packages/excalidraw/utils"; import { getFrame } from "../../src/utils";
import { useI18n } from "../../packages/excalidraw/i18n"; import { useI18n } from "../../src/i18n";
import { KEYS } from "../../packages/excalidraw/keys"; import { KEYS } from "../../src/keys";
import { Dialog } from "../../packages/excalidraw/components/Dialog"; import { Dialog } from "../../src/components/Dialog";
import { import {
copyIcon, copyIcon,
playerPlayIcon, playerPlayIcon,
@@ -16,11 +16,11 @@ import {
shareIOS, shareIOS,
shareWindows, shareWindows,
tablerCheckIcon, tablerCheckIcon,
} from "../../packages/excalidraw/components/icons"; } from "../../src/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField"; import { TextField } from "../../src/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; import { FilledButton } from "../../src/components/FilledButton";
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg"; import { ReactComponent as CollabImage } from "../../src/assets/lock.svg";
import "./RoomDialog.scss"; import "./RoomDialog.scss";
const getShareIcon = () => { const getShareIcon = () => {

View File

@@ -1,15 +1,13 @@
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; import { ExcalidrawElement } from "../../src/element/types";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; import { AppState } from "../../src/types";
import { AppState } from "../../packages/excalidraw/types"; import { arrayToMap } from "../../src/utils";
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils"; import { orderByFractionalIndex } from "../../src/fractionalIndex";
export type ReconciledElements = readonly ExcalidrawElement[] & { export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements"; _brand: "reconciledElements";
}; };
export type BroadcastedExcalidrawElement = ExcalidrawElement & { export type BroadcastedExcalidrawElement = ExcalidrawElement;
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = ( const shouldDiscardRemoteElement = (
localAppState: AppState, localAppState: AppState,
@@ -21,7 +19,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited // local element is being edited
(local.id === localAppState.editingElement?.id || (local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id || local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id || local.id === localAppState.draggingElement?.id || // Is this still valid? As draggingElement is selection element, which is never part of the elements array
// local element is newer // local element is newer
local.version > remote.version || local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with // resolve conflicting edits deterministically by taking the one with
@@ -39,116 +37,43 @@ export const reconcileElements = (
remoteElements: readonly BroadcastedExcalidrawElement[], remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState, localAppState: AppState,
): ReconciledElements => { ): ReconciledElements => {
const localElementsData = const localElementsData = arrayToMap(localElements);
arrayToMapWithIndex<ExcalidrawElement>(localElements); const reconciledElements: ExcalidrawElement[] = [];
const added = new Set<string>();
const reconciledElements: ExcalidrawElement[] = localElements.slice(); // process remote elements
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) { for (const remoteElement of remoteElements) {
remoteElementIdx++; if (localElementsData.has(remoteElement.id)) {
const localElement = localElementsData.get(remoteElement.id);
const local = localElementsData.get(remoteElement.id); if (
localElement &&
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { shouldDiscardRemoteElement(localAppState, localElement, 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; 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 { } else {
let idx = localElementsData.has(parent) if (!added.has(remoteElement.id)) {
? 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); reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [ added.add(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 { } else {
reconciledElements.push(remoteElement); if (!added.has(remoteElement.id)) {
localElementsData.set(remoteElement.id, [ reconciledElements.push(remoteElement);
remoteElement, added.add(remoteElement.id);
reconciledElements.length - 1 - offset, }
]);
} }
} }
const ret: readonly ExcalidrawElement[] = reconciledElements.filter( // process local elements
(element) => !duplicates.has(element), for (const localElement of localElements) {
); if (!added.has(localElement.id)) {
reconciledElements.push(localElement);
added.add(localElement.id);
}
}
return ret as ReconciledElements; return orderByFractionalIndex(
reconciledElements,
) as readonly ExcalidrawElement[] as ReconciledElements;
}; };

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Footer } from "../../packages/excalidraw/index"; import { Footer } from "../../src/packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; import { PlusPromoIcon } from "../../src/components/icons";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../src/packages/excalidraw/index";
import { LanguageList } from "./LanguageList"; import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{

View File

@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; import { PlusPromoIcon } from "../../src/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n"; import { useI18n } from "../../src/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index"; import { WelcomeScreen } from "../../src/packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "../../packages/excalidraw/constants"; import { POINTER_EVENTS } from "../../src/constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;

View File

@@ -1,6 +1,6 @@
import { shield } from "../../packages/excalidraw/components/icons"; import { shield } from "../../src/components/icons";
import { Tooltip } from "../../packages/excalidraw/components/Tooltip"; import { Tooltip } from "../../src/components/Tooltip";
import { useI18n } from "../../packages/excalidraw/i18n"; import { useI18n } from "../../src/i18n";
export const EncryptedIcon = () => { export const EncryptedIcon = () => {
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,30 +1,20 @@
import React from "react"; import React from "react";
import { Card } from "../../packages/excalidraw/components/Card"; import { Card } from "../../src/components/Card";
import { ToolButton } from "../../packages/excalidraw/components/ToolButton"; import { ToolButton } from "../../src/components/ToolButton";
import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { serializeAsJSON } from "../../src/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { import { FileId, NonDeletedExcalidrawElement } from "../../src/element/types";
FileId, import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
NonDeletedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useI18n } from "../../packages/excalidraw/i18n"; import { useI18n } from "../../src/i18n";
import { import { encryptData, generateEncryptionKey } from "../../src/data/encryption";
encryptData, import { isInitializedImageElement } from "../../src/element/typeChecks";
generateEncryptionKey,
} from "../../packages/excalidraw/data/encryption";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager"; import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { MIME_TYPES } from "../../src/constants";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../src/analytics";
import { getFrame } from "../../packages/excalidraw/utils"; import { getFrame } from "../../src/utils";
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo"; import { ExcalidrawLogo } from "../../src/components/ExcalidrawLogo";
export const exportToExcalidrawPlus = async ( export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],

View File

@@ -1,7 +1,7 @@
import oc from "open-color"; import oc from "open-color";
import React from "react"; import React from "react";
import { THEME } from "../../packages/excalidraw/constants"; import { THEME } from "../../src/constants";
import { Theme } from "../../packages/excalidraw/element/types"; import { Theme } from "../../src/element/types";
// https://github.com/tholman/github-corners // https://github.com/tholman/github-corners
export const GitHubCorner = React.memo( export const GitHubCorner = React.memo(

View File

@@ -1,8 +1,8 @@
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { appLangCodeAtom } from "../App"; import { appLangCodeAtom } from "..";
import { useI18n } from "../../packages/excalidraw/i18n"; import { useI18n } from "../../src/i18n";
import { languages } from "../../packages/excalidraw/i18n"; import { languages } from "../../src/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n(); const { t, langCode } = useI18n();

View File

@@ -1,19 +1,19 @@
import { compressData } from "../../packages/excalidraw/data/encode"; import { compressData } from "../../src/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { isInitializedImageElement } from "../../src/element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
FileId, FileId,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types"; } from "../../src/element/types";
import { t } from "../../packages/excalidraw/i18n"; import { t } from "../../src/i18n";
import { import {
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFiles, BinaryFiles,
} from "../../packages/excalidraw/types"; } from "../../src/types";
export class FileManager { export class FileManager {
/** files being fetched */ /** files being fetched */

View File

@@ -11,18 +11,11 @@
*/ */
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval"; import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; import { clearAppStateForLocalStorage } from "../../src/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { clearElementsForLocalStorage } from "../../src/element";
import { import { ExcalidrawElement, FileId } from "../../src/element/types";
ExcalidrawElement, import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
FileId, import { debounce } from "../../src/utils";
} from "../../packages/excalidraw/element/types";
import {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
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";
import { Locker } from "./Locker"; import { Locker } from "./Locker";

View File

@@ -1,27 +1,20 @@
import { import { ExcalidrawElement, FileId } from "../../src/element/types";
ExcalidrawElement, import { getSceneVersion } from "../../src/element";
FileId,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal"; import Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore"; import { restoreElements } from "../../src/data/restore";
import { import {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
DataURL, DataURL,
} from "../../packages/excalidraw/types"; } from "../../src/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../packages/excalidraw/data/encode"; import { decompressData } from "../../src/data/encode";
import { import { encryptData, decryptData } from "../../src/data/encryption";
encryptData, import { MIME_TYPES } from "../../src/constants";
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation"; import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from "."; import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types"; import { ResolutionType } from "../../src/utility-types";
import type { Socket } from "socket.io-client";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -139,12 +132,12 @@ const decryptElements = async (
}; };
class FirebaseSceneVersionCache { class FirebaseSceneVersionCache {
private static cache = new WeakMap<Socket, number>(); private static cache = new WeakMap<SocketIOClient.Socket, number>();
static get = (socket: Socket) => { static get = (socket: SocketIOClient.Socket) => {
return FirebaseSceneVersionCache.cache.get(socket); return FirebaseSceneVersionCache.cache.get(socket);
}; };
static set = ( static set = (
socket: Socket, socket: SocketIOClient.Socket,
elements: readonly SyncableExcalidrawElement[], elements: readonly SyncableExcalidrawElement[],
) => { ) => {
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements)); FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
@@ -286,7 +279,7 @@ export const saveToFirebase = async (
export const loadFromFirebase = async ( export const loadFromFirebase = async (
roomId: string, roomId: string,
roomKey: string, roomKey: string,
socket: Socket | null, socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => { ): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore(); const firebase = await loadFirestore();
const db = firebase.firestore(); const db = firebase.firestore();

View File

@@ -1,36 +1,27 @@
import { import { compressData, decompressData } from "../../src/data/encode";
compressData,
decompressData,
} from "../../packages/excalidraw/data/encode";
import { import {
decryptData, decryptData,
generateEncryptionKey, generateEncryptionKey,
IV_LENGTH_BYTES, IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption"; } from "../../src/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { serializeAsJSON } from "../../src/data/json";
import { restore } from "../../packages/excalidraw/data/restore"; import { restore } from "../../src/data/restore";
import { ImportedDataState } from "../../packages/excalidraw/data/types"; import { ImportedDataState } from "../../src/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds"; import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers"; import { isInitializedImageElement } from "../../src/element/typeChecks";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { ExcalidrawElement, FileId } from "../../src/element/types";
import { import { t } from "../../src/i18n";
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import { import {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
SocketId,
UserIdleState, UserIdleState,
} from "../../packages/excalidraw/types"; } from "../../src/types";
import { bytesToHexString } from "../../packages/excalidraw/utils"; import { bytesToHexString } from "../../src/utils";
import { import {
DELETED_ELEMENT_TIMEOUT, DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES, ROOM_ID_BYTES,
WS_SUBTYPES,
} from "../app_constants"; } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager"; import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase"; import { saveFilesToFirebase } from "./firebase";
@@ -100,43 +91,32 @@ export type EncryptedData = {
}; };
export type SocketUpdateDataSource = { export type SocketUpdateDataSource = {
INVALID_RESPONSE: {
type: WS_SUBTYPES.INVALID_RESPONSE;
};
SCENE_INIT: { SCENE_INIT: {
type: WS_SUBTYPES.INIT; type: "SCENE_INIT";
payload: { payload: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
}; };
}; };
SCENE_UPDATE: { SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE; type: "SCENE_UPDATE";
payload: { payload: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
}; };
}; };
MOUSE_LOCATION: { MOUSE_LOCATION: {
type: WS_SUBTYPES.MOUSE_LOCATION; type: "MOUSE_LOCATION";
payload: { payload: {
socketId: SocketId; socketId: string;
pointer: { x: number; y: number; tool: "pointer" | "laser" }; pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up"; button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
username: string; username: string;
}; };
}; };
USER_VISIBLE_SCENE_BOUNDS: {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
payload: {
socketId: SocketId;
username: string;
sceneBounds: SceneBounds;
};
};
IDLE_STATUS: { IDLE_STATUS: {
type: WS_SUBTYPES.IDLE_STATUS; type: "IDLE_STATUS";
payload: { payload: {
socketId: SocketId; socketId: string;
userState: UserIdleState; userState: UserIdleState;
username: string; username: string;
}; };
@@ -144,7 +124,10 @@ export type SocketUpdateDataSource = {
}; };
export type SocketUpdateDataIncoming = export type SocketUpdateDataIncoming =
SocketUpdateDataSource[keyof SocketUpdateDataSource]; | SocketUpdateDataSource[keyof SocketUpdateDataSource]
| {
type: "INVALID_RESPONSE";
};
export type SocketUpdateData = export type SocketUpdateData =
SocketUpdateDataSource[keyof SocketUpdateDataSource] & { SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
@@ -295,7 +278,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,
}; };
}; };

View File

@@ -1,12 +1,12 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../packages/excalidraw/types"; import { AppState } from "../../src/types";
import { import {
clearAppStateForLocalStorage, clearAppStateForLocalStorage,
getDefaultAppState, getDefaultAppState,
} from "../../packages/excalidraw/appState"; } from "../../src/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { clearElementsForLocalStorage } from "../../src/element";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types"; import { ImportedDataState } from "../../src/data/types";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {

View File

@@ -1,3 +0,0 @@
interface Window {
__EXCALIDRAW_SHA__: string | undefined;
}

View File

@@ -1,15 +1,812 @@
import { StrictMode } from "react"; import polyfill from "../src/polyfill";
import { createRoot } from "react-dom/client"; import LanguageDetector from "i18next-browser-languagedetector";
import ExcalidrawApp from "./App"; import { useEffect, useRef, useState } from "react";
import { registerSW } from "virtual:pwa-register"; import { trackEvent } from "../src/analytics";
import { getDefaultAppState } from "../src/appState";
import { ErrorDialog } from "../src/components/ErrorDialog";
import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "../src/constants";
import { loadFromBlob } from "../src/data/blob";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
Theme,
} from "../src/element/types";
import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
import { t } from "../src/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
} from "../src/packages/excalidraw/index";
import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../src/types";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../src/utils";
import {
FIREBASE_STORAGE_PREFIXES,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import Collab, {
CollabAPI,
collabAPIAtom,
collabDialogShownAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../src/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../src/element/mutateElement";
import { isInitializedImageElement } from "../src/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "../src/data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../src/jotai";
import { appJotaiStore } from "./app-jotai";
import "../excalidraw-app/sentry"; import "./index.scss";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA; import { ResolutionType } from "../src/utility-types";
const rootElement = document.getElementById("root")!; import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
const root = createRoot(rootElement); import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
registerSW(); import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
root.render( import Trans from "../src/components/Trans";
<StrictMode>
<ExcalidrawApp /> polyfill();
</StrictMode>,
window.EXCALIDRAW_THROTTLE_RENDER = true;
let isSelfEmbedding = false;
if (window.self !== window.top) {
try {
const parentUrl = new URL(document.referrer);
const currentUrl = new URL(window.location.href);
if (parentUrl.origin === currentUrl.origin) {
isSelfEmbedding = true;
}
} catch (error) {
// ignore
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
<Trans
i18nKey="overwriteConfirm.modal.shareableLink.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
color: "danger",
} as const;
const initializeScene = async (opts: {
collabAPI: CollabAPI | null;
excalidrawAPI: ExcalidrawImperativeAPI;
}): Promise<
{ scene: ExcalidrawInitialDataState | null } & (
| { isExternalScene: true; id: string; key: string }
| { isExternalScene: false; id?: null; key?: null }
)
> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonBackendMatch = window.location.hash.match(
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
);
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
const localDataState = importFromLocalStorage();
let scene: RestoredDataState & {
scrollToContent?: boolean;
} = await loadScene(null, null, localDataState);
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
if (isExternalScene) {
if (
// don't prompt if scene is empty
!scene.elements.length ||
// don't prompt for collab scenes because we don't override local storage
roomLinkData ||
// otherwise, prompt whether user wants to override current scene
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
jsonBackendMatch[1],
jsonBackendMatch[2],
localDataState,
);
}
scene.scrollToContent = true;
if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else {
// https://github.com/excalidraw/excalidraw/issues/1919
if (document.hidden) {
return new Promise((resolve, reject) => {
window.addEventListener(
"focus",
() => initializeScene(opts).then(resolve).catch(reject),
{
once: true,
},
);
});
}
roomLinkData = null;
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else if (externalUrlMatch) {
window.history.replaceState({}, APP_NAME, window.location.origin);
const url = externalUrlMatch[1];
try {
const request = await fetch(window.decodeURIComponent(url));
const data = await loadFromBlob(await request.blob(), null, null);
if (
!scene.elements.length ||
(await openConfirmModal(shareableLinkConfirmDialog))
) {
return { scene: data, isExternalScene };
}
} catch (error: any) {
return {
scene: {
appState: {
errorMessage: t("alerts.invalidSceneUrl"),
},
},
isExternalScene,
};
}
}
if (roomLinkData && opts.collabAPI) {
const { excalidrawAPI } = opts;
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
return {
// when collaborating, the state may have already been updated at this
// point (we may have received updates from other clients), so reconcile
// elements and appState with existing state
scene: {
...scene,
appState: {
...restoreAppState(
{
...scene?.appState,
theme: localDataState?.appState?.theme || scene?.appState?.theme,
},
excalidrawAPI.getAppState(),
),
// necessary if we're invoking from a hashchange handler which doesn't
// go through App.initializeScene() that resets this flag
isLoading: false,
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
},
isExternalScene: true,
id: roomLinkData.roomId,
key: roomLinkData.roomKey,
};
} else if (scene) {
return isExternalScene && jsonBackendMatch
? {
scene,
isExternalScene,
id: jsonBackendMatch[1],
key: jsonBackendMatch[2],
}
: { scene, isExternalScene: false };
}
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
); );
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
// initial state
// ---------------------------------------------------------------------------
const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise =
resolvablePromise<ExcalidrawInitialDataState | null>();
}
useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW
setTimeout(() => {
trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
useHandleLibrary({
excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage,
});
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return;
}
if (collabAPI?.isCollaborating()) {
if (data.scene.elements) {
collabAPI
.fetchImageFilesFromFirebase({
elements: data.scene.elements,
forceFetchFiles: true,
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
} else {
const fileIds =
data.scene.elements?.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
return acc.concat(element.fileId);
}
return acc;
}, [] as FileId[]) || [];
if (data.isExternalScene) {
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
).then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
} else if (isInitialLoad) {
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
// on fresh load, clear unused files from IDB (from previous
// session)
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
}
}
};
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
const onHashChange = async (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) {
if (
collabAPI?.isCollaborating() &&
!isCollaborationLink(window.location.href)
) {
collabAPI.stopCollaboration(false);
}
excalidrawAPI.updateScene({ appState: { isLoading: true } });
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToStore: true,
});
}
});
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
}
if (
!document.hidden &&
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
) {
// don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
});
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
});
collabAPI?.setUsername(username || "");
}
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
const currFiles = excalidrawAPI.getFiles();
const fileIds =
elements?.reduce((acc, element) => {
if (
isInitializedImageElement(element) &&
// only load and update images that aren't already loaded
!currFiles[element.fileId]
) {
return acc.concat(element.fileId);
}
return acc;
}, [] as FileId[]) || [];
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
updateStaleImageStatuses({
excalidrawAPI,
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
});
}
}
}
}, SYNC_BROWSER_TABS_TIMEOUT);
const onUnload = () => {
LocalData.flushSave();
};
const visibilityChange = (event: FocusEvent | Event) => {
if (event.type === EVENT.BLUR || document.hidden) {
LocalData.flushSave();
}
if (
event.type === EVENT.VISIBILITY_CHANGE ||
event.type === EVENT.FOCUS
) {
syncData();
}
};
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onUnload, false);
window.addEventListener(EVENT.BLUR, visibilityChange, false);
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
document.removeEventListener(
EVENT.VISIBILITY_CHANGE,
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
LocalData.flushSave();
if (
excalidrawAPI &&
LocalData.fileStorage.shouldPreventUnload(
excalidrawAPI.getSceneElements(),
)
) {
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(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 = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
if (collabAPI?.isCollaborating()) {
collabAPI.syncElements(elements);
}
setTheme(appState.theme);
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
const newElement = newElementWith(element, { status: "saved" });
if (newElement !== element) {
didChange = true;
}
return newElement;
}
return element;
});
if (didChange) {
excalidrawAPI.updateScene({
elements,
skipSnapshotUpdate: true,
});
}
}
});
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
canvas: HTMLCanvasElement,
) => {
if (exportedElements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (canvas) {
try {
const { url, errorMessage } = await exportToBackend(
exportedElements,
{
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
},
files,
);
if (errorMessage) {
throw new Error(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
throw new Error(error.message);
}
}
}
};
const renderCustomStats = (
elements: readonly NonDeletedExcalidrawElement[],
appState: UIAppState,
) => {
return (
<CustomStats
setToast={(message) => excalidrawAPI!.setToast({ message })}
appState={appState}
elements={elements}
/>
);
};
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);
// browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
if (isSelfEmbedding) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
height: "100%",
}}
>
<h1>I'm not a pretzel!</h1>
</div>
);
}
return (
<div
style={{ height: "100%" }}
className={clsx("excalidraw-app", {
"is-collaborating": isCollaborating,
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{
canvasActions: {
toggleTheme: true,
export: {
onExportToBackend,
renderCustomUI: (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI?.updateScene({
appState: { openDialog: null },
});
}}
/>
);
},
},
},
}}
langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => setCollabDialogShown(true)}
/>
);
}}
>
<AppMainMenu
setCollabDialogShown={setCollabDialogShown}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
/>
<AppWelcomeScreen
setCollabDialogShown={setCollabDialogShown}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
onClick={() => {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
}}
>
{t("overwriteConfirm.action.excalidrawPlus.description")}
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
onCloseRequest={() => setLatestShareableLink(null)}
setErrorMessage={setErrorMessage}
/>
)}
{excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} />
)}
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</Excalidraw>
</div>
);
};
const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
);
};
export default ExcalidrawApp;

View File

@@ -1,40 +0,0 @@
{
"name": "excalidraw-app",
"version": "1.0.0",
"private": true,
"homepage": ".",
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
}
}

View File

@@ -1,13 +1,8 @@
import { defaultLang } from "../../packages/excalidraw/i18n"; import { defaultLang } from "../../src/i18n";
import { UI } from "../../packages/excalidraw/tests/helpers/ui"; import { UI } from "../../src/tests/helpers/ui";
import { import { screen, fireEvent, waitFor, render } from "../../src/tests/test-utils";
screen,
fireEvent,
waitFor,
render,
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../../excalidraw-app";
describe("Test LanguageList", () => { describe("Test LanguageList", () => {
it("rerenders UI on language change", async () => { it("rerenders UI on language change", async () => {

View File

@@ -1,11 +1,11 @@
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../../excalidraw-app";
import { import {
mockBoundingClientRect, mockBoundingClientRect,
render, render,
restoreOriginalGetBoundingClientRect, restoreOriginalGetBoundingClientRect,
} from "../../packages/excalidraw/tests/test-utils"; } from "../../src/tests/test-utils";
import { UI } from "../../packages/excalidraw/tests/helpers/ui"; import { UI } from "../../src/tests/helpers/ui";
describe("Test MobileMenu", () => { describe("Test MobileMenu", () => {
const { h } = window; const { h } = window;

View File

@@ -1,12 +1,8 @@
import { vi } from "vitest"; import { vi } from "vitest";
import { import { render, updateSceneData, waitFor } from "../../src/tests/test-utils";
render, import ExcalidrawApp from "../../excalidraw-app";
updateSceneData, import { API } from "../../src/tests/helpers/api";
waitFor, import { createUndoAction } from "../../src/actions/actionHistory";
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "../../packages/excalidraw/tests/helpers/api";
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
const { h } = window; const { h } = window;
Object.defineProperty(window, "crypto", { Object.defineProperty(window, "crypto", {
@@ -68,7 +64,7 @@ vi.mock("socket.io-client", () => {
}); });
describe("collaboration", () => { describe("collaboration", () => {
it("creating room should reset deleted elements", async () => { it("creating room should reset deleted elements while keeping store snapshot in sync", async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab // To update the scene with deleted elements before starting collab
updateSceneData({ updateSceneData({
@@ -80,26 +76,43 @@ describe("collaboration", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
commitToStore: true,
}); });
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }), expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }), expect.objectContaining({ id: "B", isDeleted: true }),
]); ]);
expect(API.getStateHistory().length).toBe(1); expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
}); });
window.collab.startCollaboration(null); window.collab.startCollaboration(null);
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1); // We never delete from the local store as it is used for correct diff calculation
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
}); });
const undoAction = createUndoAction(h.history); const undoAction = createUndoAction(h.history);
// noop // noop
h.app.actionManager.executeAction(undoAction); h.app.actionManager.executeAction(undoAction);
// As it was introduced #2270, undo is a noop here, but we might want to re-enable it,
// since inability to undo your own deletions could be a bigger upsetting factor here
await waitFor(() => { await waitFor(() => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1); expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
}); });
}); });
}); });

View File

@@ -1,21 +1,19 @@
import { expect } from "chai"; import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; import { ExcalidrawElement } from "../../src/element/types";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { import {
BroadcastedExcalidrawElement, BroadcastedExcalidrawElement,
ReconciledElements, ReconciledElements,
reconcileElements, reconcileElements,
} from "../../excalidraw-app/collab/reconciliation"; } from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../packages/excalidraw/random"; import { randomInteger } from "../../src/random";
import { AppState } from "../../packages/excalidraw/types"; import { AppState } from "../../src/types";
import { cloneJSON } from "../../packages/excalidraw/utils"; import { cloneJSON } from "../../src/utils";
type Id = string; type Id = string;
type ElementLike = { type ElementLike = {
id: string; id: string;
version: number; version: number;
versionNonce: number; versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
}; };
type Cache = Record<string, ExcalidrawElement | undefined>; type Cache = Record<string, ExcalidrawElement | undefined>;
@@ -44,7 +42,6 @@ const createElement = (opts: { uid: string } | ElementLike) => {
id, id,
version, version,
versionNonce: versionNonce || randomInteger(), versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
}; };
}; };
@@ -53,20 +50,15 @@ const idsToElements = (
cache: Cache = {}, cache: Cache = {},
): readonly ExcalidrawElement[] => { ): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => { return ids.reduce((acc, _uid, idx) => {
const { const { uid, id, version, versionNonce } = createElement(
uid, typeof _uid === "string" ? { uid: _uid } : _uid,
id, );
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const cached = cache[uid]; const cached = cache[uid];
const elem = { const elem = {
id, id,
version: version ?? 0, version: version ?? 0,
versionNonce, versionNonce,
...cached, ...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement; } as BroadcastedExcalidrawElement;
// @ts-ignore // @ts-ignore
cache[uid] = elem; cache[uid] = elem;
@@ -77,7 +69,6 @@ const idsToElements = (
const addParents = (elements: BroadcastedExcalidrawElement[]) => { const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => { return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el; return el;
}); });
}; };
@@ -389,13 +380,11 @@ describe("elements reconciliation", () => {
id: "A", id: "A",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}, },
{ {
id: "B", id: "B",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}, },
]; ];
@@ -408,13 +397,11 @@ describe("elements reconciliation", () => {
id: "A", id: "A",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}; };
const el2 = { const el2 = {
id: "B", id: "B",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}; };
testIdentical([el1, el2], [el2, el1], ["A", "B"]); testIdentical([el1, el2], [el2, el1], ["A", "B"]);
}); });

View File

@@ -1,46 +0,0 @@
/// <reference types="vite-plugin-pwa/vanillajs" />
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-svgr/client" />
interface ImportMetaEnv {
// The port to run the dev server
VITE_APP_PORT: string;
VITE_APP_BACKEND_V2_GET_URL: string;
VITE_APP_BACKEND_V2_POST_URL: string;
// collaboration WebSocket server (https: string
VITE_APP_WS_SERVER_URL: string;
// set this only if using the collaboration workflow we use on excalidraw.com
VITE_APP_PORTAL_URL: string;
VITE_APP_AI_BACKEND: string;
VITE_APP_FIREBASE_CONFIG: string;
// whether to disable live reload / HMR. Usuaully what you want to do when
// debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD: string;
VITE_APP_DISABLE_SENTRY: string;
// Set this flag to false if you want to open the overlay by default
VITE_APP_COLLAPSE_OVERLAY: string;
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string;
VITE_APP_GIT_SHA: string;
MODE: string;
DEV: string;
PROD: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -121,7 +121,7 @@
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" /> <link rel="stylesheet" href="/fonts.css" type="text/css" />
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %> <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
<script> <script>
{ {
@@ -195,7 +195,7 @@
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root"></div>
<script type="module" src="index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %> <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
<!-- 100% privacy friendly analytics --> <!-- 100% privacy friendly analytics -->
<script> <script>

View File

@@ -1,22 +1,64 @@
{ {
"private": true, "browserslist": {
"name": "excalidraw-monorepo", "production": [
"workspaces": [ ">0.2%",
"excalidraw-app", "not dead",
"packages/excalidraw", "not ie <= 11",
"packages/utils" "not op_mini all",
], "not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"fractional-indexing-jittered": "0.9.0",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.13.1", "jotai": "1.13.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.2.0",
"pica": "7.1.1",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "1.0.1",
"pwacompat": "2.0.17",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"socket.io-client": "4.7.2" "roughjs": "4.6.4",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.3", "@excalidraw/eslint-config": "1.0.3",
@@ -24,9 +66,12 @@
"@types/chai": "4.3.0", "@types/chai": "4.3.0",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7", "@types/lodash.throttle": "4.1.7",
"@types/pako": "1.0.3",
"@types/pica": "5.1.3",
"@types/react": "18.0.15", "@types/react": "18.0.15",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/socket.io-client": "3.0.0", "@types/resize-observer-browser": "0.1.7",
"@types/socket.io-client": "1.4.36",
"@vitejs/plugin-react": "3.1.0", "@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0", "@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2", "@vitest/ui": "0.32.2",
@@ -43,25 +88,27 @@
"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": "4.4.2",
"vite-plugin-checker": "0.6.1", "vite-plugin-checker": "0.6.1",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.6.4",
"vite-plugin-pwa": "0.17.4", "vite-plugin-pwa": "0.16.4",
"vite-plugin-svgr": "2.4.0", "vite-plugin-svgr": "2.4.0",
"vitest": "1.0.1", "vitest": "0.34.1",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.2"
}, },
"engines": { "engines": {
"node": "18.0.0 - 20.x.x" "node": "18.0.0 - 20.x.x"
}, },
"homepage": ".", "homepage": ".",
"name": "excalidraw",
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"private": true,
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build": "yarn --cwd ./excalidraw-app build", "build": "yarn build:app && yarn build:version",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
@@ -69,10 +116,10 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js", "locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install", "prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start", "start": "vite",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest", "test:app": "vitest --config vitest.config.ts",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
"test:other": "yarn prettier --list-different", "test:other": "yarn prettier --list-different",
"test:typecheck": "tsc", "test:typecheck": "tsc",

View File

@@ -1,4 +0,0 @@
node_modules
types
bundle.js
bundle.css

View File

@@ -1,83 +0,0 @@
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import { GoToCollaboratorComponentProps } from "../components/UserList";
import { eyeIcon } from "../components/icons";
import { t } from "../i18n";
import { Collaborator } from "../types";
import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator: Collaborator) => {
if (
!collaborator.socketId ||
appState.userToFollow?.socketId === collaborator.socketId ||
collaborator.isCurrentUser
) {
return {
appState: {
...appState,
userToFollow: null,
},
commitToHistory: false,
};
}
return {
appState: {
...appState,
userToFollow: {
socketId: collaborator.socketId,
username: collaborator.username || "",
},
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
};
},
PanelComponent: ({ updateData, data, appState }) => {
const { clientId, collaborator, withName, isBeingFollowed } =
data as GoToCollaboratorComponentProps;
const background = getClientColor(clientId);
return withName ? (
<div
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
onClick={() => updateData<Collaborator>(collaborator)}
>
<Avatar
color={background}
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
/>
{collaborator.username}
<div
className="UserList__collaborator-follow-status-icon"
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
aria-hidden
>
{eyeIcon}
</div>
</div>
) : (
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
/>
);
},
});

View File

@@ -1,40 +0,0 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
action: string,
label?: string,
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}
if (!import.meta.env.PROD) {
console.info("trackEvent", { category, action, label, value });
}
if (window.sa_event) {
window.sa_event(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
};

View File

@@ -1,148 +0,0 @@
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationFrameHandler } from "./animation-frame-handler";
import { AppState } from "./types";
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
import type App from "./components/App";
import { SVG_NS } from "./constants";
export interface Trail {
start(container: SVGSVGElement): void;
stop(): void;
startPath(x: number, y: number): void;
addPointToPath(x: number, y: number): void;
endPath(): void;
}
export interface AnimatedTrailOptions {
fill: (trail: AnimatedTrail) => string;
}
export class AnimatedTrail implements Trail {
private currentTrail?: LaserPointer;
private pastTrails: LaserPointer[] = [];
private container?: SVGSVGElement;
private trailElement: SVGPathElement;
constructor(
private animationFrameHandler: AnimationFrameHandler,
private app: App,
private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path");
}
get hasCurrentTrail() {
return !!this.currentTrail;
}
hasLastPoint(x: number, y: number) {
if (this.currentTrail) {
const len = this.currentTrail.originalPoints.length;
return (
this.currentTrail.originalPoints[len - 1][0] === x &&
this.currentTrail.originalPoints[len - 1][1] === y
);
}
return false;
}
start(container?: SVGSVGElement) {
if (container) {
this.container = container;
}
if (this.trailElement.parentNode !== this.container && this.container) {
this.container.appendChild(this.trailElement);
}
this.animationFrameHandler.start(this);
}
stop() {
this.animationFrameHandler.stop(this);
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
startPath(x: number, y: number) {
this.currentTrail = new LaserPointer(this.options);
this.currentTrail.addPoint([x, y, performance.now()]);
this.update();
}
addPointToPath(x: number, y: number) {
if (this.currentTrail) {
this.currentTrail.addPoint([x, y, performance.now()]);
this.update();
}
}
endPath() {
if (this.currentTrail) {
this.currentTrail.close();
this.currentTrail.options.keepHead = false;
this.pastTrails.push(this.currentTrail);
this.currentTrail = undefined;
this.update();
}
}
private update() {
this.start();
}
private onFrame() {
const paths: string[] = [];
for (const trail of this.pastTrails) {
paths.push(this.drawTrail(trail, this.app.state));
}
if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath);
}
this.pastTrails = this.pastTrails.filter((trail) => {
return trail.getStrokeOutline().length !== 0;
});
if (paths.length === 0) {
this.stop();
}
const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
}
private drawTrail(trail: LaserPointer, state: AppState): string {
const stroke = trail
.getStrokeOutline(trail.options.size / state.zoom.value)
.map(([x, y]) => {
const result = sceneCoordsToViewportCoords(
{ sceneX: x, sceneY: y },
state,
);
return [result.x, result.y];
});
return getSvgPathFromStroke(stroke, true);
}
}

View File

@@ -1,79 +0,0 @@
export type AnimationCallback = (timestamp: number) => void | boolean;
export type AnimationTarget = {
callback: AnimationCallback;
stopped: boolean;
};
export class AnimationFrameHandler {
private targets = new WeakMap<object, AnimationTarget>();
private rafIds = new WeakMap<object, number>();
register(key: object, callback: AnimationCallback) {
this.targets.set(key, { callback, stopped: true });
}
start(key: object) {
const target = this.targets.get(key);
if (!target) {
return;
}
if (this.rafIds.has(key)) {
return;
}
this.targets.set(key, { ...target, stopped: false });
this.scheduleFrame(key);
}
stop(key: object) {
const target = this.targets.get(key);
if (target && !target.stopped) {
this.targets.set(key, { ...target, stopped: true });
}
this.cancelFrame(key);
}
private constructFrame(key: object): FrameRequestCallback {
return (timestamp: number) => {
const target = this.targets.get(key);
if (!target) {
return;
}
const shouldAbort = this.onFrame(target, timestamp);
if (!target.stopped && !shouldAbort) {
this.scheduleFrame(key);
} else {
this.cancelFrame(key);
}
};
}
private scheduleFrame(key: object) {
const rafId = requestAnimationFrame(this.constructFrame(key));
this.rafIds.set(key, rafId);
}
private cancelFrame(key: object) {
if (this.rafIds.has(key)) {
const rafId = this.rafIds.get(key)!;
cancelAnimationFrame(rafId);
}
this.rafIds.delete(key);
}
private onFrame(target: AnimationTarget, timestamp: number): boolean {
const shouldAbort = target.callback(timestamp);
return shouldAbort ?? false;
}
}

View File

@@ -1,7 +0,0 @@
@import "../css/variables.module.scss";
.excalidraw {
.Avatar {
@include avatarStyles;
}
}

View File

@@ -1,59 +0,0 @@
.excalidraw {
.follow-mode {
position: absolute;
box-sizing: border-box;
pointer-events: none;
border: 2px solid var(--color-primary-hover);
z-index: 9999;
display: flex;
align-items: flex-end;
justify-content: center;
&__badge {
background-color: var(--color-primary-hover);
color: var(--color-primary-light);
padding: 0.25rem 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.5rem;
pointer-events: all;
font-size: 0.75rem;
display: flex;
gap: 0.5rem;
align-items: center;
&__label {
display: flex;
white-space: pre-wrap;
line-height: 1;
}
&__username {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
}
&__disconnect-btn {
all: unset;
cursor: pointer;
border-radius: 0.25rem;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
svg {
display: block;
width: 1rem;
height: 1rem;
}
}
}
}

View File

@@ -1,43 +0,0 @@
import { UserToFollow } from "../../types";
import { CloseIcon } from "../icons";
import "./FollowMode.scss";
interface FollowModeProps {
width: number;
height: number;
userToFollow: UserToFollow;
onDisconnect: () => void;
}
const FollowMode = ({
height,
width,
userToFollow,
onDisconnect,
}: FollowModeProps) => {
return (
<div style={{ position: "relative" }}>
<div className="follow-mode" style={{ width, height }}>
<div className="follow-mode__badge">
<div className="follow-mode__badge__label">
Following{" "}
<span
className="follow-mode__badge__username"
title={userToFollow.username}
>
{userToFollow.username}
</span>
</div>
<button
onClick={onDisconnect}
className="follow-mode__disconnect-btn"
>
{CloseIcon}
</button>
</div>
</div>
</div>
);
};
export default FollowMode;

View File

@@ -1,15 +0,0 @@
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
return (
<span
style={{
width: "1em",
margin: "0 0.5ex 0 0.5ex",
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
}}
>
{icon}
</span>
);
};

View File

@@ -1,18 +0,0 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}

View File

@@ -1,160 +0,0 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const appState = useUIAppState();
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
if (appState.openDialog?.name !== "settings") {
return null;
}
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Wireframe to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
color: "#000",
background: "pink",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
{/* <h2
style={{
margin: 0,
fontSize: "1.25rem",
paddingLeft: "2.5rem",
}}
>
AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited as
such we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set
your own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need
to insert it again after reload. But, you can persist locally in
your browser below.
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to
turn it into code. This dialog can be accessed using the{" "}
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</TTDDialogTab>
</TTDDialogTabs>
</Dialog>
);
};

View File

@@ -1,10 +0,0 @@
export const Paragraph = (props: {
children: React.ReactNode;
style?: React.CSSProperties;
}) => {
return (
<p className="excalidraw__paragraph" style={props.style}>
{props.children}
</p>
);
};

View File

@@ -1,33 +0,0 @@
import { useEffect, useRef } from "react";
import { Trail } from "../animated-trail";
import "./SVGLayer.scss";
type SVGLayerProps = {
trails: Trail[];
};
export const SVGLayer = ({ trails }: SVGLayerProps) => {
const svgRef = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (svgRef.current) {
for (const trail of trails) {
trail.start(svgRef.current);
}
}
return () => {
for (const trail of trails) {
trail.stop();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, trails);
return (
<div className="SVGLayer">
<svg ref={svgRef} />
</div>
);
};

View File

@@ -1,10 +0,0 @@
.excalidraw {
.dialog-mermaid {
&-title {
margin-block: 0.25rem;
font-size: 1.25rem;
font-weight: 700;
padding-inline: 2.5rem;
}
}
}

View File

@@ -1,128 +0,0 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../../types";
import { useApp } from "../App";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { ArrowRightIcon } from "../icons";
import "./MermaidToExcalidraw.scss";
import { t } from "../../i18n";
import Trans from "../Trans";
import {
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { EDITOR_LS_KEYS } from "../../constants";
import { debounce } from "../../utils";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
}) => {
const [text, setText] = useState(
() =>
EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
MERMAID_EXAMPLE,
);
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState<Error | null>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
useEffect(() => {
convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
}).catch(() => {});
debouncedSaveMermaidDefinition(deferredText);
}, [deferredText, mermaidToExcalidrawLib]);
useEffect(
() => () => {
debouncedSaveMermaidDefinition.flush();
},
[],
);
const onInsertToEditor = () => {
insertToEditor({
app,
data,
text,
shouldSaveMermaidDataToStorage: true,
});
};
return (
<>
<div className="ttd-dialog-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
)}
/>
</div>
<TTDDialogPanels>
<TTDDialogPanel label={t("mermaid.syntax")}>
<TTDDialogInput
input={text}
placeholder={"Write Mermaid diagram defintion here..."}
onChange={(event) => setText(event.target.value)}
onKeyboardSubmit={() => {
onInsertToEditor();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label={t("mermaid.preview")}
panelAction={{
action: () => {
onInsertToEditor();
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
>
<TTDDialogOutput
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
export default MermaidToExcalidraw;

View File

@@ -1,315 +0,0 @@
@import "../../css/variables.module.scss";
$verticalBreakpoint: 861px;
.excalidraw {
.Modal.Dialog.ttd-dialog {
padding: 1.25rem;
&.Dialog--fullscreen {
margin-top: 0;
}
.Island {
padding-inline: 0 !important;
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
box-shadow: none;
}
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
}
.Dialog__content {
flex: 1 1 auto;
}
}
.ttd-dialog-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
margin-bottom: 1.5rem;
}
.ttd-dialog-tabs-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.ttd-dialog-tab-trigger {
color: var(--color-on-surface);
font-size: 0.875rem;
margin: 0;
padding: 0 1rem;
background-color: transparent;
border: 0;
height: 2.875rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.4px;
&[data-state="active"] {
border-bottom: 2px solid var(--color-primary);
}
}
.ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1.5rem;
padding-inline: 2.5rem;
}
.ttd-dialog-content {
padding-inline: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
&[hidden] {
display: none;
}
}
.ttd-dialog-input {
width: auto;
height: 10rem;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
font-family: monospace;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
height: 100%;
}
}
.ttd-dialog-output-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
height: 400px;
width: auto;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
// acts as min-height
height: 200px;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
.ttd-dialog-output-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
.ttd-dialog-output-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
.ttd-dialog-panels {
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
}
.ttd-dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
&__header {
display: flex;
margin: 0px 4px 4px 4px;
align-items: center;
gap: 1rem;
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
}
}
&:first-child {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 4rem;
}
}
@media screen and (min-width: $verticalBreakpoint) {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 0.5rem !important;
}
}
textarea {
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
.ttd-dialog-panel-button-container {
margin-top: 1rem;
margin-bottom: 0.5rem;
&.invisible {
.ttd-dialog-panel-button {
display: none;
@media screen and (min-width: $verticalBreakpoint) {
display: block;
visibility: hidden;
}
}
}
}
.ttd-dialog-panel-button {
&.excalidraw-button {
font-family: inherit;
font-weight: 600;
height: 2.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
width: 100%;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-primary);
}
}
@media screen and (min-width: $verticalBreakpoint) {
width: auto;
min-width: 7.5rem;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
position: relative;
div {
display: contents;
&.invisible {
visibility: hidden;
}
&.Spinner {
display: flex !important;
position: absolute;
inset: 0;
--spinner-color: white;
@at-root .excalidraw.theme--dark#{&} {
--spinner-color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
.ttd-dialog-submit-shortcut {
margin-inline-start: 0.5rem;
font-size: 0.625rem;
opacity: 0.6;
display: flex;
gap: 0.125rem;
&__key {
border: 1px solid gray;
padding: 2px 3px;
border-radius: 4px;
}
}
}

View File

@@ -1,393 +0,0 @@
import { Dialog } from "../Dialog";
import { useApp, useExcalidrawSetAppState } from "../App";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TTDDialogTabs from "./TTDDialogTabs";
import { ChangeEventHandler, useEffect, useRef, useState } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab";
import { t } from "../../i18n";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
const ttdGenerationAtom = atom<{
generatedResponse: string | null;
prompt: string | null;
} | null>(null);
type OnTestSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| { generatedResponse: string | undefined; error?: null | undefined }
| {
error: Error;
generatedResponse?: null | undefined;
}
);
export const TTDDialog = (
props:
| {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
}
| { __fallback: true },
) => {
const appState = useUIAppState();
if (appState.openDialog?.name !== "ttd") {
return null;
}
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
};
/**
* Text to diagram (TTD) dialog
*/
export const TTDDialogBase = withInternalFallback(
"TTDDialogBase",
({
tab,
...rest
}: {
tab: "text-to-diagram" | "mermaid";
} & (
| {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
}
| { __fallback: true }
)) => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const someRandomDivRef = useRef<HTMLDivElement>(null);
const [ttdGeneration, setTtdGeneration] = useAtom(ttdGenerationAtom);
const [text, setText] = useState(ttdGeneration?.prompt ?? "");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
event,
) => {
setText(event.target.value);
setTtdGeneration((s) => ({
generatedResponse: s?.generatedResponse ?? null,
prompt: event.target.value,
}));
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0 ||
// means this is not a text-to-diagram dialog (needed for TS only)
"__fallback" in rest
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "ttd");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await rest.onTextSubmit(prompt);
if (typeof generatedResponse === "string") {
setTtdGeneration((s) => ({
generatedResponse,
prompt: s?.prompt ?? null,
}));
}
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
try {
await convertMermaidToExcalidraw({
canvasRef: someRandomDivRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: generatedResponse,
});
trackEvent("ai", "mermaid parse success", "ttd");
} catch (error: any) {
console.info(
`%cTTD mermaid render errror: ${error.message}`,
"color: red",
);
console.info(
`>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
"color: yellow",
);
trackEvent("ai", "mermaid parse failed", "ttd");
setError(
new Error(
"Generated an invalid diagram :(. You may also try a different prompt.",
),
);
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({
loaded: false,
api: import("@excalidraw/mermaid-to-excalidraw"),
});
useEffect(() => {
const fn = async () => {
await mermaidToExcalidrawLib.api;
setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
};
fn();
}, [mermaidToExcalidrawLib.api]);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const [error, setError] = useState<Error | null>(null);
return (
<Dialog
className="ttd-dialog"
onCloseRequest={() => {
app.setOpenDialog(null);
}}
size={1200}
title={false}
{...rest}
autofocus={false}
>
<TTDDialogTabs dialog="ttd" tab={tab}>
{"__fallback" in rest && rest.__fallback ? (
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
) : (
<TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<div style={{ display: "flex", alignItems: "center" }}>
{t("labels.textToDiagram")}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "pink",
color: "#000",
}}
>
AI Beta
</div>
</div>
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
</TTDDialogTabTriggers>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
/>
</TTDDialogTab>
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<div className="ttd-dialog-desc">
Currently we use Mermaid as a middle step, so you'll get best
results if you describe a diagram, workflow, flow chart, and
similar.
</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
renderBottomRight={() => {
if (typeof ttdGeneration?.generatedResponse === "string") {
return (
<div
className="excalidraw-link"
style={{ marginLeft: "auto", fontSize: 14 }}
onClick={() => {
if (
typeof ttdGeneration?.generatedResponse ===
"string"
) {
saveMermaidDataToStorage(
ttdGeneration.generatedResponse,
);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
});
}
}}
>
View as Mermaid
<InlineIcon icon={ArrowRightIcon} />
</div>
);
}
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color:
ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
console.info("Panel action clicked");
insertToEditor({ app, data });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</TTDDialogTab>
)}
</TTDDialogTabs>
</Dialog>
);
},
);

View File

@@ -1,52 +0,0 @@
import { ChangeEventHandler, useEffect, useRef } from "react";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
interface TTDDialogInputProps {
input: string;
placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onKeyboardSubmit?: () => void;
}
export const TTDDialogInput = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
useEffect(() => {
if (!callbackRef.current) {
return;
}
const textarea = ref.current;
if (textarea) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
event.preventDefault();
callbackRef.current?.();
}
};
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, []);
return (
<textarea
className="ttd-dialog-input"
onChange={onChange}
value={input}
placeholder={placeholder}
autoFocus
ref={ref}
/>
);
};

View File

@@ -1,39 +0,0 @@
import Spinner from "../Spinner";
const ErrorComp = ({ error }: { error: string }) => {
return (
<div
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
Error! <p>{error}</p>
</div>
);
};
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement>;
loaded: boolean;
}
export const TTDDialogOutput = ({
error,
canvasRef,
loaded,
}: TTDDialogOutputProps) => {
return (
<div className="ttd-dialog-output-wrapper">
{error && <ErrorComp error={error.message} />}
{loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="ttd-dialog-output-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
);
};

View File

@@ -1,63 +0,0 @@
import { ReactNode } from "react";
import { Button } from "../Button";
import clsx from "clsx";
import Spinner from "../Spinner";
interface TTDDialogPanelProps {
label: string;
children: ReactNode;
panelAction?: {
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode;
renderSubmitShortcut?: () => ReactNode;
renderBottomRight?: () => ReactNode;
}
export const TTDDialogPanel = ({
label,
children,
panelAction,
panelActionDisabled = false,
onTextSubmitInProgess,
renderTopRight,
renderSubmitShortcut,
renderBottomRight,
}: TTDDialogPanelProps) => {
return (
<div className="ttd-dialog-panel">
<div className="ttd-dialog-panel__header">
<label>{label}</label>
{renderTopRight?.()}
</div>
{children}
<div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
>
<Button
className="ttd-dialog-panel-button"
onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
{!panelActionDisabled &&
!onTextSubmitInProgess &&
renderSubmitShortcut?.()}
{renderBottomRight?.()}
</div>
</div>
);
};

View File

@@ -1,5 +0,0 @@
import { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return <div className="ttd-dialog-panels">{children}</div>;
};

View File

@@ -1,14 +0,0 @@
import { getShortcutKey } from "../../utils";
export const TTDDialogSubmitShortcut = () => {
return (
<div className="ttd-dialog-submit-shortcut">
<div className="ttd-dialog-submit-shortcut__key">
{getShortcutKey("CtrlOrCmd")}
</div>
<div className="ttd-dialog-submit-shortcut__key">
{getShortcutKey("Enter")}
</div>
</div>
);
};

View File

@@ -1,17 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTab = ({
tab,
children,
...rest
}: {
tab: string;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
TTDDialogTab.displayName = "TTDDialogTab";

View File

@@ -1,21 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: string;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button type="button" className="ttd-dialog-tab-trigger" {...rest}>
{children}
</button>
</RadixTabs.Trigger>
);
};
TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";

View File

@@ -1,13 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.List className="ttd-dialog-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";

View File

@@ -1,64 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { ReactNode, useRef } from "react";
import { useExcalidrawSetAppState } from "../App";
import { isMemberOf } from "../../utils";
const TTDDialogTabs = (
props: {
children: ReactNode;
} & (
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
),
) => {
const setAppState = useExcalidrawSetAppState();
const rootRef = useRef<HTMLDivElement>(null);
const minHeightRef = useRef<number>(0);
return (
<RadixTabs.Root
ref={rootRef}
className="ttd-dialog-tabs-root"
value={props.tab}
onValueChange={(
// at least in test enviros, `tab` can be `undefined`
tab: string | undefined,
) => {
if (!tab) {
return;
}
const modalContentNode =
rootRef.current?.closest<HTMLElement>(".Modal__content");
if (modalContentNode) {
const currHeight = modalContentNode.offsetHeight || 0;
if (currHeight > minHeightRef.current) {
minHeightRef.current = currHeight;
modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
}
}
if (
props.dialog === "settings" &&
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab, source: "settings" },
});
} else if (
props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab },
});
}
}}
>
{props.children}
</RadixTabs.Root>
);
};
TTDDialogTabs.displayName = "TTDDialogTabs";
export default TTDDialogTabs;

View File

@@ -1,34 +0,0 @@
import { ReactNode } from "react";
import { useTunnels } from "../../context/tunnels";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { useExcalidrawSetAppState } from "../App";
import { brainIcon } from "../icons";
import { t } from "../../i18n";
import { trackEvent } from "../../analytics";
export const TTDDialogTrigger = ({
children,
icon,
}: {
children?: ReactNode;
icon?: JSX.Element;
}) => {
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
return (
<TTDDialogTriggerTunnel.In>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "dialog open", "ttd");
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}}
icon={icon ?? brainIcon}
>
{children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);
};
TTDDialogTrigger.displayName = "TTDDialogTrigger";

View File

@@ -1,162 +0,0 @@
import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_SIZE,
EDITOR_LS_KEYS,
} from "../../constants";
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppClassProperties, BinaryFiles } from "../../types";
import { canvasToBlob } from "../../data/blob";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
const resetPreview = ({
canvasRef,
setError,
}: {
canvasRef: React.RefObject<HTMLDivElement>;
setError: (error: Error | null) => void;
}) => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
interface ConvertMermaidToExcalidrawFormatProps {
canvasRef: React.RefObject<HTMLDivElement>;
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
mermaidDefinition: string;
setError: (error: Error | null) => void;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
}
export const convertMermaidToExcalidraw = async ({
canvasRef,
mermaidToExcalidrawLib,
mermaidDefinition,
setError,
data,
}: ConvertMermaidToExcalidrawFormatProps) => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) {
return;
}
if (!mermaidDefinition) {
resetPreview({ canvasRef, setError });
return;
}
try {
const api = await mermaidToExcalidrawLib.api;
let ret;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
);
}
const { elements, files } = ret;
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (err: any) {
parent.style.background = "var(--default-bg-color)";
if (mermaidDefinition) {
setError(err);
}
throw err;
}
};
export const saveMermaidDataToStorage = (mermaidDefinition: string) => {
EditorLocalStorage.set(
EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW,
mermaidDefinition,
);
};
export const insertToEditor = ({
app,
data,
text,
shouldSaveMermaidDataToStorage,
}: {
app: AppClassProperties;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
text?: string;
shouldSaveMermaidDataToStorage?: boolean;
}) => {
const { elements: newElements, files } = data.current;
if (!newElements.length) {
return;
}
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
app.setOpenDialog(null);
if (shouldSaveMermaidDataToStorage && text) {
saveMermaidDataToStorage(text);
}
};

View File

@@ -1,136 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.UserList {
pointer-events: none;
/*github corner*/
padding: var(--space-factor) var(--space-factor) var(--space-factor)
var(--space-factor);
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.625rem;
&:empty {
display: none;
}
box-sizing: border-box;
// can fit max 4 avatars (3 avatars + show more) in a column
max-height: 120px;
// can fit max 4 avatars (3 avatars + show more) when there's enough space
max-width: 120px;
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
overflow: hidden;
}
.UserList > * {
pointer-events: var(--ui-pointerEvents);
}
.UserList_mobile {
padding: 0;
justify-content: normal;
margin: 0.5rem 0;
max-width: none;
max-height: none;
}
.UserList__more {
@include avatarStyles;
background-color: var(--color-gray-20);
border: 0 !important;
font-size: 0.5rem;
font-weight: 400;
flex-shrink: 0;
color: var(--color-gray-100);
}
.UserList__collaborator-follow-status-icon {
margin-left: auto;
flex: 0 0 auto;
width: 1rem;
display: flex;
}
--userlist-hint-bg-color: var(--color-gray-10);
--userlist-hint-heading-color: var(--color-gray-80);
--userlist-hint-text-color: var(--color-gray-60);
--userlist-collaborators-border-color: var(--color-gray-20);
&.theme--dark {
--userlist-hint-bg-color: var(--color-gray-90);
--userlist-hint-heading-color: var(--color-gray-30);
--userlist-hint-text-color: var(--color-gray-40);
--userlist-collaborators-border-color: var(--color-gray-80);
}
.UserList__collaborators {
position: static;
top: auto;
margin-top: 0;
max-height: 12rem;
overflow-y: auto;
padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color);
border-bottom: 1px solid var(--userlist-collaborators-border-color);
&__empty {
color: var(--color-gray-60);
font-size: 0.75rem;
line-height: 150%;
padding: 0.5rem 0;
}
}
.UserList__hint {
padding: 0.5rem 0.75rem;
overflow: hidden;
text-align: center;
color: var(--userlist-hint-text-color);
font-size: 0.75rem;
line-height: 150%;
}
.UserList__search-wrapper {
position: relative;
height: 2.5rem;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
.UserList__search {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
border-radius: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}

View File

@@ -1,254 +0,0 @@
import "./UserList.scss";
import React from "react";
import clsx from "clsx";
import { Collaborator, SocketId } from "../types";
import { Tooltip } from "./Tooltip";
import { useExcalidrawActionManager } from "./App";
import { ActionManager } from "../actions/manager";
import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island";
import { searchIcon } from "./icons";
import { t } from "../i18n";
import { isShallowEqual } from "../utils";
export type GoToCollaboratorComponentProps = {
clientId: ClientId;
collaborator: Collaborator;
withName: boolean;
isBeingFollowed: boolean;
};
/** collaborator user id or socket id (fallback) */
type ClientId = string & { _brand: "UserId" };
const FIRST_N_AVATARS = 3;
const SHOW_COLLABORATORS_FILTER_AT = 8;
const ConditionalTooltipWrapper = ({
shouldWrap,
children,
clientId,
username,
}: {
shouldWrap: boolean;
children: React.ReactNode;
username?: string | null;
clientId: ClientId;
}) =>
shouldWrap ? (
<Tooltip label={username || "Unknown user"} key={clientId}>
{children}
</Tooltip>
) : (
<React.Fragment key={clientId}>{children}</React.Fragment>
);
const renderCollaborator = ({
actionManager,
collaborator,
clientId,
withName = false,
shouldWrapWithTooltip = false,
isBeingFollowed,
}: {
actionManager: ActionManager;
collaborator: Collaborator;
clientId: ClientId;
withName?: boolean;
shouldWrapWithTooltip?: boolean;
isBeingFollowed: boolean;
}) => {
const data: GoToCollaboratorComponentProps = {
clientId,
collaborator,
withName,
isBeingFollowed,
};
const avatarJSX = actionManager.renderAction("goToCollaborator", data);
return (
<ConditionalTooltipWrapper
key={clientId}
clientId={clientId}
username={collaborator.username}
shouldWrap={shouldWrapWithTooltip}
>
{avatarJSX}
</ConditionalTooltipWrapper>
);
};
type UserListUserObject = Pick<
Collaborator,
"avatarUrl" | "id" | "socketId" | "username"
>;
type UserListProps = {
className?: string;
mobile?: boolean;
collaborators: Map<SocketId, UserListUserObject>;
userToFollow: SocketId | null;
};
const collaboratorComparatorKeys = [
"avatarUrl",
"id",
"socketId",
"username",
] as const;
export const UserList = React.memo(
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
const actionManager = useExcalidrawActionManager();
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
const userId = (collaborator.id || socketId) as ClientId;
uniqueCollaboratorsMap.set(
// filter on user id, else fall back on unique socketId
userId,
{ ...collaborator, socketId },
);
});
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
([_, collaborator]) => collaborator.username?.trim(),
);
const [searchTerm, setSearchTerm] = React.useState("");
if (uniqueCollaboratorsArray.length === 0) {
return null;
}
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
FIRST_N_AVATARS,
);
const firstNAvatarsJSX = firstNCollaborators.map(
([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
);
return mobile ? (
<div className={clsx("UserList UserList_mobile", className)}>
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
) : (
<div className={clsx("UserList", className)}>
{firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
</Popover.Trigger>
<Popover.Content
style={{
zIndex: 2,
width: "13rem",
textAlign: "left",
}}
align="end"
sideOffset={10}
>
<Island style={{ overflow: "hidden" }}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper">
{searchIcon}
<input
className="UserList__search"
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)}
<div className="dropdown-menu UserList__collaborators">
{filteredCollaborators.length === 0 && (
<div className="UserList__collaborators__empty">
{t("userList.search.empty")}
</div>
)}
<div className="UserList__hint">
{t("userList.hint.text")}
</div>
{filteredCollaborators.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
withName: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
</Island>
</Popover.Content>
</Popover.Root>
)}
</div>
);
},
(prev, next) => {
if (
prev.collaborators.size !== next.collaborators.size ||
prev.mobile !== next.mobile ||
prev.className !== next.className ||
prev.userToFollow !== next.userToFollow
) {
return false;
}
for (const [socketId, collaborator] of prev.collaborators) {
const nextCollaborator = next.collaborators.get(socketId);
if (
!nextCollaborator ||
!isShallowEqual(
collaborator,
nextCollaborator,
collaboratorComparatorKeys,
)
) {
return false;
}
}
return true;
},
);

View File

@@ -1,265 +0,0 @@
import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[];
}
interface DehydratedExcalidrawElement {
id: string;
versionNonce: number;
}
interface DehydratedHistoryEntry {
appState: string;
elements: DehydratedExcalidrawElement[];
}
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId,
name: appState.name,
};
};
class History {
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
private recording: boolean = true;
private stateHistory: DehydratedHistoryEntry[] = [];
private redoStack: DehydratedHistoryEntry[] = [];
private lastEntry: HistoryEntry | null = null;
private hydrateHistoryEntry({
appState,
elements,
}: DehydratedHistoryEntry): HistoryEntry {
return {
appState: JSON.parse(appState),
elements: elements.map((dehydratedExcalidrawElement) => {
const element = this.elementCache
.get(dehydratedExcalidrawElement.id)
?.get(dehydratedExcalidrawElement.versionNonce);
if (!element) {
throw new Error(
`Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,
);
}
return element;
}),
};
}
private dehydrateHistoryEntry({
appState,
elements,
}: HistoryEntry): DehydratedHistoryEntry {
return {
appState: JSON.stringify(appState),
elements: elements.map((element: ExcalidrawElement) => {
if (!this.elementCache.has(element.id)) {
this.elementCache.set(element.id, new Map());
}
const versions = this.elementCache.get(element.id)!;
if (!versions.has(element.versionNonce)) {
versions.set(element.versionNonce, deepCopyElement(element));
}
return {
id: element.id,
versionNonce: element.versionNonce,
};
}),
};
}
getSnapshotForTest() {
return {
recording: this.recording,
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
};
}
clear() {
this.stateHistory.length = 0;
this.redoStack.length = 0;
this.lastEntry = null;
this.elementCache.clear();
}
private generateEntry = (
appState: AppState,
elements: readonly ExcalidrawElement[],
): DehydratedHistoryEntry =>
this.dehydrateHistoryEntry({
appState: clearAppStatePropertiesForHistory(appState),
elements: elements.reduce((elements, element) => {
if (
isLinearElement(element) &&
appState.multiElement &&
appState.multiElement.id === element.id
) {
// don't store multi-point arrow if still has only one point
if (
appState.multiElement &&
appState.multiElement.id === element.id &&
element.points.length < 2
) {
return elements;
}
elements.push({
...element,
// don't store last point if not committed
points:
element.lastCommittedPoint !==
element.points[element.points.length - 1]
? element.points.slice(0, -1)
: element.points,
});
} else {
elements.push(element);
}
return elements;
}, [] as Mutable<typeof elements>),
});
shouldCreateEntry(nextEntry: HistoryEntry): boolean {
const { lastEntry } = this;
if (!lastEntry) {
return true;
}
if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
for (let i = nextEntry.elements.length - 1; i > -1; i--) {
const prev = nextEntry.elements[i];
const next = lastEntry.elements[i];
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
// note: this is safe because entry's appState is guaranteed no excess props
let key: keyof typeof nextEntry.appState;
for (key in nextEntry.appState) {
if (key === "editingLinearElement") {
if (
nextEntry.appState[key]?.elementId ===
lastEntry.appState[key]?.elementId
) {
continue;
}
}
if (key === "selectedElementIds" || key === "selectedGroupIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
return true;
}
}
return false;
}
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntryDehydrated = this.generateEntry(appState, elements);
const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
if (newEntry) {
if (!this.shouldCreateEntry(newEntry)) {
return;
}
this.stateHistory.push(newEntryDehydrated);
this.lastEntry = newEntry;
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
}
}
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
}
redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) {
return null;
}
const entryToRestore = this.redoStack.pop();
if (entryToRestore !== undefined) {
this.stateHistory.push(entryToRestore);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) {
return null;
}
const currentEntry = this.stateHistory.pop();
const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
if (currentEntry !== undefined) {
this.redoStack.push(currentEntry);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
/**
* Updates history's `lastEntry` to latest app state. This is necessary
* when doing undo/redo which itself doesn't commit to history, but updates
* app state in a way that would break `shouldCreateEntry` which relies on
* `lastEntry` to reflect last comittable history state.
* We can't update `lastEntry` from within history when calling undo/redo
* because the action potentially mutates appState/elements before storing
* it.
*/
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
this.lastEntry = this.hydrateHistoryEntry(
this.generateEntry(appState, elements),
);
}
// Suspicious that this is called so many places. Seems error-prone.
resumeRecording() {
this.recording = true;
}
record(state: AppState, elements: readonly ExcalidrawElement[]) {
if (this.recording) {
this.pushEntry(state, elements);
this.recording = false;
}
}
}
export default History;

View File

@@ -1,124 +0,0 @@
import { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimatedTrail, Trail } from "./animated-trail";
import { AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App";
import { SocketId } from "./types";
import { easeOut } from "./utils";
import { getClientColor } from "./clients";
export class LaserTrails implements Trail {
public localTrail: AnimatedTrail;
private collabTrails = new Map<SocketId, AnimatedTrail>();
private container?: SVGSVGElement;
constructor(
private animationFrameHandler: AnimationFrameHandler,
private app: App,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
...this.getTrailOptions(),
fill: () => "red",
});
}
private getTrailOptions() {
return {
simplify: 0,
streamline: 0.4,
sizeMapping: (c) => {
const DECAY_TIME = 1000;
const DECAY_LENGTH = 50;
const t = Math.max(
0,
1 - (performance.now() - c.pressure) / DECAY_TIME,
);
const l =
(DECAY_LENGTH -
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
DECAY_LENGTH;
return Math.min(easeOut(l), easeOut(t));
},
} as Partial<LaserPointerOptions>;
}
startPath(x: number, y: number): void {
this.localTrail.startPath(x, y);
}
addPointToPath(x: number, y: number): void {
this.localTrail.addPointToPath(x, y);
}
endPath(): void {
this.localTrail.endPath();
}
start(container: SVGSVGElement) {
this.container = container;
this.animationFrameHandler.start(this);
this.localTrail.start(container);
}
stop() {
this.animationFrameHandler.stop(this);
this.localTrail.stop();
}
onFrame() {
this.updateCollabTrails();
}
private updateCollabTrails() {
if (!this.container || this.app.state.collaborators.size === 0) {
return;
}
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
let trail!: AnimatedTrail;
if (!this.collabTrails.has(key)) {
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
...this.getTrailOptions(),
fill: () => getClientColor(key),
});
trail.start(this.container);
this.collabTrails.set(key, trail);
} else {
trail = this.collabTrails.get(key)!;
}
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
if (collabolator.button === "down" && !trail.hasCurrentTrail) {
trail.startPath(collabolator.pointer.x, collabolator.pointer.y);
}
if (
collabolator.button === "down" &&
trail.hasCurrentTrail &&
!trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y)
) {
trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
}
if (collabolator.button === "up" && trail.hasCurrentTrail) {
trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
trail.endPath();
}
}
}
for (const key of this.collabTrails.keys()) {
if (!this.app.state.collaborators.has(key)) {
const trail = this.collabTrails.get(key)!;
trail.stop();
this.collabTrails.delete(key);
}
}
}
}

View File

@@ -1,56 +0,0 @@
{
"ar-SA": 94,
"az-AZ": 17,
"bg-BG": 71,
"bn-BD": 52,
"ca-ES": 83,
"cs-CZ": 86,
"da-DK": 61,
"de-DE": 100,
"el-GR": 80,
"en": 100,
"es-ES": 96,
"eu-ES": 97,
"fa-IR": 84,
"fi-FI": 76,
"fr-FR": 99,
"gl-ES": 86,
"he-IL": 77,
"hi-IN": 76,
"hu-HU": 76,
"id-ID": 91,
"it-IT": 98,
"ja-JP": 90,
"kaa": 36,
"kab-KAB": 76,
"kk-KZ": 18,
"km-KH": 83,
"ko-KR": 100,
"ku-TR": 87,
"lt-LT": 48,
"lv-LV": 77,
"mr-IN": 98,
"my-MM": 35,
"nb-NO": 93,
"nl-NL": 75,
"nn-NO": 67,
"oc-FR": 92,
"pa-IN": 78,
"pl-PL": 99,
"pt-BR": 91,
"pt-PT": 83,
"ro-RO": 99,
"ru-RU": 92,
"si-LK": 7,
"sk-SK": 100,
"sl-SI": 100,
"sv-SE": 100,
"ta-IN": 81,
"th-TH": 44,
"tr-TR": 87,
"uk-UA": 93,
"vi-VN": 49,
"zh-CN": 100,
"zh-HK": 22,
"zh-TW": 100
}

View File

@@ -1,134 +0,0 @@
{
"name": "@excalidraw/excalidraw",
"version": "0.17.1",
"main": "./dist/prod/index.js",
"type": "module",
"module": "./dist/prod/index.js",
"exports": {
".": {
"development": "./dist/dev/index.js",
"types": "./dist/excalidraw/index.d.ts",
"default": "./dist/prod/index.js"
},
"./index.css": {
"development": "./dist/dev/index.css",
"default": "./dist/prod/index.css"
}
},
"types": "./dist/excalidraw/index.d.ts",
"files": [
"dist/*"
],
"publishConfig": {
"access": "public"
},
"description": "Excalidraw as a React component",
"repository": "https://github.com/excalidraw/excalidraw",
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-embed",
"react",
"npm",
"npm excalidraw"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"peerDependencies": {
"react": "^17.0.2 || ^18.2.0",
"react-dom": "^17.0.2 || ^18.2.0"
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "0.2.0",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
"@tldraw/vec": "1.7.1",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.13.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.2.0",
"pica": "7.1.1",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "1.0.1",
"pwacompat": "2.0.17",
"roughjs": "4.6.4",
"sass": "1.51.0",
"tunnel-rat": "0.1.2"
},
"devDependencies": {
"@babel/core": "7.18.9",
"@babel/plugin-transform-arrow-functions": "7.18.6",
"@babel/plugin-transform-async-to-generator": "7.18.6",
"@babel/plugin-transform-runtime": "7.18.9",
"@babel/plugin-transform-typescript": "7.18.8",
"@babel/preset-env": "7.18.6",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.18.6",
"@size-limit/preset-big-lib": "9.0.0",
"@types/pako": "1.0.3",
"@types/pica": "5.1.3",
"@types/resize-observer-browser": "0.1.7",
"autoprefixer": "10.4.7",
"babel-loader": "8.2.5",
"babel-plugin-transform-class-properties": "6.24.1",
"cross-env": "7.0.3",
"css-loader": "6.7.1",
"dotenv": "16.0.1",
"esbuild": "0.19.10",
"esbuild-plugin-external-global": "1.0.1",
"esbuild-sass-plugin": "2.16.0",
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
"import-meta-loader": "1.1.0",
"mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"sass-loader": "13.0.2",
"size-limit": "9.0.0",
"style-loader": "3.3.3",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5",
"ts-loader": "9.3.1",
"typescript": "4.9.4"
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildPackage.js && yarn gen:types",
"pack": "yarn build:umd && yarn pack",
"start": "node ../../scripts/buildExample.mjs && vite",
"build:example": "node ../../scripts/buildExample.mjs",
"size": "yarn build:umd && size-limit"
}
}

View File

@@ -1,61 +0,0 @@
/**
* @param func handler taking at most single parameter (event).
*/
import { unstable_batchedUpdates } from "react-dom";
import { version as ReactVersion } from "react";
import { throttleRAF } from "./utils";
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);
};
export const isRenderThrottlingEnabled = (() => {
// we don't want to throttle in react < 18 because of #5439 and it was
// getting more complex to maintain the fix
let IS_REACT_18_AND_UP: boolean;
try {
const version = ReactVersion.split(".");
IS_REACT_18_AND_UP = Number(version[0]) > 17;
} catch {
IS_REACT_18_AND_UP = false;
}
let hasWarned = false;
return () => {
if (window.EXCALIDRAW_THROTTLE_RENDER === true) {
if (!IS_REACT_18_AND_UP) {
if (!hasWarned) {
hasWarned = true;
console.warn(
"Excalidraw: render throttling is disabled on React versions < 18.",
);
}
return false;
}
return true;
}
return false;
};
})();

View File

@@ -1,10 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><button class="Dialog__close" title="Close" aria-label="Close"><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g clip-path="url(#a)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 5 15M5 5l10 10"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h20v20H0z"></path></clipPath></defs></svg></button><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style="animation-duration: 0s;"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`;

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