mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-20 00:37:04 +02:00
Compare commits
8 Commits
fix-frame
...
dwelle/v0.
Author | SHA1 | Date | |
---|---|---|---|
![]() |
db4770ed83 | ||
![]() |
58338e54c9 | ||
![]() |
640caba739 | ||
![]() |
2879c9d852 | ||
![]() |
81046ccd6b | ||
![]() |
207a0bcc6e | ||
![]() |
f53edb7437 | ||
![]() |
8d0a8ce65b |
@@ -6,6 +6,6 @@
|
||||
!.prettierrc
|
||||
!package.json
|
||||
!public/
|
||||
!packages/
|
||||
!src/
|
||||
!tsconfig.json
|
||||
!yarn.lock
|
||||
|
@@ -13,8 +13,6 @@ VITE_APP_PORTAL_URL=
|
||||
VITE_APP_PLUS_LP=https://plus.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"}'
|
||||
|
||||
# put these in your .env.local, or make sure you don't commit!
|
||||
|
@@ -9,8 +9,6 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||
VITE_APP_PLUS_LP=https://plus.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.
|
||||
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
|
||||
VITE_APP_WS_SERVER_URL=
|
||||
|
@@ -5,4 +5,4 @@ package-lock.json
|
||||
firebase/
|
||||
dist/
|
||||
public/workbox
|
||||
packages/excalidraw/types
|
||||
src/packages/excalidraw/types
|
||||
|
2
.github/workflows/autorelease-preview.yml
vendored
2
.github/workflows/autorelease-preview.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Auto release preview
|
||||
id: "autorelease"
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn add @actions/core
|
||||
yarn autorelease preview ${{ github.event.issue.number }}
|
||||
- name: Post comment post release
|
||||
if: always()
|
||||
|
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
yarn install
|
||||
yarn --frozen-lockfile
|
||||
yarn test:other
|
||||
yarn test:code
|
||||
yarn test:typecheck
|
||||
|
4
.github/workflows/locales-coverage.yml
vendored
4
.github/workflows/locales-coverage.yml
vendored
@@ -22,11 +22,11 @@ jobs:
|
||||
- name: Create report file
|
||||
run: |
|
||||
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
|
||||
git config --global user.name 'Excalidraw Bot'
|
||||
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 push
|
||||
fi
|
||||
|
12
.github/workflows/size-limit.yml
vendored
12
.github/workflows/size-limit.yml
vendored
@@ -15,14 +15,16 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Install in packages/excalidraw
|
||||
run: yarn
|
||||
working-directory: packages/excalidraw
|
||||
- name: Install
|
||||
run: yarn --frozen-lockfile
|
||||
- name: Install in src/packages/excalidraw
|
||||
run: yarn --frozen-lockfile
|
||||
working-directory: src/packages/excalidraw
|
||||
env:
|
||||
CI: true
|
||||
- uses: andresz1/size-limit-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_script: build:esm
|
||||
build_script: build:umd
|
||||
skip_step: install
|
||||
directory: packages/excalidraw
|
||||
directory: src/packages/excalidraw
|
||||
|
2
.github/workflows/test-coverage-pr.yml
vendored
2
.github/workflows/test-coverage-pr.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
with:
|
||||
node-version: "18.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn install
|
||||
run: yarn --frozen-lockfile
|
||||
- name: "Test Coverage"
|
||||
run: yarn test:coverage
|
||||
- name: "Report Coverage"
|
||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -13,5 +13,5 @@ jobs:
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn install
|
||||
yarn --frozen-lockfile
|
||||
yarn test:app
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,7 +21,10 @@ npm-debug.log*
|
||||
package-lock.json
|
||||
yarn-debug.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
|
||||
dev-dist
|
||||
html
|
||||
|
@@ -85,7 +85,7 @@ We'll be adding these features as drop-in plugins for the npm package in the fut
|
||||
|
||||
## 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
|
||||
@@ -97,7 +97,7 @@ or via yarn
|
||||
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
|
||||
|
||||
|
@@ -1,3 +1,3 @@
|
||||
files:
|
||||
- source: /packages/excalidraw/locales/en.json
|
||||
translation: /packages/excalidraw/locales/%locale%.json
|
||||
- source: /src/locales/en.json
|
||||
translation: /src/locales/%locale%.json
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -37,7 +37,7 @@ Defaults to `THEME.LIGHT` unless passed in `initialData.appState.theme`
|
||||
|
||||
### 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 **
|
||||
|
||||
|
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -19,7 +19,7 @@ convertToExcalidrawElements(
|
||||
|
||||
| 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`. |
|
||||
|
||||
**_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
|
||||
|
||||
@@ -192,7 +192,7 @@ convertToExcalidrawElements([
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -326,7 +326,7 @@ convertToExcalidrawElements([
|
||||
|
||||
### 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
|
||||
convertToExcalidrawElements([
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>
|
||||
) => void;
|
||||
@@ -17,7 +17,7 @@ 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 |
|
||||
| --- | --- | --- |
|
||||
@@ -52,7 +52,7 @@ Additionally `ready` and `readyPromise` from the API have been discontinued. The
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>
|
||||
) => void
|
||||
@@ -62,9 +62,9 @@ You can use this function to update the scene with the sceneData. It accepts the
|
||||
|
||||
| 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 |
|
||||
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
||||
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
||||
| `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/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/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`. |
|
||||
|
||||
```jsx live
|
||||
@@ -125,13 +125,13 @@ function App() {
|
||||
|
||||
<pre>
|
||||
(opts: { <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
|
||||
</a>
|
||||
;<br /> merge?: boolean; <br /> prompt?: boolean;
|
||||
<br /> openLibraryMenu?: boolean;
|
||||
<br /> defaultStatus?: "unpublished" | "published"; <br /> }) => Promise<
|
||||
<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
|
||||
</a>
|
||||
>
|
||||
@@ -141,7 +141,7 @@ You can use this function to update the library. It accepts the below attributes
|
||||
|
||||
| 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. |
|
||||
| `prompt` | boolean | `false` | Whether to prompt user for confirmation. |
|
||||
| `openLibraryMenu` | boolean | `false` | Keep the library menu open after library is updated. |
|
||||
@@ -189,7 +189,7 @@ function App() {
|
||||
</button>
|
||||
<Excalidraw
|
||||
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={{
|
||||
libraryItems: initialData.libraryItems,
|
||||
appState: { openSidebar: "library" },
|
||||
@@ -204,7 +204,7 @@ function App() {
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>
|
||||
) => void
|
||||
@@ -224,7 +224,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
|
||||
|
||||
<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[]
|
||||
</a>
|
||||
</pre>
|
||||
@@ -235,7 +235,7 @@ Returns all the elements including the deleted in the scene.
|
||||
|
||||
<pre>
|
||||
() => NonDeleted<
|
||||
<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
|
||||
</a>
|
||||
[]>
|
||||
@@ -247,7 +247,7 @@ Returns all the elements excluding the deleted in the scene
|
||||
|
||||
<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
|
||||
</a>
|
||||
</pre>
|
||||
@@ -288,7 +288,7 @@ Scroll the nearest element out of the elements supplied to the center of the vie
|
||||
|
||||
| Attribute | type | default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L115) | [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) | [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.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%) |
|
||||
@@ -336,7 +336,7 @@ The unique id of the excalidraw component. This can be used to identify the exca
|
||||
|
||||
<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
|
||||
</a>
|
||||
</pre>
|
||||
@@ -364,7 +364,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
|
||||
| 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 |
|
||||
|
||||
## setCursor
|
||||
|
@@ -1,18 +1,18 @@
|
||||
# initialData
|
||||
|
||||
<pre>
|
||||
{ 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> }
|
||||
{ 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> }
|
||||
</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.
|
||||
|
||||
| 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. |
|
||||
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) | The `AppState` 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/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 |
|
||||
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L247) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L200)> | 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. |
|
||||
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L247) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> | This library items with which `Excalidraw` should be mounted. |
|
||||
| `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.
|
||||
|
||||
|
@@ -23,7 +23,7 @@ All `props` are _optional_.
|
||||
| [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
|
||||
| [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component |
|
||||
| [`name`](#name) | `string` | | Name of the drawing |
|
||||
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
|
||||
| [`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. |
|
||||
| [`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 |
|
||||
@@ -33,7 +33,7 @@ All `props` are _optional_.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -59,11 +59,11 @@ Every time component updates, this callback if passed will get triggered and has
|
||||
(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.
|
||||
|
||||
@@ -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"]`
|
||||
|
||||
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
|
||||
(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.
|
||||
2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) of the scene.
|
||||
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/src/types.ts#L95) of the scene.
|
||||
3. `canvas`: The `HTMLCanvasElement` of the scene.
|
||||
|
||||
### onPointerDown
|
||||
@@ -96,11 +96,11 @@ This prop if passed will be triggered on pointer down events and has the below s
|
||||
|
||||
<pre>
|
||||
(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"]
|
||||
</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
|
||||
</a>) => void
|
||||
</pre>
|
||||
@@ -119,7 +119,7 @@ This callback is triggered if passed when something is pasted into the scene. Yo
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>
|
||||
, event: ClipboardEvent | null) => boolean
|
||||
@@ -135,7 +135,7 @@ This callback if supplied will get triggered when the library is updated and has
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>
|
||||
) => void | Promise<any>
|
||||
@@ -149,7 +149,7 @@ This prop if passed will be triggered when clicked on `link`. To handle the redi
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>
|
||||
, event: CustomEvent<{ nativeEvent: MouseEvent }>) => void
|
||||
@@ -182,7 +182,7 @@ const onLinkOpen: ExcalidrawProps["onLinkOpen"] = useCallback(
|
||||
|
||||
### 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
|
||||
import { defaultLang, languages } from "@excalidraw/excalidraw";
|
||||
@@ -191,7 +191,7 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
|
||||
| name | type |
|
||||
| --- | --- |
|
||||
| `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
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
|
||||
<pre>
|
||||
(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
|
||||
</a>) => JSX | null
|
||||
</pre>
|
||||
@@ -66,7 +66,7 @@ function App() {
|
||||
|
||||
<pre>
|
||||
(element: NonDeleted<ExcalidrawEmbeddableElement>, 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
|
||||
</a>
|
||||
) => JSX.Element | null
|
||||
|
@@ -4,7 +4,7 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
|
||||
|
||||
<pre>
|
||||
{
|
||||
<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
|
||||
</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
|
||||
|
||||
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 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
|
||||
|
||||
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.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| image | boolean | true | Decides whether `image` tool should be visible.
|
||||
| image | boolean | true | Decides whether `image` tool should be visible.
|
@@ -20,16 +20,16 @@ exportToCanvas({<br/>
|
||||
getDimensions,<br/>
|
||||
files,<br/>
|
||||
exportPadding?: number;<br/>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L21">ExportOpts</a>
|
||||
</pre>
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `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/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. |
|
||||
| `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. |
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ function App() {
|
||||
|
||||
<pre>
|
||||
exportToBlob(<br/>
|
||||
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L14">ExportOpts</a> & {<br/>
|
||||
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L14">ExportOpts</a> & {<br/>
|
||||
mimeType?: string,<br/>
|
||||
quality?: number,<br/>
|
||||
exportPadding?: number;<br/>
|
||||
@@ -134,16 +134,16 @@ Returns a promise which resolves with a [blob](https://developer.mozilla.org/en-
|
||||
<pre>
|
||||
exportToSvg({<br/>
|
||||
elements:
|
||||
<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[]
|
||||
</a>,<br/>
|
||||
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/>
|
||||
exportPadding: number,<br/>
|
||||
metadata: string,<br/>
|
||||
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">
|
||||
BinaryFiles
|
||||
</a>,<br/>
|
||||
});
|
||||
@@ -151,10 +151,10 @@ exportToSvg({<br/>
|
||||
|
||||
| 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 `|
|
||||
| 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 |
|
||||
| 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/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 |
|
||||
| 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.
|
||||
|
||||
@@ -164,7 +164,7 @@ This function returns a promise which resolves to `svg` of the exported drawing.
|
||||
|
||||
<pre>
|
||||
exportToClipboard(<br/>
|
||||
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a> & {<br/>
|
||||
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L21">ExportOpts</a> & {<br/>
|
||||
mimeType?: string,<br/>
|
||||
quality?: number;<br/>
|
||||
type: 'png' | 'svg' |'json'<br/>
|
||||
|
@@ -8,7 +8,7 @@ id: "restore"
|
||||
**_Signature_**
|
||||
|
||||
<pre>
|
||||
restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState["appState"]</a>,<br/> localAppState: Partial<<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/> localAppState: Partial<<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>
|
||||
|
||||
**_How to use_**
|
||||
@@ -17,7 +17,7 @@ restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob
|
||||
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.
|
||||
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>
|
||||
restoreElements(
|
||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
||||
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/>
|
||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
||||
)
|
||||
</pre>
|
||||
|
||||
| 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 |
|
||||
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
|
||||
| `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/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | 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
|
||||
|
||||
#### localElements
|
||||
@@ -70,15 +70,15 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
|
||||
|
||||
<pre>
|
||||
restore(
|
||||
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>
|
||||
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>
|
||||
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/>
|
||||
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>
|
||||
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<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: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
||||
|
||||
)
|
||||
</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_**
|
||||
|
||||
@@ -93,7 +93,7 @@ This function makes sure elements and state is set to appropriate values and set
|
||||
**_Signature_**
|
||||
|
||||
<pre>
|
||||
restoreLibraryItems(libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState["libraryItems"]</a>,<br/>
|
||||
restoreLibraryItems(libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState["libraryItems"]</a>,<br/>
|
||||
defaultStatus: "published" | "unpublished")
|
||||
</pre>
|
||||
|
||||
|
@@ -8,7 +8,7 @@ These are pure Javascript functions exported from the @excalidraw/excalidraw [`@
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -16,8 +16,8 @@ If you want to overwrite the `source` field in the `JSON` string, you can set `w
|
||||
|
||||
<pre>
|
||||
serializeAsJSON({<br/>
|
||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>,<br/>
|
||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>,<br/>
|
||||
}): string
|
||||
</pre>
|
||||
|
||||
@@ -37,7 +37,7 @@ If you want to overwrite the source field in the JSON string, you can set `windo
|
||||
|
||||
<pre>
|
||||
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>
|
||||
|
||||
**How to use**
|
||||
@@ -53,7 +53,7 @@ Returns `true` if element is invisibly small (e.g. width & height are zero).
|
||||
**_Signature_**
|
||||
|
||||
<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>
|
||||
|
||||
**How to use**
|
||||
@@ -80,10 +80,10 @@ excalidrawAPI.updateScene(scene);
|
||||
<pre>
|
||||
loadFromBlob(<br/>
|
||||
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>
|
||||
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> | null,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>
|
||||
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>
|
||||
fileHandle?: FileSystemHandle | null <br/>
|
||||
) => Promise<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/restore.ts#L61">RestoredDataState</a>>
|
||||
) => Promise<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/restore.ts#L61">RestoredDataState</a>>
|
||||
</pre>
|
||||
|
||||
### loadLibraryFromBlob
|
||||
@@ -130,10 +130,10 @@ if (contents.type === MIME_TYPES.excalidraw) {
|
||||
<pre>
|
||||
loadSceneOrLibraryFromBlob(<br/>
|
||||
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,<br/>
|
||||
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> | null,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>
|
||||
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a> | null,<br/>
|
||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null,<br/>
|
||||
fileHandle?: FileSystemHandle | null<br/>
|
||||
) => Promise<{ 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<{ 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>
|
||||
|
||||
### getFreeDrawSvgPath
|
||||
@@ -149,7 +149,7 @@ import { getFreeDrawSvgPath } from "@excalidraw/excalidraw";
|
||||
**Signature**
|
||||
|
||||
<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>
|
||||
|
||||
### isLinearElement
|
||||
@@ -165,7 +165,7 @@ import { isLinearElement } from "@excalidraw/excalidraw";
|
||||
**Signature**
|
||||
|
||||
<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>
|
||||
|
||||
### getNonDeletedElements
|
||||
@@ -181,7 +181,7 @@ import { getNonDeletedElements } from "@excalidraw/excalidraw";
|
||||
**Signature**
|
||||
|
||||
<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>
|
||||
|
||||
### mergeLibraryItems
|
||||
@@ -196,9 +196,9 @@ import { mergeLibraryItems } from "@excalidraw/excalidraw";
|
||||
|
||||
<pre>
|
||||
mergeLibraryItems(<br/>
|
||||
localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L250">LibraryItems</a>,<br/>
|
||||
otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L200">LibraryItems</a><br/>
|
||||
): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L250">LibraryItems</a>
|
||||
localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L250">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/src/types.ts#L250">LibraryItems</a>
|
||||
</pre>
|
||||
|
||||
### parseLibraryTokensFromUrl
|
||||
@@ -239,8 +239,8 @@ export const App = () => {
|
||||
|
||||
<pre>
|
||||
useHandleLibrary(opts: {<br/>
|
||||
excalidrawAPI: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L494">ExcalidrawAPI</a>,<br/>
|
||||
getInitialLibraryItems?: () => <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L253">LibraryItemsSource</a><br/>
|
||||
excalidrawAPI: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L494">ExcalidrawAPI</a>,<br/>
|
||||
getInitialLibraryItems?: () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L253">LibraryItemsSource</a><br/>
|
||||
});
|
||||
</pre>
|
||||
|
||||
@@ -253,7 +253,7 @@ This function returns the current `scene` version.
|
||||
**_Signature_**
|
||||
|
||||
<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>
|
||||
|
||||
**How to use**
|
||||
@@ -274,7 +274,7 @@ import { sceneCoordsToViewportCoords } from "@excalidraw/excalidraw";
|
||||
|
||||
<pre>
|
||||
sceneCoordsToViewportCoords({ sceneX: number, sceneY: number },<br/>
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): { x: number, y: number }
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a><br/>): { x: number, y: number }
|
||||
</pre>
|
||||
|
||||
### viewportCoordsToSceneCoords
|
||||
@@ -289,7 +289,7 @@ import { viewportCoordsToSceneCoords } from "@excalidraw/excalidraw";
|
||||
|
||||
<pre>
|
||||
viewportCoordsToSceneCoords({ clientX: number, clientY: number },<br/>
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||
</pre>
|
||||
|
||||
### useDevice
|
||||
@@ -350,8 +350,8 @@ To help with localization, we export the following.
|
||||
| name | type |
|
||||
| --- | --- |
|
||||
| `defaultLang` | `string` |
|
||||
| `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/i18n.ts#L15) |
|
||||
| `useI18n` | [`() => { langCode, t }`](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/src/i18n.ts#L15) |
|
||||
|
||||
```js
|
||||
import { defaultLang, languages, useI18n } from "@excalidraw/excalidraw";
|
||||
|
@@ -21,7 +21,7 @@ Most notably, you can customize the primary colors, by overriding these variable
|
||||
- `--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.
|
||||
|
||||
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
|
||||
.custom-styles .excalidraw {
|
||||
|
@@ -13,7 +13,7 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
||||
1. Install the dependencies
|
||||
|
||||
```bash
|
||||
cd packages/excalidraw && yarn
|
||||
cd src/packages/excalidraw && yarn
|
||||
```
|
||||
|
||||
2. Start the example app
|
||||
|
@@ -148,7 +148,7 @@ import TabItem from "@theme/TabItem";
|
||||
<h1>Excalidraw Embed Example</h1>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
<script type="text/javascript" src="packages/excalidraw/index.js"></script>
|
||||
<script type="text/javascript" src="src/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
@@ -38,9 +38,9 @@ Add the diagram type in switch case in [`parseMermaid`](https://github.com/excal
|
||||
|
||||
## 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!
|
||||
|
||||
|
@@ -6,7 +6,7 @@ In this section we will be diving into how the [flowchart parser](https://github
|
||||
|
||||

|
||||
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
|
@@ -55,11 +55,11 @@ If you want to understand how flowchart parser works, you can navigate to [Flowc
|
||||
|
||||
## 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 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).
|
||||
|
||||

|
@@ -52,6 +52,15 @@ Make sure the title starts with a semantic prefix:
|
||||
- **chore**: Other changes that don't modify src or test files
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
@@ -41,7 +41,10 @@ const config = {
|
||||
showLastUpdateTime: true,
|
||||
},
|
||||
theme: {
|
||||
customCss: [require.resolve("./src/css/custom.scss")],
|
||||
customCss: [
|
||||
require.resolve("./src/css/custom.scss"),
|
||||
require.resolve("../src/packages/excalidraw/example/App.scss"),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"outputDirectory": "build",
|
||||
"installCommand": "yarn install"
|
||||
}
|
@@ -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;
|
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../packages/excalidraw/utils";
|
||||
import { debounce, getVersion, nFormatter } from "../src/utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "./data/localStorage";
|
||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
||||
import { t } from "../packages/excalidraw/i18n";
|
||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||
import { UIAppState } from "../packages/excalidraw/types";
|
||||
import { DEFAULT_VERSION } from "../src/constants";
|
||||
import { t } from "../src/i18n";
|
||||
import { copyTextToSystemClipboard } from "../src/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../src/element/types";
|
||||
import { UIAppState } from "../src/types";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
|
@@ -15,17 +15,11 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
export const WS_EVENTS = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
USER_FOLLOW_CHANGE: "user-follow",
|
||||
USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change",
|
||||
} as const;
|
||||
};
|
||||
|
||||
export enum WS_SUBTYPES {
|
||||
INVALID_RESPONSE = "INVALID_RESPONSE",
|
||||
export enum WS_SCENE_EVENT_TYPES {
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
MOUSE_LOCATION = "MOUSE_LOCATION",
|
||||
IDLE_STATUS = "IDLE_STATUS",
|
||||
USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
|
||||
}
|
||||
|
||||
export const FIREBASE_STORAGE_PREFIXES = {
|
||||
|
@@ -1,37 +1,31 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import {
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "../../packages/excalidraw/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 { ExcalidrawImperativeAPI } from "../../src/types";
|
||||
import { ErrorDialog } from "../../src/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../src/constants";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
} from "../../src/element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
zoomToFitBounds,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||
} from "../../src/packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../src/types";
|
||||
import {
|
||||
assertNever,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
throttleRAF,
|
||||
} from "../../packages/excalidraw/utils";
|
||||
withBatchedUpdates,
|
||||
} from "../../src/utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
WS_SUBTYPES,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
WS_EVENTS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
@@ -54,35 +48,29 @@ import {
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import { UserIdleState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
} from "../../packages/excalidraw/constants";
|
||||
import { t } from "../../src/i18n";
|
||||
import { UserIdleState } from "../../src/types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
|
||||
import {
|
||||
encodeFilesForUpload,
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { AbortError } from "../../packages/excalidraw/errors";
|
||||
import { AbortError } from "../../src/errors";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "../../packages/excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
} from "../../src/element/typeChecks";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { decryptData } from "../../src/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "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 collabDialogShownAtom = atom(false);
|
||||
@@ -123,7 +111,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -163,28 +151,12 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
private onUmmount: (() => void) | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.addEventListener("online", this.onOfflineStatusToggle);
|
||||
window.addEventListener("offline", this.onOfflineStatusToggle);
|
||||
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();
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
@@ -232,7 +204,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
this.onUmmount?.();
|
||||
}
|
||||
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
@@ -385,7 +356,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
) => {
|
||||
try {
|
||||
const decrypted = await decryptData(iv, encryptedData, decryptionKey);
|
||||
|
||||
@@ -397,7 +368,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.alert(t("alerts.decryptFailed"));
|
||||
console.error(error);
|
||||
return {
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE,
|
||||
type: "INVALID_RESPONSE",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -513,9 +484,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case WS_SUBTYPES.INVALID_RESPONSE:
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case WS_SUBTYPES.INIT: {
|
||||
case WS_SCENE_EVENT_TYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
@@ -531,76 +502,42 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
case "MOUSE_LOCATION": {
|
||||
const { pointer, button, username, selectedElementIds } =
|
||||
decryptedData.payload;
|
||||
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
this.updateCollaborator(socketId, {
|
||||
pointer,
|
||||
button,
|
||||
selectedElementIds,
|
||||
username,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
collaborators.set(socketId, user);
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: zoomToFitBounds({
|
||||
appState,
|
||||
bounds: sceneBounds,
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 1,
|
||||
}).appState,
|
||||
collaborators,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case WS_SUBTYPES.IDLE_STATUS: {
|
||||
case "IDLE_STATUS": {
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
this.updateCollaborator(socketId, {
|
||||
userState,
|
||||
username,
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.userState = userState;
|
||||
user.username = username;
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
assertNever(decryptedData, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -616,17 +553,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
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.setState({
|
||||
@@ -795,39 +721,20 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: SocketId[]) {
|
||||
setCollaborators(sockets: string[]) {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
collaborators.set(
|
||||
socketId,
|
||||
Object.assign({}, this.collaborators.get(socketId), {
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
}),
|
||||
);
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
}
|
||||
}
|
||||
this.collaborators = 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) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||
};
|
||||
@@ -853,19 +760,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
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) => {
|
||||
this.portal.broadcastIdleChange(userState);
|
||||
};
|
||||
@@ -875,7 +769,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
getSceneVersion(elements) >
|
||||
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.queueBroadcastAllElements();
|
||||
}
|
||||
@@ -888,7 +782,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
WS_SUBTYPES.UPDATE,
|
||||
WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
|
@@ -6,24 +6,23 @@ import {
|
||||
|
||||
import { TCollabClass } from "./Collab";
|
||||
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import {
|
||||
OnUserFollowedPayload,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../src/types";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { encryptData } from "../../src/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
|
||||
class Portal {
|
||||
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
|
||||
roomId: string | null = null;
|
||||
roomKey: string | null = null;
|
||||
@@ -33,7 +32,7 @@ class Portal {
|
||||
this.collab = collab;
|
||||
}
|
||||
|
||||
open(socket: Socket, id: string, key: string) {
|
||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
@@ -47,12 +46,12 @@ class Portal {
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SUBTYPES.INIT,
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: SocketId[]) => {
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
@@ -84,7 +83,6 @@ class Portal {
|
||||
async _broadcastSocketData(
|
||||
data: SocketUpdateData,
|
||||
volatile: boolean = false,
|
||||
roomId?: string,
|
||||
) {
|
||||
if (this.isOpen()) {
|
||||
const json = JSON.stringify(data);
|
||||
@@ -93,7 +91,7 @@ class Portal {
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
roomId ?? this.roomId,
|
||||
this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
);
|
||||
@@ -132,11 +130,11 @@ class Portal {
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -185,9 +183,9 @@ class Portal {
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: WS_SUBTYPES.IDLE_STATUS,
|
||||
type: "IDLE_STATUS",
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
socketId: this.socket.id,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
@@ -205,9 +203,9 @@ class Portal {
|
||||
}) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION,
|
||||
type: "MOUSE_LOCATION",
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
socketId: this.socket.id,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
@@ -215,43 +213,12 @@ class Portal {
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
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;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
@import "../../src/css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.RoomDialog {
|
||||
|
@@ -1,13 +1,13 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { KEYS } from "../../packages/excalidraw/keys";
|
||||
import { copyTextToSystemClipboard } from "../../src/clipboard";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import { getFrame } from "../../src/utils";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { KEYS } from "../../src/keys";
|
||||
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import { Dialog } from "../../src/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
playerPlayIcon,
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
} from "../../src/components/icons";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
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";
|
||||
|
||||
const getShareIcon = () => {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import { arrayToMapWithIndex } from "../../src/utils";
|
||||
|
||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Footer } from "../../packages/excalidraw/index";
|
||||
import { Footer } from "../../src/packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { PlusPromoIcon } from "../../src/components/icons";
|
||||
import { MainMenu } from "../../src/packages/excalidraw/index";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { PlusPromoIcon } from "../../src/components/icons";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { WelcomeScreen } from "../../src/packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||
import { POINTER_EVENTS } from "../../src/constants";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { shield } from "../../packages/excalidraw/components/icons";
|
||||
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { shield } from "../../src/components/icons";
|
||||
import { Tooltip } from "../../src/components/Tooltip";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
|
||||
export const EncryptedIcon = () => {
|
||||
const { t } = useI18n();
|
||||
|
@@ -1,30 +1,20 @@
|
||||
import React from "react";
|
||||
import { Card } from "../../packages/excalidraw/components/Card";
|
||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { Card } from "../../src/components/Card";
|
||||
import { ToolButton } from "../../src/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../src/data/json";
|
||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||
import {
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { FileId, NonDeletedExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import {
|
||||
encryptData,
|
||||
generateEncryptionKey,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { encryptData, generateEncryptionKey } from "../../src/data/encryption";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||
import { encodeFilesForUpload } from "../data/FileManager";
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||
import { MIME_TYPES } from "../../src/constants";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import { getFrame } from "../../src/utils";
|
||||
import { ExcalidrawLogo } from "../../src/components/ExcalidrawLogo";
|
||||
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../packages/excalidraw/constants";
|
||||
import { Theme } from "../../packages/excalidraw/element/types";
|
||||
import { THEME } from "../../src/constants";
|
||||
import { Theme } from "../../src/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "../App";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { languages } from "../../packages/excalidraw/i18n";
|
||||
import { appLangCodeAtom } from "..";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { languages } from "../../src/i18n";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
||||
|
@@ -1,19 +1,19 @@
|
||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import { compressData } from "../../src/data/encode";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
} from "../../src/element/types";
|
||||
import { t } from "../../src/i18n";
|
||||
import {
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
} from "../../src/types";
|
||||
|
||||
export class FileManager {
|
||||
/** files being fetched */
|
||||
|
@@ -11,18 +11,11 @@
|
||||
*/
|
||||
|
||||
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { debounce } from "../../packages/excalidraw/utils";
|
||||
import { clearAppStateForLocalStorage } from "../../src/appState";
|
||||
import { clearElementsForLocalStorage } from "../../src/element";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
|
||||
import { debounce } from "../../src/utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
import { FileManager } from "./FileManager";
|
||||
import { Locker } from "./Locker";
|
||||
|
@@ -1,27 +1,20 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { getSceneVersion } from "../../src/element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
||||
import { restoreElements } from "../../src/data/restore";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "../../packages/excalidraw/types";
|
||||
} from "../../src/types";
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
import { decompressData } from "../../packages/excalidraw/data/encode";
|
||||
import {
|
||||
encryptData,
|
||||
decryptData,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||
import { decompressData } from "../../src/data/encode";
|
||||
import { encryptData, decryptData } from "../../src/data/encryption";
|
||||
import { MIME_TYPES } from "../../src/constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { ResolutionType } from "../../src/utility-types";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -139,12 +132,12 @@ const decryptElements = async (
|
||||
};
|
||||
|
||||
class FirebaseSceneVersionCache {
|
||||
private static cache = new WeakMap<Socket, number>();
|
||||
static get = (socket: Socket) => {
|
||||
private static cache = new WeakMap<SocketIOClient.Socket, number>();
|
||||
static get = (socket: SocketIOClient.Socket) => {
|
||||
return FirebaseSceneVersionCache.cache.get(socket);
|
||||
};
|
||||
static set = (
|
||||
socket: Socket,
|
||||
socket: SocketIOClient.Socket,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
|
||||
@@ -286,7 +279,7 @@ export const saveToFirebase = async (
|
||||
export const loadFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
socket: Socket | null,
|
||||
socket: SocketIOClient.Socket | null,
|
||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||
const firebase = await loadFirestore();
|
||||
const db = firebase.firestore();
|
||||
|
@@ -1,36 +1,27 @@
|
||||
import {
|
||||
compressData,
|
||||
decompressData,
|
||||
} from "../../packages/excalidraw/data/encode";
|
||||
import { compressData, decompressData } from "../../src/data/encode";
|
||||
import {
|
||||
decryptData,
|
||||
generateEncryptionKey,
|
||||
IV_LENGTH_BYTES,
|
||||
} from "../../packages/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||
import { restore } from "../../packages/excalidraw/data/restore";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
} from "../../packages/excalidraw/element/types";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
} from "../../src/data/encryption";
|
||||
import { serializeAsJSON } from "../../src/data/json";
|
||||
import { restore } from "../../src/data/restore";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { t } from "../../src/i18n";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||
} from "../../src/types";
|
||||
import { bytesToHexString } from "../../src/utils";
|
||||
import {
|
||||
DELETED_ELEMENT_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
ROOM_ID_BYTES,
|
||||
WS_SUBTYPES,
|
||||
} from "../app_constants";
|
||||
import { encodeFilesForUpload } from "./FileManager";
|
||||
import { saveFilesToFirebase } from "./firebase";
|
||||
@@ -100,43 +91,32 @@ export type EncryptedData = {
|
||||
};
|
||||
|
||||
export type SocketUpdateDataSource = {
|
||||
INVALID_RESPONSE: {
|
||||
type: WS_SUBTYPES.INVALID_RESPONSE;
|
||||
};
|
||||
SCENE_INIT: {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
type: "SCENE_INIT";
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
type: "SCENE_UPDATE";
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
type: WS_SUBTYPES.MOUSE_LOCATION;
|
||||
type: "MOUSE_LOCATION";
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
socketId: string;
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
USER_VISIBLE_SCENE_BOUNDS: {
|
||||
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
username: string;
|
||||
sceneBounds: SceneBounds;
|
||||
};
|
||||
};
|
||||
IDLE_STATUS: {
|
||||
type: WS_SUBTYPES.IDLE_STATUS;
|
||||
type: "IDLE_STATUS";
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
socketId: string;
|
||||
userState: UserIdleState;
|
||||
username: string;
|
||||
};
|
||||
@@ -144,7 +124,10 @@ export type SocketUpdateDataSource = {
|
||||
};
|
||||
|
||||
export type SocketUpdateDataIncoming =
|
||||
SocketUpdateDataSource[keyof SocketUpdateDataSource];
|
||||
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
||||
| {
|
||||
type: "INVALID_RESPONSE";
|
||||
};
|
||||
|
||||
export type SocketUpdateData =
|
||||
SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "../../packages/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
} from "../../src/appState";
|
||||
import { clearElementsForLocalStorage } from "../../src/element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
|
3
excalidraw-app/global.d.ts
vendored
3
excalidraw-app/global.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
interface Window {
|
||||
__EXCALIDRAW_SHA__: string | undefined;
|
||||
}
|
@@ -1,15 +1,811 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./App";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
import polyfill from "../src/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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";
|
||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
registerSW();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExcalidrawApp />
|
||||
</StrictMode>,
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../src/utility-types";
|
||||
import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../src/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 />
|
||||
{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;
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
@@ -1,13 +1,8 @@
|
||||
import { defaultLang } from "../../packages/excalidraw/i18n";
|
||||
import { UI } from "../../packages/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
render,
|
||||
} from "../../packages/excalidraw/tests/test-utils";
|
||||
import { defaultLang } from "../../src/i18n";
|
||||
import { UI } from "../../src/tests/helpers/ui";
|
||||
import { screen, fireEvent, waitFor, render } from "../../src/tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
|
||||
describe("Test LanguageList", () => {
|
||||
it("rerenders UI on language change", async () => {
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import ExcalidrawApp from "../App";
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
import {
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
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", () => {
|
||||
const { h } = window;
|
||||
|
@@ -1,12 +1,8 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
updateSceneData,
|
||||
waitFor,
|
||||
} 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";
|
||||
import { render, updateSceneData, waitFor } from "../../src/tests/test-utils";
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
import { API } from "../../src/tests/helpers/api";
|
||||
import { createUndoAction } from "../../src/actions/actionHistory";
|
||||
const { h } = window;
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import { expect } from "chai";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import {
|
||||
BroadcastedExcalidrawElement,
|
||||
ReconciledElements,
|
||||
reconcileElements,
|
||||
} from "../../excalidraw-app/collab/reconciliation";
|
||||
import { randomInteger } from "../../packages/excalidraw/random";
|
||||
import { AppState } from "../../packages/excalidraw/types";
|
||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
||||
import { randomInteger } from "../../src/random";
|
||||
import { AppState } from "../../src/types";
|
||||
import { cloneJSON } from "../../src/utils";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
||||
|
46
excalidraw-app/vite-env.d.ts
vendored
46
excalidraw-app/vite-env.d.ts
vendored
@@ -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;
|
||||
}
|
@@ -121,7 +121,7 @@
|
||||
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" ) { %>
|
||||
<script>
|
||||
{
|
||||
@@ -195,7 +195,7 @@
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<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') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script>
|
84
package.json
84
package.json
@@ -1,22 +1,63 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "excalidraw-monorepo",
|
||||
"workspaces": [
|
||||
"excalidraw-app",
|
||||
"packages/excalidraw",
|
||||
"packages/utils"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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/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",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.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",
|
||||
"react": "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": {
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
@@ -24,9 +65,12 @@
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/pako": "1.0.3",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "18.0.15",
|
||||
"@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",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vitest/ui": "0.32.2",
|
||||
@@ -43,25 +87,27 @@
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.6",
|
||||
"vite": "4.4.2",
|
||||
"vite-plugin-checker": "0.6.1",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-ejs": "1.6.4",
|
||||
"vite-plugin-pwa": "0.16.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.0.1",
|
||||
"vitest": "0.34.1",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.0.0 - 20.x.x"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"homepage": ".",
|
||||
"name": "excalidraw",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"private": true,
|
||||
"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 --cwd ./excalidraw-app build",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
@@ -69,10 +115,10 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"start": "vite",
|
||||
"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:app": "vitest",
|
||||
"test:app": "vitest --config vitest.config.ts",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
"test:other": "yarn prettier --list-different",
|
||||
"test:typecheck": "tsc",
|
||||
|
4
packages/excalidraw/.gitignore
vendored
4
packages/excalidraw/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
types
|
||||
bundle.js
|
||||
bundle.css
|
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
@@ -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);
|
||||
}
|
||||
};
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Avatar {
|
||||
@include avatarStyles;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -1,38 +0,0 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||
|
||||
export const ElementCanvasButton = (props: {
|
||||
title?: string;
|
||||
icon: JSX.Element;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__MagicButton",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"is-mobile": props.isMobile,
|
||||
},
|
||||
)}
|
||||
title={`${props.title}`}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={props.title}
|
||||
/>
|
||||
<div className="ToolIcon__icon">{props.icon}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
@@ -1,18 +0,0 @@
|
||||
.excalidraw {
|
||||
.MagicSettings {
|
||||
.Island {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.MagicSettings-confirm {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.MagicSettings__confirm {
|
||||
margin-top: 2rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
@@ -1,160 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { MagicIcon, OpenAIIcon } from "./icons";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { KEYS } from "../keys";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { InlineIcon } from "./InlineIcon";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
import "./MagicSettings.scss";
|
||||
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
|
||||
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
|
||||
|
||||
export const MagicSettings = (props: {
|
||||
openAIKey: string | null;
|
||||
isPersisted: boolean;
|
||||
onChange: (key: string, shouldPersist: boolean) => void;
|
||||
onConfirm: (key: string, shouldPersist: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
|
||||
const [shouldPersist, setShouldPersist] = useState<boolean>(
|
||||
props.isPersisted,
|
||||
);
|
||||
|
||||
const appState = useUIAppState();
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
};
|
||||
|
||||
if (appState.openDialog?.name !== "settings") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => {
|
||||
props.onClose();
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
}}
|
||||
title={
|
||||
<div style={{ display: "flex" }}>
|
||||
Wireframe to Code (AI){" "}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.1rem 0.5rem",
|
||||
marginLeft: "1rem",
|
||||
fontSize: 14,
|
||||
borderRadius: "12px",
|
||||
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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -1,10 +0,0 @@
|
||||
.excalidraw {
|
||||
.dialog-mermaid {
|
||||
&-title {
|
||||
margin-block: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
padding-inline: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -1,5 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
|
||||
return <div className="ttd-dialog-panels">{children}</div>;
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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";
|
@@ -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";
|
@@ -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";
|
@@ -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;
|
@@ -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";
|
@@ -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);
|
||||
}
|
||||
};
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
},
|
||||
);
|
@@ -1,51 +0,0 @@
|
||||
import { EDITOR_LS_KEYS } from "../constants";
|
||||
import { JSONValue } from "../types";
|
||||
|
||||
export class EditorLocalStorage {
|
||||
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
|
||||
try {
|
||||
return !!window.localStorage.getItem(key);
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.getItem error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static get<T extends JSONValue>(
|
||||
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||
) {
|
||||
try {
|
||||
const value = window.localStorage.getItem(key);
|
||||
if (value) {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.getItem error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static set = (
|
||||
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||
value: JSONValue,
|
||||
) => {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.setItem error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
static delete = (
|
||||
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||
) => {
|
||||
try {
|
||||
window.localStorage.removeItem(name);
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.removeItem error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
}
|
@@ -1,300 +0,0 @@
|
||||
export namespace OpenAIInput {
|
||||
type ChatCompletionContentPart =
|
||||
| ChatCompletionContentPartText
|
||||
| ChatCompletionContentPartImage;
|
||||
|
||||
interface ChatCompletionContentPartImage {
|
||||
image_url: ChatCompletionContentPartImage.ImageURL;
|
||||
|
||||
/**
|
||||
* The type of the content part.
|
||||
*/
|
||||
type: "image_url";
|
||||
}
|
||||
|
||||
namespace ChatCompletionContentPartImage {
|
||||
export interface ImageURL {
|
||||
/**
|
||||
* Either a URL of the image or the base64 encoded image data.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* Specifies the detail level of the image.
|
||||
*/
|
||||
detail?: "auto" | "low" | "high";
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatCompletionContentPartText {
|
||||
/**
|
||||
* The text content.
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* The type of the content part.
|
||||
*/
|
||||
type: "text";
|
||||
}
|
||||
|
||||
interface ChatCompletionUserMessageParam {
|
||||
/**
|
||||
* The contents of the user message.
|
||||
*/
|
||||
content: string | Array<ChatCompletionContentPart> | null;
|
||||
|
||||
/**
|
||||
* The role of the messages author, in this case `user`.
|
||||
*/
|
||||
role: "user";
|
||||
}
|
||||
|
||||
interface ChatCompletionSystemMessageParam {
|
||||
/**
|
||||
* The contents of the system message.
|
||||
*/
|
||||
content: string | null;
|
||||
|
||||
/**
|
||||
* The role of the messages author, in this case `system`.
|
||||
*/
|
||||
role: "system";
|
||||
}
|
||||
|
||||
export interface ChatCompletionCreateParamsBase {
|
||||
/**
|
||||
* A list of messages comprising the conversation so far.
|
||||
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
|
||||
*/
|
||||
messages: Array<
|
||||
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
|
||||
>;
|
||||
|
||||
/**
|
||||
* ID of the model to use. See the
|
||||
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
|
||||
* table for details on which models work with the Chat API.
|
||||
*/
|
||||
model:
|
||||
| (string & {})
|
||||
| "gpt-4-1106-preview"
|
||||
| "gpt-4-vision-preview"
|
||||
| "gpt-4"
|
||||
| "gpt-4-0314"
|
||||
| "gpt-4-0613"
|
||||
| "gpt-4-32k"
|
||||
| "gpt-4-32k-0314"
|
||||
| "gpt-4-32k-0613"
|
||||
| "gpt-3.5-turbo"
|
||||
| "gpt-3.5-turbo-16k"
|
||||
| "gpt-3.5-turbo-0301"
|
||||
| "gpt-3.5-turbo-0613"
|
||||
| "gpt-3.5-turbo-16k-0613";
|
||||
|
||||
/**
|
||||
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
|
||||
* existing frequency in the text so far, decreasing the model's likelihood to
|
||||
* repeat the same line verbatim.
|
||||
*
|
||||
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
|
||||
*/
|
||||
frequency_penalty?: number | null;
|
||||
|
||||
/**
|
||||
* Modify the likelihood of specified tokens appearing in the completion.
|
||||
*
|
||||
* Accepts a JSON object that maps tokens (specified by their token ID in the
|
||||
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
|
||||
* bias is added to the logits generated by the model prior to sampling. The exact
|
||||
* effect will vary per model, but values between -1 and 1 should decrease or
|
||||
* increase likelihood of selection; values like -100 or 100 should result in a ban
|
||||
* or exclusive selection of the relevant token.
|
||||
*/
|
||||
logit_bias?: Record<string, number> | null;
|
||||
|
||||
/**
|
||||
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
|
||||
*
|
||||
* The total length of input tokens and generated tokens is limited by the model's
|
||||
* context length.
|
||||
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
|
||||
* for counting tokens.
|
||||
*/
|
||||
max_tokens?: number | null;
|
||||
|
||||
/**
|
||||
* How many chat completion choices to generate for each input message.
|
||||
*/
|
||||
n?: number | null;
|
||||
|
||||
/**
|
||||
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
|
||||
* whether they appear in the text so far, increasing the model's likelihood to
|
||||
* talk about new topics.
|
||||
*
|
||||
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
|
||||
*/
|
||||
presence_penalty?: number | null;
|
||||
|
||||
/**
|
||||
* This feature is in Beta. If specified, our system will make a best effort to
|
||||
* sample deterministically, such that repeated requests with the same `seed` and
|
||||
* parameters should return the same result. Determinism is not guaranteed, and you
|
||||
* should refer to the `system_fingerprint` response parameter to monitor changes
|
||||
* in the backend.
|
||||
*/
|
||||
seed?: number | null;
|
||||
|
||||
/**
|
||||
* Up to 4 sequences where the API will stop generating further tokens.
|
||||
*/
|
||||
stop?: string | null | Array<string>;
|
||||
|
||||
/**
|
||||
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
|
||||
* sent as data-only
|
||||
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
|
||||
* as they become available, with the stream terminated by a `data: [DONE]`
|
||||
* message.
|
||||
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
|
||||
*/
|
||||
stream?: boolean | null;
|
||||
|
||||
/**
|
||||
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
|
||||
* make the output more random, while lower values like 0.2 will make it more
|
||||
* focused and deterministic.
|
||||
*
|
||||
* We generally recommend altering this or `top_p` but not both.
|
||||
*/
|
||||
temperature?: number | null;
|
||||
|
||||
/**
|
||||
* An alternative to sampling with temperature, called nucleus sampling, where the
|
||||
* model considers the results of the tokens with top_p probability mass. So 0.1
|
||||
* means only the tokens comprising the top 10% probability mass are considered.
|
||||
*
|
||||
* We generally recommend altering this or `temperature` but not both.
|
||||
*/
|
||||
top_p?: number | null;
|
||||
|
||||
/**
|
||||
* A unique identifier representing your end-user, which can help OpenAI to monitor
|
||||
* and detect abuse.
|
||||
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
|
||||
*/
|
||||
user?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace OpenAIOutput {
|
||||
export interface ChatCompletion {
|
||||
/**
|
||||
* A unique identifier for the chat completion.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* A list of chat completion choices. Can be more than one if `n` is greater
|
||||
* than 1.
|
||||
*/
|
||||
choices: Array<Choice>;
|
||||
|
||||
/**
|
||||
* The Unix timestamp (in seconds) of when the chat completion was created.
|
||||
*/
|
||||
created: number;
|
||||
|
||||
/**
|
||||
* The model used for the chat completion.
|
||||
*/
|
||||
model: string;
|
||||
|
||||
/**
|
||||
* The object type, which is always `chat.completion`.
|
||||
*/
|
||||
object: "chat.completion";
|
||||
|
||||
/**
|
||||
* This fingerprint represents the backend configuration that the model runs with.
|
||||
*
|
||||
* Can be used in conjunction with the `seed` request parameter to understand when
|
||||
* backend changes have been made that might impact determinism.
|
||||
*/
|
||||
system_fingerprint?: string;
|
||||
|
||||
/**
|
||||
* Usage statistics for the completion request.
|
||||
*/
|
||||
usage?: CompletionUsage;
|
||||
}
|
||||
export interface Choice {
|
||||
/**
|
||||
* The reason the model stopped generating tokens. This will be `stop` if the model
|
||||
* hit a natural stop point or a provided stop sequence, `length` if the maximum
|
||||
* number of tokens specified in the request was reached, `content_filter` if
|
||||
* content was omitted due to a flag from our content filters, `tool_calls` if the
|
||||
* model called a tool, or `function_call` (deprecated) if the model called a
|
||||
* function.
|
||||
*/
|
||||
finish_reason:
|
||||
| "stop"
|
||||
| "length"
|
||||
| "tool_calls"
|
||||
| "content_filter"
|
||||
| "function_call";
|
||||
|
||||
/**
|
||||
* The index of the choice in the list of choices.
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* A chat completion message generated by the model.
|
||||
*/
|
||||
message: ChatCompletionMessage;
|
||||
}
|
||||
|
||||
interface ChatCompletionMessage {
|
||||
/**
|
||||
* The contents of the message.
|
||||
*/
|
||||
content: string | null;
|
||||
|
||||
/**
|
||||
* The role of the author of this message.
|
||||
*/
|
||||
role: "assistant";
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage statistics for the completion request.
|
||||
*/
|
||||
interface CompletionUsage {
|
||||
/**
|
||||
* Number of tokens in the generated completion.
|
||||
*/
|
||||
completion_tokens: number;
|
||||
|
||||
/**
|
||||
* Number of tokens in the prompt.
|
||||
*/
|
||||
prompt_tokens: number;
|
||||
|
||||
/**
|
||||
* Total number of tokens used in the request (prompt + completion).
|
||||
*/
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface APIError {
|
||||
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
|
||||
readonly headers: Headers | undefined;
|
||||
readonly error: { message: string } | undefined;
|
||||
|
||||
readonly code: string | null | undefined;
|
||||
readonly param: string | null | undefined;
|
||||
readonly type: string | undefined;
|
||||
}
|
||||
}
|
@@ -1,104 +0,0 @@
|
||||
import { Theme } from "../element/types";
|
||||
import { DataURL } from "../types";
|
||||
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||
|
||||
export type MagicCacheData =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| { status: "done"; html: string }
|
||||
| {
|
||||
status: "error";
|
||||
message?: string;
|
||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
||||
Your role is to transform low-fidelity wireframes into working front-end HTML code.
|
||||
|
||||
YOU MUST FOLLOW FOLLOWING RULES:
|
||||
|
||||
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
|
||||
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
|
||||
- Inline JavaScript when needed
|
||||
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
|
||||
- Source images from Unsplash or create applicable placeholders
|
||||
- Interpret annotations as intended vs literal UI
|
||||
- Fill gaps using your expertise in UX and business logic
|
||||
- generate primarily for desktop UI, but make it responsive.
|
||||
- Use grid and flexbox wherever applicable.
|
||||
- Convert the wireframe in its entirety, don't omit elements if possible.
|
||||
|
||||
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
|
||||
|
||||
Your goal is a production-ready prototype that brings the wireframes to life.
|
||||
|
||||
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
||||
|
||||
export async function diagramToHTML({
|
||||
image,
|
||||
apiKey,
|
||||
text,
|
||||
theme = "light",
|
||||
}: {
|
||||
image: DataURL;
|
||||
apiKey: string;
|
||||
text: string;
|
||||
theme?: Theme;
|
||||
}) {
|
||||
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
|
||||
model: "gpt-4-vision-preview",
|
||||
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
||||
max_tokens: 4096,
|
||||
temperature: 0.1,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: image,
|
||||
detail: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result:
|
||||
| ({ ok: true } & OpenAIOutput.ChatCompletion)
|
||||
| ({ ok: false } & OpenAIOutput.APIError);
|
||||
|
||||
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const json: OpenAIOutput.ChatCompletion = await resp.json();
|
||||
result = { ...json, ok: true };
|
||||
} else {
|
||||
const json: OpenAIOutput.APIError = await resp.json();
|
||||
result = { ...json, ok: false };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@@ -1,14 +0,0 @@
|
||||
.excalidraw {
|
||||
.excalidraw-canvas-buttons {
|
||||
position: absolute;
|
||||
|
||||
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
|
||||
z-index: var(--zIndex-canvasButtons);
|
||||
background: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
@@ -1,60 +0,0 @@
|
||||
import { AppState } from "../types";
|
||||
import { sceneCoordsToViewportCoords } from "../utils";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
|
||||
import "./ElementCanvasButtons.scss";
|
||||
|
||||
const CONTAINER_PADDING = 5;
|
||||
|
||||
const getContainerCoords = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const [x1, y1] = getElementAbsoluteCoords(element);
|
||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x1 + element.width, sceneY: y1 },
|
||||
appState,
|
||||
);
|
||||
const x = viewportX - appState.offsetLeft + 10;
|
||||
const y = viewportY - appState.offsetTop;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const ElementCanvasButtons = ({
|
||||
children,
|
||||
element,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
element: NonDeletedExcalidrawElement;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
if (
|
||||
appState.contextMenu ||
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
appState.isRotating ||
|
||||
appState.openMenu ||
|
||||
appState.viewModeEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { x, y } = getContainerCoords(element, appState);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-canvas-buttons"
|
||||
style={{
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
// width: CONTAINER_WIDTH,
|
||||
padding: CONTAINER_PADDING,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user